mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54159f9b9f | ||
|
|
9b9176ccf1 |
@@ -29,7 +29,6 @@
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"TakumiI.markdowntable",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"bowlerr.sqlite-intelliview-vscode"
|
||||
|
||||
Vendored
+1
-6
@@ -6,13 +6,8 @@
|
||||
"**/build": true,
|
||||
"**/schema": true
|
||||
},
|
||||
"tailwindCSS.files.exclude": [
|
||||
"docsite/**"
|
||||
],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss",
|
||||
"**/config/*.json": "jsonc"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "src/client/index.css",
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Dashboard for Multi Scrobbler"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app. aaaa</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index-next.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1074
File diff suppressed because it is too large
Load Diff
@@ -159,21 +159,15 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@flamefrontend/sse-runtime-react": "^0.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@lukemorales/query-key-factory": "^1.3.4",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@react-hook/window-size": "^3.1.1",
|
||||
"@react-nano/use-event-source": "^0.16.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@storybook/addon-a11y": "^10.4.3",
|
||||
"@storybook/addon-docs": "^10.4.3",
|
||||
"@storybook/react-vite": "^10.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/form-core": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-form": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
@@ -199,7 +193,6 @@
|
||||
"@types/qs": "^6.15.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^8.1.9",
|
||||
@@ -208,11 +201,9 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@welldone-software/why-did-you-render": "^10.0.1",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^1.0.0-rc.2-c5a84d1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
@@ -233,22 +224,16 @@
|
||||
"msw": "^2.14.6",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemon": "^3.0.3",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sass": "^1.99.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "^10.4.3",
|
||||
"storybook-addon-remix-react-router": "^6.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
+15
-427
@@ -6,48 +6,35 @@ import { FixedSizeList } from 'fixed-size-list';
|
||||
import { PassThrough } from "node:stream";
|
||||
import { Transform } from "stream";
|
||||
import {
|
||||
DEAD_QUEUE,
|
||||
type ClientStatusData,
|
||||
type DeadLetterScrobble,
|
||||
type LogOutputConfig,
|
||||
type PlayObject,
|
||||
SOURCE_SOT,
|
||||
type SOURCE_SOT_TYPES,
|
||||
type SourcePlayerJson,
|
||||
type SourceStatusData,
|
||||
queueContextSchema,
|
||||
logLevelStandaloneSchema,
|
||||
} from "../../core/Atomic.ts";
|
||||
import { capitalize } from "../../core/StringUtils.ts";
|
||||
import type {LeveledLogData} from "../common/infrastructure/Atomic.ts";
|
||||
import { getRoot } from "../ioc.ts";
|
||||
import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts";
|
||||
import AbstractSource from "../sources/AbstractSource.ts";
|
||||
import MemorySource from "../sources/MemorySource.ts";
|
||||
import { parseBool } from "../utils.ts";
|
||||
import { sortByNewestPlayDate } from '../../core/PlayUtils.ts';
|
||||
import { setupAuthRoutes } from "./auth.ts";
|
||||
import { setupDeezerRoutes } from "./deezerRoutes.ts";
|
||||
import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.ts";
|
||||
import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.ts";
|
||||
import { makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle } from "./middleware.ts";
|
||||
import { makeComponentMiddle } from "./middleware.ts";
|
||||
import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.ts";
|
||||
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
|
||||
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
|
||||
import prom from 'prom-client';
|
||||
import { findAuthIssue, SimpleError } from "../common/errors/MSErrors.ts";
|
||||
import { DrizzlePlayRepository, type QueryPlaysOpts, type QueryPlaysOptsJson } from "../common/database/drizzle/repositories/PlayRepository.ts";
|
||||
import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.ts";
|
||||
import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.ts";
|
||||
import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";
|
||||
import {componentStateBodySchema, type ComponentClientApiJson, type ComponentSourceApiJson, type PlayApiCommonDetailed} from "../../core/Api.ts";
|
||||
import {componentStateBodySchema, type ComponentClientApiJson, type ComponentSourceApiJson} from "../../core/Api.ts";
|
||||
import { asDayjsHydratedObject } from "../../core/DataUtils.ts";
|
||||
import type {Dayjs} from "dayjs";
|
||||
import { asSerializablePlaySelect } from "../../core/PlayMarshalUtils.ts";
|
||||
import { serializeError } from "serialize-error";
|
||||
import { z } from 'zod';
|
||||
import type { createTypedRouter, TypedMiddleware } from "@minisylar/express-typed-router";
|
||||
import pEvent from "p-event";
|
||||
import { hasMetricRepositories, registerMetrics, setMetricRepositories } from "./promMetrics.ts";
|
||||
import pMap from "p-map";
|
||||
import type { PlayWith } from "../common/database/drizzle/drizzleTypes.ts";
|
||||
@@ -138,15 +125,7 @@ export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
|
||||
}
|
||||
});
|
||||
|
||||
const clientMiddleFunc = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceMiddleFunc = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
const clientRequiredMiddle = clientMiddleFunc(true);
|
||||
const sourceRequiredMiddle = sourceMiddleFunc(true);
|
||||
|
||||
const componentAwareMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients);
|
||||
const sourceAwareMiddle = makeSourceNextMiddle(scrobbleSources);
|
||||
const clientAwareMiddle = makeClientNextMiddle(scrobbleClients);
|
||||
|
||||
const setLogWebSettings: TypedMiddleware = async (req, res, next) => {
|
||||
// @ts-expect-error logLevel not part of session
|
||||
@@ -223,7 +202,7 @@ export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
|
||||
setupWebscrobblerRoutes(app, router, logger, scrobbleSources);
|
||||
setupLZEndpointRoutes(app, router, logger, scrobbleSources, scrobbleClients);
|
||||
setupLastfmEndpointRoutes(app, router, logger, scrobbleSources);
|
||||
setupAuthRoutes(app, router, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
|
||||
setupAuthRoutes(app, router, logger, scrobbleSources, scrobbleClients);
|
||||
|
||||
router.get('/api/components', {tags: ['Source/Client'], summary: 'Get All Sources/Clients'}, async (req, res, next) => {
|
||||
|
||||
@@ -285,12 +264,6 @@ export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
|
||||
return res.json([...sourceData, ...clientData]);
|
||||
});
|
||||
|
||||
router.get('/api/sources/:id/players', {middleware: [sourceAwareMiddle], hidden: true}, async (req, res, next) => {
|
||||
if(req.component instanceof MemorySource) {
|
||||
return res.json(req.component.playersToObject());
|
||||
}
|
||||
return res.json({});
|
||||
});
|
||||
router.get('/api/components/:id/players', {middleware: [componentAwareMiddle], tags: ['Source/Client'], summary: 'Get Source/Client Players'}, async (req, res, next) => {
|
||||
if(req.component instanceof MemorySource) {
|
||||
return res.json(req.component.playersToObject());
|
||||
@@ -300,21 +273,6 @@ export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
|
||||
return res.json({});
|
||||
});
|
||||
|
||||
router.get('/api/sources/:id/players/:platformId', {middleware: [sourceAwareMiddle], hidden: true}, async (req, res, next) => {
|
||||
if(req.component instanceof MemorySource) {
|
||||
const {
|
||||
params: {
|
||||
platformId
|
||||
}
|
||||
} = req;
|
||||
const player = req.component.players.get(platformId as string);
|
||||
if(player === undefined) {
|
||||
return res.status(400).json({error: `No player with platform id ${platformId} exists`});
|
||||
}
|
||||
return res.json(player);
|
||||
}
|
||||
return res.json({});
|
||||
});
|
||||
router.get('/api/components/:id/players/:platformId', {middleware: [componentAwareMiddle], tags: ['Source/Client'], summary: 'Get Specific Source/Client Player'}, async (req, res, next) => {
|
||||
const {
|
||||
params: {
|
||||
@@ -610,393 +568,23 @@ export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* new apis above
|
||||
*
|
||||
*
|
||||
* */
|
||||
|
||||
router.get('/api/status', {hidden: true}, async (req, res, next) => {
|
||||
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksDiscovered = 0,
|
||||
name,
|
||||
canPoll = false,
|
||||
polling = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base: SourceStatusData = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
players: 'players' in x ? (x as MemorySource).playersToObject() as unknown as Record<string,SourcePlayerJson> : {},
|
||||
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth as SOURCE_SOT_TYPES : SOURCE_SOT.HISTORY,
|
||||
supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed,
|
||||
manualListening: x.monitoringActivity,
|
||||
systemListeningBehavior: x.getSystemMonitoring(),
|
||||
...x.additionalApiData()
|
||||
};
|
||||
if(!x.isReady()) {
|
||||
if(x.buildOK === false) {
|
||||
base.status = 'Initializing Data Failed';
|
||||
} else if(x.connectionOK === false) {
|
||||
base.status = 'Communication Failed';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = 'Not Ready';
|
||||
}
|
||||
} else {
|
||||
if (canPoll) {
|
||||
base.status = polling ? 'Polling' : 'Idle';
|
||||
} else {
|
||||
base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
const clientData = scrobbleClients.clients.map((x) => {
|
||||
const {
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
scrobbling = false,
|
||||
} = x;
|
||||
const base: ClientStatusData = {
|
||||
status: '',
|
||||
type,
|
||||
display: capitalize(type),
|
||||
scrobbled: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
authed,
|
||||
initialized: x.isReady(),
|
||||
deadLetterScrobbles: x.deadLetterQueued, // x.deadLetterScrobbles.length,
|
||||
deadLetterScrobblesTotal: x.deadLetterLength,
|
||||
queued: x.queuedLength, // x.queuedScrobbles.length
|
||||
manualListening: x.monitoringActivity,
|
||||
systemListeningBehavior: x.getSystemMonitoring(),
|
||||
};
|
||||
if (!base.initialized) {
|
||||
if(x.buildOK === false) {
|
||||
base.status = 'Initializing Data Failed';
|
||||
} else if(x.connectionOK === false) {
|
||||
base.status = 'Communication Failed';
|
||||
} else if (requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = 'Not Ready';
|
||||
}
|
||||
} else {
|
||||
base.status = scrobbling ? 'Running' : 'Idle';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
return res.json({sources: sourceData, clients: clientData});
|
||||
});
|
||||
|
||||
router.get('/api/recent', {middleware: [sourceMiddleFunc(false)], querySchema: z.any(), hidden: true}, async (req, res, next) => {
|
||||
router.post('/api/components/:id/plays/historical', {
|
||||
middleware: [componentAwareMiddle],
|
||||
tags: ['Plays'],
|
||||
summary: 'Hydrate Historical Plays',
|
||||
description: 'If the Source/Client supports Historical Play capabilities, this route requests a manual hydration of all historical Plays'
|
||||
}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleSource: source,
|
||||
query: {
|
||||
upstream = 'false',
|
||||
next: queryNext = 'false',
|
||||
...rest
|
||||
}
|
||||
component,
|
||||
} = req;
|
||||
|
||||
let result: PlayObject[] = [];
|
||||
if (source !== undefined) {
|
||||
if (upstream === 'true' || upstream === '1') {
|
||||
if (!(source as AbstractSource).supportsUpstreamRecentlyPlayed) {
|
||||
return res.status(409).json({message: 'Fetching upstream recently played is not supported for this source'});
|
||||
}
|
||||
try {
|
||||
result = await (source as AbstractSource).getUpstreamRecentlyPlayed();
|
||||
} catch (e) {
|
||||
return res.status(500).json({message: e.message});
|
||||
}
|
||||
} else {
|
||||
if(queryNext === 'true') {
|
||||
return res.json(await (source as AbstractSource).getRecentPlaysApi(rest));
|
||||
}
|
||||
result = await (source as AbstractSource).getFlatRecentlyDiscoveredPlays();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
router.get('/api/source/art', {middleware: [sourceMiddleFunc(false)], querySchema: z.object({data: z.number()}).optional(), hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleSource,
|
||||
query: {
|
||||
data
|
||||
}
|
||||
} = req;
|
||||
|
||||
const source = scrobbleSource as AbstractSource;
|
||||
if(!(source instanceof MemorySource)) {
|
||||
return res.status(500).json({message: 'Source does not support players'});
|
||||
}
|
||||
|
||||
if('getSourceArt' in source && typeof source.getSourceArt === 'function') {
|
||||
const [stream, contentType] = await source.getSourceArt(data);
|
||||
res.writeHead(200, {'Content-Type': contentType});
|
||||
try {
|
||||
return stream.pipe(res);
|
||||
} catch (e) {
|
||||
logger.error(new Error(`Error occurred while trying to stream art for ${source.name} (${source.type}) | Data ${data}`, {cause: e}));
|
||||
return res.status(500).json({message: 'Error during art retrieval'});
|
||||
}
|
||||
} else {
|
||||
return res.status(500).json({message: `Source ${source.name} (${source.type} does not support art retrieval`});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
query = {}
|
||||
} = req;
|
||||
|
||||
const deadQuery: QueryPlaysOpts = {
|
||||
...query as Partial<QueryPlaysOpts>,
|
||||
queues: [
|
||||
{
|
||||
queueName: DEAD_QUEUE,
|
||||
queueStatus: ['queued','failed']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const result: DeadLetterScrobble<PlayObject>[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true));
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
router.put('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose('User requested processing of all dead letter scrobbles via API');
|
||||
|
||||
res.status(200).send('OK');
|
||||
|
||||
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000, 'Reprocessing bulk dead Plays initiated by user'));
|
||||
});
|
||||
|
||||
router.put('/api/dead/:id', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
params: {
|
||||
id
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
const deadId = id as string;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose(`User requested processing of dead letter scrobble ${deadId} via API call`);
|
||||
|
||||
try {
|
||||
const playEntity = await client.playRepo.findByUid(id);
|
||||
if(playEntity === undefined) {
|
||||
return res.status(404).json({message: `Play ${deadId} does not exist`});
|
||||
}
|
||||
client.queueScrobble(playEntity, {reason: 'user requested processing via API call'}).then(() => null);
|
||||
const event = await pEvent(client.emitter, 'playUpdate', {
|
||||
timeout: 10000,
|
||||
filter: (val: PlayApiCommonDetailed) => val.uid === id
|
||||
}) as PlayApiCommonDetailed;
|
||||
if(event.state === 'scrobbled') {
|
||||
return res.status(200).send();
|
||||
} else {
|
||||
// @ts-expect-error should be fine
|
||||
return res.json(playSelectToDeadScrobble(event, true));
|
||||
}
|
||||
} catch (e) {
|
||||
if(e.message.includes(`Play ${deadId} does not exist`)) {
|
||||
logger.warn(e);
|
||||
return res.status(404).json({error: e});
|
||||
}
|
||||
logger.error(e);
|
||||
return res.status(500).json({error: e});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
} = req;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose('User requested deletion of all dead letter scrobbles via API');
|
||||
|
||||
(client as AbstractScrobbleClient).removeDeadLetterScrobbles().then(() => null).catch((e) => logger.error(e));
|
||||
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
router.delete('/api/dead/:id', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
params: {
|
||||
id
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
const deadId = id as string;
|
||||
|
||||
(client as AbstractScrobbleClient).logger.verbose(`User requested removal of dead letter scrobble ${deadId} via API call`)
|
||||
|
||||
const playEntity = await client.playRepo.findByUid(id);
|
||||
if(playEntity === undefined) {
|
||||
return res.status(404).json({message: `Play ${deadId} does not exist`});
|
||||
}
|
||||
|
||||
try {
|
||||
await (client as AbstractScrobbleClient).removeDeadLetterScrobble(playEntity);
|
||||
return res.status(200).send();
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return res.status(500).json({error: e});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/scrobbled', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleClient: client,
|
||||
query
|
||||
} = req;
|
||||
|
||||
let result: PlayObject[] = [];
|
||||
if (client !== undefined) {
|
||||
const q: Partial<QueryPlaysOpts> = {
|
||||
...query as Partial<QueryPlaysOpts>,
|
||||
state: ['scrobbled']
|
||||
}
|
||||
result = [...(await (client as AbstractScrobbleClient).getPlaysPaginated(q)).data.map(x => x.play)].sort(sortByNewestPlayDate);
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
router.post('/api/source/init', {middleware: [sourceRequiredMiddle], querySchema: z.object({force: z.boolean()}).optional(), hidden: true}, async (req, res) => {
|
||||
const source = req.scrobbleSource as AbstractSource;
|
||||
|
||||
const {
|
||||
query: {
|
||||
force: forceQ = false
|
||||
}
|
||||
} = req;
|
||||
|
||||
const force = parseBool(forceQ, false);
|
||||
|
||||
source.logger.verbose(`User requested${force ? ' a FORCED' :''} (re)init via API call`);
|
||||
|
||||
res.status(200).send('OK');
|
||||
|
||||
if(source.polling) {
|
||||
source.logger.info('Source is already polling! Restarting polling...');
|
||||
const stopRes = await source.tryStopPolling(new SimpleError('user initiated', {simple: true, shortStack: true}));
|
||||
if(stopRes === true) {
|
||||
source.poll({force, notify: false}).catch(e => source.logger.error(e));
|
||||
}
|
||||
} else {
|
||||
source.poll({force, notify: false}).catch(e => source.logger.error(e));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/source/listen', {middleware: [sourceRequiredMiddle], querySchema: z.object({listening: z.boolean()}).optional(), hidden: true}, async (req, res) => {
|
||||
const source = req.scrobbleSource as AbstractSource;
|
||||
|
||||
const {
|
||||
query: {
|
||||
listening: listeningQ
|
||||
}
|
||||
} = req;
|
||||
|
||||
let listening: boolean | undefined;
|
||||
if(listeningQ !== undefined) {
|
||||
listening = parseBool(listeningQ)
|
||||
}
|
||||
source.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`);
|
||||
|
||||
source.monitoringActivity = listening;
|
||||
|
||||
res.status(200).json({listening});
|
||||
});
|
||||
|
||||
router.post('/api/client/listen', {middleware: [clientRequiredMiddle], querySchema: z.object({listening: z.boolean()}).optional(), hidden: true}, async (req, res) => {
|
||||
const client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
|
||||
const {
|
||||
query: {
|
||||
listening: listeningQ
|
||||
}
|
||||
} = req;
|
||||
|
||||
let listening: boolean | undefined;
|
||||
if(listeningQ !== undefined) {
|
||||
listening = parseBool(listeningQ)
|
||||
}
|
||||
client.logger.verbose(`User requested Monitoring status ${listening === undefined ? 'system' : listening}`);
|
||||
|
||||
client.monitoringActivity = listening;
|
||||
|
||||
res.status(200).json({listening});
|
||||
});
|
||||
|
||||
router.post('/api/client/init', {middleware: [clientRequiredMiddle], querySchema: z.object({force: z.boolean()}).optional(), hidden: true}, async (req, res) => {
|
||||
const client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
|
||||
const {
|
||||
query: {
|
||||
force: forceQ = false
|
||||
}
|
||||
} = req;
|
||||
|
||||
const force = parseBool(forceQ, false);
|
||||
|
||||
client.logger.verbose(`User requested${force ? ' a FORCED' :''} (re)init via API call`);
|
||||
|
||||
client.logger.info('Checking (and trying) to stop scrobbler if already running...');
|
||||
if(false === (await client.tryStopScrobbling(new SimpleError('user initiated', {simple: true, shortStack: true})))) {
|
||||
return res.status(500).send();
|
||||
}
|
||||
|
||||
client.logger.info('Trying to start scrobbler...');
|
||||
client.initScrobbleMonitoring({force, notify: false}).catch(e => client.logger.error(e));
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
router.post('/api/client/historical', {middleware: [clientRequiredMiddle], hidden: true}, async (req, res) => {
|
||||
const client = req.scrobbleClient as AbstractScrobbleClient;
|
||||
if(client instanceof AbstractHistoricalScrobbleClient) {
|
||||
client.logger.info('User requested historical play hydration');
|
||||
client.hydrateHistoricalScrobbles();
|
||||
if(component instanceof AbstractHistoricalScrobbleClient) {
|
||||
component.logger.info('User requested historical play hydration');
|
||||
component.hydrateHistoricalScrobbles();
|
||||
res.status(200).send('OK');
|
||||
} else {
|
||||
client.logger.warn('This client does not have historical play capabilities');
|
||||
return res.status(400).json({error: 'This client does not have historical play capabilities'});
|
||||
component.logger.warn('This client does not have historical play capabilities');
|
||||
return res.status(409).json({error: 'This client does not have historical play capabilities'});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {Express} from 'express';
|
||||
import type {Logger} from "@foxxmd/logging";
|
||||
import passport from "passport";
|
||||
import type LastfmScrobbler from "../scrobblers/LastfmScrobbler.ts";
|
||||
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
|
||||
import type LastfmSource from "../sources/LastfmSource.ts";
|
||||
@@ -10,12 +9,11 @@ import type YTMusicSource from "../sources/YTMusicSource.ts";
|
||||
import type LibrefmScrobbler from "../scrobblers/LibrefmScrobbler.ts";
|
||||
import type LibrefmSource from "../sources/LibrefmSource.ts";
|
||||
import AbstractSource from "../sources/AbstractSource.ts";
|
||||
import { makeComponentMiddle, type ClientCheckedMiddleTypedMiddleware, type SourceCheckMiddleTypedMiddleware } from './middleware.ts';
|
||||
import { makeComponentMiddle } from './middleware.ts';
|
||||
import { findAuthIssue, SimpleError } from '../common/errors/MSErrors.ts';
|
||||
import type { createTypedRouter } from '@minisylar/express-typed-router';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, logger: Logger, sourceMiddle: SourceCheckMiddleTypedMiddleware, clientMiddle: ClientCheckedMiddleTypedMiddleware, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, logger: Logger, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
const componentAwareMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients);
|
||||
|
||||
router.get('/api/components/:componentVal/auth', {middleware: [componentAwareMiddle], tags: ['Source/Client'], summary: 'Get Source/Client Auth URL'}, async (req, res, next) => {
|
||||
@@ -36,56 +34,6 @@ export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTy
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/client/auth', {middleware: [clientMiddle], hidden: true}, async (req, res) => {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
|
||||
switch (scrobbleClient.type) {
|
||||
case 'lastfm':
|
||||
case 'librefm':
|
||||
res.redirect(scrobbleClient.api.getAuthUrl());
|
||||
break;
|
||||
case 'tealfm':
|
||||
res.redirect(await scrobbleClient.getAuthorizeUrl());
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/source/auth', {middleware: [sourceMiddle], hidden: true}, async (req, res, next) => {
|
||||
const {
|
||||
scrobbleSource: source,
|
||||
sourceName: name,
|
||||
} = req;
|
||||
|
||||
switch (source.type) {
|
||||
case 'spotify':
|
||||
if ((source as SpotifySource).spotifyApi === undefined) {
|
||||
res.status(400).send('Spotify configuration is not valid');
|
||||
} else {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
res.redirect((source as SpotifySource).createAuthUrl());
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
case 'librefm':
|
||||
res.redirect((source as LastfmSource).api.getAuthUrl());
|
||||
break;
|
||||
case 'deezer':
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
req.session.deezerSource = name;
|
||||
return passport.authenticate(`deezer-${source.name}`)(req,res,next);
|
||||
case 'ytmusic':
|
||||
await (source as YTMusicSource).reauthenticate();
|
||||
res.redirect((source as YTMusicSource).verificationUrl);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(/.*callback$/, async (req, res) => {
|
||||
if(req.url.indexOf('/api') !== 0) {
|
||||
return res.redirect(307, `/api${req.url}`);
|
||||
|
||||
@@ -6,56 +6,6 @@ import type AbstractSource from "../sources/AbstractSource.ts";
|
||||
import type AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts";
|
||||
import type { TypedMiddleware } from "@minisylar/express-typed-router";
|
||||
|
||||
export type SourceCheckMiddleTypedMiddleware = TypedMiddleware<{sourceName: string, scrobbleSource: AbstractSource}>;
|
||||
|
||||
export const makeSourceCheckMiddle = (sources: any) => (required: boolean): SourceCheckMiddleTypedMiddleware => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
name,
|
||||
type
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Source name must be defined');
|
||||
} else if (name !== undefined) {
|
||||
const source = sources.getByNameAndType(name, type);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export type ClientCheckedMiddleTypedMiddleware = TypedMiddleware<{scrobbleClient: AbstractScrobbleClient}>;
|
||||
|
||||
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ClientCheckedMiddleTypedMiddleware => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
name
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Client name must be defined');
|
||||
} else if (name !== undefined) {
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export const nonEmptyBody = (logger: Logger, origin: string = 'Origin'): TypedMiddleware => async (req, res, next) => {
|
||||
const bodyEmpty = req.body === undefined || req.body === null || (typeof req.body === 'object' && Object.keys(req.body).length === 0);
|
||||
if (bodyEmpty) {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
// test('renders learn react link', () => {
|
||||
// render(<App />);
|
||||
// const linkElement = screen.getByText(/learn react/i);
|
||||
// expect(linkElement).toBeInTheDocument();
|
||||
// });
|
||||
+61
-99
@@ -1,21 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Box, Container } from '@chakra-ui/react';
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createHashRouter, type RouteObject,
|
||||
RouterProvider, useLocation,
|
||||
createHashRouter,
|
||||
Outlet,
|
||||
type RouteObject,
|
||||
RouterProvider, useLocation
|
||||
} from "react-router-dom";
|
||||
import {connect, type ConnectedProps, Provider} from 'react-redux'
|
||||
import './App.css';
|
||||
import CopyToClipboard from "./components/CopyToClipboard";
|
||||
import ExternalLink from "./components/ExternalLink";
|
||||
import {store} from './store';
|
||||
import Dashboard from "./dashboard/dashboard";
|
||||
import RecentPage from "./recent/RecentPage";
|
||||
import ScrobbledPage from "./scrobbled/ScrobbledPage";
|
||||
import DeadPage from "./deadLetter/DeadPage";
|
||||
import {clientUpdate, sourceUpdate} from "./status/ducks";
|
||||
import {useEventSource, useEventSourceListener} from "@react-nano/use-event-source";
|
||||
import Version from "./Version";
|
||||
import type {MsSseEvent} from '../core/Api';
|
||||
import { AppHeader } from './components/AppHeader';
|
||||
import { MSErrorBoundary } from './components/ErrorBoundary';
|
||||
import { ComponentDetailedRoutable } from './components/msComponent/MSComponentDetailed';
|
||||
import { MSComponentListFetchable } from './components/msComponent/MSComponentList';
|
||||
import { Provider } from './components/Provider';
|
||||
import { SettingsContainer } from './components/settings/settings';
|
||||
import { Toaster } from './components/Toaster';
|
||||
import { WelcomeToast } from './components/WelcomeToast';
|
||||
|
||||
function NoMatch() {
|
||||
const location = useLocation();
|
||||
@@ -47,28 +47,48 @@ function MissingDocs() {
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<CopyToClipboard text="npm run docs:install && npm run docs:build"/>
|
||||
<pre>npm run docs:install && npm run docs:build</pre>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
const Layout = () => {
|
||||
//const [logsEnabled, setLogsEnabled] = useState(true);
|
||||
const location = useLocation();
|
||||
return (<>
|
||||
<Box px="4" py="2" mb="4" pb="4" position="sticky" top="0" zIndex="1" bg="bg" borderBottomWidth="1px">
|
||||
<AppHeader fetchable/>
|
||||
</Box>
|
||||
<Container display="flex">
|
||||
<Box hideBelow="md" display="flex" flexDir="column" pr="2" gap="6" flexShrink="1"></Box>
|
||||
<Outlet/>
|
||||
</Container>
|
||||
</>);
|
||||
}
|
||||
|
||||
const routesNested: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
element: <Dashboard/>,
|
||||
},
|
||||
{
|
||||
path: "/recent",
|
||||
element: <RecentPage/>,
|
||||
},
|
||||
{
|
||||
path: "/scrobbled",
|
||||
element: <ScrobbledPage/>,
|
||||
},
|
||||
{
|
||||
path: "/dead",
|
||||
element: <DeadPage/>,
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <MSErrorBoundary><MSComponentListFetchable/></MSErrorBoundary>,
|
||||
},
|
||||
{
|
||||
path: "components/:componentId",
|
||||
element: <Container boxSize="full" p="0" maxWidth="8xl"><MSErrorBoundary><ComponentDetailedRoutable/></MSErrorBoundary></Container>
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <Container p="0" boxSize="full" maxWidth="4xl"><MSErrorBoundary><SettingsContainer/></MSErrorBoundary></Container>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NoMatch/>
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/docs",
|
||||
@@ -82,83 +102,25 @@ const routes: RouteObject[] = [
|
||||
|
||||
const genRouter = () => {
|
||||
const useHashRouter = __USE_HASH_ROUTER__ === 'true';
|
||||
return useHashRouter ? createHashRouter(routes) : createBrowserRouter(routes);
|
||||
return useHashRouter ? createHashRouter(routesNested) : createBrowserRouter(routesNested);
|
||||
}
|
||||
|
||||
const router = genRouter();
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
updateSource: (payload) => dispatch(sourceUpdate(payload)),
|
||||
updateClient: (payload) => dispatch(clientUpdate(payload))
|
||||
}
|
||||
export const sseProviderOptions = {
|
||||
key: ['events'],
|
||||
url: '/api/events?next=true'
|
||||
}
|
||||
|
||||
const connector = connect(null, mapDispatchToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
const Global = (props: PropsFromRedux) => {
|
||||
const {
|
||||
updateSource,
|
||||
updateClient
|
||||
} = props;
|
||||
|
||||
const [sourceEventSource, eventSourceStatus] = useEventSource("api/events", false);
|
||||
useEventSourceListener(sourceEventSource, ['source', 'client'], evt => {
|
||||
const data = JSON.parse(evt.data);
|
||||
if(data.from === 'source') {
|
||||
updateSource(data);
|
||||
} else if(data.from === 'client') {
|
||||
updateClient(data);
|
||||
}
|
||||
}, [updateSource, updateClient]);
|
||||
|
||||
return <span/>;
|
||||
}
|
||||
|
||||
const ConnectedGlobal = connector(Global);
|
||||
|
||||
// eslint-disable-next-line prefer-arrow-functions/prefer-arrow-functions
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<div className="min-w-screen min-h-screen bg-gray-800 font-sans text-white">
|
||||
<div className="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/" className="flex items-center no-underline pr-4">
|
||||
<img src="/icon.svg" style={{maxWidth: '30px'}}/>
|
||||
<span className="ml-2">Multi Scrobbler</span>
|
||||
</a>
|
||||
<Version/>
|
||||
<span className="space-x-3" style={{marginLeft: 'auto'}}>
|
||||
<span className="p-2">
|
||||
<span className="font-bold">✨
|
||||
<a href="/next">
|
||||
Try the New UI
|
||||
</a>
|
||||
✨
|
||||
</span>
|
||||
</span>
|
||||
<a target="_blank" href="https://status.multi-scrobbler.app">
|
||||
Services Monitor
|
||||
</a>
|
||||
<a href="/docs">
|
||||
Docs
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/FoxxMD/multi-scrobbler">
|
||||
Github <ExternalLink/>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto">
|
||||
<ConnectedGlobal/>
|
||||
<RouterProvider router={router}/>
|
||||
</div>
|
||||
</div>
|
||||
<Provider>
|
||||
<Toaster/>
|
||||
<WelcomeToast/>
|
||||
{/* <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> */}
|
||||
<SSEProvider<MsSseEvent> options={sseProviderOptions}>
|
||||
<RouterProvider router={router}/>
|
||||
</SSEProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Box, Container } from '@chakra-ui/react';
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createHashRouter,
|
||||
Outlet,
|
||||
type RouteObject,
|
||||
RouterProvider, useLocation
|
||||
} from "react-router-dom";
|
||||
import type {MsSseEvent} from '../core/Api';
|
||||
import './App.css';
|
||||
import { AppHeader } from './components/AppHeader';
|
||||
import CopyToClipboard from "./components/CopyToClipboard";
|
||||
import { MSErrorBoundary } from './components/ErrorBoundary';
|
||||
import { ComponentDetailedRoutable } from './components/msComponent/MSComponentDetailed';
|
||||
import { MSComponentListFetchable } from './components/msComponent/MSComponentList';
|
||||
import { Provider } from './components/Provider';
|
||||
import { SettingsContainer } from './components/settings/settings';
|
||||
import { Toaster } from './components/Toaster';
|
||||
import { WelcomeToast } from './components/WelcomeToast';
|
||||
|
||||
function NoMatch() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
No page for <code>{location.pathname}</code> exists!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// https://tailwindflex.com/@sienna/copy-code-block
|
||||
function MissingDocs() {
|
||||
return (
|
||||
<div>
|
||||
<div>Oops! You need to build docs first. Run the following commands to build:</div>
|
||||
<code
|
||||
className="mt-5 text-sm sm:text-base inline-flex text-left items-center space-x-4 bg-gray-900 text-white rounded-lg p-4 pl-6">
|
||||
<span className="flex gap-4">
|
||||
<span className="shrink-0 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
|
||||
<span className="flex-1">
|
||||
<span>
|
||||
npm run <span className="text-yellow-500">docs:install</span> && npm run <span
|
||||
className="text-yellow-500">docs:build</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<CopyToClipboard text="npm run docs:install && npm run docs:build"/>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Layout = () => {
|
||||
//const [logsEnabled, setLogsEnabled] = useState(true);
|
||||
const location = useLocation();
|
||||
return (<>
|
||||
<Box px="4" py="2" mb="4" pb="4" position="sticky" top="0" zIndex="1" bg="bg" borderBottomWidth="1px">
|
||||
<AppHeader fetchable/>
|
||||
</Box>
|
||||
<Container display="flex">
|
||||
<Box hideBelow="md" display="flex" flexDir="column" pr="2" gap="6" flexShrink="1"></Box>
|
||||
<Outlet/>
|
||||
</Container>
|
||||
</>);
|
||||
}
|
||||
|
||||
const routesNested: RouteObject[] = [
|
||||
{
|
||||
path: "/next",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <MSErrorBoundary><MSComponentListFetchable/></MSErrorBoundary>,
|
||||
},
|
||||
{
|
||||
path: "components/:componentId",
|
||||
element: <Container boxSize="full" p="0" maxWidth="8xl"><MSErrorBoundary><ComponentDetailedRoutable/></MSErrorBoundary></Container>
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <Container p="0" boxSize="full" maxWidth="4xl"><MSErrorBoundary><SettingsContainer/></MSErrorBoundary></Container>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NoMatch/>
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/docs",
|
||||
element: <MissingDocs/>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NoMatch/>
|
||||
}
|
||||
];
|
||||
|
||||
const genRouter = () => {
|
||||
const useHashRouter = __USE_HASH_ROUTER__ === 'true';
|
||||
return useHashRouter ? createHashRouter(routesNested) : createBrowserRouter(routesNested);
|
||||
}
|
||||
|
||||
const router = genRouter();
|
||||
|
||||
export const sseProviderOptions = {
|
||||
key: ['events'],
|
||||
url: '/api/events?next=true'
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Provider>
|
||||
<Toaster/>
|
||||
<WelcomeToast/>
|
||||
{/* <Box px="4" py="2" pb="4"><AppHeader fetchable/></Box> */}
|
||||
<SSEProvider<MsSseEvent> options={sseProviderOptions}>
|
||||
<RouterProvider router={router}/>
|
||||
</SSEProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,35 +1,10 @@
|
||||
import React, {type ComponentProps} from 'react';
|
||||
import { type QueryFunctionContext, useQuery } from '@tanstack/react-query';
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
|
||||
import ky from 'ky';
|
||||
import { baseUrl } from './utils';
|
||||
import { TextMuted } from './components/TextMuted';
|
||||
|
||||
export const versionApi = createApi({
|
||||
reducerPath: 'versionApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getVersion: builder.query<{version: string}, undefined>({
|
||||
query: () => `version`,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { useGetVersionQuery } = versionApi;
|
||||
|
||||
const Version = () => {
|
||||
const {
|
||||
data = undefined,
|
||||
} = useGetVersionQuery(undefined);
|
||||
|
||||
return <span className="break-normal">
|
||||
<span className="text-xs version">{data === undefined ? null : `${data.version}`}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
export default Version;
|
||||
|
||||
export const VersionNext = (props: ComponentProps<typeof TextMuted> = {}) => {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Flex, Heading, HStack, IconButton, Image, LinkBox, LinkOverlay, Stack, Status, Switch, Text } from '@chakra-ui/react';;
|
||||
import { Box, Flex, Heading, HStack, IconButton, Image, LinkBox, LinkOverlay, Stack, Status, Switch, Text } from '@chakra-ui/react';
|
||||
import React, { type ComponentProps, type PropsWithChildren } from "react";
|
||||
import { Link as RouterLink } from 'react-router';
|
||||
import { VersionNext } from "../Version";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface Props {
|
||||
classNames?: string[]
|
||||
style?: object
|
||||
text?: string
|
||||
}
|
||||
|
||||
const defaultStyle = {}
|
||||
const defaultClassNames: string[] = ['shrink-0', 'h-5', 'w-5', 'transition', 'text-gray-500', 'hover:text-white', 'cursor-pointer'];
|
||||
|
||||
const CopyToClipboard = (props: Props) => {
|
||||
const {classNames = [], style = defaultStyle, text } = props;
|
||||
const classes = defaultClassNames;
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<svg
|
||||
onClick={() => {navigator.clipboard.writeText(text)}}
|
||||
className={clsx(classes.concat(classNames))} style={style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"></path>
|
||||
<path
|
||||
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z">
|
||||
</path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyToClipboard;
|
||||
@@ -1,24 +0,0 @@
|
||||
import type {PropsWithChildren} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface TooltipProps {
|
||||
classNames?: string[]
|
||||
style?: object
|
||||
}
|
||||
|
||||
const defaultStyle = {marginLeft: '2px', display: 'inline-block'};
|
||||
|
||||
const ExternalLink = (props: PropsWithChildren<TooltipProps>) => {
|
||||
const {classNames = [], style = defaultStyle } = props;
|
||||
const classes = [];
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<svg width="13.5" height="13.5" aria-hidden="true" viewBox="0 0 24 24"
|
||||
className={clsx(classes.concat(classNames))} style={style}>
|
||||
<path fill="currentColor"
|
||||
d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExternalLink;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Fragment, type ReactElement } from 'react';
|
||||
|
||||
import type {JsonPlayObject, TrackStringOptions} from "../../core/Atomic";
|
||||
import { buildTrackString } from "../../core/StringUtils";
|
||||
import { buildTrackStringReactOptions } from "../utils/index";
|
||||
|
||||
interface PlayObjectProps {
|
||||
data: JsonPlayObject
|
||||
artistLength?: number
|
||||
trackLength?: number
|
||||
buildOptions?: TrackStringOptions<ReactElement> & {includeWeb?: boolean}
|
||||
}
|
||||
const PlayDisplay = (props: PlayObjectProps) => {
|
||||
const {
|
||||
data,
|
||||
artistLength,
|
||||
trackLength,
|
||||
buildOptions = {},
|
||||
} = props || {};
|
||||
|
||||
const {
|
||||
transformers,
|
||||
includeWeb,
|
||||
...restBuild
|
||||
} = buildOptions;
|
||||
|
||||
const bOpts: TrackStringOptions<ReactElement> = {
|
||||
...restBuild,
|
||||
transformers: {
|
||||
...buildTrackStringReactOptions.transformers,
|
||||
...transformers,
|
||||
track: (t, data, hasExistingParts) => {
|
||||
const existingPartPrefix = hasExistingParts ? `- ` : '';
|
||||
if(includeWeb && data.meta.url?.web !== undefined) {
|
||||
return <Fragment key="web">{existingPartPrefix}<a href={data.meta.url.web}>{t}</a></Fragment>
|
||||
}
|
||||
return <Fragment key="web">{existingPartPrefix}{t}</Fragment>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildTrackString<ReactElement>(data, bOpts);
|
||||
}
|
||||
export default PlayDisplay;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
QueryClient,
|
||||
QueryClientProvider
|
||||
} from '@tanstack/react-query'
|
||||
import '../index-next.css'
|
||||
import '../index.css'
|
||||
import {
|
||||
ColorModeProvider,
|
||||
type ColorModeProviderProps,
|
||||
|
||||
@@ -133,11 +133,11 @@ export const NAV_LINKS: SideNavProps[] = [
|
||||
items: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/next/'
|
||||
url: '/'
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
url: '/next/settings'
|
||||
url: '/settings'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, {type PropsWithChildren} from 'react';
|
||||
|
||||
export type StatusType = 'active' | 'warn' | 'error' | 'inactive' | string;
|
||||
|
||||
// must use full class names for tailwind to avoid purging
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/7745#discussioncomment-3304940
|
||||
const statusToColor = (status: StatusType) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-500';
|
||||
case 'warn':
|
||||
return 'bg-yellow-500';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
case 'inactive':
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
const StatusIndicator = (props: PropsWithChildren<{ type: StatusType }>) => {
|
||||
const cn = `flex w-3 h-3 ${statusToColor(props.type)} rounded-full ml-1.5`
|
||||
return <span className={cn}>{props.children}</span>;
|
||||
}
|
||||
|
||||
export default StatusIndicator;
|
||||
@@ -1,25 +0,0 @@
|
||||
import type {PropsWithChildren, ReactElement} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface TooltipProps {
|
||||
message: string | ReactElement
|
||||
classNames?: string[]
|
||||
style?: object
|
||||
}
|
||||
|
||||
const defaultStyle = {};
|
||||
|
||||
const Tooltip = (props: PropsWithChildren<TooltipProps>) => {
|
||||
const {children, message, classNames = [], style = defaultStyle } = props;
|
||||
const classes = ['group','relative','flex'];
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<div className={clsx(classes.concat(classNames))} style={style}>
|
||||
{children}
|
||||
<span
|
||||
className="absolute top-5 scale-0 transition-all rounded bg-gray-800 p-2 text-xs text-white group-hover:scale-100">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useCopyToClipboard(resetInterval = 2000) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(async (text) => {
|
||||
if (!text) return false;
|
||||
|
||||
let success = false;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
console.warn("Clipboard API failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for insecure contexts
|
||||
if (!success) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
success = document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback copy failed:", err);
|
||||
success = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
setIsCopied(success);
|
||||
if (success && resetInterval > 0) {
|
||||
setTimeout(() => setIsCopied(false), resetInterval);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [resetInterval]);
|
||||
|
||||
return { copy, isCopied };
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.connected.loading {
|
||||
display: inline;
|
||||
}
|
||||
.loading {
|
||||
height: 35px;
|
||||
fill: white;
|
||||
display: none;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import './Loading.css';
|
||||
|
||||
/*https://codepen.io/nikhil8krishnan/pen/rVoXJa*/
|
||||
export const Loading = (props: {show?: boolean}) => {
|
||||
const {show = false} = props || {};
|
||||
const classes = ['loading'];
|
||||
if(show) {
|
||||
classes.push('connected');
|
||||
}
|
||||
return <svg className={clsx(classes)} version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px" y="0px"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 100 100" xmlSpace="preserve">
|
||||
<path
|
||||
d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="1s"
|
||||
from="0 50 50"
|
||||
to="360 50 50"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
@@ -30,7 +30,7 @@ import { COMPONENT_AUTH_TYPE } from "../../../core/Atomic.js";
|
||||
|
||||
export const ComponentBackButton = (props: ComponentProps<typeof ChevronLeftButton> = {}) => {
|
||||
return (
|
||||
<Link to={`/next`}>
|
||||
<Link to={`/`}>
|
||||
<ChevronLeftButton variant="ghost" iconProps={{style: {width: 'unset', height: 'unset', fontSize: "2em"}}} {...props} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import './player.scss';
|
||||
import PlayerTimestamp from "./PlayerTimestamp";
|
||||
import {SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson} from "../../../core/Atomic";
|
||||
import PlayerInfo from "./PlayerInfo";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBars, faTimes, faQuestion} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import {capitalize} from "../../../core/StringUtils";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export interface PlayerProps {
|
||||
data: SourcePlayerJson
|
||||
sot?: SOURCE_SOT_TYPES
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
name: string
|
||||
artist: string
|
||||
album: string
|
||||
year: number
|
||||
duration: number
|
||||
artwork: string
|
||||
}
|
||||
|
||||
const Player = (props: PlayerProps) => {
|
||||
const {
|
||||
data,
|
||||
sot = SOURCE_SOT.PLAYER
|
||||
} = props;
|
||||
|
||||
const {
|
||||
play: {
|
||||
data: {
|
||||
track = '???',
|
||||
artists = [{name: '???'}],
|
||||
duration = 0
|
||||
} = {},
|
||||
meta: {
|
||||
art = {},
|
||||
} = {}
|
||||
} = {},
|
||||
play,
|
||||
listenedDuration = 0,
|
||||
nowPlayingMode = false,
|
||||
status: {
|
||||
calculated = '???',
|
||||
reported,
|
||||
stale,
|
||||
orphaned
|
||||
}
|
||||
} = data;
|
||||
|
||||
let durPer = null;
|
||||
if(!nowPlayingMode) {
|
||||
if(duration !== undefined && duration !== null && duration !== 0) {
|
||||
if(listenedDuration === 0 || listenedDuration === null) {
|
||||
durPer = ' (0%)';
|
||||
} else {
|
||||
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const [viewMode, setViewMode] = useState('player');
|
||||
|
||||
const toggleViewMode = useCallback(() => {
|
||||
let newViewMode = "";
|
||||
switch(viewMode) {
|
||||
case "player":
|
||||
newViewMode = "playlist";
|
||||
break;
|
||||
case "playlist":
|
||||
newViewMode = "player"
|
||||
break;
|
||||
}
|
||||
setViewMode(newViewMode);
|
||||
}, [viewMode, setViewMode]);
|
||||
|
||||
const playArt = art.track ?? art.album ?? art.artist ?? undefined;
|
||||
|
||||
return (
|
||||
<article className={["player", "mb-2"].join(' ')}>
|
||||
<div className="player__wrapper">
|
||||
{sot !== SOURCE_SOT.PLAYER ? <span className="player-tooltip"><Tooltip
|
||||
classNames={['justify-end', 'mr-4']}
|
||||
message="This player is for DISPLAY ONLY and likely represents a 'Now Playing' status exposed by the Source. For scrobbling Multi Scrobbler uses the 'recently played' or 'history' information provided by this source.">
|
||||
<FontAwesomeIcon width={9} color="black" icon={faQuestion}/>
|
||||
</Tooltip></span> : null}
|
||||
<button className="button toggle-playlist" onClick={toggleViewMode}>
|
||||
<FontAwesomeIcon color="black" icon={viewMode === 'playlist' ? faTimes : faBars}/>
|
||||
</button>
|
||||
<section className="player__body">
|
||||
<div className="player__info" style={{position: 'relative', zIndex: 0}}>
|
||||
<div className="player-album">{playArt !== undefined ? <img style={{height: '100%', maxWidth: '90px'}} src={playArt}></img> : null}</div>
|
||||
<p className="title">{calculated !== 'stopped' ? track : '-'}</p>
|
||||
<p className="subtitle">{calculated !== 'stopped' ? artists.map(x => x.name).join(' / ') : '-'}</p>
|
||||
</div>
|
||||
|
||||
<PlayerTimestamp duration={duration} indeterminate={(calculated === 'playing' && data.position === undefined) || nowPlayingMode} current={data.position || 0} />
|
||||
<div className="flex">
|
||||
<p className="stats flex-1 text-left">Status: {['unknown','playing'].includes(calculated) && nowPlayingMode ? 'Now Playing' : capitalize(calculated)}</p>
|
||||
<p className="stats flex-1 text-right">Listened: {nowPlayingMode !== true && calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
|
||||
</div>
|
||||
</section>
|
||||
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -1,111 +0,0 @@
|
||||
import React from "react";
|
||||
import './controls.scss';
|
||||
|
||||
export interface ControlsProps {
|
||||
isPlaying: boolean
|
||||
isLiked: boolean
|
||||
prevTrack: () => any
|
||||
togglePlay: () => any
|
||||
nextTrack: () => any
|
||||
likeTrack: () => any
|
||||
}
|
||||
|
||||
const Controls = (props: ControlsProps) => {
|
||||
let playClass = "fa fa-fw ";
|
||||
if (!props.isPlaying) {
|
||||
playClass += "fa-play";
|
||||
} else {
|
||||
playClass += "fa-pause";
|
||||
}
|
||||
|
||||
let likeClass = "button like ";
|
||||
let heartClass = "fa fa-fw ";
|
||||
if (!props.isLiked) {
|
||||
heartClass += "fa-heart-o";
|
||||
} else {
|
||||
likeClass += "is-liked";
|
||||
heartClass += "fa-heart";
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="controls">
|
||||
<button type="button" className="button repeat">
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-repeat"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button prev" onClick={props.prevTrack}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-step-backward"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button play" onClick={props.togglePlay}>
|
||||
<span className="icon">
|
||||
<i className={playClass}></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button next" onClick={props.nextTrack}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-step-forward"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className={likeClass} onClick={props.likeTrack}>
|
||||
<span className="icon">
|
||||
<i className={heartClass}></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*export class ControlsC extends React.Component {
|
||||
render() {
|
||||
let playClass = "fa fa-fw ";
|
||||
if (!this.props.isPlaying) {
|
||||
playClass += "fa-play";
|
||||
} else {
|
||||
playClass += "fa-pause";
|
||||
}
|
||||
|
||||
let likeClass = "button like ";
|
||||
let heartClass = "fa fa-fw ";
|
||||
if (!this.props.isLiked) {
|
||||
heartClass += "fa-heart-o";
|
||||
} else {
|
||||
likeClass += "is-liked";
|
||||
heartClass += "fa-heart";
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="controls">
|
||||
<button type="button" className="button repeat">
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-repeat"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button prev" onClick={this.props.prevTrack}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-step-backward"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button play" onClick={this.props.togglePlay}>
|
||||
<span className="icon">
|
||||
<i className={playClass}></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="button next" onClick={this.props.nextTrack}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-fw fa-step-forward"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className={likeClass} onClick={this.props.likeTrack}>
|
||||
<span className="icon">
|
||||
<i className={heartClass}></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}*/
|
||||
|
||||
export default Controls;
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import './trackInfo.scss';
|
||||
import type {SourcePlayerJson} from "../../../core/Atomic";
|
||||
import {isoToHuman} from "../../utils/index";
|
||||
|
||||
export interface PlayerInfoProps {
|
||||
data: SourcePlayerJson
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
const PlayerInfo = (props: PlayerInfoProps) => {
|
||||
const {
|
||||
isVisible = true,
|
||||
data,
|
||||
data: {
|
||||
play,
|
||||
play: {
|
||||
meta: {
|
||||
sessionId
|
||||
} = {}
|
||||
} = {},
|
||||
status: {
|
||||
calculated,
|
||||
reported
|
||||
}
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
let isHidden = "";
|
||||
if (!isVisible) {
|
||||
isHidden = "hidden";
|
||||
}
|
||||
|
||||
if (play === undefined || play === null) {
|
||||
return <ul className={["playlist", isHidden].join(' ')}>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={["playlist", isHidden, 'bg-gray-600'].join(' ')}>
|
||||
<div className="playlist_body">
|
||||
<div className="full">Player ID: <small>{data.platformId}{sessionId !== undefined ? ` (Session ${sessionId})` : null}</small></div>
|
||||
<div className="full">Player Updated: <small>{isoToHuman(data.playerLastUpdatedAt, {includeRelative: true})}</small></div>
|
||||
<div className="full">Track Seen: <small>{isoToHuman(data.playFirstSeenAt, {includeRelative: true})}</small></div>
|
||||
<div className="full">Track Updated: <small>{isoToHuman(data.playLastUpdatedAt, {includeRelative: true})}</small></div>
|
||||
<div className="full">Status: <small>Calculated -> {calculated.toUpperCase()} | Reported -> {reported.toUpperCase()} </small></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default PlayerInfo;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react";
|
||||
import './timestamp.scss';
|
||||
|
||||
export interface TimestampProps {
|
||||
current: number
|
||||
duration: number
|
||||
indeterminate?: boolean
|
||||
}
|
||||
|
||||
const convertTime = (rawTime: number) => {
|
||||
const mins = Math.floor(rawTime / 60);
|
||||
const seconds = rawTime - (mins * 60);
|
||||
let secStr: string = seconds.toString();
|
||||
if (seconds < 10) {
|
||||
secStr = "0" + seconds;
|
||||
}
|
||||
return mins + ":" + secStr;
|
||||
}
|
||||
|
||||
const Timestamp = (props: TimestampProps) => {
|
||||
return(
|
||||
<div className="timestamp">
|
||||
<div className="timestamp__current" style={{left: props.indeterminate ? '1em' : '0'}}>
|
||||
{props.indeterminate ? '-' : convertTime(Math.floor(props.current))}
|
||||
</div>
|
||||
<div className="timestamp__progress">
|
||||
<div className={props.indeterminate ? 'indeterminate' : ''} style={{ width: props.indeterminate ? '100%' : (props.current === 0 && props.duration === 0 ? 0 : Math.floor((props.current / props.duration) * 100)) + "%" }}></div>
|
||||
</div>
|
||||
<div className="timestamp__total">
|
||||
{convertTime(Math.floor(props.duration) - Math.floor(props.current))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Timestamp;
|
||||
@@ -1 +0,0 @@
|
||||
Player designed based on [React Music Player UI](https://codepen.io/keithpickering/pen/wjYJKM) by [Keith Pickering](https://keithpickering.github.io/)
|
||||
@@ -1,40 +0,0 @@
|
||||
@use './playerBase.scss';
|
||||
$spacing: 1rem;
|
||||
$primary: #00ACC1;
|
||||
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
height: calc($spacing*2.5);
|
||||
text-align: center;
|
||||
background: $primary;
|
||||
|
||||
.button {
|
||||
height: 100%;
|
||||
width: calc($spacing*2.5);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
transition: background 250ms;
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: lighten($primary, 4%);
|
||||
}
|
||||
|
||||
&.play {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
&.like.is-liked {
|
||||
background: #F06292;
|
||||
}
|
||||
|
||||
.icon {
|
||||
//transition: transform 150ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
@use './playerBase.scss';
|
||||
@use "sass:color";
|
||||
$spacing: 0.5rem;
|
||||
$primary: #00ACC1;
|
||||
|
||||
|
||||
.player {
|
||||
color: black;
|
||||
overflow: hidden;
|
||||
//max-width: 90%;
|
||||
max-height: 90vh;
|
||||
//min-height: 487px;
|
||||
border-radius: 2px;
|
||||
background: white;
|
||||
//box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
|
||||
|
||||
&__wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-tooltip {
|
||||
width: 90%;
|
||||
position: absolute;
|
||||
top: $spacing;
|
||||
left: calc($spacing/0.5);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
text-shadow: rgba(black, 0.75) 0 0 5px;
|
||||
box-shadow: none !important;
|
||||
color: white !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.player-album {
|
||||
//width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: calc($spacing/1);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
text-shadow: rgba(black, 0.75) 0 0 5px;
|
||||
box-shadow: none !important;
|
||||
color: white !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toggle-playlist {
|
||||
position: absolute;
|
||||
top: calc($spacing/2);
|
||||
right: calc($spacing/0.5);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
text-shadow: rgba(black, 0.75) 0 0 5px;
|
||||
box-shadow: none !important;
|
||||
color: white !important;
|
||||
z-index: 1;
|
||||
transition: all 500ms;
|
||||
|
||||
[class*="is-sliding"] & {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__bg {
|
||||
position: fixed;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
bottom: -40px;
|
||||
left: -40px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(20px);
|
||||
z-index: -2;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
bottom: -40px;
|
||||
left: -40px;
|
||||
background: rgba(#263238, 0.8);
|
||||
transition: background 1s;
|
||||
|
||||
[class*="is-sliding"] & {
|
||||
background: rgba(#263238, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.prev,
|
||||
&.next {
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
&__head {
|
||||
padding: calc($spacing/2) $spacing;
|
||||
}
|
||||
|
||||
&__art {
|
||||
position: relative;
|
||||
padding-bottom: 100%;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #ECEFF1;
|
||||
background: color.adjust($color: #263238, $lightness: -10%);
|
||||
|
||||
.is-sliding-next & {
|
||||
transform: translate(-100%, 0);
|
||||
transition: transform 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.is-sliding-prev & {
|
||||
transform: translate(100%, 0);
|
||||
transition: transform 500ms ease-in-out;
|
||||
}
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
&.prev {
|
||||
left: -100%;
|
||||
}
|
||||
|
||||
&.next {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
text-align: center;
|
||||
padding: $spacing;
|
||||
//position: relative;
|
||||
transition: all 250ms;
|
||||
|
||||
[class*="is-sliding"] & {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: #90A4AE;
|
||||
padding-top: calc($spacing/4);
|
||||
margin-bottom: $spacing;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
$spacing: 1.5rem;
|
||||
$primary: #00ACC1;
|
||||
|
||||
.button {
|
||||
&:active {
|
||||
> .icon {
|
||||
transform: scale(0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
@use './playerBase.scss';
|
||||
$spacing: 1.5rem;
|
||||
$primary: #556a77;
|
||||
|
||||
|
||||
.timestamp {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $spacing;
|
||||
|
||||
&__progress,
|
||||
&__current,
|
||||
&__total {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
position: relative;
|
||||
top: calc($spacing/4);
|
||||
left: 50%;
|
||||
height: calc($spacing/4);
|
||||
width: calc(100% - #{$spacing*3});
|
||||
transform: translate(-50%, 0);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: #ECEFF1;
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: $primary;
|
||||
}
|
||||
|
||||
> div.indeterminate {
|
||||
background-color: #ECEFF1;
|
||||
animation: indeterminateAnimation 3s infinite linear;
|
||||
transform-origin: 0% 50%;
|
||||
background: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
&__current {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&__total {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes indeterminateAnimation {
|
||||
0% {
|
||||
transform: translateX(0) scaleX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0) scaleX(0.5);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) scaleX(0.5);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
$spacing: 1.5rem;
|
||||
$primary: #00ACC1;
|
||||
|
||||
|
||||
.playlist {
|
||||
color: white;
|
||||
font-size:0.80rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: calc($spacing/1.7);
|
||||
padding-right: calc($spacing/1.5);
|
||||
//overflow: auto;
|
||||
//background: #282c34;
|
||||
|
||||
.playlist_body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.full {
|
||||
width: 100%;
|
||||
flex: 100%;
|
||||
}
|
||||
.half {
|
||||
width: 100%;
|
||||
flex: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__item.media {
|
||||
align-items: center;
|
||||
border-top: 2px solid #37474F;
|
||||
|
||||
&:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle {
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.85rem;
|
||||
color: #B0BEC5;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: - calc($spacing/2);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #607D8B
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-playlist {
|
||||
position: absolute;
|
||||
top: calc($spacing/2);
|
||||
right: calc($spacing/2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
text-shadow: rgba(black, 0.75) 0 0 5px;
|
||||
box-shadow: none !important;
|
||||
color: white !important;
|
||||
z-index: 1;
|
||||
transition: all 500ms;
|
||||
|
||||
[class*="is-sliding"] & {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
const SkeletonParagraph = () => {
|
||||
return (
|
||||
<div role="status" className="max-w-sm animate-pulse">
|
||||
<div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[330px] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[300px] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]"></div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonParagraph;
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
export interface SkeletonTitleProps {
|
||||
show?: boolean
|
||||
}
|
||||
const SkeletonTitle = (props?: SkeletonTitleProps) => {
|
||||
const {show = true} = props;
|
||||
const cn = `max-w-sm animate-pulse${show ? '' : ' hidden'}`;
|
||||
return (
|
||||
<div className={cn}>
|
||||
<div className="h-2.5 rounded-full bg-gray-400 w-48 mb-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonTitle;
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, {Fragment, useCallback} from 'react';
|
||||
import StatusCardSkeleton, {type StatusCardSkeletonData} from "./StatusCardSkeleton";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import {clientAdapter} from "../../status/ducks";
|
||||
import type {RootState} from "../../store";
|
||||
import {connect, type ConnectedProps} from "react-redux";
|
||||
import {Link} from "react-router-dom";
|
||||
import {useStartClientMutation, useListenClientMutation} from "./clientDucks";
|
||||
|
||||
export interface ClientStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
|
||||
loading?: boolean
|
||||
key: any
|
||||
}
|
||||
|
||||
const statusToStatusType = (status: string) => {
|
||||
const lower = status.toLowerCase();
|
||||
if(lower.includes('running') || lower.includes('data')) {
|
||||
return 'active';
|
||||
}
|
||||
if(lower.includes('idle')) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const ClientStatusCard = (props: ClientStatusCardData) => {
|
||||
const {
|
||||
loading = false,
|
||||
data,
|
||||
data: {
|
||||
name,
|
||||
type,
|
||||
display,
|
||||
status,
|
||||
scrobbled: scrobbledCount = 0,
|
||||
queued = 0,
|
||||
deadLetterScrobbles = 0,
|
||||
deadLetterScrobblesTotal = 0,
|
||||
} = {}
|
||||
} = props;
|
||||
|
||||
const [startClientPut, startResult] = useStartClientMutation();
|
||||
const [listenPut, listenResult] = useListenClientMutation();
|
||||
|
||||
const tryStart = useCallback((name: string, force?: boolean) => startClientPut({name, force}), [startClientPut]);
|
||||
const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => {
|
||||
// cycle through states
|
||||
let nextListen: boolean | undefined;
|
||||
switch(currentListening) {
|
||||
case true:
|
||||
nextListen = false;
|
||||
break;
|
||||
case false:
|
||||
nextListen = undefined;
|
||||
break;
|
||||
case undefined:
|
||||
nextListen = true;
|
||||
break;
|
||||
}
|
||||
listenPut({name, type, listening: nextListen});
|
||||
}, [listenPut]);
|
||||
|
||||
let header: string | undefined = display;
|
||||
let body = <SkeletonParagraph/>;
|
||||
const startClientElement = (
|
||||
<Fragment>
|
||||
<div onClick={() => tryStart(name)} className="capitalize underline cursor-pointer inline mr-1">{status === 'Running' ? 'Restart' : 'Start'}</div>
|
||||
(<div onClick={() => tryStart(name, true)} className="capitalize underline cursor-pointer inline">Force</div>)
|
||||
</Fragment>)
|
||||
let subtitleElement = startClientElement;
|
||||
if(data !== undefined) {
|
||||
const {
|
||||
hasAuth,
|
||||
hasAuthInteraction,
|
||||
manualListening,
|
||||
systemListeningBehavior,
|
||||
name,
|
||||
type,
|
||||
authed,
|
||||
initialized
|
||||
} = data;
|
||||
header = `${display} (Client)`;
|
||||
|
||||
const ml = useMemo(() => {
|
||||
if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) {
|
||||
return manualListening;
|
||||
}
|
||||
return (listenResult.data as any).listening;
|
||||
}, [manualListening, listenResult]);
|
||||
|
||||
const manualListenElement = (<Fragment>
|
||||
<span>Monitoring:</span>
|
||||
<div onClick={() => tryListen(name, type, ml)}
|
||||
className="capitalize underline cursor-pointer inline mr-1 ml-1">
|
||||
{ml !== undefined ? (ml ? 'Yes' : 'No') : null}
|
||||
{ml === undefined ? <span>System {systemListeningBehavior ? '(Yes)' : '(No)'}</span> : null}
|
||||
</div>
|
||||
{/* {ml !== undefined ? <div onClick={() => tryListen(name, type, undefined)}
|
||||
className="capitalize underline cursor-pointer inline">Clear
|
||||
</div> : null} */}
|
||||
</Fragment>);
|
||||
subtitleElement = <Fragment>{startClientElement}</Fragment>;//<Fragment>{manualListenElement} | {startClientElement}</Fragment>;
|
||||
|
||||
const scrobbled = initialized && (!hasAuth || (hasAuth && authed)) ? <Link to={`/scrobbled?type=${type}&name=${name}`}>Tracks Scrobbled</Link> : <span>Tracks Scrobbled</span>;
|
||||
body = (<Fragment>
|
||||
<div>{scrobbled}: {scrobbledCount}</div>
|
||||
<div>Queued Scrobbles: {queued}</div>
|
||||
<div><Link to={`/dead?type=${type}&name=${name}`}>Failed Scrobbles</Link>: {deadLetterScrobbles} Queued / {deadLetterScrobblesTotal} Total</div>
|
||||
{hasAuthInteraction ? <a target="_blank" href={`/api/client/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
|
||||
</Fragment>);
|
||||
}
|
||||
return (
|
||||
<StatusCardSkeleton
|
||||
loading={loading}
|
||||
title={header}
|
||||
subtitle={name}
|
||||
status={status}
|
||||
subtitleRight={subtitleElement}
|
||||
statusType={statusToStatusType(status)}>
|
||||
{body}
|
||||
</StatusCardSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const simpleSelectors = clientAdapter.getSelectors();
|
||||
|
||||
const mapStateToProps = (state: RootState, props) => ({
|
||||
data: simpleSelectors.selectById(state.clients, props.id)
|
||||
});
|
||||
|
||||
const connector = connect(mapStateToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>
|
||||
|
||||
export default connector(ClientStatusCard);
|
||||
@@ -1,176 +0,0 @@
|
||||
import React, {Fragment, useCallback, useMemo} from 'react';
|
||||
import StatusCardSkeleton, {type StatusCardSkeletonData} from "./StatusCardSkeleton";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import {Link} from "react-router-dom";
|
||||
import {sourceAdapter} from "../../status/ducks";
|
||||
import type {RootState} from "../../store";
|
||||
import {connect, type ConnectedProps} from "react-redux";
|
||||
import Player from "../player/Player";
|
||||
import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks";
|
||||
import './statusCard.scss';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const ambiguousTypes = ['lastfm','listenbrainz','koito','librefm','maloja','rocksky'];
|
||||
export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const statusToStatusType = (status: string) => {
|
||||
const lower = status.toLowerCase();
|
||||
if(lower.includes('running') || lower.includes('polling') || lower.includes('received data')) {
|
||||
return 'active';
|
||||
}
|
||||
if(lower.includes('idle') || lower.includes('awaiting data')) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const SourceStatusCard = (props: SourceStatusCardData) => {
|
||||
const {
|
||||
loading = false,
|
||||
data,
|
||||
data: {
|
||||
display,
|
||||
name,
|
||||
type,
|
||||
status,
|
||||
} = {}
|
||||
} = props;
|
||||
let header: string | undefined = display;
|
||||
let body = <SkeletonParagraph/>;
|
||||
|
||||
const [startPut, startResult] = useStartSourceMutation();
|
||||
const [listenPut, listenResult] = useListenSourceMutation();
|
||||
|
||||
const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]);
|
||||
const tryListen = useCallback((name: string, type: string, currentListening?: boolean) => {
|
||||
// cycle through states
|
||||
let nextListen: boolean | undefined;
|
||||
switch(currentListening) {
|
||||
case true:
|
||||
nextListen = false;
|
||||
break;
|
||||
case false:
|
||||
nextListen = undefined;
|
||||
break;
|
||||
case undefined:
|
||||
nextListen = true;
|
||||
break;
|
||||
}
|
||||
listenPut({name, type, listening: nextListen});
|
||||
}, [listenPut]);
|
||||
let subtitleElement = null;
|
||||
|
||||
if(data !== undefined)
|
||||
{
|
||||
const {
|
||||
display,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth,
|
||||
authed,
|
||||
status,
|
||||
tracksDiscovered,
|
||||
hasAuthInteraction,
|
||||
type,
|
||||
players = {},
|
||||
sot,
|
||||
supportsUpstreamRecentlyPlayed,
|
||||
manualListening,
|
||||
systemListeningBehavior
|
||||
} = data;
|
||||
if(ambiguousTypes.includes(type)) {
|
||||
header = `${display} (Source)`;
|
||||
}
|
||||
|
||||
let startText = '';
|
||||
if(canPoll) {
|
||||
startText = status === 'Polling' ? 'Restart' : 'Start'
|
||||
} else {
|
||||
startText = status === 'Running' ? 'Reinit' : 'Init'
|
||||
}
|
||||
|
||||
const ml = useMemo(() => {
|
||||
if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) {
|
||||
return manualListening;
|
||||
}
|
||||
return (listenResult.data as any).listening;
|
||||
}, [manualListening, listenResult]);
|
||||
|
||||
const manualListenElement = type !== 'azuracast' && type !== 'icecast' ? null : (<Fragment>
|
||||
<span>Monitoring <Tooltip message="test"
|
||||
style={{
|
||||
display: 'inline-flex'
|
||||
}}>
|
||||
<FontAwesomeIcon color="white" icon={faQuestionCircle}/></Tooltip> :
|
||||
</span>
|
||||
<div onClick={() => tryListen(name, type, ml)}
|
||||
className="capitalize underline cursor-pointer inline mr-1 ml-1">
|
||||
{ml !== undefined ? (ml ? 'Yes' : 'No') : null}
|
||||
{ml === undefined ? <span>System {systemListeningBehavior ? '(Yes)' : '(No)'}</span> : null}
|
||||
</div>
|
||||
{/* {ml !== undefined ? <div onClick={() => tryListen(name, type, undefined)}
|
||||
className="capitalize underline cursor-pointer inline">Clear
|
||||
</div> : null} */}
|
||||
</Fragment>);
|
||||
|
||||
const startSourceElement = (<Fragment>
|
||||
<div onClick={() => tryStart(name, type)}
|
||||
className="capitalize underline cursor-pointer inline mr-1">{startText}
|
||||
</div>
|
||||
(<div onClick={() => tryStart(name, type, true)}
|
||||
className="capitalize underline cursor-pointer inline">Force
|
||||
</div>)
|
||||
</Fragment>);
|
||||
|
||||
if(manualListenElement !== null) {
|
||||
subtitleElement = <Fragment>{manualListenElement} | {startSourceElement}</Fragment>
|
||||
} else {
|
||||
subtitleElement = startSourceElement;
|
||||
}
|
||||
|
||||
const platformIds = Object.keys(players);
|
||||
|
||||
const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
|
||||
|
||||
let upstreamRecent = null;
|
||||
if(supportsUpstreamRecentlyPlayed && (!hasAuth || authed)) {
|
||||
upstreamRecent = <div><Link to={`/recent?type=${type}&name=${name}&upstream=1`}>See Recent from Source API</Link></div>;
|
||||
}
|
||||
|
||||
// TODO links
|
||||
body = (<div className="statusCardBody">
|
||||
{platformIds.map(x => <Player key={x} data={players[x]} sot={sot}/>)}
|
||||
<div>{discovered}: {tracksDiscovered}</div>
|
||||
{upstreamRecent}
|
||||
{canPoll && hasAuthInteraction ? <a target="_blank" href={`/api/source/auth?name=${name}&type=${type}`}>(Re)authenticate</a> : null}
|
||||
{type === 'ytmusic' && 'userCode' in data ? <div>Code: <strong>{data.userCode as string}</strong></div> : null}
|
||||
</div>);
|
||||
}
|
||||
return (
|
||||
<StatusCardSkeleton
|
||||
loading={loading}
|
||||
title={header}
|
||||
subtitle={name}
|
||||
status={status}
|
||||
subtitleRight={subtitleElement}
|
||||
statusType={statusToStatusType(status)}>
|
||||
{body}
|
||||
</StatusCardSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
const simpleSelectors = sourceAdapter.getSelectors();
|
||||
|
||||
const mapStateToProps = (state: RootState, props) => ({
|
||||
data: simpleSelectors.selectById(state.sources, props.id)
|
||||
});
|
||||
|
||||
const connector = connect(mapStateToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>
|
||||
|
||||
export default connector(SourceStatusCard);
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, {type PropsWithChildren, type ReactElement} from 'react';
|
||||
import SkeletonTitle from "../skeleton/SkeletonTitle";
|
||||
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
|
||||
import StatusIndicator, {type StatusType} from "../StatusIndicator";
|
||||
|
||||
export interface StatusCardSkeletonData {
|
||||
loading?: boolean
|
||||
header?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
subtitleRight?: string | ReactElement
|
||||
status?: string
|
||||
statusType?: StatusType
|
||||
}
|
||||
|
||||
const StatusCardSkeleton = (props: PropsWithChildren<StatusCardSkeletonData>) => {
|
||||
const {
|
||||
loading,
|
||||
header,
|
||||
status = null,
|
||||
statusType,
|
||||
title,
|
||||
subtitle,
|
||||
subtitleRight,
|
||||
children
|
||||
} = props || {};
|
||||
|
||||
const showLoading = loading || (header === undefined && title === undefined);
|
||||
return (
|
||||
<div className="shadow-md rounded bg-gray-500">
|
||||
<div className="p-3 bg-gray-700">
|
||||
<SkeletonTitle show={showLoading}/>
|
||||
<div className={`flex ${showLoading ? 'hidden' : ''}`}>
|
||||
<div className="flex-auto text-left">
|
||||
{header ? <div className="font-semibold">{header}</div> : null}
|
||||
{title ? <div className="font-semibold">{title}</div> : null}
|
||||
{subtitle ? <div className="text-sm">{subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end">{status}<StatusIndicator type={statusType}/></div>
|
||||
{subtitleRight ? <div className="text-sm justify-end">{subtitleRight}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{loading ? <SkeletonParagraph/> : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default StatusCardSkeleton;
|
||||
@@ -1,38 +0,0 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
|
||||
export const scrobblerApi = createApi({
|
||||
reducerPath: 'scrobblerApi',
|
||||
baseQuery: fetchBaseQuery({baseUrl: '/api/'}),
|
||||
endpoints: (builder) => ({
|
||||
startClient: builder.mutation<undefined, {
|
||||
name: string,
|
||||
force?: boolean
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/client/init',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name,
|
||||
force: params.force
|
||||
}
|
||||
})
|
||||
}),
|
||||
listenClient: builder.mutation<undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
listening?: boolean
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/client/listen',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
listening: params.listening
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const {useStartClientMutation, useListenClientMutation} = scrobblerApi;
|
||||
@@ -1,40 +0,0 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
|
||||
export const sourceApi = createApi({
|
||||
reducerPath: 'sourceApi',
|
||||
baseQuery: fetchBaseQuery({baseUrl: '/api/'}),
|
||||
endpoints: (builder) => ({
|
||||
startSource: builder.mutation<undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
force?: boolean
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/source/init',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
force: params.force
|
||||
}
|
||||
})
|
||||
}),
|
||||
listenSource: builder.mutation<undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
listening?: boolean
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: '/source/listen',
|
||||
method: 'POST',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type,
|
||||
listening: params.listening
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export const {useStartSourceMutation, useListenSourceMutation} = sourceApi;
|
||||
@@ -1,7 +0,0 @@
|
||||
.statusCardBody {
|
||||
article {
|
||||
&:last-of-type {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import StatusSection from "../status/StatusSection";
|
||||
import LogsSection from "../logs/LogsSection";
|
||||
|
||||
const dashboard = () => {
|
||||
return (
|
||||
<>
|
||||
<StatusSection/>
|
||||
<LogsSection/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default dashboard;
|
||||
@@ -1,124 +0,0 @@
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import PlayDisplay from "../components/PlayDisplay";
|
||||
import {recentIncludes} from "../../core/Atomic";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {
|
||||
useGetDeadQuery,
|
||||
useRemoveDeadSingleMutation,
|
||||
useProcessDeadSingleMutation,
|
||||
useLazyProcessDeadQuery,
|
||||
useLazyRemoveDeadQuery,
|
||||
deadAdapter,
|
||||
clearDead,
|
||||
} from "./deadLetterDucks";
|
||||
import dayjs from "dayjs";
|
||||
import type {RootState} from "../store";
|
||||
import {connect, type ConnectedProps} from "react-redux";
|
||||
import { faBug } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import clsx from "clsx";
|
||||
import { useCopyToClipboard } from '../components/copyToClipboardHook';
|
||||
import { messageWithCauses } from '../../core/ErrorUtils';
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
includeWeb: true
|
||||
}
|
||||
|
||||
const dead = (props: PropsFromRedux) => {
|
||||
const {
|
||||
data = [],
|
||||
} = props;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
isLoading,
|
||||
isSuccess
|
||||
} = useGetDeadQuery({name: searchParams.get('name'), type: searchParams.get('type')});
|
||||
|
||||
const { copy, isCopied } = useCopyToClipboard();
|
||||
|
||||
const [copiedIndex, setIndex] = useState(null);
|
||||
|
||||
const copyActionCB = useCallback((obj, index) => {
|
||||
copy(JSON.stringify(obj, null, 2));
|
||||
setIndex(index);
|
||||
},[copy, setIndex]);
|
||||
|
||||
const baseClass = ['mr-3'];
|
||||
|
||||
const [removeDeadFetch, removeResult] = useRemoveDeadSingleMutation();
|
||||
const [retryDeadFetch, processResult] = useProcessDeadSingleMutation();
|
||||
const [removeAllDeadFetch] = useLazyRemoveDeadQuery();
|
||||
const [retryAllDeadFetch] = useLazyProcessDeadQuery();
|
||||
|
||||
const retryDead = useCallback((id: string) => retryDeadFetch({name: searchParams.get('name'), type: searchParams.get('type'), id}), [retryDeadFetch, searchParams]);
|
||||
const removeDead = useCallback((id: string) => removeDeadFetch({name: searchParams.get('name'), type: searchParams.get('type'), id}), [removeDeadFetch, searchParams]);
|
||||
// this deletes everything, no undo, so double check with the user first
|
||||
const removeAllDead = useCallback(() => {
|
||||
if (window.confirm('Remove all failed scrobbles? This cannot be undone.')) {
|
||||
removeAllDeadFetch({name: searchParams.get('name'), type: searchParams.get('type')});
|
||||
}
|
||||
}, [removeAllDeadFetch, searchParams]);
|
||||
const retryAllDead = useCallback(() => retryAllDeadFetch({name: searchParams.get('name'), type: searchParams.get('type')}), [retryAllDeadFetch, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="shadow-md rounded bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2><span className="mr-1">Failed Scrobbles -</span> <span onClick={() => retryAllDead()} className="capitalize underline cursor-pointer max-w-fit">Retry All</span>
|
||||
<span className="mx-2">|</span> <span onClick={() => removeAllDead()} className="capitalize underline cursor-pointer max-w-fit">Remove All</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{isSuccess && !isLoading && data.length === 0 ? 'No failed scrobbles!' : null}
|
||||
<ul>{data.map(x =>
|
||||
{
|
||||
let errorSummary: string;
|
||||
try {
|
||||
// @ts-expect-error
|
||||
errorSummary = messageWithCauses(x.error, undefined, '\n caused by: ');
|
||||
} catch (e) {
|
||||
console.warn(new Error('Could not convert error to summary', {cause: e}));
|
||||
}
|
||||
const classes = [...baseClass].concat(copiedIndex !== x.id ? ['underline','cursor-pointer'] : []);
|
||||
return (<li className="my-2.5" key={x.id}>
|
||||
<div className="text-lg">
|
||||
<button className={clsx(classes)} onClick={() => copyActionCB(x, x.id)}>{copiedIndex === x.id ? 'Copied!' : <FontAwesomeIcon
|
||||
color="white" icon={faBug}/>}</button>
|
||||
<PlayDisplay data={x.play} buildOptions={displayOpts}/></div>
|
||||
<div><span className="font-semibold">Source</span>:{x.source === undefined ? 'Source - Unknown' : x.source.replace('Source -', '')}</div>
|
||||
<div><span className="font-semibold">Status</span>:{x.status}</div>
|
||||
<div><span className="font-semibold">Retries</span>: {x.retries}</div>
|
||||
<div><span className="font-semibold">Last Retried</span>: {x.lastRetry === undefined ? 'Never' : dayjs.duration(dayjs(x.lastRetry).diff(dayjs())).humanize(true)}</div>
|
||||
{errorSummary === undefined ? null : <div><span className="font-semibold">Error Summary</span>: <pre className="font-mono text-sm">{errorSummary}</pre></div>}
|
||||
<div onClick={() => retryDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Retry</div>
|
||||
<div onClick={() => removeDead(x.id)} className="capitalize underline cursor-pointer max-w-fit">Remove</div>
|
||||
</li>)
|
||||
}
|
||||
)}</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const deadSelectors = deadAdapter.getSelectors();
|
||||
|
||||
const mapStateToProps = (state: RootState) => {
|
||||
return {
|
||||
data: deadSelectors.selectAll(state.deadLetter)
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
clearDeadLetter: () => dispatch(clearDead())
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>
|
||||
|
||||
export default connector(dead);
|
||||
@@ -1,148 +0,0 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
import type {DeadLetterScrobble, JsonPlayObject} from "../../core/Atomic";
|
||||
import {createAction, createEntityAdapter, createSlice} from "@reduxjs/toolkit";
|
||||
import {type ApiEventPayload, clientUpdate} from "../status/ducks";
|
||||
|
||||
type DeadResponse = DeadLetterScrobble<JsonPlayObject, string>[];
|
||||
export const deadApi = createApi({
|
||||
reducerPath: 'deadApi',
|
||||
baseQuery: fetchBaseQuery({baseUrl: './api/'}),
|
||||
tagTypes: ['DeadLetters'],
|
||||
endpoints: (builder) => ({
|
||||
getDead: builder.query<DeadResponse, { name: string, type: string }>({
|
||||
query: (params) => `dead?name=${params.name}&type=${params.type}&sort=seenAt&order=desc`,
|
||||
providesTags: ['DeadLetters']
|
||||
}),
|
||||
processDead: builder.query<DeadResponse, { name: string, type: string }>({
|
||||
query: (params) => ({
|
||||
url: `dead`,
|
||||
method: 'PUT',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type
|
||||
}
|
||||
}),
|
||||
providesTags: ['DeadLetters']
|
||||
}),
|
||||
processDeadSingle: builder.mutation<DeadLetterScrobble<JsonPlayObject, string> | undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
id: string
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: `/dead/${params.id}`,
|
||||
method: 'PUT',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type
|
||||
}
|
||||
})
|
||||
}),
|
||||
removeDead: builder.query<DeadResponse, { name: string, type: string }>({
|
||||
query: (params) => ({
|
||||
url: `dead`,
|
||||
method: 'DELETE',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type
|
||||
}
|
||||
}),
|
||||
providesTags: ['DeadLetters']
|
||||
}),
|
||||
removeDeadSingle: builder.mutation<DeadLetterScrobble<JsonPlayObject, string> | undefined, {
|
||||
name: string,
|
||||
type: string,
|
||||
id: string
|
||||
}>({
|
||||
query: (params) => ({
|
||||
url: `/dead/${params.id}`,
|
||||
method: 'DELETE',
|
||||
params: {
|
||||
name: params.name,
|
||||
type: params.type
|
||||
},
|
||||
transformResponse: (response, meta, arg) => {
|
||||
if (response === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
},
|
||||
invalidatesTags: ['DeadLetters']
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const deadAdapter = createEntityAdapter<DeadLetterScrobble<JsonPlayObject, string>>({
|
||||
selectId: (data) => data.id
|
||||
});
|
||||
|
||||
export const deadSlice = createSlice({
|
||||
name: 'deadLetter',
|
||||
initialState: deadAdapter.getInitialState(),
|
||||
reducers: {
|
||||
deadUpdated: deadAdapter.updateOne,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addMatcher(
|
||||
(action) => deadApi.endpoints.getDead.matchPending(action),
|
||||
(state, action) => {
|
||||
state = deadAdapter.getInitialState();
|
||||
}
|
||||
)
|
||||
builder.addMatcher(
|
||||
(action) => deadApi.endpoints.getDead.matchFulfilled(action),
|
||||
(state, action) => {
|
||||
deadAdapter.setAll(state, action.payload);
|
||||
}
|
||||
)
|
||||
// .addMatcher(
|
||||
// (action) => deadApi.endpoints.removeDeadSingle.matchFulfilled(action),
|
||||
// (state, action) => {
|
||||
// deadAdapter.removeOne(state, action.meta.arg.originalArgs.id)
|
||||
// }
|
||||
// )
|
||||
// .addMatcher(
|
||||
// (action) => deadApi.endpoints.processDeadSingle.matchFulfilled(action),
|
||||
// (state, action) => {
|
||||
// if (action.payload === undefined) {
|
||||
// deadAdapter.removeOne(state, action.meta.arg.originalArgs.id);
|
||||
// } else {
|
||||
// state.entities[action.meta.arg.originalArgs.id] = action.payload;
|
||||
// //deadAdapter.updateOne(state, action.meta.arg.originalArgs.id);
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
.addMatcher(
|
||||
(action) => clearDead.match(action),
|
||||
(state, action) => {
|
||||
state = deadAdapter.getInitialState();
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'deadLetter',
|
||||
(state, action) => {
|
||||
state.entities[(action.payload as ApiEventPayload).data.dead.id] = (action.payload as ApiEventPayload).data.dead;
|
||||
state.ids.push((action.payload as ApiEventPayload).data.dead.id);
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'removeDeadLetter',
|
||||
(state, action) => {
|
||||
delete state.entities[(action.payload as ApiEventPayload).data.dead.id];
|
||||
state.ids = state.ids.filter(x => x !== (action.payload as ApiEventPayload).data.dead.id);
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'updateDeadLetter',
|
||||
(state, action) => {
|
||||
state.entities[(action.payload as ApiEventPayload).data.dead.id] = (action.payload as ApiEventPayload).data.dead;
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
export const clearDead = createAction('clearDead');
|
||||
|
||||
export const {useGetDeadQuery, useProcessDeadSingleMutation, useRemoveDeadSingleMutation, useLazyProcessDeadQuery, useLazyRemoveDeadQuery} = deadApi;
|
||||
@@ -1,98 +0,0 @@
|
||||
@keyframes dashed-player {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-fade {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-fade-half {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chakra-stack .MuiLinearProgress-dashed {
|
||||
/* animation: dashed-player 3s infinite linear !important;
|
||||
background-image: radial-gradient(var(--chakra-colors-color-palette-solid) 0%, var(--chakra-colors-color-palette-solid) 16%, transparent 42%);
|
||||
background-position: 0 -1px !important; */
|
||||
animation: unset !important;
|
||||
background-image: unset !important;
|
||||
background-position: unset !important;
|
||||
background-color: var(--chakra-colors-color-palette-muted)
|
||||
}
|
||||
.playerContainer .chakra-stack .MuiLinearProgress-dashed {
|
||||
background-color: var(--chakra-colors-color-palette-emphasized)
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-bar1 {
|
||||
background-color: var(--chakra-colors-color-palette-solid) !important;
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-bar2 {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
}
|
||||
|
||||
/** high animation **/
|
||||
|
||||
/* .chakra-stack .MuiLinearProgress-indeterminate {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
} */
|
||||
|
||||
|
||||
/** low animation **/
|
||||
|
||||
@keyframes slide { to { background-position: 26px 0; } }
|
||||
.chakra-stack .MuiLinearProgress-indeterminate {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
background: linear-gradient(45deg,
|
||||
#fff 25%,
|
||||
#71717a 25%,
|
||||
#71717a 50%,
|
||||
#fff 50%,
|
||||
#fff 75%,
|
||||
#71717a 75%);
|
||||
background: linear-gradient(45deg,
|
||||
var(--chakra-colors-color-palette-700) 25%,
|
||||
var(--chakra-colors-color-palette-400) 25%,
|
||||
var(--chakra-colors-color-palette-400) 50%,
|
||||
var(--chakra-colors-color-palette-700) 50%,
|
||||
var(--chakra-colors-color-palette-700) 75%,
|
||||
var(--chakra-colors-color-palette-400) 75%);
|
||||
background-size: 25px 26px;
|
||||
animation: slide 3s linear infinite;
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-indeterminate .MuiLinearProgress-bar1,.chakra-stack .MuiLinearProgress-indeterminate .MuiLinearProgress-bar2 {
|
||||
display: none;
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-root {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.ansi-cyan-fg {
|
||||
color: var(--chakra-colors-color-palette-solid)
|
||||
}
|
||||
|
||||
.logline code span:first-child {
|
||||
color: var(--chakra-colors-gray-500);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//import './wdyr.js';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './AppNext.js';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
+84
-46
@@ -1,60 +1,98 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@utility container {
|
||||
padding-inline: 1rem;
|
||||
@keyframes dashed-player {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--breakpoint-md: 1024px;
|
||||
@keyframes icon-fade {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@keyframes icon-fade-half {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
.chakra-stack .MuiLinearProgress-dashed {
|
||||
/* animation: dashed-player 3s infinite linear !important;
|
||||
background-image: radial-gradient(var(--chakra-colors-color-palette-solid) 0%, var(--chakra-colors-color-palette-solid) 16%, transparent 42%);
|
||||
background-position: 0 -1px !important; */
|
||||
animation: unset !important;
|
||||
background-image: unset !important;
|
||||
background-position: unset !important;
|
||||
background-color: var(--chakra-colors-color-palette-muted)
|
||||
}
|
||||
.playerContainer .chakra-stack .MuiLinearProgress-dashed {
|
||||
background-color: var(--chakra-colors-color-palette-emphasized)
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-bar1 {
|
||||
background-color: var(--chakra-colors-color-palette-solid) !important;
|
||||
}
|
||||
.chakra-stack .MuiLinearProgress-bar2 {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
/** high animation **/
|
||||
|
||||
/* .chakra-stack .MuiLinearProgress-indeterminate {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
} */
|
||||
|
||||
|
||||
/** low animation **/
|
||||
|
||||
@keyframes slide { to { background-position: 26px 0; } }
|
||||
.chakra-stack .MuiLinearProgress-indeterminate {
|
||||
background-color: var(--chakra-colors-color-palette-500) !important;
|
||||
background: linear-gradient(45deg,
|
||||
#fff 25%,
|
||||
#71717a 25%,
|
||||
#71717a 50%,
|
||||
#fff 50%,
|
||||
#fff 75%,
|
||||
#71717a 75%);
|
||||
background: linear-gradient(45deg,
|
||||
var(--chakra-colors-color-palette-700) 25%,
|
||||
var(--chakra-colors-color-palette-400) 25%,
|
||||
var(--chakra-colors-color-palette-400) 50%,
|
||||
var(--chakra-colors-color-palette-700) 50%,
|
||||
var(--chakra-colors-color-palette-700) 75%,
|
||||
var(--chakra-colors-color-palette-400) 75%);
|
||||
background-size: 25px 26px;
|
||||
animation: slide 3s linear infinite;
|
||||
}
|
||||
.loading {
|
||||
height: 35px;
|
||||
fill: black;
|
||||
display: none;
|
||||
.chakra-stack .MuiLinearProgress-indeterminate .MuiLinearProgress-bar1,.chakra-stack .MuiLinearProgress-indeterminate .MuiLinearProgress-bar2 {
|
||||
display: none;
|
||||
}
|
||||
.connected .loading {
|
||||
display: inline;
|
||||
}
|
||||
.dark .loading {
|
||||
fill: white;
|
||||
}
|
||||
.blue,.ansi-blue-fg {
|
||||
color: rgb(97, 175, 239)
|
||||
}
|
||||
.red,.ansi-red-fg {
|
||||
color: rgb(255 123 133);
|
||||
}
|
||||
.green,.ansi-green-fg {
|
||||
color: rgb(170 255 109);
|
||||
}
|
||||
.purple,.ansi-magenta-fg {
|
||||
color: rgb(224 124 253);
|
||||
}
|
||||
.yellow,.ansi-yellow-fg {
|
||||
color: rgb(253 198 94);
|
||||
.chakra-stack .MuiLinearProgress-root {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.ansi-cyan-fg {
|
||||
color: rgb(255, 255, 255)
|
||||
color: var(--chakra-colors-color-palette-solid)
|
||||
}
|
||||
|
||||
.ansi-bright-black-fg {
|
||||
color: rgb(169, 169, 169)
|
||||
}
|
||||
.logline code span:first-child {
|
||||
color: var(--chakra-colors-gray-500);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//import './wdyr.js';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import React, {type PropsWithChildren} from 'react';
|
||||
import * as AnsiImport from "ansi-to-react";
|
||||
|
||||
// @ts-expect-error Ansi export is built incorrectly
|
||||
const Ansi = AnsiImport.default.default as typeof AnsiImport.default;
|
||||
|
||||
const LogLine = (props: PropsWithChildren<{level: number, levelLabel: string, message: string}>) => {
|
||||
return (
|
||||
<div className="line"><Ansi useClasses>{props.message}</Ansi></div>
|
||||
)
|
||||
};
|
||||
|
||||
export default LogLine;
|
||||
@@ -1,6 +0,0 @@
|
||||
.line {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.675rem;
|
||||
line-height: 0.9rem;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useEventSource, useEventSourceListener } from "@react-nano/use-event-source";
|
||||
import { FixedSizeList } from "fixed-size-list";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { connect, type ConnectedProps } from "react-redux";
|
||||
import Loading from "../components/loading/Loading";
|
||||
import type {RootState} from "../store";
|
||||
import LogLine from "./LogLine";
|
||||
import { useGetLogsQuery, useLazySetLogSettingsQuery } from "./logsApi";
|
||||
import './LogsSection.css';
|
||||
|
||||
const logBuffer: { message: string, id: string, level: number, levelLabel:string }[] = [];
|
||||
|
||||
interface MinLogInfo {
|
||||
message: string,
|
||||
id: string,
|
||||
level: number
|
||||
levelLabel: string
|
||||
}
|
||||
|
||||
const createFixedList = (size, initialList: MinLogInfo[] = []): FixedSizeList<MinLogInfo> => {
|
||||
return new FixedSizeList<MinLogInfo>(size, initialList);
|
||||
}
|
||||
|
||||
let list = createFixedList(50);
|
||||
|
||||
interface LogLevelButtonProps {
|
||||
name: string,
|
||||
active: boolean,
|
||||
onClick: (val: number) => void
|
||||
}
|
||||
const LogLevelButton = (props: LogLevelButtonProps) => {
|
||||
const {name, active, onClick} = props;
|
||||
const click = useCallback(() => active ? null : onClick(name), [onClick, name, active]);
|
||||
const className = active ? "mx-1" : "capitalize underline cursor-pointer mx-1";
|
||||
return <span onClick={click} className={className}>{name.toUpperCase()}</span>;
|
||||
}
|
||||
const LogLimitButton = (props: {val: number, active: boolean, onClick: (val: number) => void}) => {
|
||||
const {val, active, onClick} = props;
|
||||
const click = useCallback(() => active ? null : onClick(val), [onClick, val, active]);
|
||||
const className = active ? "mx-1" : "capitalize underline cursor-pointer mx-1";
|
||||
return <span onClick={click} className={className}>{val}</span>;
|
||||
}
|
||||
|
||||
const LogsSection = (props: PropsFromRedux) => {
|
||||
const {
|
||||
logs,
|
||||
settings,
|
||||
settings: {
|
||||
limit = 50,
|
||||
level
|
||||
}
|
||||
} = props;
|
||||
|
||||
const [logList, setLogList] = useState(logBuffer);
|
||||
const [logLevel, setLogLevel] = useState(level ?? 'trace')
|
||||
|
||||
useGetLogsQuery(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
list = createFixedList(settings.limit, logs.map((x, index) => ({...x, message: x.line, id: index.toString()})));
|
||||
setLogList(Array.from(list.data));
|
||||
setLogLevel(settings.level);
|
||||
}, [logs, settings, setLogList, setLogLevel]);
|
||||
|
||||
const [setSettings] = useLazySetLogSettingsQuery();
|
||||
|
||||
const fetchLevel = useCallback(async (val) => {
|
||||
setSettings({level: val});
|
||||
}, [setSettings]);
|
||||
const fetchLimit = useCallback(async (val) => {
|
||||
setSettings({limit: val});
|
||||
}, [setSettings]);
|
||||
|
||||
const [eventSource, eventSourceStatus] = useEventSource("api/logs/stream", false);
|
||||
useEventSourceListener(eventSource, ['messsage', 'stream'], evt => {
|
||||
const data = JSON.parse(evt.data);
|
||||
// @ts-ignore
|
||||
list.add({message: data.message, id: evt.lastEventId, level: data.level, levelLabel: data.levelLabel});
|
||||
setLogList(Array.from(list.data));
|
||||
//console.log(evt);
|
||||
}, [setLogList]);
|
||||
|
||||
// TODO may eventually add log filtering back in but idk if anyone even uses it right now
|
||||
return (
|
||||
<div className="grid ">
|
||||
<div className="shadow-md rounded my-6 bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2>Log (Most Recent)
|
||||
<Loading show/>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>Level : <LogLevelButton name="trace" active={logLevel === 'trace'} onClick={fetchLevel}/> |
|
||||
<LogLevelButton name="debug" active={logLevel === 'debug'} onClick={fetchLevel}/> |
|
||||
<LogLevelButton name="verbose" active={logLevel === 'verbose'} onClick={fetchLevel}/> |
|
||||
<LogLevelButton name="info" active={logLevel === 'info'} onClick={fetchLevel}/> |
|
||||
<LogLevelButton name="warn" active={logLevel === 'warn'} onClick={fetchLevel}/> |
|
||||
<LogLevelButton name="error" active={logLevel === 'error'} onClick={fetchLevel}/>
|
||||
</div>
|
||||
<div>Limit : <LogLimitButton val={50} active={limit === 50} onClick={fetchLimit}/> |
|
||||
<LogLimitButton val={100} active={limit === 100} onClick={fetchLimit}/> |
|
||||
<LogLimitButton val={200} active={limit === 200} onClick={fetchLimit}/>
|
||||
</div>
|
||||
<br/>
|
||||
<div className="logs font-mono">
|
||||
{
|
||||
logList.map(x => <LogLine key={x.id} level={x.level} levelLabel={x.levelLabel} message={x.message}/>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-auto flex min-h-0 overflow-auto">
|
||||
<div className="w-full relative flex-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
logs: state.logs.data,
|
||||
settings: state.logs.settings
|
||||
});
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>
|
||||
|
||||
const connector = connect(mapStateToProps);
|
||||
|
||||
export default connector(LogsSection);
|
||||
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
createReducer
|
||||
} from '@reduxjs/toolkit';
|
||||
import type {LogOutputConfig} from "../../core/Atomic";
|
||||
import { logsApi } from "./logsApi";
|
||||
export interface LogsState {
|
||||
// TODO remove after new ui switchover
|
||||
// needed to remove this type to remove @foxxmd/logging from vite deps
|
||||
data: any[],
|
||||
settings: LogOutputConfig
|
||||
}
|
||||
const initialState: LogsState = {data: [], settings: {level: 'trace', sort: 'desc', limit: 50}};
|
||||
const logsReducer = createReducer(initialState, (builder) => {
|
||||
builder
|
||||
.addMatcher(
|
||||
(action) => logsApi.endpoints.getLogs.matchFulfilled(action) || logsApi.endpoints.setLogSettings.matchFulfilled(action),
|
||||
(state, action) => {
|
||||
state.data = action.payload.data.slice(0, action.payload.settings.limit + 1);
|
||||
state.settings = action.payload.settings;
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
export { logsReducer };
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import type { LogOutputConfig } from '../../core/Atomic';
|
||||
|
||||
export const logsApi = createApi({
|
||||
reducerPath: 'logsApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getLogs: builder.query<{ data: any[], settings: LogOutputConfig }, undefined>({
|
||||
query: () => `logs`,
|
||||
}),
|
||||
setLogSettings: builder.query<{ data: any[], settings: LogOutputConfig }, object>({
|
||||
query: (settings) => ({
|
||||
url: '/logs',
|
||||
method: 'PUT',
|
||||
body: settings
|
||||
})
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetLogsQuery, useLazySetLogSettingsQuery } = logsApi;
|
||||
@@ -1,107 +0,0 @@
|
||||
import React, { Fragment, useMemo, useCallback, useState } from 'react';
|
||||
import PlayDisplay from "../components/PlayDisplay";
|
||||
import { recentIncludes } from "../../core/Atomic";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useGetRecentQuery } from "./recentDucks";
|
||||
import Tooltip from "../components/Tooltip";
|
||||
import { faQuestionCircle, faBug } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useCopyToClipboard } from '../components/copyToClipboardHook';
|
||||
import clsx from "clsx";
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
includeWeb: true
|
||||
}
|
||||
|
||||
const apiTipContent = <Fragment>
|
||||
<div>Data that is directly returned by the Source API.</div>
|
||||
<div>If you do not see your recent plays in this data it is likely the Source's data is lagging behind your
|
||||
actual activity.
|
||||
</div>
|
||||
<div className="mt-2"></div>
|
||||
</Fragment>;
|
||||
|
||||
const tsTip = <div className="mt-2">
|
||||
<div>
|
||||
<code>(C)</code> - Scrobble timestamped when listen was <strong>completed</strong>
|
||||
</div>
|
||||
<div>
|
||||
<code>(S)</code> - Scrobble timestamped when listen was <strong>started</strong>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const recent = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess
|
||||
} = useGetRecentQuery({
|
||||
name: searchParams.get('name'),
|
||||
type: searchParams.get('type'),
|
||||
upstream: searchParams.get('upstream')
|
||||
});
|
||||
|
||||
const { copy, isCopied } = useCopyToClipboard();
|
||||
|
||||
const [copiedIndex, setIndex] = useState(null);
|
||||
|
||||
const copyActionCB = useCallback((obj, index) => {
|
||||
copy(JSON.stringify(obj, null, 2));
|
||||
setIndex(index);
|
||||
},[copy, setIndex]);
|
||||
|
||||
const baseClass = ['mr-3'];
|
||||
|
||||
const isUpstream = searchParams.get('upstream') === '1';
|
||||
|
||||
const tipContents = useMemo(() => {
|
||||
return <Fragment>
|
||||
{isUpstream ? apiTipContent : null}
|
||||
{tsTip}
|
||||
</Fragment>
|
||||
}, [isUpstream]);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="shadow-md rounded bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2>Recently Played{isUpstream ? ' from Source API' : null}<Tooltip message={tipContents}
|
||||
classNames={['ml-2']}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
width: '35%'
|
||||
}}><FontAwesomeIcon
|
||||
color="white" icon={faQuestionCircle}/></Tooltip>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{/*{isUpstream ? <span className="mb-3">Below is data directly returned by the Source API. MS uses</span> : null}*/}
|
||||
{isSuccess && !isLoading && data.length === 0 ? 'No recently played tracks!' : null}
|
||||
<ul>{data.map(x => {
|
||||
const classes = [...baseClass].concat(copiedIndex !== x.index ? ['underline','cursor-pointer'] : []);
|
||||
// let last = x.meta.lifecycle.original;
|
||||
// x.meta.lifecycle.steps = x.meta.lifecycle.steps.map(y => {
|
||||
// if(y.patch !== undefined) {
|
||||
// // @ts-ignore
|
||||
// y.full = patch(last, y.patch);
|
||||
// // @ts-ignore
|
||||
// last = y.full;
|
||||
// }
|
||||
// return y;
|
||||
// });
|
||||
return <li key={x.index}>
|
||||
<button className={clsx(classes)} onClick={() => copyActionCB(x, x.index)}>{copiedIndex === x.index ? 'Copied!' : <FontAwesomeIcon
|
||||
color="white" icon={faBug}/>}</button>
|
||||
<PlayDisplay data={x} buildOptions={displayOpts}/>
|
||||
</li>
|
||||
})}</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default recent;
|
||||
@@ -1,18 +0,0 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
import type {JsonPlayObject} from "../../core/Atomic";
|
||||
|
||||
type RecentResponse = (JsonPlayObject & { index: number })[];
|
||||
export const recentApi = createApi({
|
||||
reducerPath: 'recentApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getRecent: builder.query<RecentResponse, {name: string, type: string, upstream?: string}>({
|
||||
query: (params) => `recent?name=${params.name}&type=${params.type}&upstream=${params.upstream ?? 0}`,
|
||||
transformResponse: (response: RecentResponse, meta, arg) => {
|
||||
return response.map((x, index) => ({...x, index: index + 1}))
|
||||
}
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetRecentQuery } = recentApi;
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import PlayDisplay from "../components/PlayDisplay";
|
||||
import {recentIncludes} from "../../core/Atomic";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {useGetRecentQuery} from "./scrobbledDucks";
|
||||
import { useCopyToClipboard } from '../components/copyToClipboardHook';
|
||||
import { faBug } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import clsx from "clsx";
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
includeWeb: true
|
||||
}
|
||||
|
||||
const scrobbled = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess
|
||||
} = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type')});
|
||||
|
||||
const { copy, isCopied } = useCopyToClipboard();
|
||||
|
||||
const [copiedIndex, setIndex] = useState(null);
|
||||
|
||||
const copyActionCB = useCallback((obj, index) => {
|
||||
copy(JSON.stringify(obj, null, 2));
|
||||
setIndex(index);
|
||||
},[copy, setIndex]);
|
||||
|
||||
const baseClass = ['mr-3'];
|
||||
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="shadow-md rounded bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2>Recently Scrobbled
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{isSuccess && !isLoading && data.length === 0 ? 'No recently scrobbled tracks!' : null}
|
||||
<ul>{data.map(x => {
|
||||
const classes = [...baseClass].concat(copiedIndex !== x.index ? ['underline','cursor-pointer'] : []);
|
||||
|
||||
return <li key={x.index}>
|
||||
<button className={clsx(classes)} onClick={() => copyActionCB(x, x.index)}>{copiedIndex === x.index ? 'Copied!' : <FontAwesomeIcon
|
||||
color="white" icon={faBug}/>}</button>
|
||||
<PlayDisplay data={x} buildOptions={displayOpts}/>
|
||||
</li>;
|
||||
})} </ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default scrobbled;
|
||||
@@ -1,18 +0,0 @@
|
||||
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
||||
import type {JsonPlayObject} from "../../core/Atomic";
|
||||
|
||||
type ScrobbledResponse = (JsonPlayObject & { index: number })[];
|
||||
export const scrobbledApi = createApi({
|
||||
reducerPath: 'scrobbledApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getRecent: builder.query<ScrobbledResponse, {name: string, type: string}>({
|
||||
query: (params) => `scrobbled?name=${params.name}&type=${params.type}&sort=seenAt&order=desc`,
|
||||
transformResponse: (response: ScrobbledResponse, meta, arg) => {
|
||||
return response.map((x, index) => ({...x, index: index + 1}))
|
||||
}
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetRecentQuery } = scrobbledApi;
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {connect, type ConnectedProps} from "react-redux";
|
||||
import StatusCardSkeleton from "../components/statusCard/StatusCardSkeleton";
|
||||
import SourceStatusCard from "../components/statusCard/SourceStatusCard";
|
||||
import ClientStatusCard from "../components/statusCard/ClientStatusCard";
|
||||
import {useGetStatusQuery} from "./statusApi";
|
||||
import {clientAdapter, sourceAdapter} from "./ducks";
|
||||
import type {RootState} from "../store";
|
||||
const StatusSection = (props: PropsFromRedux) => {
|
||||
const {
|
||||
} = props;
|
||||
const {data, error, isLoading} = useGetStatusQuery(undefined);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="grid md:grid-cols-3 gap-x-5 gap-y-4 grid-flow-dense">
|
||||
{isLoading ? <StatusCardSkeleton loading={isLoading}/> : undefined}
|
||||
{props.sourceIds.map(x => <SourceStatusCard id={x} key={x}/>)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 mt-4 gap-x-5 gap-y-4">
|
||||
{!isLoading ? props.clientIds.map(x => <ClientStatusCard id={x} key={x}/>) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const simpleSourceSelectors = sourceAdapter.getSelectors();
|
||||
const simpleClientSelectors = clientAdapter.getSelectors();
|
||||
const mapStateToProps = (state: RootState) => {
|
||||
return {
|
||||
sourceIds: simpleSourceSelectors.selectIds(state.sources),
|
||||
clientIds: simpleClientSelectors.selectIds(state.clients),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
}
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>
|
||||
|
||||
export default connector(StatusSection);
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
createAction, createEntityAdapter,
|
||||
createSlice
|
||||
} from '@reduxjs/toolkit'
|
||||
import {statusApi} from "./statusApi";
|
||||
import type {ClientStatusData, SourcePlayerJson, SourceStatusData} from "../../core/Atomic";
|
||||
|
||||
export interface ApiEventPayload {
|
||||
type: string,
|
||||
name: string,
|
||||
event: string,
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const sourceAdapter = createEntityAdapter<SourceStatusData>({
|
||||
// Assume IDs are stored in a field other than `book.id`
|
||||
selectId: (data) => `${data.type}-${data.name}`,
|
||||
// Keep the "all IDs" array sorted based on book titles
|
||||
sortComparer: (a, b) => `${a.type}-${a.name}`.localeCompare(`${b.type}-${b.name}`),
|
||||
});
|
||||
const clientAdapter = createEntityAdapter<ClientStatusData>({
|
||||
// Assume IDs are stored in a field other than `book.id`
|
||||
selectId: (data) => `${data.type}-${data.name}`,
|
||||
// Keep the "all IDs" array sorted based on book titles
|
||||
sortComparer: (a, b) => `${a.type}-${a.name}`.localeCompare(`${b.type}-${b.name}`),
|
||||
});
|
||||
|
||||
const sourceSlice = createSlice({
|
||||
name: 'sources',
|
||||
initialState: sourceAdapter.getInitialState(),
|
||||
reducers: {
|
||||
sourceUpdated: sourceAdapter.updateOne,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addMatcher(
|
||||
(action) => statusApi.endpoints.getStatus.matchFulfilled(action),
|
||||
(state, action) => {
|
||||
sourceAdapter.setAll(state, action.payload.sources);
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => sourceUpdate.match(action) && action.payload.event === 'discovered',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].tracksDiscovered++;
|
||||
}
|
||||
}
|
||||
).addMatcher(
|
||||
(action) => sourceUpdate.match(action) && action.payload.event === 'playerUpdate',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
const playerState = action.payload.data as SourcePlayerJson;
|
||||
state.entities[action.payload.id].players[playerState.platformId] = playerState;
|
||||
}
|
||||
}).addMatcher(
|
||||
(action) => sourceUpdate.match(action) && action.payload.event === 'playerDelete',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
const playerState = action.payload.data as {platformId: string};
|
||||
delete state.entities[action.payload.id].players[playerState.platformId];
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => sourceUpdate.match(action) && action.payload.event === 'statusChange',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].status = action.payload.data.status;
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
const clientSlice = createSlice({
|
||||
name: 'clients',
|
||||
initialState: clientAdapter.getInitialState(),
|
||||
reducers: {
|
||||
clientUpdated: clientAdapter.updateOne,
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addMatcher(
|
||||
(action) => statusApi.endpoints.getStatus.matchFulfilled(action),
|
||||
(state, action) => {
|
||||
clientAdapter.setAll(state, action.payload.clients);
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'scrobble',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].scrobbled++;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'deadLetter',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].deadLetterScrobbles++;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'removeDeadLetter',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].deadLetterScrobbles--;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'statusChange',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].status = action.payload.data.status;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'scrobbleQueued',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].queued++;
|
||||
}
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
(action) => clientUpdate.match(action) && action.payload.event === 'scrobbleDequeued',
|
||||
(state, action) => {
|
||||
if(state.entities[action.payload.id] !== undefined) {
|
||||
state.entities[action.payload.id].queued--;
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
export const sourceUpdate = createAction('source/update', (payload: ApiEventPayload) => {
|
||||
return {
|
||||
payload: {
|
||||
id: `${payload.type}-${payload.name}`,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const clientUpdate = createAction('client/update', (payload: ApiEventPayload) => {
|
||||
return {
|
||||
payload: {
|
||||
id: `${payload.type}-${payload.name}`,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export {sourceSlice, clientSlice, sourceAdapter, clientAdapter};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import type {ClientStatusData, SourceStatusData} from "../../core/Atomic";
|
||||
|
||||
export const statusApi = createApi({
|
||||
reducerPath: 'statusApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: './api/' }),
|
||||
endpoints: (builder) => ({
|
||||
getStatus: builder.query<{ sources: SourceStatusData[], clients: ClientStatusData[] }, undefined>({
|
||||
query: () => `status`,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetStatusQuery } = statusApi;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
// Or from '@reduxjs/toolkit/query/react'
|
||||
import { setupListeners } from '@reduxjs/toolkit/query'
|
||||
import { scrobblerApi } from './components/statusCard/clientDucks';
|
||||
import { sourceApi } from './components/statusCard/sourceDucks';
|
||||
import { deadApi, deadSlice } from "./deadLetter/deadLetterDucks";
|
||||
import { logsReducer } from "./logs/logDucks";
|
||||
import { logsApi } from "./logs/logsApi";
|
||||
import { recentApi } from "./recent/recentDucks";
|
||||
import { scrobbledApi } from "./scrobbled/scrobbledDucks";
|
||||
import { clientSlice, sourceSlice } from "./status/ducks";
|
||||
import { statusApi } from './status/statusApi';
|
||||
import { versionApi } from "./Version";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
// Add the generated reducer as a specific top-level slice
|
||||
[statusApi.reducerPath]: statusApi.reducer,
|
||||
[logsApi.reducerPath]: logsApi.reducer,
|
||||
[recentApi.reducerPath]: recentApi.reducer,
|
||||
[deadApi.reducerPath]: deadApi.reducer,
|
||||
[scrobbledApi.reducerPath]: scrobbledApi.reducer,
|
||||
[scrobblerApi.reducerPath]: scrobblerApi.reducer,
|
||||
[sourceApi.reducerPath]: sourceApi.reducer,
|
||||
[versionApi.reducerPath]: versionApi.reducer,
|
||||
//parts: statusReducer
|
||||
clients: clientSlice.reducer,
|
||||
sources: sourceSlice.reducer,
|
||||
deadLetter: deadSlice.reducer,
|
||||
logs: logsReducer
|
||||
},
|
||||
// Adding the api middleware enables caching, invalidation, polling,
|
||||
// and other useful features of `rtk-query`.
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat([statusApi.middleware, logsApi.middleware, recentApi.middleware, scrobbledApi.middleware, deadApi.middleware, scrobblerApi.middleware, sourceApi.middleware, versionApi.middleware]),
|
||||
})
|
||||
|
||||
// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
|
||||
// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization
|
||||
setupListeners(store.dispatch)
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
@@ -4,7 +4,7 @@ import { sse } from 'msw';
|
||||
|
||||
import { Container } from '@chakra-ui/react';
|
||||
import { DeadLetterIndicatorStreamable } from "../../client/components/msComponent/Stats.js";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import {Provider} from "../../client/components/Provider.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sse } from 'msw';
|
||||
|
||||
import { Container } from '@chakra-ui/react';
|
||||
import { CountIndicatorStreamable } from "../../client/components/msComponent/Stats.js";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import {Provider} from "../../client/components/Provider.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sse } from 'msw';
|
||||
|
||||
import { Container } from '@chakra-ui/react';
|
||||
import { QueuedIndicatorStreamable } from "../../client/components/msComponent/Stats.js";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import {Provider} from "../../client/components/Provider.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Provider} from "../../client/components/Provider.js";
|
||||
import { generateClientApiJson, generateSourceApiJson, generateSourcePlayerJson } from "../../core/tests/utils/apiFixtures.js";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { withRouter } from 'storybook-addon-remix-react-router';
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Provider} from "../../client/components/Provider.js";
|
||||
import { generateSourcePlayerJson } from "../../core/tests/utils/apiFixtures.js";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import dayjs from "dayjs";
|
||||
import { asJsonPlayObject } from "../../core/PlayMarshalUtils.js";
|
||||
import { generatePlay } from "../../core/tests/utils/PlayTestUtils.js";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Provider} from "../../client/components/Provider.js";
|
||||
import { generateClientApiJson, generateFakeError, generatePlayApiCommonDetailed, generatePlayApiCommonDetailedList, generateSourceApiJson, generateSourcePlayerJson } from "../../core/tests/utils/apiFixtures.js";
|
||||
import type {PaginatedResponse, MsSseEvent, PlayApiCommonDetailed} from "../../core/Api.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import dayjs from "dayjs";
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { generateArray } from "../../core/DataUtils.js";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import type {MsSseEvent} from "../../core/Api.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import { sseProviderOptions } from "../../client/AppNext.js";
|
||||
import { sseProviderOptions } from "../../client/App.js";
|
||||
import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
||||
|
||||
@@ -16,7 +16,7 @@ import { generatePlayApiCommonDetailed, generatePlayApiCommonDetailedList } from
|
||||
import type { PaginatedResponse, QueryPlaysOptsJson} from "../../core/Api.js";
|
||||
import type {MsSseEvent, PlayApiCommonDetailed} from "../../core/Api.js";
|
||||
import { SSEProvider } from "@flamefrontend/sse-runtime-react";
|
||||
import { sseProviderOptions } from '../../client/AppNext.js';
|
||||
import { sseProviderOptions } from '../../client/App.js';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
||||
|
||||
+1
-3
@@ -2,7 +2,6 @@ import react,{ reactCompilerPreset } from '@vitejs/plugin-react';
|
||||
import babel from '@rolldown/plugin-babel';
|
||||
import normalizeUrl from "normalize-url";
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
@@ -50,7 +49,6 @@ export default defineConfig(() => {
|
||||
babel({
|
||||
presets: [reactCompilerPreset()]
|
||||
}),
|
||||
tailwindcss(),
|
||||
svgr()
|
||||
],
|
||||
build: {
|
||||
@@ -59,7 +57,7 @@ export default defineConfig(() => {
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
main: resolve(import.meta.dirname, 'index.html'),
|
||||
next: resolve(import.meta.dirname, 'next/index.html'),
|
||||
// next: resolve(import.meta.dirname, 'next/index.html'),
|
||||
},
|
||||
keepNames: true,
|
||||
sourcemap: true
|
||||
|
||||
Reference in New Issue
Block a user