mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68dea5193 | ||
|
|
b5d56b75d7 | ||
|
|
c8daffb935 | ||
|
|
18a7f26c54 | ||
|
|
e7395e79a5 | ||
|
|
08ee6d379f | ||
|
|
9c692539ce | ||
|
|
5cd5061f20 | ||
|
|
46bcf6d6a6 | ||
|
|
4da931269c | ||
|
|
d19d40d479 | ||
|
|
b3e1c7f660 | ||
|
|
1242a710df | ||
|
|
b724eca9be | ||
|
|
376d4936cc | ||
|
|
a5da0573c5 | ||
|
|
4694ce45a7 | ||
|
|
7c7d710cd3 |
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
|
||||
DOCS_BASE: '/multi-scrobbler'
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
@@ -21,6 +21,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using Listenbrainz Source
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
@@ -38,6 +39,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
|
||||
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
@@ -45,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
|
||||
@@ -16,5 +16,15 @@
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "koitoClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD",
|
||||
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -765,17 +765,18 @@ http://localhost:9078/api/lastfm/mySlug
|
||||
|
||||
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
You will need to run your own Listenbrainz server, [Koito](https://koito.io) server, or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
:::note
|
||||
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
|
||||
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the **File** or AOI config to setup Listenbrainz as a Source.
|
||||
:::
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
@@ -2267,10 +2268,12 @@ or replace `localhost:9078` with your own base URL
|
||||
|
||||
### [Listenbrainz](https://listenbrainz.org)
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
You will need to run your own Listenbrainz server, [Koito](https://koito.io) or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
|
||||
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
|
||||
@@ -6,12 +6,6 @@ title: Flatpak
|
||||
description: Building Flatpak App locally
|
||||
---
|
||||
|
||||
:::note
|
||||
|
||||
These steps are for building the flatpak from source. If you want to install the application normally then [get it through flathub](../installation/installation.mdx#flatpak)
|
||||
|
||||
:::
|
||||
|
||||
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
|
||||
|
||||
## Install Requirements
|
||||
|
||||
@@ -105,12 +105,6 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
|
||||
|
||||
### Flatpak
|
||||
|
||||
You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
|
||||
```shell
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
:::warning
|
||||
|
||||
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
|
||||
@@ -118,7 +112,9 @@ Flatpak users have experienced issues when using multi-scrobbler as a long-runni
|
||||
* to use a [Docker](#docker) installation if possible or
|
||||
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
|
||||
|
||||
::::
|
||||
:::
|
||||
|
||||
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
|
||||
@@ -31,27 +31,14 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=flatpak run io.github.multiscrobbler
|
||||
WorkingDirectory=/path/to/multi-scrobbler/directory
|
||||
ExecStart=npm run start
|
||||
Restart=no
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
The above assumes you [installed multi-scrobbler using flatpak](installation.mdx#flatpak)
|
||||
|
||||
### Node.js Installs
|
||||
|
||||
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/path/to/multi-scrobbler/directory
|
||||
ExecStart=node src/index.js
|
||||
Restart=no
|
||||
```
|
||||
|
||||
## Start the Service
|
||||
|
||||
Save the file then run:
|
||||
|
||||
@@ -173,6 +173,15 @@ const config: Config = {
|
||||
runmeLinkLabel: 'Checkout via Runme'
|
||||
}
|
||||
} satisfies Preset.ThemeConfig,
|
||||
headTags: [
|
||||
{
|
||||
tagName: 'meta',
|
||||
attributes: {
|
||||
name: 'google-site-verification',
|
||||
content: process.env.GSITEVERIFICATION ?? 'none'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
|
||||
@@ -22,6 +22,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
@@ -39,6 +40,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz)
|
||||
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
|
||||
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
|
||||
* Web server interface for stats, basic control, and detailed logs
|
||||
@@ -46,7 +48,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker) or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](docs/quickstart)
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
<li>MPRIS (linux desktop)</li>
|
||||
<li>Google Cast (Chromecast)</li>
|
||||
<li>Musikcube</li>
|
||||
<li>MPDF (Music Player Daemon)</li>
|
||||
<li>MPD (Music Player Daemon)</li>
|
||||
<li>VLC</li>
|
||||
<li>Azuracast</li>
|
||||
<li>Icecast</li>
|
||||
<li>Yamaha MusicCast</li>
|
||||
<li>Listenbrainz and Last.fm Endpoints</li>
|
||||
</ul>
|
||||
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
|
||||
<ul>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ListensResponse {
|
||||
items: ListenObjectResponse[]
|
||||
total_record_count: number
|
||||
items_per_page: number
|
||||
has_next_page: boolean
|
||||
current_page: number
|
||||
}
|
||||
|
||||
export interface ListenObjectResponse {
|
||||
/** ISO8601 timestamp */
|
||||
time: string
|
||||
track: TrackResponse
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
id: number
|
||||
title: string
|
||||
artists: ArtistResponse[]
|
||||
musicbrainz_id: string | null
|
||||
listen_count: number
|
||||
duration: number
|
||||
image: string | null
|
||||
album_id: number
|
||||
time_listened: number
|
||||
}
|
||||
|
||||
export interface ArtistResponse {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
+68
-8
@@ -1,7 +1,7 @@
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { PlayObject, URLData } from "../../../core/Atomic.js";
|
||||
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
@@ -16,6 +16,10 @@ import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
|
||||
import { parseRegexSingleOrFail } from '../../utils.js';
|
||||
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
|
||||
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
|
||||
|
||||
|
||||
export interface ArtistMBIDMapping {
|
||||
@@ -112,17 +116,27 @@ export interface ListenResponse {
|
||||
track_metadata: TrackResponse;
|
||||
}
|
||||
|
||||
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
|
||||
|
||||
export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: string;
|
||||
url: URLData;
|
||||
isKoito: boolean = false;
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
const {
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
this.url = url;
|
||||
let cleanUrl = url;
|
||||
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
cleanUrl = url.replace(LZ_VERSION_PATH, '');
|
||||
}
|
||||
this.url = normalizeWebAddress(cleanUrl);
|
||||
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +192,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testConnection = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(this.url))
|
||||
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
|
||||
this.isKoito = await this.checkKoito();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
|
||||
@@ -190,18 +205,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async checkKoito(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
|
||||
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
|
||||
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.');
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -217,11 +244,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
|
||||
.query({
|
||||
period: 'all_time',
|
||||
page: 0,
|
||||
limit: maxTracks
|
||||
})
|
||||
);
|
||||
const { body } = resp as any;
|
||||
return body as KoitoListensResponse;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
|
||||
try {
|
||||
|
||||
const resp = await this.callApi(request
|
||||
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
|
||||
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
|
||||
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
|
||||
.timeout({
|
||||
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
|
||||
@@ -238,6 +283,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
|
||||
if(this.isKoito) {
|
||||
return this.getRecentlyPlayedKoito(maxTracks)
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.getUserListens(maxTracks, user);
|
||||
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
|
||||
@@ -247,6 +296,17 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await this.getUserListensKoito(maxTracks);
|
||||
return resp.items.map(x => listenObjectResponseToPlay(x));
|
||||
} catch (e) {
|
||||
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
submitListen = async (play: PlayObject, log: boolean = false) => {
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
@@ -257,7 +317,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
// so no useful information
|
||||
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
|
||||
// TODO may we should make a call to recent-listens to get the parsed scrobble?
|
||||
const resp = await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
|
||||
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Response: ${resp.text}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import dayjs from "dayjs";
|
||||
import { PlayObject } from "../../../../core/Atomic.js";
|
||||
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
|
||||
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
|
||||
|
||||
export class KoitoApiClient extends AbstractApiClient {
|
||||
|
||||
constructor(name: any, config: any, options: AbstractApiOptions) {
|
||||
super('Koito', name, config, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => {
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track: obj.track.title,
|
||||
artists: (obj.track.artists ?? []).map(x => x.name),
|
||||
duration: obj.track.duration,
|
||||
playDate: dayjs(obj.time)
|
||||
},
|
||||
meta: {
|
||||
source: 'Koito',
|
||||
newFromSource: options.newFromSource ?? false,
|
||||
trackId: obj.track.id.toString()
|
||||
}
|
||||
}
|
||||
if(obj.track.musicbrainz_id !== null) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: obj.track.musicbrainz_id
|
||||
}
|
||||
}
|
||||
}
|
||||
return play;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzClientConfig;
|
||||
|
||||
@@ -44,6 +45,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
await this.api.testConnection();
|
||||
this.isKoito = this.api.isKoito;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
|
||||
if (payload !== undefined) {
|
||||
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
|
||||
|
||||
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
|
||||
const pSources = scrobbleSources.getByType('plex').filter(x => x instanceof PlexSource) as PlexSource[];
|
||||
if (pSources.length === 0) {
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
|
||||
plexLog.warn('Received valid Plex webhook payload but no Plex Webhook sources are configured');
|
||||
}
|
||||
|
||||
for (const source of pSources) {
|
||||
|
||||
@@ -46,7 +46,7 @@ import EventEmitter from "events";
|
||||
import { nanoid } from "nanoid";
|
||||
import pEvent from "p-event";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
RunTimeTicks,
|
||||
Type, // should be BaseItemKind.Audio
|
||||
UserData,
|
||||
ProviderIds = {}
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
const meta: BrainzMeta = {};
|
||||
|
||||
if(ProviderIds.MusicBrainzAlbum !== undefined) {
|
||||
meta.album = ProviderIds.MusicBrainzAlbum;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzTrack !== undefined) {
|
||||
meta.track = ProviderIds.MusicBrainzTrack;
|
||||
}
|
||||
if(ProviderIds.MusicBrainzArtist !== undefined) {
|
||||
meta.artist = [ProviderIds.MusicBrainzArtist];
|
||||
}
|
||||
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
|
||||
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
|
||||
}
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
artists: Artists,
|
||||
album: Album,
|
||||
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
|
||||
source: 'Jellyfin',
|
||||
}
|
||||
}
|
||||
if(Object.keys(meta).length > 0) {
|
||||
play.data.meta = { brainz: meta };
|
||||
}
|
||||
return play;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
|
||||
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
|
||||
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
|
||||
|
||||
export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
api: ListenbrainzApiClient;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = false;
|
||||
isKoito: boolean = false;
|
||||
|
||||
declare config: ListenBrainzSourceConfig;
|
||||
|
||||
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
await request.get(this.api.url);
|
||||
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
|
||||
const isKoito = await this.api.checkKoito();
|
||||
this.api.isKoito = isKoito;
|
||||
this.isKoito = isKoito;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(isNodeNetworkException(e)) {
|
||||
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
|
||||
|
||||
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
|
||||
const {limit = 20} = options;
|
||||
if(this.isKoito) {
|
||||
return await this.api.getRecentlyPlayedKoito(limit);
|
||||
}
|
||||
const now = await this.api.getPlayingNow();
|
||||
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
|
||||
@@ -2,7 +2,12 @@ import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
|
||||
|
||||
const RT_TICK = 500;
|
||||
export const RT_TICK_DEFAULT = 500;
|
||||
let RT_TICK = 500;
|
||||
|
||||
export const setRtTick = (tick: number) => {
|
||||
RT_TICK = tick;
|
||||
}
|
||||
|
||||
export abstract class RealtimePlayer {
|
||||
|
||||
@@ -13,7 +18,7 @@ export abstract class RealtimePlayer {
|
||||
private clockTS: Dayjs = dayjs();
|
||||
|
||||
protected constructor(/* logger: Logger */) {
|
||||
//this.logger = childLogger(logger, `RT`);
|
||||
this.position = 0;
|
||||
const job = new SimpleIntervalJob({
|
||||
milliseconds: RT_TICK,
|
||||
runImmediately: true
|
||||
@@ -27,7 +32,6 @@ export abstract class RealtimePlayer {
|
||||
}), { id: 'rt' });
|
||||
this.scheduler.addSimpleIntervalJob(job);
|
||||
this.scheduler.stop();
|
||||
this.position = 0;
|
||||
}
|
||||
|
||||
public play(position?: number) {
|
||||
@@ -52,14 +56,18 @@ export abstract class RealtimePlayer {
|
||||
}
|
||||
|
||||
public getPosition(asSeconds: boolean = false) {
|
||||
if(this.position === 0 || this.position === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return !asSeconds ? this.position : this.position / 1000;
|
||||
}
|
||||
|
||||
public setPosition(time?: number) {
|
||||
if(time === undefined) {
|
||||
this.position += Math.abs(dayjs().diff(this.clockTS, 'ms'));
|
||||
} else {
|
||||
this.position = time;
|
||||
}
|
||||
this.position = time;
|
||||
this.clockTS = dayjs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('Sample Configs', function () {
|
||||
await copyFile(samplePath(componentType), `${componentType}.json`);
|
||||
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
|
||||
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
|
||||
expect(clients.clients).length(1);
|
||||
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
|
||||
expect(clients.clients).length(expectedCount);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,8 +9,7 @@ export class TestSource extends AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
export class TestMemorySource extends MemoryPositionalSource {
|
||||
|
||||
export class TestMemorySource extends MemorySource {
|
||||
}
|
||||
|
||||
export class TestMemoryPositionalSource extends MemoryPositionalSource {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { SourceConfig } from "../../common/infrastructure/config/source/sources.
|
||||
import MemorySource from "../../sources/MemorySource.js";
|
||||
import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
|
||||
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
|
||||
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
|
||||
import { sleep } from "../../utils.js";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
@@ -186,9 +188,13 @@ describe('Player Cleanup', function () {
|
||||
|
||||
this.afterEach(() => {
|
||||
MockDate.reset();
|
||||
setRtTick(RT_TICK_DEFAULT);
|
||||
});
|
||||
this.beforeEach(() => {
|
||||
setRtTick(1);
|
||||
});
|
||||
|
||||
const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const cleanedUpDuration = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
@@ -202,6 +208,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -210,25 +217,27 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
|
||||
}
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemorySource);
|
||||
it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () {
|
||||
await cleanedUpDuration(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Discovers cleaned up Play with correct duration (Positional Source)', function () {
|
||||
cleanedUpDuration(generateMemoryPositionalSource);
|
||||
it('Discovers cleaned up Play with correct duration (Positional Source)', async function () {
|
||||
await cleanedUpDuration(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const noScrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -243,6 +252,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -251,12 +261,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(1);
|
||||
@@ -269,12 +281,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
|
||||
@@ -283,15 +297,15 @@ describe('Player Cleanup', function () {
|
||||
}
|
||||
|
||||
|
||||
it('Does not discover same Play after becoming active again (Non Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemorySource);
|
||||
it('Does not discover same Play after becoming active again (Non Positional Source)', async function () {
|
||||
await noScrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover same Play after becoming active again (Positional Source)', function () {
|
||||
noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
it('Does not discover same Play after becoming active again (Positional Source)', async function () {
|
||||
await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const noScrobbleStale = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -308,6 +322,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -316,6 +331,7 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
@@ -326,15 +342,15 @@ describe('Player Cleanup', function () {
|
||||
|
||||
}
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () {
|
||||
noScrobbleStale(generateMemorySource);
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () {
|
||||
await noScrobbleStale(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () {
|
||||
noScrobbleStale(generateMemoryPositionalSource);
|
||||
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () {
|
||||
await noScrobbleStale(generateMemoryPositionalSource);
|
||||
});
|
||||
|
||||
const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
const scrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
|
||||
|
||||
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
|
||||
const initialDate = dayjs();
|
||||
@@ -351,6 +367,7 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
@@ -359,12 +376,14 @@ describe('Player Cleanup', function () {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
expect(source.processRecentPlays([]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 2;
|
||||
|
||||
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const discoveredPlays = source.processRecentPlays([]);
|
||||
// cleanup should not discover stale play
|
||||
expect(discoveredPlays.length).to.be.eq(0);
|
||||
@@ -378,12 +397,14 @@ describe('Player Cleanup', function () {
|
||||
position += 10;
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
|
||||
}
|
||||
|
||||
timeSince += 10;
|
||||
MockDate.set(initialDate.add(position, 'seconds').toDate());
|
||||
await sleep(1);
|
||||
// new Play
|
||||
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
|
||||
// should return discovered play with ~90 seconds of duration
|
||||
@@ -393,12 +414,12 @@ describe('Player Cleanup', function () {
|
||||
|
||||
}
|
||||
|
||||
it('Does discover Play after becoming active again (Non Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemorySource);
|
||||
it('Does discover Play after becoming active again (Non Positional Source)', async function () {
|
||||
await scrobbleRediscoveryOnActive(generateMemorySource);
|
||||
});
|
||||
|
||||
it('Does discover Play after becoming active again (Positional Source)', function () {
|
||||
scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
it('Does discover Play after becoming active again (Positional Source)', async function () {
|
||||
await scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
|
||||
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
|
||||
|
||||
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
|
||||
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
||||
let v4: string,
|
||||
|
||||
@@ -41,7 +41,7 @@ art = {},
|
||||
} = {}
|
||||
} = {},
|
||||
play,
|
||||
listenedDuration,
|
||||
listenedDuration = 0,
|
||||
status: {
|
||||
calculated = '???',
|
||||
reported,
|
||||
@@ -52,7 +52,7 @@ art = {},
|
||||
|
||||
let durPer = null;
|
||||
if(duration !== undefined && duration !== null && duration !== 0) {
|
||||
if(listenedDuration === 0) {
|
||||
if(listenedDuration === 0 || listenedDuration === null) {
|
||||
durPer = ' (0%)';
|
||||
} else {
|
||||
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
|
||||
@@ -97,7 +97,7 @@ art = {},
|
||||
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
|
||||
<div className="flex">
|
||||
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
</div>
|
||||
</section>
|
||||
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
|
||||
|
||||
Reference in New Issue
Block a user