diff --git a/.vscode/settings.json b/.vscode/settings.json index f5bd08c..70e1c0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,16 @@ { "cSpell.words": [ + "Brainz", + "Castlabs", "flac", + "Flatpak", "geqnfr", "hifi", + "listenbrainz", "playpause", "rescrobbler", + "scrobble", + "scrobbling", "Songwhip", "trackid", "tracklist", diff --git a/CHANGELOG.md b/CHANGELOG.md index e99c0ed..5733718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.5.0 + +- ListenBrainz integration added (thanks @Mar0xy) + ## 5.4.0 - Removed Windows builds (from publishes) as they don't work anymore. diff --git a/README.md b/README.md index 222cc3a..9b42866 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The web version of [listen.tidal.com](https://listen.tidal.com) running in elect - Custom [integrations](#integrations) - [Settings feature](./docs/images/settings.png) to disable certain functionality. (`ctrl+=` or `ctrl+0`) - AlbumArt in integrations ([best-effort](https://github.com/Mastermindzh/tidal-hifi/pull/88#pullrequestreview-840814847)) +- [ListenBrainz](https://listenbrainz.org/?redirect=false) integration ## Contributions @@ -133,7 +134,7 @@ To install and work with the code on this project follow these steps: ## Integrations -Tidal-hifi comes with several integrations out of the box. +tidal-hifi comes with several integrations out of the box. You can find these in the settings menu (`ctrl + =` by default) under the "integrations" tab. ![integrations menu, showing a list of integrations](./docs/images/integrations.png) @@ -153,11 +154,11 @@ Not included: The last.fm login doesn't work, as is evident from the following issue: [Last.fm login doesn't work](https://github.com/Mastermindzh/tidal-hifi/issues/4). However, in that same issue you can read about a workaround using [rescrobbler](https://github.com/InputUsername/rescrobbled). -For now that will be the default workaround. +For now, that will be the default workaround. #### DRM not working on Windows -Most Windows users run into DRM issues when trying to use Tidal-hifi. +Most Windows users run into DRM issues when trying to use tidal-hifi. Nothing I can do about that I'm afraid... Tidal is working on removing/changing DRM so when they finish with that we can give it another shot. ## Special thanks to diff --git a/package-lock.json b/package-lock.json index eddb972..57399f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tidal-hifi", - "version": "5.4.0", + "version": "5.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tidal-hifi", - "version": "5.4.0", + "version": "5.5.0", "license": "MIT", "dependencies": { "@electron/remote": "^2.0.10", @@ -9340,4 +9340,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9fefb66..d835194 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tidal-hifi", - "version": "5.4.0", + "version": "5.5.0", "description": "Tidal on Electron with widevine(hifi) support", "main": "ts-dist/main.js", "scripts": { @@ -72,4 +72,4 @@ "typescript": "^5.1.6" }, "prettier": "@mastermindzh/prettier-config" -} +} \ No newline at end of file diff --git a/src/constants/settings.ts b/src/constants/settings.ts index df2c39f..f437a57 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -20,6 +20,12 @@ export const settings = { disableHardwareMediaKeys: "disableHardwareMediaKeys", enableCustomHotkeys: "enableCustomHotkeys", enableDiscord: "enableDiscord", + ListenBrainz: { + root: "ListenBrainz", + enabled: "ListenBrainz.enabled", + api: "ListenBrainz.api", + token: "ListenBrainz.token", + }, flags: { root: "flags", disableHardwareMediaKeys: "flags.disableHardwareMediaKeys", diff --git a/src/constants/statuses.ts b/src/constants/statuses.ts deleted file mode 100644 index 3209307..0000000 --- a/src/constants/statuses.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const statuses = { - playing: "playing", - paused: "paused", -}; diff --git a/src/features/listenbrainz/listenbrainz.ts b/src/features/listenbrainz/listenbrainz.ts new file mode 100644 index 0000000..1032a72 --- /dev/null +++ b/src/features/listenbrainz/listenbrainz.ts @@ -0,0 +1,134 @@ +import axios from "axios"; +import { ipcRenderer } from "electron"; +import Store from "electron-store"; +import { settings } from "../../constants/settings"; +import { MediaStatus } from "../../models/mediaStatus"; +import { settingsStore } from "../../scripts/settings"; +import { Logger } from "../logger"; +import { StoreData } from "./models/storeData"; + +const ListenBrainzStore = new Store({ name: "listenbrainz" }); + +export const ListenBrainzConstants = { + oldData: "oldData", +}; + +export class ListenBrainz { + /** + * Create the object to store old information in the Store :) + * @param title + * @param artists + * @param duration + * @returns data passed along in an object + a "listenedAt" key with the current time + */ + private static constructStoreData(title: string, artists: string, duration: number): StoreData { + return { + listenedAt: Math.floor(new Date().getTime() / 1000), + title, + artists, + duration, + }; + } + + /** + * Call the ListenBrainz API and create playing now payload and scrobble old song + * @param title + * @param artists + * @param status + * @param duration + */ + public static async scrobble( + title: string, + artists: string, + status: string, + duration: number + ): Promise { + try { + if (status === MediaStatus.paused) { + return; + } else { + // Fetches the oldData required for scrobbling and proceeds to construct a playing_now data payload for the Playing Now area + const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData; + const playing_data = { + listen_type: "playing_now", + payload: [ + { + track_metadata: { + additional_info: { + media_player: "Tidal Hi-Fi", + submission_client: "Tidal Hi-Fi", + music_service: "tidal.com", + duration: duration, + }, + artist_name: artists, + track_name: title, + }, + }, + ], + }; + + await axios.post( + `${settingsStore.get(settings.ListenBrainz.api)}/1/submit-listens`, + playing_data, + { + headers: { + "Content-Type": "application/json", + Authorization: `Token ${settingsStore.get( + settings.ListenBrainz.token + )}`, + }, + } + ); + if (!oldData) { + ListenBrainzStore.set( + ListenBrainzConstants.oldData, + this.constructStoreData(title, artists, duration) + ); + } else { + if (oldData.title !== title) { + // This constructs the data required to scrobble the data after the song finishes + const scrobble_data = { + listen_type: "single", + payload: [ + { + listened_at: oldData.listenedAt, + track_metadata: { + additional_info: { + media_player: "Tidal Hi-Fi", + submission_client: "Tidal Hi-Fi", + music_service: "listen.tidal.com", + duration: oldData.duration, + }, + artist_name: oldData.artists, + track_name: oldData.title, + }, + }, + ], + }; + await axios.post( + `${settingsStore.get(settings.ListenBrainz.api)}/1/submit-listens`, + scrobble_data, + { + headers: { + "Content-Type": "application/json", + Authorization: `Token ${settingsStore.get( + settings.ListenBrainz.token + )}`, + }, + } + ); + ListenBrainzStore.set( + ListenBrainzConstants.oldData, + this.constructStoreData(title, artists, duration) + ); + } + } + } + } catch (error) { + const logger = new Logger(ipcRenderer); + logger.log(JSON.stringify(error)); + } + } +} + +export { ListenBrainzStore }; diff --git a/src/features/listenbrainz/models/storeData.ts b/src/features/listenbrainz/models/storeData.ts new file mode 100644 index 0000000..3501c69 --- /dev/null +++ b/src/features/listenbrainz/models/storeData.ts @@ -0,0 +1,9 @@ +/** + * Data saved for ListenBrainz + */ +export interface StoreData { + listenedAt: number; + title: string; + artists: string; + duration: number; +} diff --git a/src/features/logger.ts b/src/features/logger.ts index 020a3cc..11868f0 100644 --- a/src/features/logger.ts +++ b/src/features/logger.ts @@ -26,8 +26,7 @@ export class Logger { public log(content: string, object: object = {}) { if (this.ipcRenderer) { this.ipcRenderer.send(globalEvents.log, { content, object }); - } else { - console.log(`${content} \n ${JSON.stringify(object, null, 2)}`); } + console.log(`${content} \n ${JSON.stringify(object, null, 2)}`); } } diff --git a/src/main.ts b/src/main.ts index 7de3b94..130a7a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,7 +58,7 @@ function setFlags() { } /** - * Update the menuBarVisbility according to the store value + * Update the menuBarVisibility according to the store value * */ function syncMenuBarWithStore() { diff --git a/src/pages/settings/preload.ts b/src/pages/settings/preload.ts index 53ad096..42f2e7b 100644 --- a/src/pages/settings/preload.ts +++ b/src/pages/settings/preload.ts @@ -25,7 +25,11 @@ let adBlock: HTMLInputElement, skippedArtists: HTMLInputElement, theme: HTMLSelectElement, trayIcon: HTMLInputElement, - updateFrequency: HTMLInputElement; + updateFrequency: HTMLInputElement, + enableListenBrainz: HTMLInputElement, + ListenBrainzAPI: HTMLInputElement, + ListenBrainzToken: HTMLInputElement; + function getThemeFiles() { const selectElement = document.getElementById("themesList") as HTMLSelectElement; const builtInThemes = getThemeListFromDirectory(process.resourcesPath); @@ -87,6 +91,9 @@ function refreshSettings() { skippedArtists.value = settingsStore.get(settings.skippedArtists).join("\n"); trayIcon.checked = settingsStore.get(settings.trayIcon); updateFrequency.value = settingsStore.get(settings.updateFrequency); + enableListenBrainz.checked = settingsStore.get(settings.ListenBrainz.enabled); + ListenBrainzAPI.value = settingsStore.get(settings.ListenBrainz.api); + ListenBrainzToken.value = settingsStore.get(settings.ListenBrainz.token); } /** @@ -137,6 +144,10 @@ window.addEventListener("DOMContentLoaded", () => { } else { settingsStore.set(key, source.value); } + // Live update the view for ListenBrainz input, hide if disabled/show if enabled + if (source.value === "on" && source.id === "enableListenBrainz") { + source.checked ? document.getElementById("listenbrainz__options").removeAttribute("hidden") : document.getElementById("listenbrainz__options").setAttribute("hidden", "true"); + } ipcRenderer.send(globalEvents.storeChanged); }); } @@ -183,8 +194,12 @@ window.addEventListener("DOMContentLoaded", () => { skippedArtists = get("skippedArtists"); singleInstance = get("singleInstance"); updateFrequency = get("updateFrequency"); + enableListenBrainz = get("enableListenBrainz"); + ListenBrainzAPI = get("ListenBrainzAPI"); + ListenBrainzToken = get("ListenBrainzToken"); refreshSettings(); + enableListenBrainz.checked ? document.getElementById("listenbrainz__options").removeAttribute("hidden") : document.getElementById("listenbrainz__options").setAttribute("hidden", "true"); addInputListener(adBlock, settings.adBlock); addInputListener(api, settings.api); @@ -206,4 +221,7 @@ window.addEventListener("DOMContentLoaded", () => { addSelectListener(theme, settings.theme); addInputListener(trayIcon, settings.trayIcon); addInputListener(updateFrequency, settings.updateFrequency); + addInputListener(enableListenBrainz, settings.ListenBrainz.enabled); + addTextAreaListener(ListenBrainzAPI, settings.ListenBrainz.api); + addTextAreaListener(ListenBrainzToken, settings.ListenBrainz.token); }); diff --git a/src/pages/settings/settings.html b/src/pages/settings/settings.html index 583b24b..6b90c07 100644 --- a/src/pages/settings/settings.html +++ b/src/pages/settings/settings.html @@ -212,6 +212,35 @@ +
+

ListenBrainz

+
+
+

Enable ListenBrainz

+

Scrobble your listens directly to ListenBrainz.

+
+ +
+ +
diff --git a/src/preload.ts b/src/preload.ts index dffedb6..ea3077a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,19 +4,25 @@ import fs from "fs"; import Player from "mpris-service"; import { globalEvents } from "./constants/globalEvents"; import { settings } from "./constants/settings"; -import { statuses } from "./constants/statuses"; import { Songwhip } from "./features/songwhip/songwhip"; +import { + ListenBrainz, + ListenBrainzConstants, + ListenBrainzStore, +} from "./features/listenbrainz/listenbrainz"; import { Options } from "./models/options"; import { downloadFile } from "./scripts/download"; import { addHotkey } from "./scripts/hotkeys"; import { settingsStore } from "./scripts/settings"; import { setTitle } from "./scripts/window-functions"; +import { StoreData } from "./features/listenbrainz/models/storeData"; +import { MediaStatus } from "./models/mediaStatus"; const notificationPath = `${app.getPath("userData")}/notification.jpg`; const appName = "Tidal Hifi"; let currentSong = ""; let player: Player; -let currentPlayStatus = statuses.paused; +let currentPlayStatus = MediaStatus.paused; const elements = { play: '*[data-test="play"]', @@ -105,7 +111,7 @@ const elements = { window.location.href.includes("/playlist/") || window.location.href.includes("/mix/") ) { - if (currentPlayStatus === statuses.playing) { + if (currentPlayStatus === MediaStatus.playing) { // find the currently playing element from the list (which might be in an album icon), traverse back up to the mediaItem (row) and select the album cell. // document.querySelector("[class^='isPlayingIcon'], [data-test-is-playing='true']").closest('[data-type="mediaItem"]').querySelector('[class^="album"]').textContent const row = window.document.querySelector(this.currentlyPlaying).closest(this.mediaItem); @@ -178,7 +184,7 @@ function addCustomCss() { * make sure it returns a number, if not use the default */ function getUpdateFrequency() { - const storeValue = settingsStore.get(settings.updateFrequency) as number; + const storeValue = settingsStore.get(settings.updateFrequency); const defaultValue = 500; if (!isNaN(storeValue)) { @@ -201,6 +207,11 @@ function playPause() { } } +/** + * Clears the old listenbrainz data on launch + */ +ListenBrainzStore.clear(); + /** * Add hotkeys for when tidal is focused * Reflects the desktop hotkeys found on: @@ -274,7 +285,7 @@ function handleLogout() { defaultId: 2, }) .then((result: { response: number }) => { - if (logoutOptions.indexOf("Yes, please") == result.response) { + if (logoutOptions.indexOf("Yes, please") === result.response) { for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); if (key.startsWith("_TIDAL_activeSession")) { @@ -316,6 +327,8 @@ function addIPCEventListeners() { case globalEvents.pause: elements.click("pause"); break; + default: + break; } }); }); @@ -330,9 +343,9 @@ function getCurrentlyPlayingStatus() { // if pause button is visible tidal is playing if (pause) { - status = statuses.playing; + status = MediaStatus.playing; } else { - status = statuses.paused; + status = MediaStatus.paused; } return status; } @@ -357,19 +370,41 @@ function updateMediaInfo(options: Options, notify: boolean) { if (settingsStore.get(settings.notifications) && notify) { new Notification({ title: options.title, body: options.artists, icon: options.icon }).show(); } - if (player) { - player.metadata = { - ...player.metadata, - ...{ - "xesam:title": options.title, - "xesam:artist": [options.artists], - "xesam:album": options.album, - "mpris:artUrl": options.image, - "mpris:length": convertDuration(options.duration) * 1000 * 1000, - "mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(), - }, - }; - player.playbackStatus = options.status == statuses.paused ? "Paused" : "Playing"; + updateMpris(options); + updateListenBrainz(options); + } +} + +function updateMpris(options: Options) { + if (player) { + player.metadata = { + ...player.metadata, + ...{ + "xesam:title": options.title, + "xesam:artist": [options.artists], + "xesam:album": options.album, + "mpris:artUrl": options.image, + "mpris:length": convertDuration(options.duration) * 1000 * 1000, + "mpris:trackid": "/org/mpris/MediaPlayer2/track/" + getTrackID(), + }, + }; + player.playbackStatus = options.status === MediaStatus.paused ? "Paused" : "Playing"; + } +} + +function updateListenBrainz(options: Options) { + if (settingsStore.get(settings.ListenBrainz.enabled)) { + const oldData = ListenBrainzStore.get(ListenBrainzConstants.oldData) as StoreData; + if ( + (!oldData && options.status === MediaStatus.playing) || + (oldData && oldData.title !== options.title) + ) { + ListenBrainz.scrobble( + options.title, + options.artists, + options.status, + convertDuration(options.duration) + ); } } } diff --git a/src/scripts/express.ts b/src/scripts/express.ts index 68c9fd8..d731039 100644 --- a/src/scripts/express.ts +++ b/src/scripts/express.ts @@ -1,11 +1,11 @@ import { BrowserWindow, dialog } from "electron"; import express, { Response } from "express"; import fs from "fs"; +import { settings } from "../constants/settings"; +import { MediaStatus } from "../models/mediaStatus"; import { globalEvents } from "./../constants/globalEvents"; -import { statuses } from "./../constants/statuses"; import { mediaInfo } from "./mediaInfo"; import { settingsStore } from "./settings"; -import { settings } from "../constants/settings"; /** * Function to enable tidal-hifi's express api @@ -44,7 +44,7 @@ export const startExpress = (mainWindow: BrowserWindow) => { expressApp.get("/next", (req, res) => handleGlobalEvent(res, globalEvents.next)); expressApp.get("/previous", (req, res) => handleGlobalEvent(res, globalEvents.previous)); expressApp.get("/playpause", (req, res) => { - if (mediaInfo.status == statuses.playing) { + if (mediaInfo.status === MediaStatus.playing) { handleGlobalEvent(res, globalEvents.pause); } else { handleGlobalEvent(res, globalEvents.play); diff --git a/src/scripts/mediaInfo.ts b/src/scripts/mediaInfo.ts index 1341a08..b03b316 100644 --- a/src/scripts/mediaInfo.ts +++ b/src/scripts/mediaInfo.ts @@ -1,12 +1,12 @@ import { MediaInfo } from "../models/mediaInfo"; -import { statuses } from "./../constants/statuses"; +import { MediaStatus } from "../models/mediaStatus"; export const mediaInfo = { title: "", artists: "", album: "", icon: "", - status: statuses.paused, + status: MediaStatus.paused as string, url: "", current: "", duration: "", diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index 614f8d1..086504e 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -18,6 +18,11 @@ export const settingsStore = new Store({ disableHardwareMediaKeys: false, enableCustomHotkeys: false, enableDiscord: false, + ListenBrainz: { + enabled: false, + api: "https://api.listenbrainz.org", + token: "", + }, flags: { gpuRasterization: true, disableHardwareMediaKeys: false,