Compare commits

...
Author SHA1 Message Date
FoxxMD 54159f9b9f chore: Remove legacy backend api code 2026-08-31 15:54:04 +00:00
FoxxMD 9b9176ccf1 chore: Remove legacy ui code and dependencies 2026-08-31 15:39:55 +00:00
72 changed files with 178 additions and 4460 deletions
-1
View File
@@ -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"
+1 -6
View File
@@ -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",
}
}
-21
View File
@@ -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>
-1074
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -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
View File
@@ -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'});
}
});
+2 -54
View File
@@ -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}`);
-50
View File
@@ -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) {
-42
View File
@@ -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;
}
-6
View File
@@ -1,6 +0,0 @@
// test('renders learn react link', () => {
// render(<App />);
// const linkElement = screen.getByText(/learn react/i);
// expect(linkElement).toBeInTheDocument();
// });
+61 -99
View File
@@ -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>
);
}
-130
View File
@@ -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;
-25
View File
@@ -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 -1
View File
@@ -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";
-30
View File
@@ -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;
-24
View File
@@ -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;
-44
View File
@@ -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;
+1 -1
View File
@@ -6,7 +6,7 @@ import {
QueryClient,
QueryClientProvider
} from '@tanstack/react-query'
import '../index-next.css'
import '../index.css'
import {
ColorModeProvider,
type ColorModeProviderProps,
+2 -2
View File
@@ -133,11 +133,11 @@ export const NAV_LINKS: SideNavProps[] = [
items: [
{
title: 'Dashboard',
url: '/next/'
url: '/'
},
{
title: 'Settings',
url: '/next/settings'
url: '/settings'
}
]
},
-26
View File
@@ -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;
-25
View File
@@ -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;
}
-30
View File
@@ -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>
);
-113
View File
@@ -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 -&gt; {calculated.toUpperCase()} | Reported -&gt; {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
View File
@@ -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;
}
}
}
-170
View File
@@ -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;
}
}
}
-13
View File
@@ -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;
-124
View File
@@ -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);
-148
View File
@@ -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;
-98
View File
@@ -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);
}
-18
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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';
-13
View File
@@ -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;
-6
View File
@@ -1,6 +0,0 @@
.line {
display: block;
white-space: pre-wrap;
font-size: 0.675rem;
line-height: 0.9rem;
}
-129
View File
@@ -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);
-25
View File
@@ -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 };
-21
View File
@@ -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;
-107
View File
@@ -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;
-18
View File
@@ -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;
-61
View File
@@ -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;
-18
View File
@@ -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;
-45
View File
@@ -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);
-158
View File
@@ -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};
-14
View File
@@ -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;
-42
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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