├── tests
├── testFile.txt
└── backend
│ ├── TempManager.js
│ └── ApiWrapper.js
├── app
├── static
│ └── defaultArtistImage.jpg
├── backend
│ ├── Configuration
│ │ ├── defaultCredentialsConfig.json
│ │ ├── credentialsConfigTypeChecker.js
│ │ ├── App.js
│ │ ├── defaultAppConfig.json
│ │ ├── Credentials.js
│ │ ├── appConfigTypeChecker.js
│ │ └── abstract
│ │ │ └── Configuration.js
│ ├── models
│ │ ├── Artist.js
│ │ ├── Album.js
│ │ ├── Playlist.js
│ │ ├── Track.js
│ │ └── modelsBySearchTypes.js
│ ├── api
│ │ ├── ApiInterface.js
│ │ └── ApiWrapper.js
│ ├── player
│ │ └── Player.js
│ └── TempManager
│ │ └── TempManager.js
├── actions
│ ├── index.js
│ ├── ui.js
│ ├── basic.js
│ └── player.js
├── UI
│ ├── abstract
│ │ ├── ActivityRunner.js
│ │ ├── Panel.js
│ │ ├── Activity.js
│ │ ├── Screen.js
│ │ └── BaseElement.js
│ ├── panels
│ │ ├── userInputActions
│ │ │ ├── conditionsBlockingActions.js
│ │ │ ├── shortcutActions.js
│ │ │ ├── errors.js
│ │ │ ├── actions.js
│ │ │ └── ActionsInputPanel.js
│ │ ├── ActivityRunners
│ │ │ ├── BottomPanel.js
│ │ │ └── TopPanel.js
│ │ ├── UserPlaylistsPanel.js
│ │ ├── SearchPanel.js
│ │ ├── HomePanel.js
│ │ ├── QueuePanel.js
│ │ ├── AlbumPanel.js
│ │ ├── PlaylistPanel.js
│ │ ├── ArtistPanel.js
│ │ ├── PlayerPanel.js
│ │ └── SigninPanel.js
│ ├── uiComponents
│ │ ├── basicUI
│ │ │ ├── RadioSet.js
│ │ │ ├── LoadingIndicator.js
│ │ │ ├── Text.js
│ │ │ ├── RadioButton.js
│ │ │ ├── Button.js
│ │ │ ├── TextInputBar.js
│ │ │ ├── Image.js
│ │ │ └── List.js
│ │ └── specializedUI
│ │ │ ├── List
│ │ │ ├── listTypes.js
│ │ │ ├── ArtistsList.js
│ │ │ ├── PlaylistsList.js
│ │ │ ├── AlbumsList.js
│ │ │ └── TracksList.js
│ │ │ ├── Image
│ │ │ ├── AlbumImage.js
│ │ │ ├── ArtistImage.js
│ │ │ └── PlaylistImage.js
│ │ │ ├── PlayTracksButton.js
│ │ │ └── TrackInfoText.js
│ └── MainScreen.js
├── reducers
│ ├── index.js
│ ├── basic.js
│ ├── ui.js
│ └── player.js
├── installDependencies.sh
└── index.js
├── input.txt
├── .idea
└── vcs.xml
├── .eslintrc.json
├── LICENSE
├── .gitignore
├── .travis.yml
├── package.json
├── README.md
├── CODE_OF_CONDUCT.md
└── CONTRIBUTING.md
/tests/testFile.txt:
--------------------------------------------------------------------------------
1 | Test
--------------------------------------------------------------------------------
/app/static/defaultArtistImage.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/okonek/tidal-cli-client/HEAD/app/static/defaultArtistImage.jpg
--------------------------------------------------------------------------------
/app/backend/Configuration/defaultCredentialsConfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "USERNAME": "",
3 | "PASSWORD": "",
4 | "STREAM_QUALITY": "HIGH"
5 | }
--------------------------------------------------------------------------------
/app/actions/index.js:
--------------------------------------------------------------------------------
1 | exports.basic = require("./basic");
2 | exports.player = require("./player");
3 | exports.ui = require("./ui");
4 |
5 |
--------------------------------------------------------------------------------
/input.txt:
--------------------------------------------------------------------------------
1 | ! TRAVIS input file
2 | ! Created with TRAVIS version compiled at Feb 28 2018 23:32:05
3 | ! Input file written at Wed Feb 28 23:33:58 2018.
4 |
--------------------------------------------------------------------------------
/app/UI/abstract/ActivityRunner.js:
--------------------------------------------------------------------------------
1 | const Panel = require("./Panel");
2 |
3 | module.exports = class extends Panel {
4 | constructor(parent, element) {
5 | super(parent, element);
6 | }
7 | };
--------------------------------------------------------------------------------
/app/UI/abstract/Panel.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("./BaseElement");
2 |
3 | module.exports = class extends BaseElement {
4 | constructor(parent, element) {
5 | super(parent, element);
6 | }
7 | };
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/UI/panels/userInputActions/conditionsBlockingActions.js:
--------------------------------------------------------------------------------
1 | const errors = require("./errors");
2 |
3 | module.exports = [
4 | store => store.getState().basic.tidalApiLoginState === false ? errors.cannotInputWhenNotLoggedIn : false
5 | ];
--------------------------------------------------------------------------------
/app/backend/models/Artist.js:
--------------------------------------------------------------------------------
1 | module.exports = class Artist {
2 | constructor(artistObject) {
3 | this.id = artistObject.id;
4 | this.name = artistObject.name;
5 | this.tracks = [];
6 | this.picture = artistObject.picture;
7 | }
8 | };
--------------------------------------------------------------------------------
/app/reducers/index.js:
--------------------------------------------------------------------------------
1 | const combineReducers = require("redux").combineReducers;
2 | const basic = require("./basic");
3 | const player = require("./player");
4 | const ui = require("./ui");
5 | module.exports = combineReducers({basic, player, ui});
6 |
--------------------------------------------------------------------------------
/app/backend/models/Album.js:
--------------------------------------------------------------------------------
1 | module.exports = class Album {
2 | constructor(albumObject) {
3 | this.id = albumObject.id;
4 | this.title = albumObject.title;
5 | this.artists = albumObject.artists;
6 | this.coverArt = albumObject.coverArt;
7 | }
8 | };
--------------------------------------------------------------------------------
/app/backend/models/Playlist.js:
--------------------------------------------------------------------------------
1 | module.exports = class {
2 | constructor(playlistObject) {
3 | this.uuid = playlistObject.uuid;
4 | this.title = playlistObject.title;
5 | this.description = playlistObject.description;
6 | this.coverArt = playlistObject.image;
7 | }
8 | };
--------------------------------------------------------------------------------
/app/backend/Configuration/credentialsConfigTypeChecker.js:
--------------------------------------------------------------------------------
1 | const ApiInterface = require("../api/ApiInterface");
2 |
3 | const checkString = x => typeof x === "string";
4 |
5 | module.exports = {
6 | USERNAME: x => checkString(x),
7 | PASSWORD: x => checkString(x),
8 | STREAM_QUALITY: x => Object.values(ApiInterface.STREAM_QUALITY).includes(x)
9 | };
--------------------------------------------------------------------------------
/app/backend/models/Track.js:
--------------------------------------------------------------------------------
1 | module.exports = class Track {
2 | constructor(trackObject) {
3 | this.id = trackObject.id;
4 | this.title = trackObject.title;
5 | this.artists = trackObject.artists;
6 | this.album = trackObject.album;
7 | }
8 |
9 | async updateStreamURL(tidalApi) {
10 | this.streamURL = await tidalApi.getTrackURL(this);
11 | }
12 | };
--------------------------------------------------------------------------------
/app/UI/panels/userInputActions/shortcutActions.js:
--------------------------------------------------------------------------------
1 | const actions = require("../../../actions");
2 |
3 | module.exports = (action, store, parent) => {
4 | switch (action) {
5 | case "OPEN_INPUT_BAR":
6 | parent.showTextInputBar();
7 | parent.textInputBar.setValue("search ");
8 | break;
9 |
10 | case "PLAY_NEXT_TRACK":
11 | store.dispatch(actions.player.skipTracks(1));
12 | break;
13 | }
14 | };
--------------------------------------------------------------------------------
/app/UI/abstract/Activity.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("./BaseElement");
2 |
3 | module.exports = class extends BaseElement {
4 | constructor(parent, element) {
5 | super(parent, element);
6 | }
7 |
8 | async afterShowElements() {
9 | if(this.children.length < 2) {
10 | return;
11 | }
12 | this.children.map(x => {
13 | x.element.key("tab", () => {
14 | this.focusNext();
15 | });
16 | });
17 | }
18 | };
--------------------------------------------------------------------------------
/app/UI/panels/userInputActions/errors.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | undefinedSearchValue: Error("Enter the search query"),
3 | unexistingCommand: Error("This command doesn't exist"),
4 | cannotInputWhenNotLoggedIn: Error("You have to login first"),
5 | "Username and password are required arguments of login()": Error("Enter login and password"),
6 | "Username or password is wrong": Error("The username or password is wrong"),
7 | tidalError: Error("Tidal error has occurred, please try again")
8 | };
--------------------------------------------------------------------------------
/app/backend/models/modelsBySearchTypes.js:
--------------------------------------------------------------------------------
1 | const SearchTypes = require("../api/ApiInterface").SEARCH_TYPES;
2 | const Album = require("./Album");
3 | const Artist = require("./Artist");
4 | const Playlist = require("./Playlist");
5 | const Track = require("./Track");
6 |
7 | module.exports = (type) => {
8 | switch(type) {
9 | case SearchTypes.ALBUMS:
10 | return Album;
11 |
12 | case SearchTypes.ARTISTS:
13 | return Artist;
14 |
15 | case SearchTypes.PLAYLISTS:
16 | return Playlist;
17 |
18 | case SearchTypes.TRACKS:
19 | return Track;
20 | }
21 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/RadioSet.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("../../abstract/BaseElement");
2 | const blessed = require("blessed");
3 |
4 | module.exports = class extends BaseElement {
5 | constructor(parent, options, children) {
6 | super(parent, blessed.radioset(options));
7 | this.children = children;
8 | }
9 |
10 | getSelectedValue() {
11 | const checked = this.children.find(x => x.element.checked);
12 | return checked ? checked.element.text : undefined;
13 | }
14 |
15 | async run() {
16 | await this.showElements(this.children);
17 | }
18 | };
--------------------------------------------------------------------------------
/app/backend/Configuration/App.js:
--------------------------------------------------------------------------------
1 | const Configuration = require("./abstract/Configuration");
2 | const homeDir = require("os").homedir();
3 | const fs = require("fs");
4 |
5 | module.exports = class extends Configuration {
6 | constructor() {
7 | super(homeDir + "/.config/tidal-cli-client/app.json");
8 |
9 | this.defaultConfig = JSON.parse(fs.readFileSync(__dirname + "/defaultAppConfig.json"));
10 | this.typeChecker = require("./appConfigTypeChecker");
11 | }
12 |
13 | prepareConfigFile() {
14 | if(!this.exists) {
15 | this.writeConfig(this.defaultConfig);
16 | }
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/LoadingIndicator.js:
--------------------------------------------------------------------------------
1 | const blessed = require("blessed");
2 | const BaseElement = require("../../abstract/BaseElement");
3 |
4 | module.exports = class extends BaseElement {
5 | constructor(parent, options, loadingText = "Loading") {
6 | super(parent, blessed.loading(Object.assign({}, {}, options)));
7 |
8 | this.options = options;
9 | this.loadingText = loadingText;
10 | }
11 |
12 | load() {
13 | this.element.load(this.loadingText);
14 | this.element.show();
15 | }
16 |
17 | stop() {
18 | this.element.stop();
19 | this.element.hide();
20 | }
21 |
22 |
23 | };
--------------------------------------------------------------------------------
/app/actions/ui.js:
--------------------------------------------------------------------------------
1 | const actions = {
2 | SET_CURRENT_ACTIVITY: "SET_CURRENT_ACTIVITY",
3 | SET_PIXEL_RATIO: "SET_PIXEL_RATIO",
4 | SHOW_ERROR: "SHOW_ERROR"
5 | };
6 |
7 | exports.actions = actions;
8 |
9 | exports.setCurrentActivity = (activity, argumentsObject) => ({type: actions.SET_CURRENT_ACTIVITY, payload: {activity, argumentsObject, timestamp: new Date()}});
10 |
11 | exports.setPixelRatio = pixelRatio => ({type: actions.SET_PIXEL_RATIO, payload: pixelRatio});
12 |
13 | exports.showError = (message, timeout = 5000) => ({type: actions.SHOW_ERROR, payload: {message, timeout, timestamp: new Date()}});
14 |
--------------------------------------------------------------------------------
/app/actions/basic.js:
--------------------------------------------------------------------------------
1 | const actions = {
2 | SET_TIDAL_API: "SET_TIDAL_API",
3 | SET_TEMP_MANAGER: "SET_TEMP_MANAGER",
4 | SET_TIDAL_API_LOGIN_STATE: "SET_TIDAL_API_LOGIN_STATE",
5 | EXIT: "EXIT"
6 | };
7 |
8 | exports.actions = actions;
9 |
10 | exports.setTidalApi = tidalApi => ({type: actions.SET_TIDAL_API, payload: tidalApi});
11 |
12 | exports.setTidalApiLoginState = loginState => ({type: actions.SET_TIDAL_API_LOGIN_STATE, payload: loginState});
13 |
14 | exports.setTempManager = tempManager => ({type: actions.SET_TEMP_MANAGER, payload: tempManager});
15 |
16 | exports.exit = () => ({type: actions.EXIT});
17 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/Text.js:
--------------------------------------------------------------------------------
1 | const blessed = require("blessed");
2 | const BaseElement = require("../../abstract/BaseElement");
3 | const AppConfiguration = require("../../../backend/Configuration/App");
4 |
5 | module.exports = class extends BaseElement {
6 | constructor(parent, options, text = undefined) {
7 | const appConfiguration = new AppConfiguration().getConfig();
8 |
9 | super(parent, blessed.text(Object.assign({}, {
10 | content: text,
11 | style: {
12 | fg: appConfiguration["STYLES"]["TEXT_COLOR"]
13 | }
14 | }, options)));
15 |
16 | this.options = options;
17 | this.text = text;
18 | }
19 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/List/listTypes.js:
--------------------------------------------------------------------------------
1 | const SearchTypes = require("../../../../backend/api/ApiInterface").SEARCH_TYPES;
2 | const AlbumsList = require("./AlbumsList");
3 | const TracksList = require("./TracksList");
4 | const ArtistsList = require("./ArtistsList");
5 | const PlaylistsList = require("./PlaylistsList");
6 |
7 | module.exports = searchType => {
8 | switch (searchType) {
9 | case SearchTypes.ALBUMS:
10 | return AlbumsList;
11 |
12 | case SearchTypes.TRACKS:
13 | return TracksList;
14 |
15 | case SearchTypes.ARTISTS:
16 | return ArtistsList;
17 |
18 | case SearchTypes.PLAYLISTS:
19 | return PlaylistsList;
20 | }
21 | };
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es6": true,
4 | "node": true
5 | },
6 | "parser": "babel-eslint",
7 | "extends": "eslint:recommended",
8 | "parserOptions": {
9 | "ecmaVersion": 2015
10 | },
11 | "rules": {
12 | "indent": [
13 | "error",
14 | "tab"
15 | ],
16 | "linebreak-style": [
17 | "error",
18 | "unix"
19 | ],
20 | "quotes": [
21 | "error",
22 | "double"
23 | ],
24 | "semi": [
25 | "error",
26 | "always"
27 | ],
28 | "strict": 0
29 | }
30 | }
--------------------------------------------------------------------------------
/app/UI/panels/ActivityRunners/BottomPanel.js:
--------------------------------------------------------------------------------
1 | const ActivityRunner = require("../../abstract/ActivityRunner");
2 | const PlayerPanel = require("../PlayerPanel");
3 | const blessed = require("blessed");
4 |
5 | module.exports = class extends ActivityRunner {
6 | constructor(parent, store) {
7 | super(parent, blessed.box({
8 | width: "100%",
9 | height: "20%",
10 | bottom: 0,
11 | border: {
12 | type: "line"
13 | },
14 | autoPadding: true,
15 | style: {
16 | border: {
17 | fg: "#FFFFFF"
18 | }
19 | },
20 | }));
21 | this.store = store;
22 | }
23 |
24 | async run() {
25 | await this.showElements([new PlayerPanel(this, this.store)]);
26 | }
27 | };
--------------------------------------------------------------------------------
/app/backend/Configuration/defaultAppConfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "STYLES": {
3 | "TEXT_COLOR": "#ecf0f1",
4 | "PRIMARY_COLOR": "#34495e",
5 | "SECONDARY_COLOR": "#3498db",
6 | "ERROR_COLOR": "#e74c3c"
7 | },
8 | "INPUT_BAR_ACTIONS": {
9 | "PAUSE": "pause",
10 | "RESUME": "resume",
11 | "PLAYLISTS": "playlists",
12 | "QUEUE": "queue",
13 | "NEXT": "next",
14 | "SKIP": "skip",
15 | "SHUFFLE": "shuffle",
16 | "QUIT": "quit",
17 | "SEARCH": "search"
18 | },
19 | "SHORTCUTS": {
20 | "PLAY_AS_NEXT_BUTTON": "n",
21 | "PLAY_AS_LAST_BUTTON": "a",
22 | "LIST_DOWN": "k",
23 | "LIST_UP": "j",
24 | "OPEN_INPUT_BAR": "f2",
25 | "PLAY_NEXT_TRACK": "l"
26 | }
27 | }
--------------------------------------------------------------------------------
/app/backend/api/ApiInterface.js:
--------------------------------------------------------------------------------
1 | module.exports = class {
2 |
3 | static get DEFAULT_LIMIT() {
4 | return 50;
5 | }
6 |
7 | static get STREAM_QUALITY() {
8 | return {
9 | LOW: "LOW",
10 | HIGH: "HIGH",
11 | LOSSLESS: "LOSSLESS"
12 | };
13 | }
14 |
15 | static get SEARCH_TYPES() {
16 | return {
17 | ARTISTS: "artists",
18 | ALBUMS: "albums",
19 | TRACKS: "tracks",
20 | PLAYLISTS: "playlists"
21 | };
22 | }
23 |
24 | static get ALBUM_COVER_SIZES() {
25 | return {
26 | SMALL: "sm",
27 | MEDIUM: "md",
28 | LARGE: "lg",
29 | XL: "xl"
30 | };
31 | }
32 |
33 | static get ARTIST_COVER_SIZES() {
34 | return {
35 | SMALL: "sm",
36 | MEDIUM: "md",
37 | LARGE: "lg",
38 | };
39 | }
40 | };
--------------------------------------------------------------------------------
/app/reducers/basic.js:
--------------------------------------------------------------------------------
1 | const actions = require("../actions/index").basic.actions;
2 |
3 | const initialState = {
4 | tidalApi: undefined,
5 | tidalApiLoginState: false,
6 | tempManager: undefined,
7 | exit: false
8 | };
9 |
10 | module.exports = (state = initialState, action) => {
11 | switch (action.type) {
12 | case actions.SET_TIDAL_API:
13 | return Object.assign({}, state, {tidalApi: action.payload});
14 |
15 | case actions.SET_TIDAL_API_LOGIN_STATE:
16 | return Object.assign({}, state, {tidalApiLoginState: action.payload});
17 |
18 | case actions.SET_TEMP_MANAGER:
19 | return Object.assign({}, state, {tempManager: action.payload});
20 |
21 | case actions.EXIT:
22 | return Object.assign({}, state, {exit: true});
23 |
24 | default:
25 | return state;
26 | }
27 | };
--------------------------------------------------------------------------------
/app/backend/player/Player.js:
--------------------------------------------------------------------------------
1 | const MPV = require("node-mpv");
2 |
3 | module.exports = class {
4 | constructor() {
5 | let init_json = {
6 | "audio_only": true
7 | };
8 | this.mpv = new MPV(init_json);
9 | this.playing = false;
10 | }
11 |
12 | static get playbackStates() {
13 | return {
14 | PAUSED: "PAUSED",
15 | PLAYING: "PLAYING",
16 | LOADING: "LOADING"
17 | };
18 | }
19 |
20 | on(event, callback) {
21 | this.mpv.on(event, callback);
22 | }
23 |
24 | play(trackUrl) {
25 | if(this.playing) {
26 | this.pause();
27 | }
28 |
29 | this.mpv.load(trackUrl);
30 | this.resume();
31 | }
32 |
33 | resume() {
34 | this.mpv.resume();
35 | this.playing = true;
36 | }
37 |
38 | pause() {
39 | this.mpv.pause();
40 | this.playing = false;
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/app/installDependencies.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | YUM_CMD=$(which yum)
4 | APT_GET_CMD=$(which apt-get)
5 | PACMAN_CMD=$(which pacman)
6 | DNF_CMD=$(which dnf)
7 | ZYPPER_CMD=$(which zypper)
8 | EMERGE_CMD=$(which emerge)
9 |
10 | echo "Auto installing dependencies started. You'll have to pass your sudo password if needed."
11 |
12 | if [[ ! -z ${YUM_CMD} ]]; then
13 | yum install -y mpv
14 | yum install -y w3m-img
15 | elif [[ ! -z ${APT_GET_CMD} ]]; then
16 | sudo apt-get install -y mpv w3m-img
17 | elif [[ ! -z ${PACMAN_CMD} ]]; then
18 | sudo pacman -S --noconfirm mpv w3m
19 | elif [[ ! -z ${DNF_CMD} ]]; then
20 | dnf install -y mpv w3m-img
21 | elif [[ ! -z ${ZYPPER_CMD} ]]; then
22 | zypper install -n mpv w3m
23 | elif [[ ! -z ${EMERGE_CMD} ]]; then
24 | emerge media-video/mpv
25 | else
26 | echo "Can't install dependencies."
27 | fi
--------------------------------------------------------------------------------
/app/backend/Configuration/Credentials.js:
--------------------------------------------------------------------------------
1 | const Configuration = require("./abstract/Configuration");
2 | const homeDir = require("os").homedir();
3 | const fs = require("fs");
4 |
5 | module.exports = class extends Configuration {
6 | constructor() {
7 | super(homeDir + "/.config/tidal-cli-client/credentials.json");
8 |
9 | this.typeChecker = require("./credentialsConfigTypeChecker");
10 | this.defaultConfig = JSON.parse(fs.readFileSync(__dirname + "/defaultCredentialsConfig.json"));
11 | }
12 |
13 | getCredentials() {
14 | return this.getConfig();
15 | }
16 |
17 | async saveCredentials(username = this.defaultConfig.username, password = this.defaultConfig.password, streamQuality = this.defaultConfig.streamQuality) {
18 | this.set("USERNAME", username);
19 | this.set("PASSWORD", password);
20 | this.set("STREAM_QUALITY", streamQuality);
21 | this.updateConfig();
22 | }
23 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/List/ArtistsList.js:
--------------------------------------------------------------------------------
1 | const List = require("../../basicUI/List");
2 | const actions = require("../../../../actions");
3 | const ArtistPanel = require("../../../panels/ArtistPanel");
4 |
5 | module.exports = class extends List {
6 | constructor(parent, options, store, artists) {
7 | super(parent, options, artists, []);
8 | this.store = store;
9 |
10 | this.bindOnItemSelect(this.openArtistPanel.bind(this));
11 | }
12 |
13 | showList() {
14 | this.loadingIndicator.stop();
15 | this.setKeys(this.elements.map(x => x.name));
16 | }
17 |
18 | setElements(elements) {
19 | this.elements = elements;
20 | this.showList();
21 | }
22 |
23 | async afterShowElements() {
24 | this.loadingIndicator.load();
25 |
26 | if(this.elements) {
27 | this.showList();
28 | }
29 | }
30 |
31 | openArtistPanel(artist) {
32 | this.store.dispatch(actions.ui.setCurrentActivity(ArtistPanel, {artistId: artist.id}));
33 | }
34 |
35 |
36 | };
--------------------------------------------------------------------------------
/app/reducers/ui.js:
--------------------------------------------------------------------------------
1 | const actions = require("../actions/index").ui.actions;
2 |
3 | const initialState = {
4 | currentActivity: {
5 | activity: undefined,
6 | arguments: {},
7 | timestamp: undefined
8 | },
9 | pixelRatio: undefined,
10 | error: {
11 | message: undefined,
12 | timeout: undefined,
13 | timestamp: undefined
14 | }
15 | };
16 |
17 | module.exports = (state = initialState, action) => {
18 | switch (action.type) {
19 | case actions.SET_CURRENT_ACTIVITY:
20 | return Object.assign({}, state, {
21 | currentActivity: {
22 | activity: action.payload.activity,
23 | arguments: action.payload.argumentsObject,
24 | timestamp: action.payload.timestamp
25 | }
26 | });
27 |
28 | case actions.SHOW_ERROR:
29 | return Object.assign({}, state, {
30 | error: action.payload
31 | });
32 |
33 | case actions.SET_PIXEL_RATIO:
34 | return Object.assign({}, state, {pixelRatio: action.payload});
35 |
36 | default:
37 | return state;
38 | }
39 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/List/PlaylistsList.js:
--------------------------------------------------------------------------------
1 | const List = require("../../basicUI/List");
2 | const actions = require("../../../../actions");
3 | const PlaylistPanel = require("../../../panels/PlaylistPanel");
4 |
5 | module.exports = class extends List {
6 | constructor(parent, options, store, playlists) {
7 | super(parent, options, playlists, []);
8 | this.store = store;
9 |
10 | this.bindOnItemSelect(this.openPlaylistPanel.bind(this));
11 | }
12 |
13 | showList() {
14 | this.loadingIndicator.stop();
15 |
16 | this.setKeys(this.elements.map(x => x.title));
17 | }
18 |
19 | setElements(elements) {
20 | this.elements = elements;
21 | this.showList();
22 | }
23 |
24 | async afterShowElements() {
25 | this.loadingIndicator.load();
26 |
27 | if(this.elements) {
28 | this.showList();
29 | }
30 | }
31 |
32 | openPlaylistPanel(playlist) {
33 | this.store.dispatch(actions.ui.setCurrentActivity(PlaylistPanel, {playlistUuid: playlist.uuid}));
34 | }
35 | };
--------------------------------------------------------------------------------
/app/backend/Configuration/appConfigTypeChecker.js:
--------------------------------------------------------------------------------
1 | const checkHexColor = x => /^#[0-9A-F]{6}$/i.test(x);
2 | const checkString = x => typeof x === "string";
3 |
4 | module.exports = {
5 | STYLES: {
6 | TEXT_COLOR: x => checkHexColor(x),
7 | PRIMARY_COLOR: x => checkHexColor(x),
8 | SECONDARY_COLOR: x => checkHexColor(x),
9 | ERROR_COLOR: x => checkHexColor(x)
10 | },
11 | INPUT_BAR_ACTIONS: {
12 | PAUSE: x => checkString(x),
13 | RESUME: x => checkString(x),
14 | PLAYLISTS: x => checkString(x),
15 | QUEUE: x => checkString(x),
16 | NEXT: x => checkString(x),
17 | SKIP: x => checkString(x),
18 | SHUFFLE: x => checkString(x),
19 | QUIT: x => checkString(x),
20 | SEARCH: x => checkString(x)
21 | },
22 | SHORTCUTS: {
23 | PLAY_AS_NEXT_BUTTON: x => checkString(x),
24 | PLAY_AS_LAST_BUTTON: x => checkString(x),
25 | LIST_DOWN: x => checkString(x),
26 | LIST_UP: x => checkString(x),
27 | OPEN_INPUT_BAR: x => checkString(x),
28 | PLAY_NEXT_TRACK: x => checkString(x)
29 | }
30 | };
--------------------------------------------------------------------------------
/app/actions/player.js:
--------------------------------------------------------------------------------
1 | const actions = {
2 | SET_CURRENT_TRACK: "SET_CURRENT_TRACK",
3 | SET_NEXT_TRACKS: "SET_NEXT_TRACK",
4 | SET_LAST_TRACKS: "SET_LAST_TRACK",
5 | SET_TRACKS_QUEUE: "SET_TRACKS_QUEUE",
6 | SHUFFLE_TRACKS_QUEUE: "SHUFFLE_TRACKS_QUEUE",
7 | SET_PLAYBACK_STATE: "SET_PLAYBACK_STATE",
8 | SKIP_TRACKS: "SKIP_TRACKS"
9 | };
10 |
11 | exports.actions = actions;
12 |
13 | exports.setCurrentTrack = trackId => ({type: actions.SET_CURRENT_TRACK, payload: trackId});
14 |
15 | exports.setNextTracks = tracksList => ({type: actions.SET_NEXT_TRACKS, payload: tracksList});
16 |
17 | exports.setLastTracks = tracksList => ({type: actions.SET_LAST_TRACKS, payload: tracksList});
18 |
19 | exports.shuffleTracksQueue = () => ({type: actions.SHUFFLE_TRACKS_QUEUE});
20 |
21 | exports.setTracksQueue = queue => ({type: actions.SET_TRACKS_QUEUE, payload: queue});
22 |
23 | exports.setPlaybackState = playbackState => ({type: actions.SET_PLAYBACK_STATE, payload: playbackState});
24 |
25 | exports.skipTracks = amount => ({type: actions.SKIP_TRACKS, payload: amount});
26 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/Image/AlbumImage.js:
--------------------------------------------------------------------------------
1 | const Image = require("../../basicUI/Image");
2 | const errors = require("../../../panels/userInputActions/errors");
3 | const actions = require("../../../../actions");
4 |
5 | module.exports = class extends Image {
6 | constructor(parent, options, store, album) {
7 | super(parent, options);
8 |
9 | this.store = store;
10 | this.album = album;
11 | }
12 |
13 | async downloadImage() {
14 | let artSrc;
15 | if(this.store.getState().basic.tempManager.fileExists(this.album.coverArt)) {
16 | artSrc = this.store.getState().basic.tempManager.getFilePath(this.album.coverArt);
17 | }
18 | else {
19 | let artUrl = "";
20 |
21 | try {
22 | artUrl = await this.store.getState().basic.tidalApi.getAlbumArtUrl(this.album.coverArt);
23 | }
24 | catch (e) {
25 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
26 | }
27 |
28 | artSrc = await this.store.getState().basic.tempManager.writeFile(this.album.coverArt, artUrl);
29 | }
30 |
31 | this.updateElement(artSrc);
32 | }
33 |
34 |
35 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/Image/ArtistImage.js:
--------------------------------------------------------------------------------
1 | const Image = require("../../basicUI/Image");
2 | const errors = require("../../../panels/userInputActions/errors");
3 | const actions = require("../../../../actions");
4 |
5 | module.exports = class extends Image {
6 | constructor(parent, options, store, artist) {
7 | super(parent, options);
8 |
9 | this.store = store;
10 | this.artist = artist;
11 | }
12 |
13 | async downloadImage() {
14 | let artSrc;
15 | if(this.store.getState().basic.tempManager.fileExists(this.artist.picture)) {
16 | artSrc = this.store.getState().basic.tempManager.getFilePath(this.artist.picture);
17 | }
18 | else {
19 | let picture = "";
20 | try {
21 | picture = await this.store.getState().basic.tidalApi.getArtistArtUrl(this.artist.picture);
22 | }
23 | catch (e) {
24 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
25 | }
26 |
27 | artSrc = await this.store.getState().basic.tempManager.writeFile(this.artist.picture, picture);
28 | }
29 |
30 | this.updateElement(artSrc);
31 | }
32 |
33 |
34 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/Image/PlaylistImage.js:
--------------------------------------------------------------------------------
1 | const Image = require("../../basicUI/Image");
2 | const errors = require("../../../panels/userInputActions/errors");
3 | const actions = require("../../../../actions");
4 |
5 | module.exports = class extends Image {
6 | constructor(parent, options, store, playlist) {
7 | super(parent, options);
8 |
9 | this.store = store;
10 | this.playlist = playlist;
11 | }
12 |
13 | async downloadImage() {
14 | let artSrc;
15 | if(this.store.getState().basic.tempManager.fileExists(this.playlist.coverArt)) {
16 | artSrc = this.store.getState().basic.tempManager.getFilePath(this.playlist.coverArt);
17 | }
18 | else {
19 | let artUrl = "";
20 |
21 | try {
22 | artUrl = await this.store.getState().basic.tidalApi.getArtistArtUrl(this.playlist.coverArt);
23 | }
24 | catch (e) {
25 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
26 | }
27 |
28 | artSrc = await this.store.getState().basic.tempManager.writeFile(this.playlist.coverArt, artUrl);
29 | }
30 |
31 | this.updateElement(artSrc);
32 | }
33 |
34 |
35 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/RadioButton.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("../../abstract/BaseElement");
2 | const blessed = require("blessed");
3 | const AppConfiguration = require("../../../backend/Configuration/App");
4 |
5 | module.exports = class extends BaseElement {
6 | constructor(parent, options) {
7 | const appConfiguration = new AppConfiguration().getConfig();
8 |
9 | super(parent, blessed.radiobutton(Object.assign({}, {
10 | mouse: true,
11 | style: {
12 | bg: appConfiguration["STYLES"]["PRIMARY_COLOR"],
13 | focus: {
14 | bg: appConfiguration["STYLES"]["SECONDARY_COLOR"]
15 | }
16 | }
17 | }, options)));
18 |
19 | this.checkSubscribers = [];
20 |
21 | this.element.on("check", this.buttonSelected.bind(this));
22 | }
23 |
24 | buttonSelected() {
25 | if(this.checkSubscribers[0]) {
26 | this.checkSubscribers.map(x => {
27 | if(x instanceof Promise) {
28 | x().then(() => {}).catch(e => {throw e;});
29 | }
30 | else {
31 | x();
32 | }
33 | });
34 | }
35 | }
36 |
37 | bindOnPress(subscriber) {
38 | this.checkSubscribers.push(subscriber);
39 | }
40 |
41 | };
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Jan Okoński
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/Button.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("../../abstract/BaseElement");
2 | const blessed = require("blessed");
3 | const AppConfiguration = require("../../../backend/Configuration/App");
4 |
5 | module.exports = class extends BaseElement {
6 | constructor(parent, options) {
7 | const appConfiguration = new AppConfiguration().getConfig();
8 |
9 | super(parent, blessed.button(Object.assign({}, {
10 | keys: true,
11 | mouse: true,
12 | style: {
13 | bg: appConfiguration["STYLES"]["PRIMARY_COLOR"],
14 | focus: {
15 | bg: appConfiguration["STYLES"]["SECONDARY_COLOR"]
16 | }
17 | }
18 | }, options)));
19 |
20 | this.pressSubscribers = [];
21 |
22 | this.element.on("press", this.buttonPressed.bind(this));
23 | }
24 |
25 | buttonPressed() {
26 | if(this.pressSubscribers[0]) {
27 | this.pressSubscribers.map(x => {
28 | if(x instanceof Promise) {
29 | x().then(() => {}).catch(e => {throw e;});
30 | }
31 | else {
32 | x();
33 | }
34 | });
35 | }
36 | }
37 |
38 | bindOnClick(subscriber) {
39 | this.pressSubscribers.push(subscriber);
40 | }
41 |
42 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | #config with password
9 | config.js
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 | *.pid.lock
16 |
17 | # Directory for instrumented libs generated by jscoverage/JSCover
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 | coverage
22 |
23 | # nyc test coverage
24 | .nyc_output
25 |
26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
27 | .grunt
28 |
29 | # Bower dependency directory (https://bower.io/)
30 | bower_components
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (http://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # Typescript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 |
63 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/TextInputBar.js:
--------------------------------------------------------------------------------
1 | const BaseElement = require("../../abstract/BaseElement");
2 | const blessed = require("blessed");
3 | const AppConfiguration = require("../../../backend/Configuration/App");
4 |
5 | module.exports = class extends BaseElement {
6 | constructor(parent, options) {
7 | const appConfiguration = new AppConfiguration().getConfig();
8 |
9 | super(parent, blessed.textbox(Object.assign({}, {
10 | keys: true,
11 | style: {
12 | bg: appConfiguration["STYLES"]["PRIMARY_COLOR"],
13 | focus: {
14 | bg: appConfiguration["STYLES"]["SECONDARY_COLOR"]
15 | }
16 | }
17 | }, options)));
18 | this.readingInput = false;
19 | this.element.on("cancel", () => this.readingInput = false);
20 | }
21 |
22 | readInput(inputParser) {
23 | this.element.readInput((error, value) => {
24 | inputParser(error, value);
25 | this.readingInput = false;
26 | });
27 | this.readingInput = true;
28 | }
29 |
30 | get value() {
31 | return this.element.value;
32 | }
33 |
34 | setValue(value) {
35 | this.element.setValue(value);
36 | this.render();
37 | }
38 |
39 | clearValue() {
40 | this.element.clearValue();
41 | this.render();
42 | }
43 | };
--------------------------------------------------------------------------------
/app/UI/panels/UserPlaylistsPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const PlaylistsList = require("../uiComponents/specializedUI/List/PlaylistsList");
4 | const errors = require("./userInputActions/errors");
5 | const actions = require("../../actions");
6 |
7 | module.exports = class extends Activity {
8 | constructor(parent, store) {
9 | super(parent, blessed.box({
10 | width: "100%",
11 | height: "95%",
12 | }));
13 | this.store = store;
14 |
15 | this.children = [];
16 | }
17 |
18 | async getPlaylistsList() {
19 | let playlists = [];
20 |
21 | try {
22 | playlists = await this.tidalApi.getUserPlaylists();
23 | }
24 | catch (e) {
25 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
26 | }
27 |
28 | return new PlaylistsList(this, {
29 | width: "100%",
30 | height: "100%",
31 | left: 0
32 | }, this.store, playlists);
33 | }
34 |
35 | get tidalApi() {
36 | return this.store.getState().basic.tidalApi;
37 | }
38 |
39 | async run() {
40 | this.playlistsList = await this.getPlaylistsList();
41 | this.children = [this.playlistsList];
42 | await this.showElements(this.children);
43 | }
44 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/List/AlbumsList.js:
--------------------------------------------------------------------------------
1 | const List = require("../../basicUI/List");
2 | const errors = require("../../../panels/userInputActions/errors");
3 | const actions = require("../../../../actions");
4 | const AlbumPanel = require("../../../panels/AlbumPanel");
5 |
6 | module.exports = class extends List {
7 | constructor(parent, options, store, albums) {
8 | super(parent, options, albums, []);
9 |
10 | this.store = store;
11 |
12 | this.bindOnItemSelect(this.openAlbumPanel.bind(this));
13 | }
14 |
15 | loadArtists() {
16 | Promise.all(this.elements.map(async x => {
17 | let artistsNames = await Promise.all(x.artists.map(async a => (await this.store.getState().basic.tidalApi.getArtist(a)).name));
18 | return x.title + " - " + artistsNames.join(", ");
19 | })).then(keys => {
20 | this.setKeys(keys);
21 | this.loadingIndicator.stop();
22 | }).catch(() => {
23 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
24 | });
25 | }
26 |
27 | setElements(elements) {
28 | this.elements = elements;
29 | this.loadArtists();
30 | }
31 |
32 | async afterShowElements() {
33 | this.loadingIndicator.load();
34 |
35 | if(this.elements) {
36 | this.loadArtists();
37 | }
38 | }
39 |
40 | openAlbumPanel(album) {
41 | this.store.dispatch(actions.ui.setCurrentActivity(AlbumPanel, {albumId: album.id}));
42 | }
43 |
44 | };
--------------------------------------------------------------------------------
/app/UI/panels/ActivityRunners/TopPanel.js:
--------------------------------------------------------------------------------
1 | const ActivityRunner = require("../../abstract/ActivityRunner");
2 | const blessed = require("blessed");
3 | const ActionsInputPanel = require("../userInputActions/ActionsInputPanel");
4 |
5 | module.exports = class extends ActivityRunner {
6 | constructor(parent, store) {
7 | super(parent, blessed.box({
8 | width: "100%",
9 | height: "80%",
10 | top: 0
11 | }));
12 | this.store = store;
13 |
14 | this.currentActivity = undefined;
15 | this.currentActivityTimestamp = undefined;
16 | this.currentActivityArguments = [];
17 | }
18 |
19 | async storeListener() {
20 | await this.currentActivityChangeListener();
21 | }
22 |
23 | async currentActivityChangeListener() {
24 | let storeCurrentActivityTimestamp = this.store.getState().ui.currentActivity.timestamp;
25 | if(storeCurrentActivityTimestamp && this.currentActivityTimestamp !== storeCurrentActivityTimestamp) {
26 | this.currentActivity = this.store.getState().ui.currentActivity.activity;
27 | this.currentActivityTimestamp = storeCurrentActivityTimestamp;
28 | this.currentActivityArguments = this.store.getState().ui.currentActivity.arguments;
29 | await this.showCurrentActivity();
30 | }
31 | }
32 |
33 | async showCurrentActivity() {
34 | await this.showElements([new this.currentActivity(this, this.store, this.currentActivityArguments)]);
35 | }
36 |
37 | async run() {
38 | this.store.subscribe(() => this.storeListener().then());
39 | await this.showElements([new ActionsInputPanel(this, this.store)]);
40 | }
41 | };
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '8'
4 | before_install:
5 | - sudo apt-get update -qq
6 | - sudo apt-get install mpv
7 | script:
8 | - npm run test
9 | env:
10 | global:
11 | - secure: GL7OipQeSqJTDDqRUyIYHHpAWQWe7zx7ghR7DIUf3TqWxexUo7XMxg55ui6BoKVcFU6nZn/4u88631CK/OCLnYR1QO30Km/yKIZbG3pObTLnegtSmRyVBAH8vWoOhy5mcpvDWNHCK23NfYV1yLNgry/bAfVfV7AI2m8zKhlo0v7yLCgDbianNHyGFItaHGL0mETDr+qRDGsXzczPN46YyM4YjWnjxTEkQpqUVhH1SNZ3PxbOydDz57GpcrDPyAlIyLKF73Rqjj4TXFvxOWMLp6RSYw0N4rzL1aTA55T/I+eODGrvxT43CRzL04thNTcmScwNrNkhG9oyJW1aTzFlISC1EL/ZrF4YvgVo+ilgk/zj4GlWLOb8liwwhI7o/oDxJr9aoaz3RpgKuEWp8p0UNR5yDj/g8I7ipdhBaJLbd4mPsB8ZA7DDxgUJ9lWM4APC672uoIIct2PBGm7OXBBZSmCBJf9kdR5JdF4Gw2cQD+LUeUtOfF+tjc81OVryY+0WnbsNfk3aM3UwD40WxoDhuGnV9Hzb6YFFz+4RLeDI6ZQjgjFG2jlzabeiF7J/Zh0S8Tqmq9uMx0DQMa4qUkI8Is5yVWYpBIcUoecNUDR7q8W7JWQbqUxC2TZNSiQPW+kBu5nuYTWfDlfnmAi1WREE3JnE+BZy219tXs1Z2XM/RcI=
12 | - secure: giCurQsCC7jFzmUOHe/nkuC5xJ2OobpMJbVByIAiSukJvi6pQjMs4wndiyABUAnUbUr/BLg8yeTmKyf4ot3GghqOixvxsZtWRd8UsRv5aHUGOOWMX/THpvIQfiiserhVmJdWj0UNVKdkNtxf09yhpMaFrdOVxW30leGbSaSYiRVfAkRUUFPaCf5VgCY36UmFpaJqu0efuneDe1JC3POH7vCFJtHvztpxYGWPWYfi8Z4o7n/P8yJ6s05NR2Yrqq7OmsRAHyC/tSn093KDAo5HkLCXDLX+yD5ivYLhADKTEe6JmD2sTuWEB/nLI3pEyPY2oA2lsID9DTx6hgYIVg6VAAlf49ZTlajuYzAPozcLkM03EO/QSpa4xGYYizfvuokLdKV9pJdOg4M6P/73j3zAbuhtJandVz8EQJ4+VGLyRLvuAFELY5vWVN5JQF4DI3Z8FGkz+kunBt9UWTTBRrnl2a9kxYEnfwOvdG6OgVgSmkhMaRpoEYcXRLUIkeGRRXhrPK/6TYSuFe+GltwNmKevb6dT8eKtrmQsWEF1qc1pZUCM7L7rfvqMFvid0yQ8/J7bmvuYzOdTkKDpvnkWzQJm96IspPlVFzexl6bVnFZ8YW+sD9DuRQ7Mmxk24liPjliErlXiMI2iVr6DAdiLI1NY6aXN7ZQ5R6GbPgxV2ECp/oY=
13 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/PlayTracksButton.js:
--------------------------------------------------------------------------------
1 | const Button = require("../basicUI/Button");
2 | const actions = require("../../../actions");
3 | const AppConfiguration = require("../../../backend/Configuration/App");
4 |
5 | module.exports = class extends Button {
6 | constructor(parent, options, store, tracks) {
7 | super(parent, options);
8 |
9 | this.store = store;
10 | this.appConfiguration = new AppConfiguration().getConfig();
11 | this.tracks = tracks.map(x => x.id);
12 | this.functionOnButtonPress = this.playTracks;
13 |
14 | this.element.key(this.appConfiguration["SHORTCUTS"]["PLAY_AS_NEXT_BUTTON"], function () {
15 | this.functionOnButtonPress = this.playTracksNext;
16 | this.element.press();
17 | }.bind(this));
18 |
19 | this.element.key(this.appConfiguration["SHORTCUTS"]["PLAY_AS_LAST_BUTTON"], function () {
20 | this.functionOnButtonPress = this.playTracksLast;
21 | this.element.press();
22 | }.bind(this));
23 |
24 | this.bindOnClick(function () {
25 | this.functionOnButtonPress();
26 | }.bind(this));
27 | }
28 |
29 | playTracks() {
30 | this.store.dispatch(actions.player.setCurrentTrack(this.tracks[0]));
31 | this.store.dispatch(actions.player.setNextTracks(this.tracks.slice(1)));
32 | }
33 |
34 | playTracksNext() {
35 | this.store.dispatch(actions.player.setNextTracks(this.tracks));
36 | this.functionOnButtonPress = this.playTracks;
37 | }
38 |
39 | playTracksLast() {
40 | this.store.dispatch(actions.player.setLastTracks(this.tracks));
41 | this.functionOnButtonPress = this.playTracks;
42 | }
43 | };
--------------------------------------------------------------------------------
/app/reducers/player.js:
--------------------------------------------------------------------------------
1 | const actions = require("../actions/index").player.actions;
2 | const playbackStates = require("../backend/player/Player").playbackStates;
3 |
4 | const initialState = {
5 | currentTrack: undefined,
6 | tracksQueue: [],
7 | playbackState: playbackStates.PAUSED
8 | };
9 |
10 | module.exports = (state = initialState, action) => {
11 | switch (action.type) {
12 | case actions.SET_CURRENT_TRACK:
13 | return Object.assign({}, state, {currentTrack: action.payload});
14 |
15 | case actions.SET_NEXT_TRACKS:
16 | if(!Array.isArray(action.payload)) {
17 | action.payload = [action.payload];
18 | }
19 |
20 | return {...state, tracksQueue: [...action.payload, ...state.tracksQueue]};
21 |
22 | case actions.SET_LAST_TRACKS:
23 | if(!Array.isArray(action.payload)) {
24 | action.payload = [action.payload];
25 | }
26 |
27 | return {...state, tracksQueue: [...state.tracksQueue, ... action.payload]};
28 |
29 | case actions.SET_TRACKS_QUEUE:
30 | return Object.assign({}, state, {tracksQueue: action.payload});
31 |
32 | case actions.SHUFFLE_TRACKS_QUEUE:
33 | return {...state, tracksQueue: state.tracksQueue.sort(() => Math.random() - .5)};
34 |
35 | case actions.SET_PLAYBACK_STATE:
36 | return Object.assign({}, state, {playbackState: action.payload});
37 |
38 | case actions.SKIP_TRACKS: {
39 | let currentTrack = state.currentTrack;
40 | let tracksQueue = state.tracksQueue;
41 |
42 | for (let i = 0; i < action.payload; i++) {
43 | currentTrack = tracksQueue.shift();
44 | }
45 | return Object.assign({}, state, {currentTrack, tracksQueue});
46 | }
47 |
48 | default:
49 | return state;
50 | }
51 | };
--------------------------------------------------------------------------------
/app/backend/TempManager/TempManager.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs-extra");
2 | const validUrl = require("valid-url");
3 | const https = require("https");
4 | const path = require("path");
5 |
6 | module.exports = class {
7 | constructor(tempDir) {
8 | this.tempDir = tempDir;
9 |
10 | this.createTempDir();
11 | }
12 |
13 | async writeFile(fileName, fileSrc) {
14 | return new Promise((resolve) => {
15 | const pathToWrite = this.getFilePath(fileName, path.extname(fileSrc).split(".").join(""));
16 | if(validUrl.isUri(fileSrc)) {
17 | let file = fs.createWriteStream(pathToWrite);
18 | https.get(fileSrc, response => {
19 | response.pipe(file);
20 | file.on("finish", () => {
21 | resolve(pathToWrite);
22 | });
23 | });
24 | }
25 | else {
26 | let stream = fs.createWriteStream(pathToWrite);
27 | fs.createReadStream(fileSrc).pipe(stream);
28 | stream.on("finish", () => {
29 | resolve(pathToWrite);
30 | });
31 | }
32 | });
33 | }
34 |
35 | getFilePath(fileName, extension = undefined) {
36 | let filenames = fs.readdirSync(this.tempDir);
37 | let searchedFileIndex = filenames.map(x => x.split(".")[0]).indexOf(fileName);
38 |
39 | if(!extension) {
40 | extension = filenames[searchedFileIndex];
41 | }
42 |
43 | return this.tempDir + "/" + fileName + "." + extension;
44 | }
45 |
46 | fileExists(fileName) {
47 | return fs.existsSync(this.getFilePath(fileName));
48 | }
49 |
50 | createTempDir() {
51 | try {
52 | fs.mkdirSync(this.tempDir);
53 | }
54 | catch(err) {
55 | if(err.code !== "EEXIST") throw err;
56 | }
57 | }
58 |
59 | removeTempDir() {
60 | fs.removeSync(this.tempDir);
61 | }
62 | };
--------------------------------------------------------------------------------
/app/UI/panels/userInputActions/actions.js:
--------------------------------------------------------------------------------
1 | const playbackStates = require("../../../backend/player/Player").playbackStates;
2 | const actions = require("../../../actions");
3 | const SearchPanel = require("../SearchPanel");
4 | const errors = require("./errors");
5 | const QueuePanel = require("../QueuePanel");
6 | const UserPlaylistsPanel = require("../UserPlaylistsPanel");
7 | const AppConfiguration = require("../../../backend/Configuration/App");
8 | const appConfiguration = new AppConfiguration().getConfig();
9 |
10 | module.exports = {
11 | loginRequired: {
12 | [appConfiguration["INPUT_BAR_ACTIONS"]["PAUSE"]]: store => store.dispatch(actions.player.setPlaybackState(playbackStates.PAUSED)),
13 | [appConfiguration["INPUT_BAR_ACTIONS"]["RESUME"]]: store => store.dispatch(actions.player.setPlaybackState(playbackStates.PLAYING)),
14 | [appConfiguration["INPUT_BAR_ACTIONS"]["SEARCH"]]: (store, searchValue) => {
15 | if (!searchValue) {
16 | return errors.undefinedSearchValue;
17 | }
18 | store.dispatch(actions.ui.setCurrentActivity(SearchPanel, {searchValue}));
19 | },
20 | [appConfiguration["INPUT_BAR_ACTIONS"]["PLAYLISTS"]]: store => store.dispatch(actions.ui.setCurrentActivity(UserPlaylistsPanel, {})),
21 | [appConfiguration["INPUT_BAR_ACTIONS"]["QUEUE"]]: store => store.dispatch(actions.ui.setCurrentActivity(QueuePanel, {})),
22 | [appConfiguration["INPUT_BAR_ACTIONS"]["NEXT"]]: store => store.dispatch(actions.player.skipTracks(1)),
23 | [appConfiguration["INPUT_BAR_ACTIONS"]["SKIP"]]: (store, amount = 1) => store.dispatch(actions.player.skipTracks(amount)),
24 | [appConfiguration["INPUT_BAR_ACTIONS"]["SHUFFLE"]]: store => store.dispatch(actions.player.shuffleTracksQueue()),
25 | },
26 | loginNotRequired: {
27 | [appConfiguration["INPUT_BAR_ACTIONS"]["QUIT"]]: store => store.dispatch(actions.basic.exit())
28 | }
29 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/Image.js:
--------------------------------------------------------------------------------
1 | const blessed = require("blessed");
2 | const BaseElement = require("../../abstract/BaseElement");
3 | const imageSize = require("image-size");
4 |
5 | module.exports = class extends BaseElement {
6 | constructor(parent, options, imageSrc = undefined) {
7 | super(parent);
8 |
9 | this.options = options;
10 | this.updateElement(imageSrc);
11 | }
12 |
13 | updateElement(imageSrc) {
14 | if(!imageSrc) {
15 | return;
16 | }
17 |
18 | this.imageSrc = imageSrc;
19 |
20 | if(!this.options.pixelRatio) {
21 | this.element = blessed.text(Object.assign({}, this.options, {
22 | width: (this.options.width * 100) + "%",
23 | height: (this.options.height * 100) + "%",
24 | content: "Images are not displayed.\nTo display images install w3m and w3m-img."
25 | }));
26 |
27 | return;
28 | }
29 |
30 | let width;
31 | let height;
32 |
33 | let imageDiemensions = imageSize(this.imageSrc);
34 | let proportions = imageDiemensions.width / imageDiemensions.height;
35 |
36 | let masterParent = this.getMasterParent();
37 |
38 | if(this.options.width) {
39 | width = (this.options.pixelRatio.tw * masterParent.width * this.options.width) / this.options.pixelRatio.tw;
40 | height = (this.options.pixelRatio.tw * masterParent.width * this.options.width) / this.options.pixelRatio.th / proportions;
41 | }
42 | else if(this.options.height) {
43 | width = (this.options.pixelRatio.th * masterParent.height * this.options.height) / this.options.pixelRatio.tw;
44 | height = (this.options.pixelRatio.th * masterParent.height * this.options.height) / this.options.pixelRatio.th / proportions;
45 | }
46 |
47 | this.element = blessed.image(Object.assign({}, this.options, {
48 | type: "overlay",
49 | width,
50 | height,
51 | file: this.imageSrc,
52 | search: true
53 | }));
54 | }
55 | };
--------------------------------------------------------------------------------
/app/UI/abstract/Screen.js:
--------------------------------------------------------------------------------
1 | const blessed = require("blessed");
2 | const BaseElement = require("./BaseElement");
3 | const fs = require("fs");
4 |
5 | module.exports = class Screen extends BaseElement {
6 | constructor() {
7 | super();
8 | this.element = blessed.screen({
9 | smartCSR: true,
10 | grabKeys: false,
11 | });
12 | }
13 |
14 | getScreenPixelRatio(appArguments) {
15 | return new Promise((resolve, reject) => {
16 | const img = blessed.image({
17 | parent: this.screen,
18 | type: "overlay"
19 | });
20 |
21 | if(!(Screen.findFile("/usr", "w3mimgdisplay") || Screen.findFile("/lib", "w3mimgdisplay") || Screen.findFile("/bin", "w3mimgdisplay")) || appArguments.includes("--no-images")) {
22 | resolve(undefined);
23 | }
24 |
25 | img.getPixelRatio((error, ratio) => {
26 | if(error) {
27 | reject(error);
28 | }
29 | else {
30 | resolve(ratio);
31 | }
32 | });
33 | });
34 | }
35 |
36 | static findFile (start, target) {
37 | return (function read(dir) {
38 | let files, file, stat, out;
39 |
40 | if (dir === "/dev" || dir === "/sys"
41 | || dir === "/proc" || dir === "/net") {
42 | return null;
43 | }
44 |
45 | try {
46 | files = fs.readdirSync(dir);
47 | } catch (e) {
48 | files = [];
49 | }
50 |
51 | for (let i = 0; i < files.length; i++) {
52 | file = files[i];
53 |
54 | if (file === target) {
55 | return (dir === "/" ? "" : dir) + "/" + file;
56 | }
57 |
58 | try {
59 | stat = fs.lstatSync((dir === "/" ? "" : dir) + "/" + file);
60 | } catch (e) {
61 | stat = null;
62 | }
63 |
64 | if (stat && stat.isDirectory() && !stat.isSymbolicLink()) {
65 | out = read((dir === "/" ? "" : dir) + "/" + file);
66 | if (out) return out;
67 | }
68 | }
69 |
70 | return null;
71 | })(start);
72 | }
73 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tidal-cli-client",
3 | "version": "2.0.6",
4 | "description": "You can now use Tidal on linux. With your loved CLI. <3",
5 | "main": "app/index.js",
6 | "scripts": {
7 | "app": "node app/index.js",
8 | "test": "mocha tests --recursive --timeout 5000",
9 | "lint": "eslint app --fix",
10 | "predeploy": "npm run lint && npm run test",
11 | "deploy": "npm publish"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/okonek/tidal-cli-client.git"
16 | },
17 | "keywords": [
18 | "tidal",
19 | "music",
20 | "cli",
21 | "linux"
22 | ],
23 | "bin": {
24 | "tidal-cli": "app/index.js"
25 | },
26 | "author": "okonek83 ",
27 | "license": "MIT",
28 | "bugs": {
29 | "url": "https://github.com/okonek/tidal-cli-client/issues"
30 | },
31 | "homepage": "https://github.com/okonek/tidal-cli-client#readme",
32 | "dependencies": {
33 | "blessed": "^0.1.81",
34 | "clipboardy": "^1.2.3",
35 | "fs-extra": "^7.0.0",
36 | "image-size": "^0.6.3",
37 | "keys-diff": "^1.0.7",
38 | "mkdirp": "^0.5.1",
39 | "node-cache-promise": "^1.0.0",
40 | "node-fetch": "^2.2.0",
41 | "node-mpv": "^1.4.2",
42 | "raven": "^2.5.0",
43 | "redux": "^4.0.0",
44 | "shelljs": "^0.8.2",
45 | "tidal-api-wrapper-okonek": "^1.8.4",
46 | "valid-url": "^1.0.9"
47 | },
48 | "devDependencies": {
49 | "babel-cli": "^6.26.0",
50 | "babel-core": "^6.26.3",
51 | "babel-eslint": "^9.0.0",
52 | "chai": "^4.1.2",
53 | "chai-fs": "^2.0.0",
54 | "eslint": "^5.4.0",
55 | "mocha": "^5.2.0",
56 | "then-request": "^6.0.0"
57 | },
58 | "node_deb": {
59 | "init": "none",
60 | "dependencies": "mpv, w3m",
61 | "entrypoints": {
62 | "cli": "app/index.js"
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/TrackInfoText.js:
--------------------------------------------------------------------------------
1 | const Text = require("../basicUI/Text");
2 | const playbackStates = require("../../../backend/player/Player").playbackStates;
3 | const errors = require("../../panels/userInputActions/errors");
4 | const actions = require("../../../actions");
5 |
6 | module.exports = class extends Text {
7 | constructor(parent, options, store) {
8 | super(parent, options);
9 |
10 | this.playbackState = playbackStates.PAUSED;
11 | this.currentTrack = undefined;
12 | this.store = store;
13 | this.store.subscribe(() => this.storeListener().then());
14 | this.updateContent();
15 | }
16 |
17 | updateContent() {
18 | if(this.playbackState === playbackStates.LOADING) {
19 | this.setContent("Loading...");
20 | }
21 | else {
22 | this.setContent((this.currentTrack ? this.currentTrack.title + " - " + this.playbackState : "No track"));
23 | }
24 | }
25 |
26 | async storeListener() {
27 | this.playbackStateChangeListener();
28 | await this.trackChangeListener();
29 | }
30 |
31 | async trackChangeListener() {
32 | let storeCurrentTrackId = this.store.getState().player.currentTrack;
33 | if(storeCurrentTrackId && ((!this.currentTrack) || (storeCurrentTrackId !== this.currentTrack))) {
34 | try {
35 | this.currentTrack = await this.tidalApi.getTrack(storeCurrentTrackId);
36 | }
37 | catch (e) {
38 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
39 | }
40 | this.updateContent();
41 | }
42 | }
43 |
44 | playbackStateChangeListener() {
45 | let storeCurrentPlaybackState = this.store.getState().player.playbackState;
46 | if(storeCurrentPlaybackState && this.playbackState !== storeCurrentPlaybackState) {
47 | this.playbackState = this.store.getState().player.playbackState;
48 | this.updateContent();
49 | }
50 | }
51 |
52 | get tidalApi() {
53 | return this.store.getState().basic.tidalApi;
54 | }
55 |
56 | };
--------------------------------------------------------------------------------
/app/UI/MainScreen.js:
--------------------------------------------------------------------------------
1 | const Screen = require("./abstract/Screen");
2 | const TopPanel = require("./panels/ActivityRunners/TopPanel");
3 | const BottomPanel = require("./panels/ActivityRunners/BottomPanel");
4 | const createStore = require("redux").createStore;
5 | const reducers = require("../reducers");
6 | const actions = require("../actions");
7 | const TidalApi = require("../backend/api/ApiWrapper");
8 | const TempManager = require("../backend/TempManager/TempManager");
9 | const SigninPanel = require("./panels/SigninPanel");
10 | const childProcess = require("child_process");
11 |
12 | module.exports = class extends Screen {
13 | constructor(appArguments) {
14 | super();
15 | this.store = createStore(reducers);
16 | this.tidalApi = new TidalApi();
17 | this.tempManager = new TempManager("/tmp/tidal-cli-client");
18 |
19 | this.getScreenPixelRatio(appArguments).then(values => {
20 | this.pixelRatio = values;
21 | this.store.dispatch(actions.basic.setTempManager(this.tempManager));
22 | this.store.dispatch(actions.ui.setPixelRatio(this.pixelRatio));
23 | this.store.dispatch(actions.basic.setTidalApi(this.tidalApi));
24 | this.run().then();
25 | });
26 | }
27 |
28 | async exitValueChangeListener() {
29 | const storeCurrentExitValue = this.store.getState().basic.exit;
30 | if(storeCurrentExitValue) {
31 | this.exitApp();
32 | }
33 | }
34 |
35 | async storeListener() {
36 | await this.exitValueChangeListener();
37 | }
38 |
39 | exitApp() {
40 | //TODO: IMPROVE MPV SESSION EXITING METHOD
41 | childProcess.exec("pkill -9 mpv");
42 | process.exit(0);
43 | }
44 |
45 | async run() {
46 | this.store.subscribe(() => this.storeListener().then(() => {}));
47 |
48 | this.children = [new TopPanel(this, this.store), new BottomPanel(this, this.store)];
49 | await this.showElements(this.children);
50 |
51 | this.store.dispatch(actions.ui.setCurrentActivity(SigninPanel, {}));
52 | }
53 |
54 | };
--------------------------------------------------------------------------------
/app/UI/panels/SearchPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const actions = require("../../actions");
4 | const List = require("../uiComponents/basicUI/List");
5 | const ApiInterface = require("../../backend/api/ApiInterface");
6 | const listTypes = require("../uiComponents/specializedUI/List/listTypes");
7 | const errors = require("./userInputActions/errors");
8 |
9 | module.exports = class extends Activity {
10 | constructor(parent, store, options) {
11 | super(parent, blessed.box({
12 | width: "shrink",
13 | height: "shrink",
14 | }));
15 | this.store = store;
16 |
17 | this.searchValue = options.searchValue;
18 | this.searchTypesList = this.getSearchTypesList();
19 | this.children = [this.searchTypesList];
20 | }
21 |
22 | get tidalApi() {
23 | return this.store.getState().basic.tidalApi;
24 | }
25 |
26 | getSearchTypesList() {
27 | let searchTypesList = new List(this, {
28 | width: "100%",
29 | height: "95%"
30 | }, Object.values(ApiInterface.SEARCH_TYPES), Object.keys(ApiInterface.SEARCH_TYPES));
31 |
32 | searchTypesList.bindOnItemSelect(this.searchTypesListElementSelected.bind(this));
33 |
34 | return searchTypesList;
35 | }
36 |
37 | getResultsList(type, elements) {
38 | let listType = listTypes(type);
39 |
40 | return new listType(this, {
41 | width: "100%",
42 | height: "95%"
43 | }, this.store, elements);
44 | }
45 |
46 | async searchTypesListElementSelected(element) {
47 | let resultsList = this.getResultsList(element);
48 | await this.showElements([resultsList]);
49 | let searchResults = [];
50 | try {
51 | searchResults = await this.tidalApi.search(this.searchValue, element);
52 | }
53 | catch (e) {
54 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
55 | }
56 | resultsList.setElements(searchResults);
57 | }
58 |
59 | async run() {
60 | await this.showElements(this.children);
61 | }
62 | };
--------------------------------------------------------------------------------
/app/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 |
4 | const raven = require("raven");
5 | const packageJson = require("../package.json");
6 | const AppConfiguration = require("./backend/Configuration/App");
7 | const shell = require("shelljs");
8 | const readline = require("readline");
9 | const Screen = require("./UI/abstract/Screen");
10 | let mainScreen;
11 |
12 | raven.config("https://efdf3446813b44adab06794b2c031c6c@sentry.io/1189461", {
13 | captureUnhandledRejections: true,
14 | release: packageJson.version
15 | }).install((err, sendErr, eventId) => {
16 | if(mainScreen) mainScreen.element.destroy();
17 |
18 | if (!sendErr) {
19 | console.log("Successfully sent fatal error with eventId " + eventId + " to the developer");
20 | console.error(err.stack);
21 | }
22 | console.log("Paste this eventId in the Github issue, so I can help you faster");
23 | process.exit(1);
24 | });
25 |
26 |
27 | const startApp = () => {
28 | const appConfiguration = new AppConfiguration();
29 | appConfiguration.prepareConfigFile();
30 |
31 | const appArguments = [];
32 |
33 | if(process.argv.includes("--no-images")) {
34 | appArguments.push("--no-images");
35 | }
36 |
37 | const MainScreen = require("./UI/MainScreen");
38 | mainScreen = new MainScreen(appArguments);
39 | };
40 |
41 | if(!shell.which("mpv")) {
42 | shell.exec(__dirname + "/installDependencies.sh");
43 | startApp();
44 | }
45 | else if(!(Screen.findFile("/usr", "w3mimgdisplay") || Screen.findFile("/lib", "w3mimgdisplay") || Screen.findFile("/bin", "w3mimgdisplay"))) {
46 | const inputInterface = readline.createInterface({
47 | input: process.stdin,
48 | output: process.stdout,
49 | terminal: false
50 | });
51 |
52 | inputInterface.question("Would you like to install w3m-img to display images in app? (y/n): ", answer => {
53 | inputInterface.close();
54 |
55 | if(answer === "y" || answer === "Y") {
56 | shell.exec(__dirname + "/installDependencies.sh");
57 | }
58 | startApp();
59 | });
60 | }
61 | else {
62 | startApp();
63 | }
--------------------------------------------------------------------------------
/tests/backend/TempManager.js:
--------------------------------------------------------------------------------
1 | const chai = require("chai");
2 | const chaiFs = require("chai-fs");
3 | chai.use(chaiFs);
4 |
5 | const expect = chai.expect;
6 | const request = require("then-request");
7 | const fs = require("fs");
8 | const TempManager = require("../../app/backend/TempManager/TempManager");
9 | let manager;
10 | const tempDir = "/tmp/tidal-cli-client-tests";
11 | const testFileName = "test";
12 | const testFileSrc = __dirname + "/../testFile.txt";
13 | const testFileContent = fs.readFileSync(testFileSrc, "utf8");
14 | const fileExtension = "txt";
15 |
16 | const onlineTestFileName = "online_test";
17 | const onlineTestFileUrl = "https://www.w3.org/TR/PNG/iso_8859-1.txt";
18 |
19 | describe("TempManager", async () => {
20 |
21 | beforeEach(() => {
22 | manager = new TempManager(tempDir);
23 | });
24 |
25 | describe("writeFile()", async () => {
26 | it("writeFile() should write a file to a temp directory with passed name and return the written file src if existing file is passed", async () => {
27 | let fileSrc = await manager.writeFile(testFileName, testFileSrc);
28 |
29 | expect(fileSrc).to.be.a.file().with.content(testFileContent);
30 | });
31 |
32 | it("writeFile() should write a file to a temp directory with passed name and return the written file src if existing file from url is passed", async () => {
33 | const onlineTestFileContent = await request("GET", onlineTestFileUrl).getBody("utf8");
34 | let fileSrc = await manager.writeFile(onlineTestFileName, onlineTestFileUrl);
35 |
36 | expect(fileSrc).to.be.a.file().with.content(onlineTestFileContent);
37 | });
38 | });
39 |
40 | describe("getFilePath()", async () => {
41 | it("getFilePath() should return a file path in temp dir with the name and extension passed", async () => {
42 | let fileSrc = manager.getFilePath(testFileName, fileExtension);
43 |
44 | expect(fileSrc).to.be.a("string");
45 | expect(fileSrc).to.equal(tempDir + "/" + testFileName + "." + fileExtension);
46 | manager.removeTempDir();
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/app/backend/Configuration/abstract/Configuration.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const mkdirp = require("mkdirp");
4 | const keysDiff = require("keys-diff");
5 |
6 | module.exports = class {
7 | constructor(configurationFileLocation) {
8 | this.configurationFileLocation = configurationFileLocation;
9 | this.config = {};
10 | }
11 |
12 | set(key, value) {
13 | this.config[key] = value;
14 | }
15 |
16 | get exists() {
17 | return fs.existsSync(this.configurationFileLocation);
18 | }
19 |
20 | updateConfig(config = this.config) {
21 | mkdirp.sync(path.dirname(this.configurationFileLocation));
22 | fs.writeFileSync(this.configurationFileLocation, JSON.stringify({...this.getConfig(), ...config}), "utf8");
23 | }
24 |
25 | writeConfig(config = this.config) {
26 | mkdirp.sync(path.dirname(this.configurationFileLocation));
27 | fs.writeFileSync(this.configurationFileLocation, JSON.stringify(config), "utf8");
28 | }
29 |
30 | writeBackupFile(config = this.config) {
31 | mkdirp.sync(path.dirname(this.configurationFileLocation));
32 | fs.writeFileSync(this.configurationFileLocation + ".bak", JSON.stringify(config), "utf8");
33 | }
34 |
35 | checkConfig(config) {
36 | if(this.defaultConfig) {
37 | const configDifference = keysDiff(config, this.defaultConfig);
38 |
39 | if(configDifference[0].length > 0 || configDifference[1].length > 0) {
40 | this.writeConfig(this.defaultConfig);
41 | this.writeBackupFile(this.defaultConfig);
42 | throw new Error("Your config file is invalid. New, correct file has been generated and the old is now under the name " + this.configurationFileLocation + ".bak");
43 | }
44 | }
45 | if(this.typeChecker) {
46 | const checkObject = (checker, object) => Object.keys(checker).map(x => Object.keys(checker[x]).length > 0 ? checkObject(checker[x], object[x]) : checker[x](object[x]));
47 | const deepFlatten = a => a.reduce((acc, val) => Array.isArray(val) ? acc.concat(deepFlatten(val)) : acc.concat(val), []);
48 |
49 | if(!deepFlatten(checkObject(this.typeChecker, config)).every(x => x)) {
50 | throw new Error("YO");
51 | }
52 | }
53 | }
54 |
55 | getConfig() {
56 | let config = {};
57 |
58 | if(fs.existsSync(this.configurationFileLocation)) {
59 | config = JSON.parse(fs.readFileSync(this.configurationFileLocation, "utf8"));
60 | this.checkConfig(config);
61 | }
62 |
63 | return config;
64 | }
65 |
66 |
67 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/basicUI/List.js:
--------------------------------------------------------------------------------
1 | const blessed = require("blessed");
2 | const BaseElement = require("../../abstract/BaseElement");
3 | const LoadingIndicator = require("../basicUI/LoadingIndicator");
4 | const AppConfiguration = require("../../../backend/Configuration/App");
5 |
6 | module.exports = class extends BaseElement {
7 | constructor(parent, options, elements, keys) {
8 | const appConfiguration = new AppConfiguration().getConfig();
9 |
10 | super(parent, blessed.list(Object.assign({}, {
11 | keys: true,
12 | mouse: true,
13 | style: {
14 | fg: appConfiguration["STYLES"]["TEXT_COLOR"],
15 | focus: {
16 | selected: {
17 | bg: appConfiguration["STYLES"]["SECONDARY_COLOR"]
18 | }
19 | }
20 | },
21 | items: keys
22 | }, options)));
23 |
24 | this.options = options;
25 | this.elements = elements;
26 | this.keys = keys;
27 | this.onItemSelect = [];
28 | this.noItemsText = "No items";
29 |
30 | this.element.focus();
31 |
32 | this.element.on("select", this.keySelected.bind(this));
33 | this.element.key(appConfiguration["SHORTCUTS"]["LIST_UP"], function () {
34 | this.up();
35 | }.bind(this));
36 | this.element.key(appConfiguration["SHORTCUTS"]["LIST_DOWN"], function () {
37 | this.down();
38 | }.bind(this));
39 | }
40 |
41 | up(amount = 1) {
42 | this.element.up(amount);
43 | this.render();
44 | }
45 |
46 | down(amount = 1) {
47 | this.element.down(amount);
48 | this.render();
49 | }
50 |
51 | get selected() {
52 | return this.element.selected;
53 | }
54 |
55 | setKeys(keys) {
56 | if(keys.length === 0) {
57 | this.setContent(this.noItemsText);
58 | }
59 | else {
60 | this.element.setItems(keys);
61 | this.render();
62 | }
63 | }
64 |
65 | bindOnItemSelect(onItemSelect) {
66 | this.onItemSelect.push(onItemSelect);
67 | }
68 |
69 | pick(callback) {
70 | this.element.pick(callback);
71 | }
72 |
73 | keySelected(element, index) {
74 | if(this.onItemSelect[0]) {
75 | this.onItemSelect.map(x => {
76 | if(x instanceof Promise) {
77 | x(this.elements[index]).then(() => {});
78 | }
79 | else {
80 | x(this.elements[index]);
81 | }
82 | });
83 | }
84 | }
85 |
86 | async run() {
87 | this.loadingIndicator = new LoadingIndicator(this, {
88 | left: "50%",
89 | top: "30%"
90 | });
91 | this.children = [this.loadingIndicator];
92 | await this.showElements(this.children);
93 | }
94 |
95 | };
--------------------------------------------------------------------------------
/app/UI/panels/HomePanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const Text = require("../uiComponents/basicUI/Text");
4 | const Button = require("../uiComponents/basicUI/Button");
5 | const clipboardy = require("clipboardy");
6 |
7 | module.exports = class extends Activity {
8 | constructor(parent, store) {
9 | super(parent, blessed.box({
10 | width: "100%",
11 | height: "95%",
12 | }));
13 | this.store = store;
14 |
15 | this.paypalLink = "https://goo.gl/m2HsD6";
16 | this.bitcoinAddress = "1FJqNsijJpctJwsFB4LPhf7KEKNYVb1Mcd";
17 | this.donationInfoText = "If you liked this app and want to help me with the development, you can donate to me in Bitcoin or with PayPal\n\n" +
18 | "My Bitcoin address is: " + this.bitcoinAddress + "\n\n" +
19 | "My PayPal donations link: " + this.paypalLink;
20 | this.paypalLinkCopyButtonText = "Copy PayPal link";
21 | this.bitcoinAddressCopyButtonText = "Copy Bitcoin Address";
22 | this.children = [];
23 | }
24 |
25 | getDonationInfoTextBox() {
26 | return new Text(this, {
27 | width: "50%",
28 | top: "5%",
29 | left: "3%",
30 | fg: "#2cff4a"
31 | }, this.donationInfoText);
32 | }
33 |
34 | getPaypalLinkCopyButton() {
35 | let paypalLinkCopyButton = new Button(this, {
36 | top: "5%",
37 | left: "97%-" + this.element.strWidth(this.paypalLinkCopyButtonText),
38 | shrink: true
39 | });
40 | paypalLinkCopyButton.setContent(this.paypalLinkCopyButtonText);
41 | paypalLinkCopyButton.bindOnClick(() => {
42 | clipboardy.writeSync(this.paypalLink);
43 | });
44 |
45 | return paypalLinkCopyButton;
46 | }
47 |
48 | getBitcoinAddressCopyButton() {
49 | let bitcoinAddressCopyButton = new Button(this, {
50 | top: "5%+2",
51 | left: "97%-" + this.element.strWidth(this.bitcoinAddressCopyButtonText),
52 | shrink: true
53 | });
54 | bitcoinAddressCopyButton.setContent(this.bitcoinAddressCopyButtonText);
55 | bitcoinAddressCopyButton.bindOnClick(() => {
56 | clipboardy.writeSync(this.bitcoinAddress);
57 | });
58 |
59 | return bitcoinAddressCopyButton;
60 | }
61 |
62 | async run() {
63 | this.donationInfoTextBox = this.getDonationInfoTextBox();
64 | this.paypalLinkCopyButton = this.getPaypalLinkCopyButton();
65 | this.bitcoinLinkCopyButton = this.getBitcoinAddressCopyButton();
66 |
67 | this.children = [this.donationInfoTextBox, this.paypalLinkCopyButton, this.bitcoinLinkCopyButton];
68 |
69 | await this.showElements(this.children);
70 | this.paypalLinkCopyButton.focus();
71 | }
72 | };
--------------------------------------------------------------------------------
/app/UI/panels/QueuePanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const actions = require("../../actions");
4 | const TracksList = require("../uiComponents/specializedUI/List/TracksList");
5 | const errors = require("./userInputActions/errors");
6 |
7 | module.exports = class extends Activity {
8 | constructor(parent, store) {
9 | super(parent, blessed.box({
10 | width: "shrink",
11 | height: "shrink",
12 | }));
13 | this.store = store;
14 |
15 | this.children = [this.searchTypesList];
16 | this.tracksQueue = [];
17 | }
18 |
19 | get tidalApi() {
20 | return this.store.getState().basic.tidalApi;
21 | }
22 |
23 | getQueueList() {
24 | return new TracksList(this, {
25 | width: "100%",
26 | height: "95%"
27 | }, this.store, this.tracksQueue);
28 | }
29 |
30 | async tracksQueueChangeListener() {
31 | let storeCurrentTracksQueue = this.store.getState().player.tracksQueue;
32 | if(storeCurrentTracksQueue && (storeCurrentTracksQueue.length !== this.tracksQueue.length || !storeCurrentTracksQueue.every((x, i) => x === this.tracksQueue[i].id))) {
33 | this.tracksQueue = await this.getTracksQueue();
34 | this.queueList.setElements(this.tracksQueue);
35 | }
36 | }
37 |
38 | async getTracksQueue() {
39 | if(this.tracksQueue.length > 0) {
40 | const tracks = this.store.getState().player.tracksQueue.map(x => this.tracksQueue.find(a => a.id === x));
41 |
42 | return await Promise.all(tracks.map(async (x, i) => x === undefined ? await this.tidalApi.getTrack(this.store.getState().player.tracksQueue[i]) : x));
43 | }
44 | else {
45 | return await Promise.all(this.store.getState().player.tracksQueue.map(async x => await this.tidalApi.getTrack(x)));
46 | }
47 | }
48 |
49 | async storeListener() {
50 | await this.tracksQueueChangeListener();
51 | }
52 |
53 | shiftSelectedQueueItem(amount) {
54 | const from = this.queueList.selected;
55 | const to = this.queueList.selected + amount;
56 |
57 | let shiftedTracksQueue = this.tracksQueue.map(x => x.id);
58 | shiftedTracksQueue.splice(to, 0, shiftedTracksQueue.splice(from, 1)[0]);
59 | this.store.dispatch(actions.player.setTracksQueue(shiftedTracksQueue));
60 | }
61 |
62 | async run() {
63 | this.store.subscribe(() => this.storeListener().then(() => {}));
64 |
65 | try {
66 | this.tracksQueue = await this.getTracksQueue();
67 | }
68 | catch (e) {
69 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
70 | }
71 |
72 | this.queueList = this.getQueueList();
73 | this.children = [this.queueList];
74 | await this.showElements(this.children);
75 |
76 | this.queueList.focus();
77 |
78 | this.queueList.element.key("w", function () {
79 | this.shiftSelectedQueueItem(-1);
80 | }.bind(this));
81 |
82 | this.queueList.element.key("s", function () {
83 | this.shiftSelectedQueueItem(1);
84 | }.bind(this));
85 | }
86 | };
--------------------------------------------------------------------------------
/app/UI/abstract/BaseElement.js:
--------------------------------------------------------------------------------
1 | module.exports = class {
2 | constructor(parent = undefined, element = undefined) {
3 | this.element = element;
4 | this.parent = parent;
5 | this.currentFocusedItemIndex = 0;
6 | }
7 |
8 | append(baseElement) {
9 | this.element.append(baseElement.element);
10 | }
11 |
12 | setContent(content) {
13 | this.element.setContent(content);
14 | this.render();
15 | }
16 |
17 | getMasterParent() {
18 | if(this.parent) {
19 | return this.parent.getMasterParent();
20 | }
21 | else {
22 | return this.element;
23 | }
24 | }
25 |
26 | show() {
27 | this.element.show();
28 | this.render();
29 | }
30 |
31 | focus() {
32 | this.element.focus();
33 | if(this.children && this.children.filter(x => !x.element.hidden).length > 0) {
34 | this.currentFocusedItemIndex = 0;
35 | this.children.filter(x => !x.element.hidden)[this.currentFocusedItemIndex].focus();
36 | }
37 | else {
38 | const focusedIndex = this.parent.children.findIndex(x => x.element.focused);
39 | this.getMasterParent().debug(focusedIndex);
40 | this.parent.currentFocusedItemIndex = focusedIndex > -1 ? focusedIndex : this.parent.currentFocusedItemIndex;
41 | }
42 | this.render();
43 | }
44 |
45 | hide() {
46 | this.element.hide();
47 | this.render();
48 | }
49 |
50 | getRelativeDiemensions() {
51 | if(this.parent) {
52 | let currentItem = this.parent;
53 | return {
54 | height: this.element.height / currentItem.element.height * currentItem.getRelativeDiemensions().height,
55 | width: this.element.width / currentItem.element.width * currentItem.getRelativeDiemensions().width
56 | };
57 | }
58 | else {
59 | return {
60 | width: 1,
61 | height: 1
62 | };
63 | }
64 | }
65 |
66 | render() {
67 | if(this.parent) {
68 | this.parent.render();
69 | }
70 | else {
71 | this.element.render();
72 | }
73 | }
74 |
75 | async afterShowElements() {
76 | if(!this.children || this.children.length < 2) {
77 | return;
78 | }
79 | this.children.map((x, i, a) => {
80 | x.element.key("tab", () => i === a.length - 1 ? this.parent.focusNext() : this.focusNext());
81 | });
82 | }
83 |
84 | focusNext() {
85 | this.currentFocusedItemIndex++;
86 |
87 | if(this.currentFocusedItemIndex === this.children.length) {
88 | this.currentFocusedItemIndex = 0;
89 | }
90 |
91 | this.getMasterParent().debug("dsdsd " + this.currentFocusedItemIndex);
92 | this.children[this.currentFocusedItemIndex].focus();
93 | this.getMasterParent().debug("sdsdd " + this.children.findIndex(x => x.element.focused));
94 | }
95 |
96 | async showElements(elements) {
97 | if(!elements) return;
98 | elements.map(async x => {
99 | this.append(x);
100 | if(x.run) await x.run();
101 | });
102 |
103 | this.render();
104 | if(this.afterShowElements) await this.afterShowElements();
105 | }
106 | };
--------------------------------------------------------------------------------
/app/UI/panels/AlbumPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const actions = require("../../actions");
4 | const AlbumImage = require("../uiComponents/specializedUI/Image/AlbumImage");
5 | const TracksList = require("../uiComponents/specializedUI/List/TracksList");
6 | const Text = require("../uiComponents/basicUI/Text");
7 | const PlayTracksButton = require("../uiComponents/specializedUI/PlayTracksButton");
8 | const errors = require("./userInputActions/errors");
9 |
10 | module.exports = class AlbumPanel extends Activity {
11 | constructor(parent, store, options) {
12 | super(parent, blessed.box({
13 | width: "100%",
14 | height: "95%",
15 | }));
16 | this.store = store;
17 |
18 | this.playTracksButtonContent = "Play album";
19 | this.albumId = options.albumId;
20 | this.tracks = [];
21 | this.children = [];
22 | }
23 |
24 | getAlbumTitleBox() {
25 | return new Text(this, {
26 | width: "15%",
27 | height: "100%",
28 | left: 0
29 | }, this.album.title);
30 | }
31 |
32 | async getTracksList() {
33 | return new TracksList(this, {
34 | width: "50%",
35 | height: "100%",
36 | left: "30%"
37 | }, this.store, this.tracks);
38 | }
39 |
40 | async getAlbumImage() {
41 | let imageElementOptions = {
42 | width: this.getRelativeDiemensions().width * 0.2,
43 | right: 0,
44 | pixelRatio: this.store.getState().ui.pixelRatio
45 | };
46 |
47 | const albumImage = new AlbumImage(this, imageElementOptions, this.store, this.album);
48 | await albumImage.downloadImage();
49 | return albumImage;
50 | }
51 |
52 | getPlayAlbumButton() {
53 | let tracksButtonElementOptions = {
54 | width: "10%",
55 | height: "shrink",
56 | left: "15%"
57 | };
58 |
59 | const playTracksButton = new PlayTracksButton(this, tracksButtonElementOptions, this.store, this.tracks);
60 | playTracksButton.setContent(this.playTracksButtonContent);
61 |
62 | return playTracksButton;
63 | }
64 |
65 | get tidalApi() {
66 | return this.store.getState().basic.tidalApi;
67 | }
68 |
69 | async activityChangeListener() {
70 | let storeCurrentActivity = this.store.getState().ui.currentActivity.activity;
71 | if(storeCurrentActivity !== AlbumPanel) {
72 | this.albumImage.hide();
73 | }
74 | }
75 |
76 | async run() {
77 | try {
78 | this.album = await this.tidalApi.getAlbum(this.albumId);
79 | this.tracks = await this.tidalApi.getAlbumTracks(this.album);
80 | }
81 | catch (e) {
82 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
83 | }
84 |
85 | this.albumImage = await this.getAlbumImage();
86 | this.albumTitleBox = this.getAlbumTitleBox();
87 | this.tracksList = await this.getTracksList();
88 | this.playAlbumButton = this.getPlayAlbumButton();
89 |
90 | this.children = [this.albumTitleBox, this.playAlbumButton, this.tracksList, this.albumImage];
91 |
92 | await this.showElements(this.children);
93 |
94 | this.store.subscribe(async () => await this.activityChangeListener());
95 |
96 | }
97 | };
--------------------------------------------------------------------------------
/app/UI/panels/PlaylistPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const PlaylistImage = require("../uiComponents/specializedUI/Image/PlaylistImage");
4 | const TracksList = require("../uiComponents/specializedUI/List/TracksList");
5 | const Text = require("../uiComponents/basicUI/Text");
6 | const PlayTracksButton = require("../uiComponents/specializedUI/PlayTracksButton");
7 | const errors = require("./userInputActions/errors");
8 | const actions = require("../../actions");
9 |
10 | module.exports = class PlaylistPanel extends Activity {
11 | constructor(parent, store, options) {
12 | super(parent, blessed.box({
13 | width: "100%",
14 | height: "95%",
15 | }));
16 | this.store = store;
17 |
18 | this.playTracksButtonContent = "Play playlist";
19 | this.playlistUuid = options.playlistUuid;
20 | this.tracks = [];
21 | this.children = [];
22 | }
23 |
24 | getPlaylistTitleBox() {
25 | return new Text(this, {
26 | width: "15%",
27 | height: "100%",
28 | left: 0
29 | }, this.playlist.title);
30 | }
31 |
32 | async getTracksList() {
33 | return new TracksList(this, {
34 | width: "50%",
35 | height: "100%",
36 | left: "30%"
37 | }, this.store, this.tracks);
38 | }
39 |
40 | async getPlaylistImage() {
41 | let imageElementOptions = {
42 | width: this.getRelativeDiemensions().width * 0.2,
43 | right: 0,
44 | pixelRatio: this.store.getState().ui.pixelRatio
45 | };
46 |
47 | let playlistImage = new PlaylistImage(this, imageElementOptions, this.store, this.playlist);
48 | await playlistImage.downloadImage();
49 | return playlistImage;
50 | }
51 |
52 | getPlayPlaylistButton() {
53 | let tracksButtonElementOptions = {
54 | width: "10%",
55 | height: "shrink",
56 | left: "15%",
57 | };
58 |
59 | const playTracksButton = new PlayTracksButton(this, tracksButtonElementOptions, this.store, this.tracks);
60 | playTracksButton.setContent(this.playTracksButtonContent);
61 |
62 | return playTracksButton;
63 | }
64 |
65 | get tidalApi() {
66 | return this.store.getState().basic.tidalApi;
67 | }
68 |
69 | async activityChangeListener() {
70 | let storeCurrentActivity = this.store.getState().ui.currentActivity.activity;
71 | if(storeCurrentActivity !== PlaylistPanel) {
72 | this.playlistImage.hide();
73 | }
74 | }
75 |
76 | async run() {
77 | try {
78 | this.playlist = await this.tidalApi.getPlaylist(this.playlistUuid);
79 | this.tracks = await this.tidalApi.getPlaylistTracks(this.playlist);
80 | }
81 | catch (e) {
82 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
83 | }
84 |
85 | this.playlistImage = await this.getPlaylistImage();
86 | this.playlistTitleBox = this.getPlaylistTitleBox();
87 | this.tracksList = await this.getTracksList();
88 | this.playPlaylistButton = this.getPlayPlaylistButton();
89 |
90 | this.children = [this.playlistTitleBox,this.playPlaylistButton, this.tracksList, this.playlistImage];
91 |
92 | await this.showElements(this.children);
93 |
94 | this.store.subscribe(async () => await this.activityChangeListener());
95 |
96 | }
97 | };
--------------------------------------------------------------------------------
/app/UI/uiComponents/specializedUI/List/TracksList.js:
--------------------------------------------------------------------------------
1 | const List = require("../../basicUI/List");
2 | const errors = require("../../../panels/userInputActions/errors");
3 | const actions = require("../../../../actions");
4 | const AppConfiguration = require("../../../../backend/Configuration/App");
5 |
6 | module.exports = class extends List {
7 | constructor(parent, options, store, tracks) {
8 | super(parent, options, tracks, []);
9 |
10 | this.store = store;
11 | this.tracks = tracks;
12 | this.previousElements = [];
13 | this.artists = [];
14 | this.appConfiguration = new AppConfiguration().getConfig();
15 |
16 | this.functionOnTrackSelect = this.playSelectedTrack;
17 | this.bindOnItemSelect(this.trackSelected.bind(this));
18 |
19 | this.element.key(this.appConfiguration["SHORTCUTS"]["PLAY_AS_NEXT_BUTTON"], function() {
20 | this.functionOnTrackSelect = this.playSelectedTrackNext;
21 | this.element.enterSelected();
22 | }.bind(this));
23 |
24 | this.element.key(this.appConfiguration["SHORTCUTS"]["PLAY_AS_LAST_BUTTON"], function () {
25 | this.functionOnTrackSelect = this.playSelectedTrackLast;
26 | this.element.enterSelected();
27 | }.bind(this));
28 | }
29 |
30 | trackSelected(track) {
31 | this.functionOnTrackSelect(track);
32 | }
33 |
34 | playSelectedTrack(track) {
35 | this.store.dispatch(actions.player.setCurrentTrack(track.id));
36 | }
37 |
38 | playSelectedTrackNext(track) {
39 | this.store.dispatch(actions.player.setNextTracks(track.id));
40 | }
41 |
42 | playSelectedTrackLast(track) {
43 | this.store.dispatch(actions.player.setLastTracks(track.id));
44 | }
45 |
46 | loadArtists() {
47 | return new Promise((resolve, reject) => {
48 | if(this.artists.length > 0) {
49 | this.artists = this.elements.map(x => this.artists.find(a => a.map((y, yi) => y.id === x.artists[yi])));
50 |
51 | this.artists = Promise.all(this.artists.map(async (x, i) => x === undefined ? await Promise.all(this.elements[i].artists.map(async a => (await this.store.getState().basic.tidalApi.getArtist(a)))) : x))
52 | .then(artists => {
53 | this.artists = artists;
54 | resolve();
55 | }).catch(() => reject());
56 | }
57 | else {
58 | Promise.all(this.elements.map(async x => await Promise.all(x.artists.map(async a => (await this.store.getState().basic.tidalApi.getArtist(a))))))
59 | .then(artists => {
60 | this.artists = artists;
61 | resolve();
62 | }).catch(() => reject());
63 | }
64 | });
65 | }
66 |
67 | updateKeys() {
68 | this.loadArtists().then(() => {
69 | const keys = this.elements.map((x, i) => x.title + " - " + this.artists[i].map(y => y.name).join(", "));
70 |
71 | this.setKeys(keys);
72 | this.loadingIndicator.stop();
73 | }).catch(() => {
74 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
75 | });
76 | }
77 |
78 | setElements(elements) {
79 | this.previousElements = this.elements;
80 | this.elements = elements;
81 | this.updateKeys();
82 | }
83 |
84 | async afterShowElements() {
85 | this.loadingIndicator.load();
86 |
87 | if(this.elements) {
88 | this.updateKeys();
89 | }
90 | }
91 |
92 | };
--------------------------------------------------------------------------------
/app/UI/panels/ArtistPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const actions = require("../../actions");
4 | const ArtistImage = require("../uiComponents/specializedUI/Image/ArtistImage");
5 | const AlbumsList = require("../uiComponents/specializedUI/List/AlbumsList");
6 | const TracksList = require("../uiComponents/specializedUI/List/TracksList");
7 | const Text = require("../uiComponents/basicUI/Text");
8 | const errors = require("./userInputActions/errors");
9 |
10 | module.exports = class ArtistPanel extends Activity {
11 | constructor(parent, store, options) {
12 | super(parent, blessed.box({
13 | width: "100%",
14 | height: "95%",
15 | }));
16 | this.store = store;
17 |
18 | this.artistId = options.artistId;
19 | this.children = [];
20 | }
21 |
22 | getArtistNameBox() {
23 | return new Text(this, {
24 | width: "20%",
25 | height: "100%",
26 | left: 0
27 | }, this.artist.name);
28 | }
29 |
30 | async getTracksList() {
31 | let tracks = [];
32 |
33 | try {
34 | tracks = await this.tidalApi.getArtistTopTracks(this.artist);
35 | }
36 | catch (e) {
37 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
38 | }
39 | return new TracksList(this, {
40 | width: "30%",
41 | height: "100%",
42 | left: "20%"
43 | }, this.store, tracks);
44 | }
45 |
46 | async getAlbumsList() {
47 | let albums = [];
48 |
49 | try {
50 | albums = await this.tidalApi.getArtistAlbums(this.artist);
51 | }
52 | catch (e) {
53 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
54 | }
55 | return new AlbumsList(this, {
56 | width: "30%",
57 | height: "100%",
58 | left: "50%"
59 | }, this.store, albums);
60 | }
61 |
62 | async getArtistImage() {
63 | let imageElementOptions = {
64 | width: this.getRelativeDiemensions().width * 0.2,
65 | right: 0,
66 | pixelRatio: this.store.getState().ui.pixelRatio
67 | };
68 |
69 | let artistImage = new ArtistImage(this, imageElementOptions, this.store, this.artist);
70 | await artistImage.downloadImage();
71 | return artistImage;
72 | }
73 |
74 | get tidalApi() {
75 | return this.store.getState().basic.tidalApi;
76 | }
77 |
78 | async activityChangeListener() {
79 | let storeCurrentActivity = this.store.getState().ui.currentActivity.activity;
80 | if(storeCurrentActivity !== ArtistPanel) {
81 | this.artistImage.hide();
82 | }
83 | }
84 |
85 | async run() {
86 | try {
87 | this.artist = await this.tidalApi.getArtist(this.artistId);
88 | }
89 | catch (e) {
90 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
91 | }
92 | this.artistImage = await this.getArtistImage();
93 | this.artistNameBox = this.getArtistNameBox();
94 | this.tracksList = await this.getTracksList();
95 | this.albumsList = await this.getAlbumsList();
96 |
97 | this.children = [this.artistNameBox, this.tracksList, this.albumsList, this.artistImage];
98 |
99 | await this.showElements(this.children);
100 |
101 | this.albumsList.focus();
102 |
103 | this.store.subscribe(async () => await this.activityChangeListener());
104 | }
105 | };
--------------------------------------------------------------------------------
/app/UI/panels/userInputActions/ActionsInputPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../../abstract/Activity");
2 | const TextInputBar = require("../../uiComponents/basicUI/TextInputBar");
3 | const blessed = require("blessed");
4 | const inputActions = require("./actions");
5 | const allInputActions = {...inputActions.loginRequired, ...inputActions.loginNotRequired};
6 | const Text = require("../../uiComponents/basicUI/Text");
7 | const errors = require("./errors");
8 | const conditionsBlockingActions = require("./conditionsBlockingActions");
9 | const actions = require("../../../actions");
10 | const AppConfiguration = require("../../../backend/Configuration/App");
11 | const shortcutActions = require("./shortcutActions");
12 |
13 | module.exports = class extends Activity {
14 | constructor(parent, store) {
15 | super(parent, blessed.box({
16 | width: "100%",
17 | height: "5%",
18 | bottom: 0
19 | }));
20 |
21 | this.store = store;
22 | this.children = [];
23 | this.appConfiguration = new AppConfiguration().getConfig();
24 | this.configShortcuts = this.appConfiguration["SHORTCUTS"];
25 | this.error = {
26 | message: undefined,
27 | timestamp: undefined,
28 | timeout: undefined
29 | };
30 | }
31 |
32 | async storeListener() {
33 | await this.errorChangeListener();
34 | }
35 |
36 | async errorChangeListener() {
37 | let storeCurrentError = this.store.getState().ui.error;
38 | if(storeCurrentError.timestamp && this.error.timestamp !== storeCurrentError.timestamp) {
39 | this.error = storeCurrentError;
40 | this.showError();
41 | }
42 | }
43 |
44 | async run() {
45 | this.textInputBar = this.getTextInputBar();
46 | this.errorBar = this.getErrorBar();
47 |
48 | this.children.push(this.textInputBar);
49 | this.children.push(this.errorBar);
50 |
51 | await this.showElements(this.children);
52 | this.errorBar.hide();
53 |
54 | Object.keys(this.configShortcuts).map(x => {
55 | this.getMasterParent().key(this.configShortcuts[x], function () {
56 | shortcutActions(x, this.store, this);
57 | }.bind(this));
58 | });
59 |
60 | this.store.subscribe(() => this.storeListener().then(() => {}));
61 | }
62 |
63 | parseUserInput(error, userInput) {
64 | if(error) {
65 | throw error;
66 | }
67 |
68 | this.textInputBar.clearValue();
69 |
70 | if(!userInput) {
71 | return;
72 | }
73 |
74 | let inputCommand = userInput.split(" ", 1)[0];
75 | let inputArgument = userInput.substring(userInput.indexOf(" ") + 1);
76 | if(inputCommand === inputArgument) {
77 | inputArgument = undefined;
78 | }
79 |
80 | let actionsBlocked = Object.keys(inputActions.loginNotRequired).includes(inputCommand) ? false : !conditionsBlockingActions.every(x => {
81 | let result = x(this.store);
82 | if(result instanceof Error) {
83 | this.store.dispatch(actions.ui.showError(result.message));
84 | return false;
85 | }
86 | return true;
87 | });
88 |
89 | if(allInputActions[inputCommand] && !actionsBlocked) {
90 | let error = allInputActions[inputCommand](this.store, inputArgument);
91 |
92 | if(error instanceof Error) {
93 | this.store.dispatch(actions.ui.showError(error.message));
94 | }
95 | }
96 | else if (actionsBlocked) {
97 | this.store.dispatch(actions.ui.showError(errors.cannotInputWhenNotLoggedIn.message));
98 | }
99 | else {
100 | this.store.dispatch(actions.ui.showError(errors.unexistingCommand.message));
101 | }
102 | }
103 |
104 | showError() {
105 | this.errorBar.setContent(this.error.message);
106 | this.errorBar.show();
107 | setTimeout(() => {
108 | this.errorBar.hide();
109 | }, this.error.timeout);
110 | }
111 |
112 | getErrorBar() {
113 | return new Text(this, {
114 | width: "100%",
115 | height: "100%",
116 | fg: this.appConfiguration["STYLES"]["ERROR_COLOR"],
117 | }, "");
118 | }
119 |
120 | showTextInputBar() {
121 | this.errorBar.hide();
122 | this.textInputBar.readInput(this.parseUserInput.bind(this));
123 | }
124 |
125 | getTextInputBar() {
126 | let textInputBar = new TextInputBar(this, {
127 | width: "100%",
128 | height: "100%",
129 | });
130 | this.getMasterParent().key(":", this.showTextInputBar.bind(this));
131 |
132 | return textInputBar;
133 | }
134 | };
--------------------------------------------------------------------------------
/app/UI/panels/PlayerPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const Player = require("../../backend/player/Player");
4 | const actions = require("../../actions");
5 | const AlbumImage = require("../uiComponents/specializedUI/Image/AlbumImage");
6 | const TrackInfoText = require("../uiComponents/specializedUI/TrackInfoText");
7 | const playbackStates = require("../../backend/player/Player").playbackStates;
8 | const errors = require("./userInputActions/errors");
9 |
10 | module.exports = class extends Activity {
11 | constructor(parent, store) {
12 | super(parent, blessed.box({
13 | width: "shrink",
14 | height: "shrink"
15 | }));
16 | this.store = store;
17 |
18 | this.player = new Player();
19 | this.currentTrack = undefined;
20 | this.currentTrackId = undefined;
21 | this.tracksQueue = [];
22 | this.playbackState = playbackStates.PAUSED;
23 | this.children = [];
24 |
25 | this.player.on("stopped", this.playNextTrack.bind(this));
26 | }
27 |
28 | playNextTrack() {
29 | if(this.tracksQueue[0]) {
30 | this.store.dispatch(actions.player.setCurrentTrack(this.tracksQueue.shift()));
31 | }
32 | }
33 |
34 | async getAlbumImage() {
35 | let album;
36 |
37 | try {
38 | album = await this.tidalApi.getAlbum(this.currentTrack.album);
39 | }
40 | catch (e) {
41 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
42 | }
43 |
44 | let imageElementOptions = {
45 | height: 1 * this.getRelativeDiemensions().height,
46 | right: 0,
47 | pixelRatio: this.pixelRatio
48 | };
49 |
50 | let albumImage = new AlbumImage(this, imageElementOptions, this.store, album);
51 | await albumImage.downloadImage();
52 | return albumImage;
53 | }
54 |
55 | async getTrackInfoText() {
56 | return new TrackInfoText(this, {
57 | width: "shrink",
58 | height: "shrink",
59 | left: 0
60 | }, this.store);
61 | }
62 |
63 | async prepareChildren() {
64 | this.children = [];
65 | this.children.push(await this.getAlbumImage());
66 |
67 | await this.showElements(this.children);
68 | }
69 |
70 | get tempManager() {
71 | return this.store.getState().basic.tempManager;
72 | }
73 |
74 | get pixelRatio() {
75 | return this.store.getState().ui.pixelRatio;
76 | }
77 |
78 | get tidalApi() {
79 | return this.store.getState().basic.tidalApi;
80 | }
81 |
82 | async playTrack() {
83 | let trackUrl = "";
84 |
85 | try {
86 | this.currentTrack = await this.tidalApi.getTrack(this.currentTrackId);
87 | this.store.dispatch(actions.player.setPlaybackState(playbackStates.LOADING));
88 | trackUrl = await this.tidalApi.getTrackStreamUrl(this.currentTrack.id);
89 | }
90 | catch (e) {
91 | this.store.dispatch(actions.ui.showError(errors.tidalError.message));
92 | }
93 | this.player.play(trackUrl);
94 | this.store.dispatch(actions.player.setPlaybackState(playbackStates.PLAYING));
95 | }
96 |
97 | updatePlaybackState() {
98 | switch(this.playbackState) {
99 |
100 | case playbackStates.PAUSED:
101 | this.player.pause();
102 | break;
103 |
104 | case playbackStates.PLAYING:
105 | this.player.resume();
106 | break;
107 | }
108 | }
109 |
110 | async storeListener() {
111 | await this.trackChangeListener();
112 | await this.playbackStateChangeListener();
113 | await this.tracksQueueChangeListener();
114 | }
115 |
116 | async trackChangeListener() {
117 | let storeCurrentTrackId = this.store.getState().player.currentTrack;
118 | if(storeCurrentTrackId && ((!this.currentTrackId) || (storeCurrentTrackId !== this.currentTrackId))) {
119 | this.currentTrackId = storeCurrentTrackId;
120 | await this.playTrack();
121 | await this.prepareChildren();
122 | }
123 | }
124 |
125 | tracksQueueChangeListener() {
126 | let storeCurrentTracksQueue = this.store.getState().player.tracksQueue;
127 | if(storeCurrentTracksQueue && (storeCurrentTracksQueue.length !== this.tracksQueue.length || !storeCurrentTracksQueue.every((x, i) => x === this.tracksQueue[i]))) {
128 | this.tracksQueue = storeCurrentTracksQueue;
129 | }
130 | }
131 |
132 | playbackStateChangeListener() {
133 | let storeCurrentPlaybackState = this.store.getState().player.playbackState;
134 | if(storeCurrentPlaybackState && this.playbackState !== storeCurrentPlaybackState) {
135 | this.playbackState = this.store.getState().player.playbackState;
136 | this.updatePlaybackState();
137 | }
138 | }
139 |
140 | async run() {
141 | this.store.subscribe(() => this.storeListener().then(() => {}));
142 | this.children.push(await this.getTrackInfoText());
143 | await this.showElements(this.children);
144 | }
145 | };
--------------------------------------------------------------------------------
/app/UI/panels/SigninPanel.js:
--------------------------------------------------------------------------------
1 | const Activity = require("../abstract/Activity");
2 | const blessed = require("blessed");
3 | const actions = require("../../actions");
4 | const Text = require("../uiComponents/basicUI/Text");
5 | const TextInputBar = require("../uiComponents/basicUI/TextInputBar");
6 | const HomePanel = require("./HomePanel");
7 | const Button = require("../uiComponents/basicUI/Button");
8 | const errors = require("./userInputActions/errors");
9 | const Credentials = require("../../backend/Configuration/Credentials");
10 | const RadioButton = require("../uiComponents/basicUI/RadioButton");
11 | const ApiInterface = require("../../backend/api/ApiInterface");
12 | const RadioSet = require("../uiComponents/basicUI/RadioSet");
13 |
14 | module.exports = class extends Activity {
15 | constructor(parent, store) {
16 | super(parent, blessed.box({
17 | width: "100%",
18 | height: "95%",
19 | }));
20 | this.store = store;
21 |
22 | this.children = [];
23 | this.textboxValues = {
24 | formTitle: "Signin to Tidal",
25 | usernameInputTitleBox: "Username",
26 | passwordInputTitleBox: "Password"
27 | };
28 | this.submitButtonText = "Sign in";
29 |
30 | this.username = undefined;
31 | this.password = undefined;
32 | this.credentialsConfig = new Credentials();
33 | }
34 |
35 | get tidalApi() {
36 | return this.store.getState().basic.tidalApi;
37 | }
38 |
39 | getSigninFormTitleBox() {
40 | return new Text(this, {
41 | width: "shrink",
42 | height: "shrink",
43 | top: 0,
44 | left: 0
45 | }, this.textboxValues.formTitle);
46 | }
47 |
48 | getUsernameInputTitleBox() {
49 | return new Text(this, {
50 | width: "shrink",
51 | height: "shrink",
52 | top: "20%",
53 | left: 0
54 | }, this.textboxValues.usernameInputTitleBox);
55 | }
56 |
57 | getPasswordInputTitleBox() {
58 | return new Text(this, {
59 | width: "shrink",
60 | height: "shrink",
61 | top: "50%",
62 | left: 0
63 | }, this.textboxValues.passwordInputTitleBox);
64 | }
65 |
66 | getUsernameInput() {
67 | let usernameInput = new TextInputBar(this, {
68 | width: "30%",
69 | height: "shrink",
70 | top: "30%",
71 | });
72 |
73 | usernameInput.element.key("enter", () => {
74 | if(!usernameInput.readingInput) {
75 | usernameInput.readInput(() => {});
76 | }
77 | else {
78 | usernameInput.readingInput = false;
79 | this.passwordInput.focus();
80 | }
81 | });
82 |
83 | return usernameInput;
84 | }
85 |
86 | getPasswordInput() {
87 | let passwordInput = new TextInputBar(this, {
88 | width: "30%",
89 | height: "shrink",
90 | top: "60%",
91 | censor: true
92 | });
93 |
94 | passwordInput.element.key("enter", () => {
95 | if(!passwordInput.readingInput) {
96 | passwordInput.readInput(() => {});
97 | }
98 | else {
99 | passwordInput.readingInput = false;
100 | this.qualityRadioSet.focus();
101 | }
102 | });
103 |
104 | return passwordInput;
105 | }
106 |
107 | getQualityRadioSet(qualityRadioButtons) {
108 | return new RadioSet(this, {
109 | top: "70%",
110 | width: "100%",
111 | shrink: true
112 | }, qualityRadioButtons);
113 | }
114 |
115 | getQualityRadioButton(quality, positionFromLeft) {
116 | return new RadioButton(this, {
117 | shrink: true,
118 | text: quality,
119 | left: positionFromLeft
120 | });
121 | }
122 |
123 | getSubmitButton() {
124 | let submitButton = new Button(this, {
125 | shrink: true,
126 | top: "80%"
127 | });
128 | submitButton.setContent(this.submitButtonText);
129 | submitButton.bindOnClick(function () {
130 | this.username = this.usernameInput.value;
131 | this.password = this.passwordInput.value;
132 | this.streamQuality = this.qualityRadioSet.getSelectedValue();
133 | this.signIn();
134 | }.bind(this));
135 |
136 | return submitButton;
137 | }
138 |
139 | signIn() {
140 | this.tidalApi.login(this.username, this.password, this.streamQuality).then(async () => {
141 | this.store.dispatch(actions.ui.setCurrentActivity(HomePanel, {}));
142 | this.store.dispatch(actions.basic.setTidalApiLoginState(true));
143 | this.credentialsConfig.saveCredentials(this.username, this.password, this.streamQuality).then();
144 | }).catch(error => {
145 | if(error.response && errors[error.response.data.userMessage]) {
146 | this.store.dispatch(actions.ui.showError(errors[error.response.data.userMessage].message));
147 | this.startInput();
148 | }
149 | else if(errors[error.message]){
150 | this.store.dispatch(actions.ui.showError(errors[error.message].message));
151 | }
152 | });
153 | }
154 |
155 | startInput() {
156 | this.usernameInput.focus();
157 | }
158 |
159 | async run() {
160 | const credentials = this.credentialsConfig.getCredentials();
161 |
162 | if(credentials.USERNAME && credentials.PASSWORD && credentials.STREAM_QUALITY) {
163 | this.username = credentials.USERNAME;
164 | this.password = credentials.PASSWORD;
165 | this.streamQuality = credentials.STREAM_QUALITY;
166 | this.signIn();
167 | }
168 |
169 | this.signinFormTitleBox = this.getSigninFormTitleBox();
170 | this.usernameInputTitleBox = this.getUsernameInputTitleBox();
171 | this.passwordInputTitleBox = this.getPasswordInputTitleBox();
172 | this.usernameInput = this.getUsernameInput();
173 | this.passwordInput = this.getPasswordInput();
174 | this.submitButton = this.getSubmitButton();
175 |
176 | this.qualityRadioButtons = Object.keys(ApiInterface.STREAM_QUALITY).map((x, i) => this.getQualityRadioButton(ApiInterface.STREAM_QUALITY[x], (i * 20) + "%"));
177 | this.qualityRadioSet = this.getQualityRadioSet(this.qualityRadioButtons);
178 |
179 | this.children = [this.signinFormTitleBox, this.usernameInputTitleBox, this.usernameInput, this.passwordInputTitleBox, this.passwordInput, this.qualityRadioSet, this.submitButton];
180 |
181 | await this.showElements(this.children);
182 |
183 | this.qualityRadioButtons[0].element.check();
184 | this.startInput();
185 | }
186 | };
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tidal-cli-client
2 | [](https://travis-ci.org/okonek/tidal-cli-client) [](https://bettercodehub.com/)
3 |
4 | tidal-cli-client is an open-source Tidal client for your linux terminal. You can finally listen to your favourite tracks without any web wrappers and flash. With your loved terminal. <3
5 |
6 | ## Fiverr
7 | If You'd like me to create a website for you, check out my account on [Fiverr](https://www.fiverr.com/share/Lz4qp) :)
8 |
9 | ## Donations
10 | If you like this app and you want to help me with the development, you can donate me some money, so I can buy more RAM for my Chrome.
11 |
12 | I'm 14 years old passionated JS developer and this app is a big success for me. I would appreciate every donation.
13 |
14 | [](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=QMX8LHNPXVL4Y)
15 |
16 | Here is my Bitcoin address: `1FJqNsijJpctJwsFB4LPhf7KEKNYVb1Mcd`
17 |
18 | 
19 |
20 |
21 | ## Update 2.0.0 notes
22 | The app is now completly rewritten. Most of the new features are:
23 | * More abstraction with components and cleaner code.
24 | * Better error handling.
25 | * Caching every api request.
26 | * Configurable shortcuts.
27 | * Unit tests
28 | * Playback queue control
29 |
30 | ## Installation
31 | Installation sources:
32 | * Arch Linux:
33 |
34 | * Install a package [tidal-cli-client-git](https://aur.archlinux.org/packages/tidal-cli-client-git/) from AUR.
35 |
36 | * Other distros:
37 |
38 | * It is prefered to install `tidal-cli-client` package from npm using command `sudo npm -g i tidal-cli-client@latest`. After installation you can run app from anywhere using command `tidal-cli`.
39 |
40 | * If you don't want to use npm installation you can clone this repo. Then you install other dependencies with `npm install` and run with `npm run app`.
41 |
42 | You must have MPV and W3M installed. On some systems you'll also need to check, if you have got w3m-img, because it is not always installed directly with w3m.
43 |
44 | ## Usage instructions
45 | When you open app for the first time you can see a form. Input username (first) and password (second) to the boxes and submit. Then it loads the main app.
46 |
47 | To change focus from one item to another, press `tab`.
48 |
49 | ### Action bar
50 | On the bottom of the app there is a green bar. When you press `:` it automatically focuses itself. Most of the navigation in the app is based on this text input. The list of commands is as follows:
51 |
52 | | Command | Description | Example |
53 | |-------------|-----------------------------------------------------------------------|-----------------------|
54 | | `search` | Searches for a query specified after space | `search Led Zeppelin` |
55 | | `queue` | Opens tracks queue panel, where you can view and edit playback queue | `queue` |
56 | | `pause` | Pauses playback | `pause` |
57 | | `resume` | Resumes playback | `resume` |
58 | | `skip` | Skips a number of tracks in queue specified after space | `skip 3` |
59 | | `next` | Works as `skip 1` | `resume` |
60 | | `shuffle` | Shuffles a tracks queue randomly | `shuffle` |
61 | | `playlists` | Opens user playlists panel where you can view and play your playlists | `playlists` |
62 | | `quit` | Quits from the app | `quit` |
63 |
64 | ### Shortcuts
65 | You can press these keys at any point and they'll do their thing.
66 |
67 | | Shortcut | Description |
68 | |----------|------------------------------------------------------------------------------------------------|
69 | | `F2` | Opens actions input bar and automatically enters `search `. Then you can only enter your query |
70 | | `n` | When focused on the list item or playback button adds focused element to the queue as next |
71 | | `a` | When focused on the list item or playback button adds focused element to the queue as last |
72 | | `l` | Play next track from queue. Works as `next` |
73 | | `j` | When focused on the list, goes up |
74 | | `k` | When focused on the list, goes down |
75 | | `w` | When on the playback queue list, moves selected item up in queue |
76 | | `s` | When on the playback queue list, moves selected item down in queue |
77 |
78 | ## Config file
79 | The apps config file is located in `~/.config/tidal-cli-client/app.json`. You can configure your shortcuts from there.
80 |
81 | * `STYLES` object contains all of apps **colors** are located. You can customize them as you like with HEX color codes.
82 |
83 | * `INPUT_BAR_ACTION` object contains all [**action bar**](#action-bar) actions are stored. You can edit their values to fit your preference.
84 |
85 | * `SHORTCUTS` object contains all of apps [**shortcuts**](#shortcuts) which you can customize as you wish.
86 | > Shortcut naming for *Ctrl-(key)* is *C-(key)*, for function keys it's *f(key)*
87 |
88 | Your login credentials are stored in `~/.config/tidal-cli-client/credentials.json`. You can edit this file if you want to.
89 |
90 | * `USERNAME` is your username
91 |
92 | * `PASSWORD` is your password
93 |
94 | * `STREAM_QUALITY` is TIDAL's stream quality, either `LOW`, `HIGH` or `LOSSLESS`.
95 |
96 | ## For developers
97 | If you want to help me with the development, create a fork on Github and clone it to your machine.
98 |
99 | Call `npm install`. After the work call `npm run test` to check for any errors and the you can create a PR.
100 |
--------------------------------------------------------------------------------
/tests/backend/ApiWrapper.js:
--------------------------------------------------------------------------------
1 | const chai = require("chai");
2 | const nodeFetch = require("node-fetch");
3 |
4 | const expect = chai.expect;
5 | const ApiWrapper = require("../../app/backend/api/ApiWrapper");
6 | const ApiInterface = require("../../app/backend/api/ApiInterface");
7 | const modelsBySearchTypes = require("../../app/backend/models/modelsBySearchTypes");
8 |
9 | const username = process.env.USERNAME;
10 | const password = process.env.PASSWORD;
11 |
12 | const properSearchQuery = "Stevie Ray Vaughan";
13 | const playlistUuid = "abcfff2f-6f6b-47a4-ba1e-0a41c61aea2d";
14 | const trackId = "42233596";
15 | const albumId = "16268250";
16 | const artistId = "111";
17 | let api;
18 |
19 |
20 | describe("ApiWrapper", async () => {
21 |
22 | beforeEach(() => {
23 | api = new ApiWrapper();
24 | });
25 |
26 | describe("login()", async () => {
27 | it("login() should user data object if valid creditnails are passed", async () => {
28 | let result = await api.login(username, password);
29 | expect(result.userId).to.be.a("number");
30 | });
31 |
32 | it("login() should return an error if invalid creditnals are passed", async () => {
33 | let error;
34 |
35 | try {
36 | await api.login("", "");
37 | }
38 | catch(e) {
39 | error = e;
40 | }
41 |
42 | expect(error).to.be.an("error");
43 | });
44 | });
45 |
46 | describe("apiGetMethods", async () => {
47 | beforeEach(async () => {
48 | api = new ApiWrapper();
49 | await api.login(username, password);
50 | });
51 |
52 | it("search() should return a list of max 10 with correct type objects if called with a proper query and type and limit 10", async () => {
53 | Object.keys(ApiInterface.SEARCH_TYPES).map(async x => {
54 | let results = await api.search(properSearchQuery, ApiInterface.SEARCH_TYPES[x], 10);
55 |
56 | expect(results).to.not.have.lengthOf.above(10);
57 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES[x]));
58 | });
59 | });
60 |
61 | it("getPlaylist() should return a playlist object if called with correct uuid", async () => {
62 | let playlist = await api.getPlaylist(playlistUuid);
63 |
64 | expect(playlist).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.PLAYLISTS));
65 | });
66 |
67 | it("getTrack() should return a track object if called with correct id", async () => {
68 | let track = await api.getTrack(trackId);
69 |
70 | expect(track).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.TRACKS));
71 | });
72 |
73 | it("getAlbum() should return an album object if called with correct id", async () => {
74 | let album = await api.getAlbum(albumId);
75 |
76 | expect(album).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.ALBUMS));
77 | });
78 |
79 | it("getArtist() should return an artist object if called with correct id", async () => {
80 | let artist = await api.getArtist(artistId);
81 |
82 | expect(artist).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.ARTISTS));
83 | });
84 |
85 | describe("album get functions", async () => {
86 | let album;
87 |
88 | beforeEach(async () => {
89 | album = await api.getAlbum(albumId);
90 | });
91 |
92 | it("getAlbumTracks() should return a list of track objects if called with a proper album object", async () => {
93 | let results = await api.getAlbumTracks(album);
94 |
95 | expect(results).to.be.an("array");
96 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.TRACKS));
97 | });
98 |
99 | it("getAlbumArtUrl() should return an artUrl if called with a proper album cover art id", async () => {
100 | let artUrl = await api.getAlbumArtUrl(album.coverArt);
101 | let response = await nodeFetch(artUrl);
102 |
103 | expect(artUrl).to.be.a("string");
104 | expect(response.ok).to.be.true;
105 | });
106 | });
107 |
108 | describe("playlist get functions", async () => {
109 | let playlist;
110 |
111 | beforeEach(async () => {
112 | playlist = await api.getPlaylist(playlistUuid);
113 | });
114 |
115 | it("getPlaylistTracks() should return a list of track objects if called with a proper playlist object", async () => {
116 | let results = await api.getPlaylistTracks(playlist);
117 |
118 | expect(results).to.be.an("array");
119 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.TRACKS));
120 | });
121 |
122 | it("getUserPlaylists() should return a list of playlist objects", async () => {
123 | let results = await api.getUserPlaylists();
124 |
125 | expect(results).to.be.an("array");
126 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.PLAYLISTS));
127 | });
128 | });
129 |
130 | describe("artist get functions", async () => {
131 | let artist;
132 |
133 | beforeEach(async () => {
134 | artist = await api.getArtist(artistId);
135 | });
136 |
137 | it("getArtistTopTracks() should return a list of track objects if called with a proper artist object", async () => {
138 | let results = await api.getArtistTopTracks(artist);
139 |
140 | expect(results).to.be.an("array");
141 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.TRACKS));
142 | });
143 |
144 | it("getArtistAlbums() should return a list of album objects if called with a proper artist object", async () => {
145 | let results = await api.getArtistAlbums(artist);
146 |
147 | expect(results).to.be.an("array");
148 | expect(results[0]).to.be.an.instanceof(modelsBySearchTypes(ApiInterface.SEARCH_TYPES.ALBUMS));
149 | });
150 |
151 | it("getArtistArtUrl() should return an artUrl if called with a proper artist picture id", async () => {
152 | let artUrl = await api.getArtistArtUrl(artist.picture);
153 | let response = await nodeFetch(artUrl);
154 |
155 | expect(artUrl).to.be.a("string");
156 | expect(response.ok).to.be.true;
157 | });
158 | });
159 |
160 | describe("track get functions", async () => {
161 | let track;
162 |
163 | beforeEach(async () => {
164 | track = await api.getTrack(trackId);
165 | });
166 |
167 | it("getTrackStreamUrl should return an url if called with a proper track id", async () => {
168 | let streamUrl = await api.getTrackStreamUrl(track.id);
169 | let response = await nodeFetch(streamUrl);
170 |
171 | expect(streamUrl).to.be.a("string");
172 | expect(response.ok).to.be.true;
173 | });
174 | });
175 | });
176 | });
177 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | > A code of conduct is a set of rules outlining the social norms and rules and responsibilities of, or proper practices for, an individual, party or organization
4 |
5 | ## Sumary
6 |
7 | The **tidal-cli-client** is dedicated to providing a harassment-free working environment for all, regardless of gender, sexual orientation, disability, physical appearance, body size, race, or religion. We do not tolerate harassment of any form. All communication should be appropriate for a professional audience including people of many different backgrounds.
8 |
9 | Sexual language and imagery is not appropriate for any communication and/or talks. Be kind and do not insult or put down others. Behave professionally. Remember that harassment and sexist, racist, or exclusionary jokes are not appropriate for **tidal-cli-client**. Staff violating these rules should be reported to an appropriate line manager.
10 |
11 | These are the values to which people in the **tidal-cli-client** community should aspire:
12 |
13 | - Be friendly and welcoming
14 | - Be patient
15 | - Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.)
16 | - Be thoughtful
17 | - Productive communication requires effort. Think about how your words will be interpreted.
18 | - Remember that sometimes it is best to refrain entirely from commenting.
19 | - Be respectful
20 | - In particular, respect differences of opinion.
21 | - Be charitable
22 | - Interpret the arguments of others in good faith, do not seek to disagree.
23 | - When we do disagree, try to understand why.
24 | - Avoid destructive behavior:
25 | - Derailing: stay on topic; if you want to talk about something else, start a new conversation.
26 | - Unconstructive criticism: don't merely decry the current state of affairs; offer—or at least solicit—suggestions as to how things may be improved.
27 | - Snarking (pithy, unproductive, sniping comments)
28 | - Discussing potentially offensive or sensitive issues; this all too often leads to unnecessary conflict.
29 | - Microaggressions: brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults to a person or group.
30 |
31 | People are complicated. You should expect to be misunderstood and to misunderstand others; when this inevitably occurs, resist the urge to be defensive or assign blame. Try not to take offense where no offense was intended. Give people the benefit of the doubt. Even if the intent was to provoke, do not rise to it. It is the responsibility of all parties to de-escalate conflict when it arises.
32 |
33 | ## Reporting an incident
34 |
35 | Incidents that violate the Code of Conduct are extremely damaging to the **tidal-cli-client**, and they will not be tolerated. The silver lining is that, in many cases, these incidents present a chance for the offenders, and the teams at large, to grow, learn, and become better.
36 |
37 | > The following should be handled by a line manager who has been informed of the incident
38 |
39 | Try to get as much of the incident in written form. The important information to gather include the following:
40 |
41 | - Name and team of the participant doing the harassing
42 | - The location in which the incident occurred
43 | - The behavior that was in violation
44 | - The approximate time of the behavior
45 | - The circumstances surrounding the incident
46 | - Other people involved in the incident
47 |
48 | Depending on the severity/details of the incident, please follow these guidelines:
49 |
50 | - If there is any general threat to staff or any other doubts, summon security or police
51 | - Offer the victim a private place to sit
52 | - Ask "is there a friend or trusted person who you would like to be with you?" (if so, arrange for someone to fetch this person)
53 | - Ask them "how can I help?"
54 | - Provide them with your list of emergency contacts if they need help later
55 | - If everyone is presently physically safe, involve the police or security only at a victim's request
56 |
57 | There are also some guidelines as to what not to do as an initial response:
58 |
59 | - Do not overtly invite them to withdraw the complaint or mention that withdrawal is OK. This suggests that you want them to do so, and is therefore coercive. "If you're OK with pursuing the complaint" suggests that you are by default pursuing it and is not coercive.
60 | - Do not ask for their advice on how to deal with the complaint. This is a staff responsibility.
61 | - Do not offer them input into penalties. This is the staff's responsibility.
62 |
63 | The line manager who is handling the reported offence should find out the following:
64 |
65 | - What happened?
66 | - Are we doing anything about it?
67 | - Who is doing those things?
68 | - When are they doing them?
69 |
70 | After the above has been identified and discussed, have an appropriate line manager communicate with the alleged harasser. Make sure to inform them of what has been reported about them.
71 |
72 | Allow the alleged harasser to give their side of the story. After this point, if the report stands, let the alleged harasser know what actions will be taken against them.
73 |
74 | Some things for the staff to consider when dealing with Code of Conduct offenders:
75 |
76 | - Warning the harasser to cease their behaviour and that any further reports will result in sanctions
77 | - Requiring that the harasser avoid any interaction with, and physical proximity to, their victim until a resolution or course of action has been decided upon
78 | - Requiring that the harasser not volunteer for future events your organisation runs (either indefinitely or for a certain time period)
79 | - Depending on the severity/details of the incident, requiring that the harasser immediately be sent home
80 | - Depending on the severity/details of the incident, removing a harasser from membership of relevant **tidal-cli-client** organisations
81 | - Depending on the severity/details of the incident, publishing an account of the harassment and calling for the resignation of the harasser from their responsibilities (usually pursued by people without formal authority: may be called for if the harasser is a team leader, or refuses to stand aside from the conflict of interest)
82 |
83 | Give accused staff members a place to appeal to if there is one, but in the meantime the report stands. Keep in mind that it is not a good idea to encourage an apology from the harasser.
84 |
85 | It is very important how we deal with the incident publicly. Our policy is to make sure that everyone aware of the initial incident is also made aware that it is not according to policy and that official action has been taken - while still respecting the privacy of individual staff members. When speaking to individuals (those who are aware of the incident, but were not involved with the incident) about the incident it is a good idea to keep the details out.
86 |
87 | Depending on the incident, the head of responsible department, or designate, may decide to make one or more public announcements. If necessary, this will be done with a short announcement either during the plenary and/or through other channels. No one other than the head of responsible department or someone delegated authority from them should make any announcements. No personal information about either party will be disclosed as part of this process.
88 |
89 | If some members of staff were angered by the incident, it is best to apologise to them that the incident occurred to begin with. If there are residual hard feelings, suggest to them to write an email to the responsible head of department. It will be dealt with accordingly.
90 |
91 | ## Attribution
92 |
93 | This Code of Conduct was adapted from both [Golang](https://golang.org/conduct) an the [Golang UK Conference](http://golanguk.com/conduct/).
--------------------------------------------------------------------------------
/app/backend/api/ApiWrapper.js:
--------------------------------------------------------------------------------
1 | const TidalApi = require("tidal-api-wrapper-okonek");
2 | const ApiInterface = require("./ApiInterface");
3 | const Track = require("../models/Track");
4 | const Artist = require("../models/Artist");
5 | const Album = require("../models/Album");
6 | const Playlist = require("../models/Playlist");
7 | const nodeCache = require("node-cache-promise");
8 |
9 | module.exports = class extends ApiInterface {
10 |
11 | constructor() {
12 | super();
13 | this.api = new TidalApi();
14 | this.cache = new nodeCache();
15 | }
16 |
17 | async login(username, password, streamQuality = "HIGH") {
18 | this.streamQuality = streamQuality;
19 | return await this.api.login(username, password);
20 | }
21 |
22 | async search(query, type, limit = this.DEFAULT_LIMIT) {
23 | const searchResult = await this.api.search(query, type, limit);
24 |
25 | switch(type) {
26 | case ApiInterface.SEARCH_TYPES.ARTISTS:
27 | return await Promise.all(searchResult.map(async x => await this.getArtist(x.id)));
28 |
29 | case ApiInterface.SEARCH_TYPES.ALBUMS:
30 | return await Promise.all(searchResult.map(async x => await this.getAlbum(x.id)));
31 |
32 | case ApiInterface.SEARCH_TYPES.TRACKS:
33 | return await Promise.all(searchResult.map(async x => await this.getTrack(x.id)));
34 |
35 | case ApiInterface.SEARCH_TYPES.PLAYLISTS:
36 | return await Promise.all(searchResult.map(async x => await this.getPlaylist(x.uuid)));
37 | }
38 | }
39 |
40 | async getPlaylist(playlistUuid) {
41 | const playlistData = await this.api.getPlaylist(playlistUuid);
42 | return await this.createPlaylistFromPlaylistData(playlistData);
43 | }
44 |
45 | async getTrack(trackId) {
46 | const trackFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.TRACKS + trackId);
47 | if(trackFromCache) {
48 | return trackFromCache;
49 | }
50 |
51 | const trackData = await this.api.getTrack(trackId);
52 | const track = await this.createTrackFromTrackData(trackData);
53 | await this.cache.set(ApiInterface.SEARCH_TYPES.TRACKS + trackId, track);
54 | return track;
55 | }
56 |
57 | async getAlbum(albumId) {
58 | const albumFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ALBUMS + albumId);
59 | if(albumFromCache) {
60 | return albumFromCache;
61 | }
62 |
63 | const albumData = await this.api.getAlbum(albumId);
64 | const album = await this.createAlbumFromAlbumData(albumData);
65 | await this.cache.set(ApiInterface.SEARCH_TYPES.ALBUMS + albumId, album);
66 | return album;
67 | }
68 |
69 | async getArtist(artistId) {
70 | const artistFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ARTISTS + artistId);
71 | if(artistFromCache) {
72 | return artistFromCache;
73 | }
74 |
75 | const artistData = await this.api.getArtist(artistId);
76 | const artist = new Artist(artistData);
77 | await this.cache.set(ApiInterface.SEARCH_TYPES.ARTISTS + artistId, artist);
78 | return artist;
79 | }
80 |
81 | async getAlbumTracks(album) {
82 | const tracksFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ALBUMS + ApiInterface.SEARCH_TYPES.TRACKS + album.id);
83 | if(tracksFromCache) {
84 | return tracksFromCache;
85 | }
86 |
87 | const tracksData = await this.api.getAlbumTracks(album.id);
88 | const tracks = await Promise.all(tracksData.map(async x => await this.createTrackFromTrackData(x)));
89 | await this.cache.set(ApiInterface.SEARCH_TYPES.ALBUMS + ApiInterface.SEARCH_TYPES.TRACKS + album.id, tracks);
90 | return tracks;
91 | }
92 |
93 | async getPlaylistTracks(playlist) {
94 | const tracksFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.PLAYLISTS + ApiInterface.SEARCH_TYPES.TRACKS + playlist.uuid);
95 | if(tracksFromCache) {
96 | return tracksFromCache;
97 | }
98 |
99 | const tracksData = await this.api.getPlaylistTracks(playlist.uuid);
100 | const tracks = await Promise.all(tracksData.map(async x => await this.createTrackFromTrackData(x)));
101 | await this.cache.set(ApiInterface.SEARCH_TYPES.PLAYLISTS + ApiInterface.SEARCH_TYPES.TRACKS + playlist.uuid, tracks);
102 | return tracks;
103 | }
104 |
105 | async getArtistAlbums(artist) {
106 | const albumsFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ARTISTS + ApiInterface.SEARCH_TYPES.ALBUMS + artist.id);
107 | if(albumsFromCache) {
108 | return albumsFromCache;
109 | }
110 |
111 | const albumsData = await this.api.getArtistAlbums(artist.id);
112 | const albums = await Promise.all(albumsData.map(async x => await this.createAlbumFromAlbumData(x)));
113 | await this.cache.set(ApiInterface.SEARCH_TYPES.ARTISTS + ApiInterface.SEARCH_TYPES.ALBUMS + artist.id, albums);
114 | return albums;
115 | }
116 |
117 | async getArtistTopTracks(artist, limit = ApiInterface.DEFAULT_LIMIT) {
118 | const topTracksFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ARTISTS + "TOP_TRACKS" + artist.id);
119 | if(topTracksFromCache && topTracksFromCache.length >= limit) {
120 | return topTracksFromCache.slice(0, limit);
121 | }
122 |
123 | const tracksData = await this.api.getArtistTopTracks(artist.id, limit);
124 | const tracks = await Promise.all(tracksData.map(async x => await this.createTrackFromTrackData(x)));
125 | await this.cache.set(ApiInterface.SEARCH_TYPES.ARTISTS + "TOP_TRACKS" + artist.id, tracks);
126 | return tracks;
127 | }
128 |
129 | async getAlbumArtUrl(id, size = ApiInterface.ALBUM_COVER_SIZES.LARGE) {
130 | const albumArtUrlFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ALBUMS + "ART" + id);
131 | if(albumArtUrlFromCache) {
132 | return albumArtUrlFromCache[size];
133 | }
134 |
135 | const artUrls = (await this.api.albumArtToUrl(id));
136 | await this.cache.set(ApiInterface.SEARCH_TYPES.ALBUMS + "ART" + id, artUrls);
137 | return artUrls[size];
138 | }
139 |
140 | async getUserPlaylists() {
141 | const userPlaylistsFromCache = await this.cache.get("USER" + ApiInterface.SEARCH_TYPES.PLAYLISTS);
142 | if(userPlaylistsFromCache) {
143 | return userPlaylistsFromCache;
144 | }
145 |
146 | const userPlaylistsData = await this.api.getUserPlaylists();
147 | const userPlaylists = await Promise.all(userPlaylistsData.map(async x => await this.getPlaylist(x.uuid)));
148 | await this.cache.set("USER" + ApiInterface.SEARCH_TYPES.PLAYLISTS, userPlaylists);
149 | return userPlaylists;
150 | }
151 |
152 | async getArtistArtUrl(id, size = ApiInterface.ARTIST_COVER_SIZES.LARGE) {
153 | const artistArtUrlFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.ARTISTS + "ART" + id);
154 | if(artistArtUrlFromCache) {
155 | return artistArtUrlFromCache[size];
156 | }
157 |
158 | const artUrls = (await this.api.artistPicToUrl(id));
159 | await this.cache.set(ApiInterface.SEARCH_TYPES.ARTISTS + "ART" + id, artUrls);
160 | return artUrls[size];
161 | }
162 |
163 | async getTrackStreamUrl(id) {
164 | const trackStreamUrlFromCache = await this.cache.get(ApiInterface.SEARCH_TYPES.TRACKS + "STREAM_URL" + id);
165 | if(trackStreamUrlFromCache) {
166 | return trackStreamUrlFromCache;
167 | }
168 |
169 | const streamUrl = await this.api.getTrackStreamUrl(id, this.streamQuality);
170 | await this.cache.set(ApiInterface.SEARCH_TYPES.TRACKS + "STREAM_URL" + id, streamUrl);
171 | return streamUrl;
172 | }
173 |
174 |
175 | /**
176 | * @private
177 | */
178 | async createTrackFromTrackData(trackData) {
179 | return new Track({
180 | id: trackData.id,
181 | title: trackData.title,
182 | artists: trackData.artists.map(x => x.id),
183 | album: trackData.album.id
184 | });
185 | }
186 |
187 | async createAlbumFromAlbumData(albumData) {
188 | return new Album({
189 | id: albumData.id,
190 | title: albumData.title,
191 | artists: albumData.artists.map(x => x.id),
192 | coverArt: albumData.cover
193 | });
194 | }
195 |
196 | async createPlaylistFromPlaylistData(playlistData) {
197 | return new Playlist({
198 | uuid: playlistData.uuid,
199 | title: playlistData.title,
200 | image: playlistData.image,
201 | description: playlistData.description
202 | });
203 | }
204 |
205 | };
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | * Introduction
4 | * Reporting and requesting
5 | * [Request Support](#request-support)
6 | * [Report an Error or Bug](#report-an-error-or-bug)
7 | * [Request a Feature](#request-a-feature)
8 | * Contributing
9 | * [Project Setup](#project-setup)
10 | * [Contribute Documentation](#contribute-documentation)
11 | * [Contribute Code](#contribute-code)
12 | * [Join the Project Team](#join-the-project-team)
13 |
14 | ## Introduction
15 |
16 | Thank you so much for your interest in contributing! All types of contributions are encouraged and valued.
17 |
18 | Please make sure to read the relevant section before making your contribution! It will make it a lot easier for us to make the most of it and smooth out the experience for all involved.
19 |
20 | ## Request Support
21 |
22 | If you have a question about this project, how to use it, or just need clarification about something:
23 |
24 | * Open an Issue at https://github.com/okonek/tidal-cli-client/issues
25 | * Provide as much context as you can about what you're running into.
26 | * Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. If not, please be ready to provide that information if maintainers ask for it.
27 |
28 | Once it's filed:
29 |
30 | * The project team will label the issue.
31 | * Someone will try to have a response soon.
32 | * If you or the maintainers don't respond to an issue for 30 days, the issue will be closed. If you want to come back to it, reply (once, please), and we'll reopen the existing issue. Please avoid filing new issues as extensions of one you already made.
33 | ## Report an Error or Bug
34 |
35 | If you run into an error or bug with the project:
36 |
37 | * Open an Issue at https://github.com/okonek/tidal-cli-client/issues
38 | * Include *reproduction steps* that someone else can follow to recreate the bug or error on their own.
39 | * Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. If not, please be ready to provide that information if maintainers ask for it.
40 |
41 | Once it's filed:
42 |
43 | * The project team will label the issue.
44 | * A team member will try to reproduce the issue with your provided steps. If there are no repro steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
45 | * If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#contribute-code).
46 | * If you or the maintainers don't respond to an issue for 30 days, the issue will be closed. If you want to come back to it, reply (once, please), and we'll reopen the existing issue. Please avoid filing new issues as extensions of one you already made.
47 | * `critical` issues may be left open, depending on perceived immediacy and severity, even past the 30 day deadline.
48 | ## Request a Feature
49 |
50 | If the project doesn't do something you need or want it to do:
51 |
52 | * Open an Issue at https://github.com/okonek/tidal-cli-client/issues
53 | * Provide as much context as you can about what you're running into.
54 | * Please try and be clear about why existing features and alternatives would not work for you.
55 |
56 | Once it's filed:
57 |
58 | * The project team will label the issue.
59 | * The project team will evaluate the feature request, possibly asking you more questions to understand its purpose and any relevant requirements. If the issue is closed, the team will convey their reasoning and suggest an alternative path forward.
60 | * If the feature request is accepted, it will be marked for implementation with `feature-accepted`, which can then be done by either by a core team member or by anyone in the community who wants to [contribute code](#contribute-code).
61 |
62 | ## Project Setup
63 |
64 | So you wanna contribute some code! That's great! This project uses GitHub Pull Requests to manage contributions, so [read up on how to fork a GitHub project and file a PR](https://guides.github.com/activities/forking) if you've never done it before.
65 |
66 | If this seems like a lot or you aren't able to do all this setup, you might also be able to [edit the files directly](https://help.github.com/articles/editing-files-in-another-user-s-repository/) without having to do any of this setup. Yes, even code.
67 |
68 | If you want to go the usual route and run the project locally, though:
69 |
70 | * [Install Node.js](https://nodejs.org/en/download/)
71 | * [Install w3m and w3m-img](http://w3m.sourceforge.net/)
72 | * [Install mpv](https://mpv.io/)
73 | * [Fork the project](https://guides.github.com/activities/forking/#fork)
74 |
75 | Then in your terminal:
76 | * `cd path/to/your/clone`
77 | * `npm install`
78 |
79 | And when you are done, run:
80 | * `npm run lint`
81 | * `npm run test` (for tests to run, two environment variables are needed: USERNAME and PASSWORD, which are yours TIDAL account credentials. You can set them for your current terminal session with `export USERNAME=your_username` and `export PASSWORD=your_password`)
82 |
83 | And you should be ready to go!
84 |
85 | ## Contribute Documentation
86 |
87 | Documentation is a super important, critical part of this project. Docs are how we keep track of what we're doing, how, and why. It's how we stay on the same page about our policies. And it's how we tell others everything they need in order to be able to use this project -- or contribute to it. So thank you in advance.
88 |
89 | Documentation contributions of any size are welcome! Feel free to file a PR even if you're just rewording a sentence to be more clear, or fixing a spelling mistake!
90 |
91 | To contribute documentation:
92 |
93 | * Edit or add any relevant documentation.
94 | * Make sure your changes are formatted correctly and consistently with the rest of the documentation.
95 | * Re-read what you wrote, and run a spellchecker on it to make sure you didn't miss anything.
96 | * Make a commit.
97 | * Go to https://github.com/okonek/tidal-cli-client/pulls and open a new pull request with your changes.
98 | * If your PR is connected to an open issue, add a line in your PR's description that says `Fixes: #123`, where `#123` is the number of the issue you're fixing.
99 |
100 | Once you've filed the PR:
101 |
102 | * One or more maintainers will use GitHub's review feature to review your PR.
103 | * If the maintainer asks for any changes, edit your changes, push, and ask for another review.
104 | * If the maintainer decides to pass on your PR, they will thank you for the contribution and explain why they won't be accepting the changes. That's ok! We still really appreciate you taking the time to do it, and we don't take that lightly.
105 | * If your PR gets accepted, it will be marked as such, and merged into the `latest` branch soon after. Your contribution will be distributed to the masses next time the maintainers tag a release.
106 |
107 | ## Contribute Code
108 |
109 | We like code commits a lot! They're super handy, and they keep the project going and doing the work it needs to do to be useful to others.
110 |
111 | Code contributions of just about any size are acceptable!
112 |
113 | The main difference between code contributions and documentation contributions is that contributing code requires inclusion of relevant tests for the code being added or changed. Contributions without accompanying tests will be held off until a test is added, unless the maintainers consider the specific tests to be either impossible, or way too much of a burden for such a contribution.
114 |
115 | To contribute code:
116 |
117 | * [Set up the project](#project-setup).
118 | * Make any necessary changes to the source code.
119 | * Include any [additional documentation](#contribute-documentation) the changes might need.
120 | * Write tests that verify that your contribution works as expected.
121 | * Make a commit.
122 | * Go to https://github.com/okonek/tidal-cli-client/pulls and open a new pull request with your changes.
123 | * If your PR is connected to an open issue, add a line in your PR's description that says `Fixes: #123`, where `#123` is the number of the issue you're fixing.
124 |
125 | Once you've filed the PR:
126 |
127 | * Barring special circumstances, maintainers will not review PRs until all checks pass Travis.
128 | * One or more maintainers will use GitHub's review feature to review your PR.
129 | * If the maintainer asks for any changes, edit your changes, push, and ask for another review. Additional tags (such as `needs-tests`) will be added depending on the review.
130 | * If the maintainer decides to pass on your PR, they will thank you for the contribution and explain why they won't be accepting the changes. That's ok! We still really appreciate you taking the time to do it, and we don't take that lightly.
131 | * If your PR gets accepted, it will be marked as such, and merged into the `latest` branch soon after. Your contribution will be distributed to the masses next time the maintainers tag a release
132 |
133 | ### Adding new app features: code guideline
134 |
135 | The app actions are handled by [redux](https://redux.js.org/).
136 |
137 | #### UI
138 | App UI consists of two `ActivityRunners`, `Panels`, that run in them and `uiComponents`, from which `Panels` are made of.
139 |
140 | ##### uiComponents
141 | They are the most *low level* components in the app. Every `uiComponent` should extend the `BaseElement` class and be located in `app/UI/uiComponents/basicUI` if it's a more abstract and independent from apps state element or in `app/UI/uiComponents/specializedUI` if it's state-dependent.
142 | The good example `uiComponent` can be `app/UI/uiComponents/basicUI/Button`.
143 |
144 | First two arguments always passed to an `uiComponent` are:
145 | * `parent` is element's parent such as `Panel` or another `uiComponents`.
146 | * `options` is element's style and options. You can read about them in [blessedjs documentation](https://github.com/chjj/blessed#element-from-node).
147 | When styling your `uiComponent` with `options` you should use `AppConfiguration` to get user-defined colors.
148 |
149 | The other arguments are custom. You can add them if you need to. The argument `store` should not be passed to `basicUI` components, only to `specializedUI`.
150 |
151 | In the constructor you should pass the `parent` and a [blessedjs](https://github.com/chjj/blessed) element with `options` to **super**.
152 | If you want to learn more about `components` events and attributes, you can read it in [blessedjs docs](https://github.com/chjj/blessed).
153 |
154 | When your `uiComponent` is first rendered, the **async** `run()` function is called. You can implement it is preferred to do as much actions as possible in there rather than in the constructor.
155 |
156 | To add any `children` to your `uiComponent`, you have to first create a `children` array with all of your `uiComponent's` children. And then you should call **async** `showElements` function. Don't do it in the **contructor**.
157 |
158 | ##### Panels
159 | Panels are on higher-level than `uiComponents` and should always extend `Activity` class. They can dispatch actions in state and read it.
160 | The good example `Panel` can be `app/UI/panels/PlayerPanel`.
161 |
162 | Arguments passed to `Panel` are:
163 | * `parent` is and `ActivityRunner` in which the `Panel` should run.
164 | * `store` is app's **redux** store. You can dispatch actions to it and listen to it's state change.
165 | * `options` *(optional)* is an object with any other arguments you want to pass the `Panel`.
166 |
167 | In the constructor you should pass the `parent` and a [blessedjs](https://github.com/chjj/blessed) `box` with only **width** and **height** specified to **super**.
168 |
169 | When your `Panel` is first rendered, the **async** `run()` function is called. You can implement it is preferred to do as much actions as possible in there rather than in the constructor.
170 |
171 | To add any `children` to your `Panel`, you have to first create a `children` array with all of your `Panel's` children. And then you should call **async** `showElements` function. Don't do it in the **contructor**.
172 |
173 | To run your `Panel`, you another elements has to **dispatch** `setCurrentActivity` action with your `Panel` class as a parameter.
174 |
175 | ##### ActionsInputPanel
176 | It's an example of a *special* `Panel`. It consists of a **input bar**, where user inputs **actions** he wants to perform.
177 |
178 | To add an **action** to the `UserActionsBar`, first add it to the `app/backend/Configuration/defaultAppConfig.json` `"INPUT_BAR_ACTIONS"` object. Then add the link to it in `app/UI/panels/userInputActions/actions` either in the `loginRequired` section if it shouldn't be available when not logged in or `loginNotRequired` if it should be always available.
179 | The key should be taken from `AppConfiguration` and the value is a function, which takes the `store` argument first and then **action** argument which is *optional*.
180 |
181 | If after reading this, you still have some doubts on how to add an **action**, you can take other action as an example.
182 |
183 | ## Provide Support on Issues
184 |
185 | [Needs Collaborator](#join-the-project-team)
186 |
187 | Helping out other users with their questions is a really awesome way of contributing to any community. It's not uncommon for most of the issues on an open source projects being support-related questions by users trying to understand something they ran into, or find their way around a known bug.
188 |
189 | Sometimes, the `support` label will be added to things that turn out to actually be other things, like bugs or feature requests. In that case, suss out the details with the person who filed the original issue, add a comment explaining what the bug is, and change the label from `support` to `bug` or `feature`. If you can't do this yourself, @mention a maintainer so they can do it.
190 |
191 | In order to help other folks out with their questions:
192 |
193 | * Go to the issue tracker and [filter open issues by the `support` label](https://github.com/okonek/tidal-cli-client/issues?q=is%3Aopen+is%3Aissue+label%3Asupport).
194 | * Read through the list until you find something that you're familiar enough with to give an answer to.
195 | * Respond to the issue with whatever details are needed to clarify the question, or get more details about what's going on.
196 | * Once the discussion wraps up and things are clarified, either close the issue, or ask the original issue filer (or a maintainer) to close it for you.
197 |
198 | Some notes on picking up support issues:
199 |
200 | * Avoid responding to issues you don't know you can answer accurately.
201 | * As much as possible, try to refer to past issues with accepted answers. Link to them from your replies with the `#123` format.
202 | * Be kind and patient with users -- often, folks who have run into confusing things might be upset or impatient. This is ok. Try to understand where they're coming from, and if you're too uncomfortable with the tone, feel free to stay away or withdraw from the issue. (note: if the user is outright hostile or is violating the CoC, [refer to the Code of Conduct](CODE_OF_CONDUCT.md) to resolve the conflict).
203 |
204 | ## Join the Project Team
205 |
206 | ### Ways to Join
207 |
208 | There are many ways to contribute! Most of them don't require any official status unless otherwise noted. That said, there's a couple of positions that grant special repository abilities, and this section describes how they're granted and what they do.
209 |
210 | All of the below positions are granted based on the project team's needs, as well as their consensus opinion about whether they would like to work with the person and think that they would fit well into that position. The process is relatively informal, and it's likely that people who express interest in participating can just be granted the permissions they'd like.
211 |
212 | You can spot a collaborator on the repo by looking for the `[Collaborator]` or `[Owner]` tags next to their names.
213 |
214 | Permission | Description
215 | --- | ---
216 | Issue Tracker | Granted to contributors who express a strong interest in spending time on the project's issue tracker. These tasks are mainly labeling issues, cleaning up old ones, and reviewing pull requests, as well as all the usual things non-team-member contributors can do. Issue handlers should not merge pull requests, tag releases, or directly commit code themselves: that should still be done through the usual pull request process. Becoming an Issue Handler means the project team trusts you to understand enough of the team's process and context to implement it on the issue tracker.
217 | Committer | Granted to contributors who want to handle the actual pull request merges, tagging new versions, etc. Committers should have a good level of familiarity with the codebase, and enough context to understand the implications of various changes, as well as a good sense of the will and expectations of the project team.
218 | Admin/Owner | Granted to people ultimately responsible for the project, its community, etc.
219 |
220 | ## Attribution
221 |
222 | This guide was generated using the WeAllJS `CONTRIBUTING.md` generator. [Make your own](https://npm.im/weallcontribute)!
223 |
224 |
--------------------------------------------------------------------------------