Compare commits

...
Author SHA1 Message Date
dependabot[bot] ff56bbccef chore(deps): bump lodash-es
Bumps the sec-updates group with 1 update in the /docsite directory: [lodash-es](https://github.com/lodash/lodash).


Updates `lodash-es` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: sec-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 15:39:59 +00:00
Matt Foxx 212afaa5e5 Merge pull request #674 from FoxxMD/writeApi
Write api
2026-08-28 11:37:09 -04:00
Matt Foxx 006fbe1079 Merge pull request #685 from FoxxMD/catchConsoleDiff
fix(transform): Catch and log errors during console diff generation
2026-08-28 11:35:51 -04:00
FoxxMD 5cecac937b feat(ui): Implement bulk play retries 2026-08-28 15:28:31 +00:00
FoxxMD 297c7dee95 fix(ui): Change component state values to be lowercase so they pass validation 2026-08-28 15:27:58 +00:00
FoxxMD 42a6887141 fix: fix method for getting last transformed data 2026-08-28 12:34:19 +00:00
FoxxMD cbdb58b36e fix(transform): fix method for getting last transformed play data
Instead of assuming the previous step had data, reverse search the history array for the most current data to use for patching
2026-08-28 00:48:13 +00:00
FoxxMD ae20d46456 one more fix for queue filter state 2026-08-27 20:09:10 +00:00
FoxxMD 6b12bd2ef2 fix(ui): fix dead queue filtering 2026-08-27 19:55:38 +00:00
FoxxMD dde19cfc83 fix stale error on requeue 2026-08-27 19:50:00 +00:00
FoxxMD 0938e95e1d last fix for dead letter queue counts 2026-08-27 19:39:19 +00:00
FoxxMD 3a40478cb1 fix: fix queue status in requeued event 2026-08-27 19:31:19 +00:00
FoxxMD 935845a976 fix: further queue state cleanup before converting to event 2026-08-27 19:11:16 +00:00
FoxxMD b494e8f93f fix: don't need queue context after processing 2026-08-27 18:51:46 +00:00
FoxxMD 9e13ead40f feat: Add more queue context and render context in timeline 2026-08-27 18:35:43 +00:00
FoxxMD 3b2f5ee1c8 fix: Fix queue event names 2026-08-27 17:25:51 +00:00
FoxxMD 51de79b92f fix(ui): Fix missed statt text arrow color 2026-08-27 17:25:35 +00:00
FoxxMD b6f7e0e586 refactor(ui): Simplify and make stats code reusable 2026-08-27 17:10:45 +00:00
FoxxMD c69af47978 fix(transform): Catch and log errors during console diff generation
This functionality is not critical to transforms, just helpful for console debugging. It should not stop transforms from occuring so catch all errors and log.

#684
2026-08-27 15:05:35 +00:00
FoxxMD edde8f9e74 try moving openapi generate into docs script 2026-08-27 00:27:46 +00:00
FoxxMD 72d702acf0 docs: Add stability disclaimer 2026-08-27 00:18:07 +00:00
FoxxMD 67b13ffe40 docs: Add npm scripts to build openapi and generate docs 2026-08-27 00:10:17 +00:00
FoxxMD b464c7507f add openapi.json to gitignore 2026-08-27 00:05:59 +00:00
FoxxMD 3cf5b82247 working poc for openapi doc generation 2026-08-27 00:05:35 +00:00
FoxxMD 1571f06662 docs: Update docusaurs to 3.10.2 2026-08-26 20:05:29 +00:00
FoxxMD b3fb04a6ae docs: generate openapi spec 2026-08-26 20:01:13 +00:00
FoxxMD cb2a6168fd Update openapi doc meta for friendly names, descriptions, and categories 2026-08-26 15:01:39 +00:00
FoxxMD 56fcbedff9 feat: Implement zod validation for endpointlz requests 2026-08-26 14:32:05 +00:00
FoxxMD 548c614b0a test(lastfm): Add smoke tests for 2.0 endpoints 2026-08-26 13:28:10 +00:00
FoxxMD d3469aee7a fix(lastfm): Fix xml builder when fields are undefined 2026-08-26 12:57:35 +00:00
FoxxMD 0109cc6fcd fix(lastfm): Fix urlencoded number string coercion in request validation 2026-08-26 12:57:08 +00:00
FoxxMD 1988fc5f54 mostly figured out lfm validation... 2026-08-26 01:48:50 +00:00
FoxxMD aa93600166 chore: bump express-typed-router 2026-08-26 00:09:35 +00:00
FoxxMD 09e9d0f79a feat: Use new InferSchemaHandler type to simplify route handlers 2026-08-26 00:07:37 +00:00
FoxxMD 9c84e2d8e3 fxi(webscrobbler): Fix test data generation 2026-08-25 23:26:11 +00:00
FoxxMD 4b8d4932aa test: fix signal used to wait for queued played to finish processing before checking queue length 2026-08-25 23:06:47 +00:00
FoxxMD 970e200e76 fix types for matchRequest 2026-08-25 22:39:11 +00:00
FoxxMD 3e05b3f964 feat: Implement tests for listenbrainz and webscrobbler routes 2026-08-25 21:16:30 +00:00
Matt Foxx 86b8b4ad90 Merge pull request #681 from DirazCoder/master
confirm before removing all failed scrobbles
2026-08-25 14:15:08 -04:00
FoxxMD 64d160782b replace webscrobbler types with zod 2026-08-25 17:28:35 +00:00
FoxxMD 39f8367392 fill in the majority of routes with new typed routes for regex 2026-08-25 17:02:52 +00:00
FoxxMD 62905e8010 feat(ui): Implement retry options and delete flow 2026-08-25 13:15:22 +00:00
FoxxMD 203f4c343e feat(ui): Wire up non-form play actions 2026-08-25 13:15:22 +00:00
FoxxMD 3b93073694 chore: Bump express-typed-routes for type fixes 2026-08-25 13:15:22 +00:00
FoxxMD 649280582e feat(ui): Implement play actions 2026-08-25 13:15:22 +00:00
FoxxMD 0ad86ad692 feat: Implement api endpoitns for requeuing, cancelling, and deleting dead states 2026-08-25 13:15:22 +00:00
FoxxMD e80f35f1b1 feat: dead queue to ingress migration 2026-08-25 13:15:22 +00:00
FoxxMD a885079a07 Finish queue implementation for source and various fixes 2026-08-25 13:15:22 +00:00
FoxxMD ab994770ab fix(deezer): Fix missing closest play for some fuzzy scenarios 2026-08-25 13:15:22 +00:00
FoxxMD 17d7882710 feat(client): Unify queue processing for regular and dead plays 2026-08-25 13:15:22 +00:00
FoxxMD 1dcbf92fed fix(ui): Fallback to source for music service icon 2026-08-25 13:15:22 +00:00
FoxxMD 8686309993 fix(ui): Fix music service icon string variable assumption 2026-08-25 13:15:22 +00:00
FoxxMD b83f8872c2 start simplifying queue scrobble for clients 2026-08-25 13:15:22 +00:00
FoxxMD 9f22c56e87 feat(source): Implement queue context option usage during play processing 2026-08-25 13:15:22 +00:00
FoxxMD 6b86ef8629 feat(transform): Implement path to control cache usage 2026-08-25 13:15:22 +00:00
FoxxMD d0001d2f62 feat(source): Implement Play retry logic 2026-08-25 13:15:22 +00:00
FoxxMD e381be2f7e feat: Implement queue context with migration 2026-08-25 13:15:22 +00:00
FoxxMD c1e9757aed feat(ui): Optimize musicbrainz/external links layout in play data 2026-08-25 13:15:22 +00:00
FoxxMD 61fbc39a91 feat(ui): add play source to activity summary 2026-08-25 13:15:22 +00:00
FoxxMD 4c764879d7 more ignore behavior fixes 2026-08-25 13:15:22 +00:00
FoxxMD 692b2160bf fix: Fix auth routes with new typed routing 2026-08-25 13:15:22 +00:00
FoxxMD 344c0a15ad feat: Add now playing behavior for monitoring state 2026-08-25 13:15:22 +00:00
FoxxMD f44835dfba feat: Migrate to typed router 2026-08-25 13:15:22 +00:00
FoxxMD 204cc0bdb8 Improved monitoring behavior
* Discard plays early to avoid unnecessary transforms and existing checks
* Don't throw client scrobble processing, just set play as discarded
* Fix/improve source play processing on failure
2026-08-25 13:15:22 +00:00
FoxxMD 4b7542ddd9 fix: Add missing play update events 2026-08-25 13:15:22 +00:00
FoxxMD 1fc3ad1bd4 fix(source): Missing original data on play create 2026-08-25 13:15:22 +00:00
FoxxMD 9f34929140 create transform events with createdAt as now 2026-08-25 13:15:22 +00:00
FoxxMD 1178b0b6fb fix: Queue event added in repo by explicit parameter 2026-08-25 13:15:22 +00:00
FoxxMD cad42cba04 refactor play event data into factory functions 2026-08-25 13:15:22 +00:00
FoxxMD 68b7385f31 test: Fix useless polling wait slowing tests 2026-08-25 13:15:22 +00:00
FoxxMD 9a93f13a7d fix: fix missing events in apifixtures 2026-08-25 13:15:22 +00:00
FoxxMD 649ccde43d refactor: db retention cleanup for transform data 2026-08-25 13:15:22 +00:00
FoxxMD fc06c6a8a9 feat(ui): Add state change timeline event 2026-08-25 13:15:22 +00:00
FoxxMD d089b960f9 feat: Refactor activity timeline elements into proper event structure
* Implement new database playEvents table/entity
* Refactor usage of lifecycle,scrobbler,etc... with write-only play events
* Refactor/simplify activity timeline to use new event structure
2026-08-25 13:15:21 +00:00
FoxxMD c210590c8f fix: Add queue id to consumeQueue and implement queued id map to prevent processing an already processing play 2026-08-25 13:15:21 +00:00
FoxxMD 78aa418b7d feat: Filter next queued by optional id 2026-08-25 13:15:21 +00:00
FoxxMD 48cc18d373 fix(source): Use playDate SOT when generating temporal range for sql query 2026-08-25 13:15:21 +00:00
FoxxMD 258cd61b4b feat(source): Improved queue prunning for backlog and history sources 2026-08-25 13:15:21 +00:00
FoxxMD 185ccdf71c fix(source): Refactor existing play list used for existing check to use *all* processed plays
Instead of just discovered, since we now dupe/discard plays that persist in db, change the cached existing list logic to include all processed plays
2026-08-25 13:15:21 +00:00
FoxxMD b9dad6149b feat(deezer): Improve documentation on fuzzyDiscovery option usage 2026-08-25 13:15:21 +00:00
FoxxMD 9ae74a193f fix: using incorrect variable for existing 2026-08-25 13:15:21 +00:00
FoxxMD 99b30956ba fix: check for undefined event payload 2026-08-25 13:15:21 +00:00
FoxxMD 93a5775363 feat: add parsed from meta 2026-08-25 13:15:21 +00:00
FoxxMD e8a998ddda finish source queue discovery migration 2026-08-25 13:15:21 +00:00
FoxxMD 1b02dc684e test: Fix app migrations relative folder 2026-08-25 13:15:21 +00:00
FoxxMD 23813a65ef feat(source): Implement discovery queue 2026-08-25 13:15:21 +00:00
FoxxMD 0583c66743 db: Add playHash to to play input 2026-08-25 13:15:21 +00:00
FoxxMD cd4e88b232 refactor: rename queue constant to remove client
will be using for both source and client so name is less restrictive now
2026-08-25 13:15:21 +00:00
FoxxMD 8be739efb4 feat: Simplify component state updates for start/stop/restart and replace error usage 2026-08-25 13:15:21 +00:00
FoxxMD 3f2e29c5c8 feat: Implement convenience functions for replacing/clearning component errors 2026-08-25 13:15:21 +00:00
FoxxMD b109157350 disable ignore actions for now (not yet implemented) 2026-08-25 13:15:21 +00:00
FoxxMD ce15c4049c fix: component start invocation runs heartbeat immediately 2026-08-25 13:15:21 +00:00
FoxxMD 5a7c01a6d9 feat(ui): Implement test auth action 2026-08-25 13:15:21 +00:00
FoxxMD befd1ac043 feat(ui): Implement auth actions 2026-08-25 13:15:21 +00:00
FoxxMD 714678fdab finish implementation of AuthError 2026-08-25 13:15:21 +00:00
FoxxMD 2a58edd9b2 refactor: Strongly type and simplify auth error logic
* Use separate errors for init auth check throws and root auth error throws (to be implemented in each component)
* Consolidate auth issue discovery to one reusable function
* rethrow on testAuth based on auth error type and unrecoverable flag
2026-08-25 13:15:21 +00:00
FoxxMD abfceeb5f8 partial auth api 2026-08-25 13:15:21 +00:00
FoxxMD d7c88d4191 feat: Implement multiple stateful errors/warnings on component 2026-08-25 13:15:21 +00:00
FoxxMD a44fe48314 feat(ui): Implement basic component state change actions #500
Start/Stop/Restart client/sources from the ui
2026-08-25 13:15:21 +00:00
Matt Foxx 29ffc6b804 Merge pull request #680 from FoxxMD/listenbrainzUA
feat: Version and user-agent improvements
2026-08-24 18:10:39 -04:00
FoxxMD 3bdc9447b8 chore(release): Bump version for release 2026-08-24 21:38:46 +00:00
DirazCoder 4a27806825 Add confirmation for removing all dead scrobbles
Added confirmation dialog before removing all failed scrobbles.
2026-08-24 22:23:02 +01:00
FoxxMD b8cd53f554 fix(listenbrainz): Add user-agent to requests #679 2026-08-24 21:05:30 +00:00
Matt Foxx e7218074f8 Merge pull request #678 from halkeye/patch-1
fix(docsite): path.join doesn't really handle http links
2026-08-24 15:20:11 -04:00
Gavin Mogan a48ada9ac3 fix(docsite): path.join doesn't really handle http links 2026-08-22 23:30:43 -07:00
148 changed files with 13427 additions and 3235 deletions
+4 -1
View File
@@ -18,4 +18,7 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lib
lib
docs/api/
static/openapi.json
+60 -13
View File
@@ -3,6 +3,7 @@ import type { Config } from '@docusaurus/types';
import * as themes from 'prism-react-renderer';
import * as Renderers from './lib/socialCard/ImageRenderers.js';
import path from 'node:path';
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
//import sidebars from './sidebars';
let baseSite: string = 'https://foxxmd.github.io';
@@ -32,7 +33,7 @@ const config: Config = {
v4: {
removeLegacyPostBuildHeadAttribute: true, // required
},
experimental_faster: {
faster: {
ssgWorkerThreads: true,
rspackBundler: true, // required flag
rspackPersistentCache: true, // new flag
@@ -75,20 +76,29 @@ const config: Config = {
[
'classic',
{
docs: {
sidebarPath: './sidebars.ts',
docItemComponent: "@theme/ApiItem",
routeBasePath: '/',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: (params: {
version: string;
versionDocsDirPath: string;
docPath: string;
permalink: string;
locale: string;
}) => {
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
}
version: string;
versionDocsDirPath: string;
docPath: string;
permalink: string;
locale: string;
}) => {
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
},
// based on this https://github.com/facebook/docusaurus/issues/6127#issuecomment-3937124499
// to filter out the autogenerated api folder sidebar item
sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
const items = await defaultSidebarItemsGenerator(args);
const filteredItems = items.filter(item => !(item.type === 'category' && item.label === 'api'));
return filteredItems
},
},
// blog: {
// showReadingTime: true,
@@ -100,7 +110,7 @@ const config: Config = {
blog: false,
theme: {
customCss: './src/css/custom.css',
customCss: ['./src/css/custom.css','./src/css/openapiBase.css','./src/css/evergreen.css'],
},
} satisfies Preset.Options,
],
@@ -122,10 +132,11 @@ const config: Config = {
// ```
},
],
'docusaurus-theme-github-codeblock'
'docusaurus-theme-github-codeblock',
"docusaurus-theme-openapi-docs"
],
plugins: [
'docusaurus-plugin-sass',
'docusaurus-plugin-sass',
[
'@docusaurus/plugin-client-redirects',
{
@@ -167,7 +178,38 @@ const config: Config = {
'docusaurus-plugin-content-pages': Renderers.docs,
},
},
]
],
[
'docusaurus-plugin-openapi-docs',
{
id: "api", // plugin id
docsPluginId: "classic", // configured for preset-classic
config: {
ms: {
specPath: "static/openapi.json",
outputDir: "docs/api",
showInfoPage: true,
maskCredentials: false, // Disable credential masking in code snippets
sidebarOptions: {
groupPathsBy: "tag",
},
} satisfies OpenApiPlugin.Options,
}
},
],
// [
// '@docusaurus/plugin-content-docs',
// {
// sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
// const items = await defaultSidebarItemsGenerator(args);
// for(const i of items) {
// console.log(i);
// }
// //const filteredItems = items.filter(item => item.type !== 'doc' || !item.id.endsWith('/index'));
// //return filteredItems
// },
// }
// ]
],
themeConfig:
{
@@ -193,6 +235,11 @@ const config: Config = {
position: 'left',
label: 'Config Playground',
},
{
to: 'api/multi-scrobbler-api',
position: 'left',
label: 'API',
},
{
href: 'https://github.com/foxxmd/multi-scrobbler',
label: 'GitHub',
+2284 -490
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -14,14 +14,15 @@
"typecheck": "tsc",
"postinstall": "patch-package",
"previews:build-renderer": "tsc --project tsconfig.client.json || exit 0",
"prebuild": "npm run -s previews:build-renderer"
"prebuild": "npm run -s previews:build-renderer && npm run -s openapi:generate",
"openapi:generate": "docusaurus clean-api-docs ms && docusaurus gen-api-docs ms"
},
"dependencies": {
"@bony_chops/docusaurus-og": "^1.0.3-beta.3",
"@docusaurus/core": "3.9.2",
"@docusaurus/faster": "3.9.2",
"@docusaurus/plugin-client-redirects": "^3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@docusaurus/core": "3.10.2",
"@docusaurus/faster": "3.10.2",
"@docusaurus/plugin-client-redirects": "^3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@easyops-cn/docusaurus-search-local": "0.51.1",
"@mdx-js/react": "^3.0.0",
"ajv": "^8.18.0",
@@ -30,6 +31,7 @@
"clsx": "^2.0.0",
"docusaurus-plugin-sass": "^0.2.6",
"docusaurus-theme-github-codeblock": "^2.0.2",
"docusaurus-theme-openapi-docs": "^5.2.0",
"embla-carousel": "^9.0.0-rc02",
"embla-carousel-react": "^9.0.0-rc02",
"json5": "^2.2.3",
@@ -44,9 +46,9 @@
"sass": "^1.97.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"@types/react": "^19.2.4",
"typescript": "~5.6.2"
},
+6 -14
View File
@@ -1,5 +1,5 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
import msSidebar from "./docs/api/sidebar.js";
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
@@ -19,21 +19,13 @@ const sidebars: SidebarsConfig = {
type: "link",
label: "Config Playground",
href: "/playground"
}
],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
},
{
type: "category",
label: "Api",
items: msSidebar,
},
],
*/
};
export default sidebars;
+323
View File
@@ -0,0 +1,323 @@
/**
* Evergreen theme for docusaurus-openapi-docs demo.
*
* A modern riff on the classic Docusaurus green. Keeps the signature
* #2e8555 primary family, adds Plus Jakarta Sans, a richer dark mode,
* and cleaner shadows — what the default template could look like with
* a bit more design polish.
*
* Load alongside custom.css in docusaurus.config.ts:
*
* customCss: ["./src/css/custom.css", "./src/css/themes/evergreen.css"]
*/
/* ============================================================
* Fonts — Plus Jakarta Sans (body) + Fira Code (code)
* ============================================================ */
@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap");
/* ============================================================
* Light mode
* ============================================================ */
:root {
/* Primary — the classic Docusaurus green, slightly enriched */
/* --ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e; */
/* Content */
/* --ifm-color-content: #1c1e21;
--ifm-heading-color: #1c1e21;
--ifm-color-content-secondary: #525860; */
/* Semantic */
/* --ifm-color-success: #2e8555;
--ifm-color-warning: #d97706;
--ifm-color-danger: #dc2626;
--ifm-color-info: #0284c7; */
/* Typography */
/* --ifm-font-size-base: 95%;
--ifm-code-font-size: 85%;
--ifm-font-family-base:
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ifm-heading-font-family:
"Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--ifm-font-family-monospace:
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Courier New", monospace;
--font-mono:
"Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Courier New", monospace;
--ifm-font-weight-base: 400;
--ifm-font-weight-semibold: 600;
--ifm-heading-font-weight: 700; */
/* Heading scale */
/* --ifm-h1-font-size: 2rem;
--ifm-h2-font-size: 1.6rem;
--ifm-h3-font-size: 1.3rem;
--ifm-h4-font-size: 1.1rem;
--ifm-h5-font-size: 1rem;
--ifm-h6-font-size: 0.9rem; */
/* Theme hooks for custom.css */
--theme-primary-rgb: 46, 133, 85;
--site-primary-hue-saturation: 148 49%;
--theme-border-radius-sm: 4px;
--theme-border-radius-md: 8px;
--theme-border-radius-lg: 12px;
--theme-border-radius-xl: 16px;
--theme-border-radius-2xl: 24px;
/* Shadow scale — neutral, not green-tinted */
--theme-shadow-sm:
0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
--theme-shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
--theme-shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.06);
/* --ifm-hr-background-color: var(--ifm-color-emphasis-200);
--docusaurus-highlighted-code-line-bg: rgba(46, 133, 85, 0.1); */
/* Explorer — unified font size for code tabs and request body */
--openapi-explorer-font-size-code: 13px;
}
/* ============================================================
* Dark mode — richer than the Docusaurus default
* ============================================================ */
[data-theme="dark"] {
/* Lighter green — readable on dark surfaces */
/* --ifm-color-primary: #3cad6e;
--ifm-color-primary-dark: #359962;
--ifm-color-primary-darker: #33925d;
--ifm-color-primary-darkest: #2e8555;
--ifm-color-primary-light: #4db87c;
--ifm-color-primary-lighter: #5ec389;
--ifm-color-primary-lightest: #85d4a8; */
/* --ifm-color-content: #dadde1;
--ifm-color-content-secondary: #9ba1aa;
--ifm-heading-color: #f5f6f7; */
/* Richer dark background vs. the flat Docusaurus default */
/* --ifm-background-color: #18191a !important;
--ifm-navbar-background-color: #18191a; */
/* Warm-neutral dark gray scale */
/* --ifm-color-emphasis-100: #242526;
--ifm-color-emphasis-200: #3a3b3c;
--ifm-color-emphasis-300: #4e4f50;
--ifm-color-emphasis-400: #6b6c6d;
--ifm-color-emphasis-500: #8d8f91;
--ifm-color-emphasis-600: #b0b3b8;
--ifm-color-emphasis-700: #dadde1;
--ifm-color-emphasis-800: #e4e6eb;
--ifm-color-emphasis-900: #f0f2f5; */
/* --ifm-link-color: #3cad6e;
--ifm-link-hover-color: #5ec389; */
--docusaurus-highlighted-code-line-bg: rgba(60, 173, 110, 0.12);
/* --theme-primary-rgb: 60, 173, 110; */
}
/* ============================================================
* Method badge colors — sidebar pills
* ============================================================ */
.get > .menu__link::before {
background-color: rgba(46, 133, 85, 0.12);
color: #29784c;
}
.get > .menu__link--active::before {
background-color: #2e8555;
color: #ffffff;
}
.post > .menu__link::before {
background-color: rgba(2, 132, 199, 0.12);
color: #0369a1;
}
.post > .menu__link--active::before {
background-color: #0284c7;
color: #ffffff;
}
.delete > .menu__link::before {
background-color: rgba(220, 38, 38, 0.12);
color: #dc2626;
}
.delete > .menu__link--active::before {
background-color: #dc2626;
color: #ffffff;
}
.put > .menu__link::before {
background-color: rgba(217, 119, 6, 0.15);
color: #b45309;
}
.put > .menu__link--active::before {
background-color: #d97706;
color: #ffffff;
}
.patch > .menu__link::before {
background-color: rgba(217, 119, 6, 0.15);
color: #b45309;
}
.patch > .menu__link--active::before {
background-color: #d97706;
color: #ffffff;
}
/* Dark mode */
[data-theme="dark"] .get > .menu__link::before {
background-color: rgba(60, 173, 110, 0.15);
color: #3cad6e;
}
[data-theme="dark"] .get > .menu__link--active::before {
background-color: #3cad6e;
color: #18191a;
}
[data-theme="dark"] .post > .menu__link::before {
background-color: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
[data-theme="dark"] .post > .menu__link--active::before {
background-color: #0ea5e9;
color: #18191a;
}
[data-theme="dark"] .delete > .menu__link::before {
background-color: rgba(239, 68, 68, 0.12);
color: #f87171;
}
[data-theme="dark"] .delete > .menu__link--active::before {
background-color: #ef4444;
color: #ffffff;
}
[data-theme="dark"] .put > .menu__link::before {
background-color: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
[data-theme="dark"] .put > .menu__link--active::before {
background-color: #f59e0b;
color: #18191a;
}
[data-theme="dark"] .patch > .menu__link::before {
background-color: rgba(251, 191, 36, 0.12);
color: #fbbf24;
}
[data-theme="dark"] .patch > .menu__link--active::before {
background-color: #f59e0b;
color: #18191a;
}
/* ============================================================
* API page method badges (.badge--*)
* ============================================================ */
.badge--primary {
/* GET */
background-color: #2e8555;
color: #ffffff;
--ifm-badge-border-color: transparent;
}
.badge--success {
/* POST */
background-color: #0284c7;
color: #ffffff;
--ifm-badge-border-color: transparent;
}
.badge--danger {
/* DELETE */
background-color: #dc2626;
color: #ffffff;
--ifm-badge-border-color: transparent;
}
.badge--info {
/* PUT */
background-color: #d97706;
color: #ffffff;
--ifm-badge-border-color: transparent;
}
.badge--warning {
/* PATCH */
background-color: #d97706;
color: #ffffff;
--ifm-badge-border-color: transparent;
}
[data-theme="dark"] .badge--primary {
/* GET */
background-color: #3cad6e;
color: #18191a;
}
[data-theme="dark"] .badge--success {
/* POST */
background-color: #0ea5e9;
color: #18191a;
}
[data-theme="dark"] .badge--danger {
/* DELETE */
background-color: #ef4444;
color: #ffffff;
}
[data-theme="dark"] .badge--info {
/* PUT */
background-color: #f59e0b;
color: #18191a;
}
[data-theme="dark"] .badge--warning {
/* PATCH */
background-color: #f59e0b;
color: #18191a;
}
/* ============================================================
* API Explorer — request body editor font size
*
* react-simple-code-editor renders an inner textarea + pre that
* can slip past the outer !important font shorthand. Target them
* directly so the body editor matches the code snippet tabs.
* ============================================================ */
.openapi-explorer__playground-editor,
.openapi-explorer__playground-editor textarea,
.openapi-explorer__playground-editor pre {
font-size: var(--openapi-explorer-font-size-code) !important;
}
/* ============================================================
* API Explorer — Send button dark mode
* ============================================================ */
[data-theme="dark"] .openapi-explorer__request-btn {
background-color: #2e8555;
color: #ffffff;
}
[data-theme="dark"] .openapi-explorer__request-btn:hover {
background-color: #3cad6e;
}
+580
View File
@@ -0,0 +1,580 @@
/**
* Structural CSS for the docusaurus-openapi-docs demo site.
*
* This file contains layout, component behavior, animations, and utilities.
* It is intentionally free of hard-coded colors and fonts — those live in
* a theme file loaded alongside this one via docusaurus.config.ts:
*
* customCss: ["./src/css/custom.css", "./src/css/themes/panw.css"]
*
* To try a different palette, swap the theme file. The following CSS
* variables must be defined by the active theme:
*
* --theme-primary-rgb RGB triplet of primary color (for animations)
* --theme-border-radius-sm/md Border radius tokens
* --site-primary-hue-saturation Hue+saturation for the announcement bar
*
* Structure inspired by @gleanwork/docusaurus-theme-glean.
*/
/* ============================================================
* Typography
* ============================================================ */
.markdown h1,
article h1,
.main-wrapper h1 {
letter-spacing: 0.025em;
font-weight: var(--ifm-heading-font-weight);
line-height: 1.2;
margin-bottom: 1.5rem;
color: var(--ifm-heading-color);
}
.markdown h2,
article h2,
.markdown h3,
article h3 {
font-weight: var(--ifm-font-weight-semibold);
}
/* .markdown strong,
article strong,
.markdown b,
article b {
font-weight: var(--ifm-font-weight-semibold);
} */
.font-mono {
font-family: var(--font-mono);
/* font-variant-ligatures: none; */
}
.table-of-contents {
font-size: 0.95rem;
}
html {
scroll-padding-top: 50px;
}
/* ============================================================
* Links
* ============================================================ */
a:any-link:hover {
text-decoration: none;
}
article p a,
article ul a,
article li a,
.markdown p a,
.theme-doc-markdown p a {
text-decoration: underline 1px var(--ifm-color-primary);
transition: color 0.2s ease;
}
article p a:hover,
article ul a:hover,
article li a:hover,
.markdown p a:hover,
.theme-doc-markdown p a:hover {
text-decoration: underline 2px var(--ifm-color-primary);
}
/* ============================================================
* Sidebar structure
* ============================================================ */
/* Slightly off-white sidebar in light mode */
.theme-doc-sidebar-container {
background-color: #f6f6f6;
}
[data-theme="dark"] .theme-doc-sidebar-container {
background-color: unset;
}
/* Tighter nested indentation */
.menu__list .menu__list {
padding-left: 10px;
}
/* No underlines on menu links */
.menu__link {
text-decoration: none;
color: var(--ifm-color-emphasis-700);
}
/* Top-level category labels: uppercase, small caps, bold */
/* .theme-doc-sidebar-item-category-level-1
> .menu__list-item-collapsible
> .menu__link,
.theme-doc-sidebar-item-category-level-1
> .menu__list-item-collapsible
> .menu__link.menu__link--active {
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
line-height: 16px;
font-size: 12px;
color: var(--ifm-color-emphasis-900);
} */
/* Hover: text darkens, no background fill */
/* .menu__link:hover {
background-color: transparent;
color: var(--ifm-color-emphasis-800);
text-decoration: none;
} */
/* Active item: primary-colored text, no background */
/* .menu__link--active:not(
.theme-doc-sidebar-item-category-level-1
> .menu__list-item-collapsible
> .menu__link
) {
color: var(--ifm-color-primary);
background-color: transparent;
font-weight: 500;
}
.menu__link--active:not(
.theme-doc-sidebar-item-category-level-1
> .menu__list-item-collapsible
> .menu__link
):hover {
color: var(--ifm-color-primary);
background-color: transparent;
} */
/* [data-theme="dark"]
.menu__link--active:not(
.theme-doc-sidebar-item-category-level-1
> .menu__list-item-collapsible
> .menu__link
) {
color: var(--ifm-color-primary-light);
}
.menu__list-item-collapsible:hover,
.menu__list-item-collapsible:active,
.menu__list-item-collapsible:focus-within,
.menu__list-item-collapsible.menu__list-item-collapsible--active {
background: transparent;
} */
/* Custom caret */
.menu__link--sublist-caret:after,
.menu__caret::before {
--ifm-menu-link-sublist-icon: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="8px" height="8px" viewBox="0 0 24 24"><path fill="rgba(0,0,0,0.5)" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"></path></svg>');
content: " ";
background-size: contain;
}
/* ============================================================
* Sidebar Method labels — Default (pill style)
*
* Shape and content labels only. Colors are in the theme file.
* ============================================================ */
.api-method > .menu__link,
.schema > .menu__link {
align-items: center;
justify-content: start;
}
.api-method > .menu__link::before,
.schema > .menu__link::before {
font-size: 0.55rem;
font-weight: 700;
padding: 0.125rem 0.25rem;
border-radius: 0.375rem;
line-height: 1.25;
width: 2.25rem;
height: auto;
border: none;
text-transform: uppercase;
margin-right: var(--ifm-spacing-horizontal);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-align: center;
}
/* Content labels */
.get > .menu__link::before {
content: "get";
}
.post > .menu__link::before {
content: "post";
}
.delete > .menu__link::before {
content: "del";
}
.put > .menu__link::before {
content: "put";
}
.patch > .menu__link::before {
content: "patch";
}
/* HEAD / EVENT / SCHEMA — neutral colors, no theme needed */
.head > .menu__link::before {
content: "head";
background-color: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-700);
}
.event > .menu__link::before {
content: "event";
background-color: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-700);
}
.schema > .menu__link::before {
content: "schema";
background-color: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-700);
}
/* Deprecated */
.menu__list-item--deprecated > .menu__link,
.menu__list-item--deprecated > .menu__link:hover {
text-decoration: line-through;
}
/* ============================================================
* Sidebar Method labels — High Contrast variant (unchanged)
* ============================================================ */
.api-method-contrast > .menu__link,
.schema-contrast > .menu__link {
align-items: center;
justify-content: start;
}
.api-method-contrast > .menu__link::before,
.schema-contrast > .menu__link::before {
width: 55px;
height: 20px;
font-size: 12px;
line-height: 20px;
text-transform: uppercase;
font-weight: 600;
border-radius: 0.25rem;
border: 1px solid;
border-inline-start-width: 5px;
margin-right: var(--ifm-spacing-horizontal);
text-align: center;
flex-shrink: 0;
}
.get-contrast > .menu__link::before {
content: "get";
background-color: var(--ifm-color-info-contrast-background);
color: var(--ifm-color-info-contrast-foreground);
border-color: var(--ifm-color-info-dark);
}
.post-contrast > .menu__link::before {
content: "post";
background-color: var(--ifm-color-success-contrast-background);
color: var(--ifm-color-success-contrast-foreground);
border-color: var(--ifm-color-success-dark);
}
.delete-contrast > .menu__link::before {
content: "del";
background-color: var(--ifm-color-danger-contrast-background);
color: var(--ifm-color-danger-contrast-foreground);
border-color: var(--ifm-color-danger-dark);
}
.put-contrast > .menu__link::before {
content: "put";
background-color: var(--ifm-color-warning-contrast-background);
color: var(--ifm-color-warning-contrast-foreground);
border-color: var(--ifm-color-warning-dark);
}
.patch-contrast > .menu__link::before {
content: "patch";
background-color: var(--ifm-color-success-contrast-background);
color: var(--ifm-color-success-contrast-foreground);
border-color: var(--ifm-color-success-dark);
}
.head-contrast > .menu__link::before {
content: "head";
background-color: var(--ifm-color-secondary-contrast-background);
color: var(--ifm-color-secondary-contrast-foreground);
border-color: var(--ifm-color-secondary-dark);
}
.event-contrast > .menu__link::before {
content: "event";
background-color: var(--ifm-color-secondary-contrast-background);
color: var(--ifm-color-secondary-contrast-foreground);
border-color: var(--ifm-color-secondary-dark);
}
.schema-contrast > .menu__link::before {
content: "schema";
background-color: var(--ifm-color-secondary-contrast-background);
color: var(--ifm-color-secondary-contrast-foreground);
border-color: var(--ifm-color-secondary-dark);
}
/* ============================================================
* Sidebar Method labels — Simple variant (unchanged)
* ============================================================ */
.api-method-simple > .menu__link {
align-items: center;
justify-content: start;
}
.api-method-simple > .menu__link::before {
width: 55px;
height: 20px;
font-size: 12px;
line-height: 20px;
text-transform: uppercase;
font-weight: 600;
border-radius: 0.25rem;
align-content: start;
margin-right: var(--ifm-spacing-horizontal);
text-align: right;
flex-shrink: 0;
border-color: transparent;
}
.get-simple > .menu__link::before {
content: "get";
color: var(--ifm-color-info);
}
.post-simple > .menu__link::before {
content: "post";
color: var(--ifm-color-success);
}
.delete-simple > .menu__link::before {
content: "del";
color: var(--ifm-color-danger);
}
.put-simple > .menu__link::before {
content: "put";
color: var(--ifm-color-warning);
}
.patch-simple > .menu__link::before {
content: "patch";
color: var(--ifm-color-warning);
}
.head-simple > .menu__link::before {
content: "head";
color: var(--ifm-color-secondary-contrast-foreground);
}
.event-simple > .menu__link::before {
content: "event";
color: var(--ifm-color-secondary-contrast-foreground);
}
.schema-simple > .menu__link::before {
content: "schema";
color: var(--ifm-color-secondary-contrast-foreground);
}
/* ============================================================
* Admonitions
* ============================================================ */
.alert {
border: none;
border-radius: var(--theme-border-radius-md, 8px);
}
/* ============================================================
* Navbar header icons
* ============================================================ */
.header-github-link:hover {
opacity: 0.6;
}
.header-github-link:before {
content: "";
width: 24px;
height: 24px;
display: flex;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
no-repeat;
}
html[data-theme="dark"] .header-github-link:before {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
no-repeat;
}
.header-medium-link:hover {
opacity: 0.6;
}
.header-medium-link:before {
content: "\f23a";
font-family: "Font Awesome 5 Brands";
font-size: x-large;
}
/* ============================================================
* Announcement bar
* ============================================================ */
div[class^="announcementBar_"] {
--site-announcement-bar-stripe-color1: hsl(
var(--site-primary-hue-saturation) 85%
);
--site-announcement-bar-stripe-color2: hsl(
var(--site-primary-hue-saturation) 95%
);
background: repeating-linear-gradient(
35deg,
var(--site-announcement-bar-stripe-color1),
var(--site-announcement-bar-stripe-color1) 20px,
var(--site-announcement-bar-stripe-color2) 10px,
var(--site-announcement-bar-stripe-color2) 40px
);
font-weight: bold;
}
/* ============================================================
* Schema parameter highlight animation
*
* Color driven by --theme-primary-rgb (defined in the theme file).
* ============================================================ */
[id]:target {
animation: highlight-flash 2s ease-out;
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
border-radius: 4px;
padding: 2px 4px;
margin: -2px -4px;
}
@keyframes highlight-flash {
0% {
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.25);
}
50% {
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
box-shadow: 0 0 0 2px rgba(var(--theme-primary-rgb, 0, 76, 157), 0.15);
}
100% {
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08);
box-shadow: none;
}
}
.openapi-markdown__details [id]:target,
.openapi-schema__list-item [id]:target {
animation: highlight-flash 2s ease-out;
background-color: rgba(var(--theme-primary-rgb, 0, 76, 157), 0.08) !important;
}
/* ============================================================
* Code tab scrollbar
* ============================================================ */
.tabs-container [role="tabpanel"] {
min-width: 0;
overflow-x: auto;
}
.tabs-container [role="tabpanel"] pre {
overflow-x: auto;
white-space: pre;
word-wrap: normal;
max-width: 100%;
}
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar {
height: 6px;
}
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-track {
background: var(--ifm-color-emphasis-200);
border-radius: var(--theme-border-radius-sm, 4px);
}
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-400);
border-radius: var(--theme-border-radius-sm, 4px);
}
.tabs-container [role="tabpanel"] pre::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-500);
}
/* ============================================================
* OpenAPI code tab language icons
* ============================================================ */
.openapi-tabs__code-item--python {
color: var(--ifm-color-success);
}
.openapi-tabs__code-item--python::after {
content: "";
width: var(--code-tab-logo-width);
height: var(--code-tab-logo-height);
background: url("https://raw.githubusercontent.com/devicons/devicon/master/icons/python/python-plain.svg")
no-repeat;
margin-block: auto;
}
.openapi-tabs__code-item--python.active {
box-shadow: 0 0 0 3px var(--openapi-code-tab-shadow-color-python);
border-color: var(--openapi-code-tab-border-color-python);
}
/* ============================================================
* Grid utilities
* ============================================================ */
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.gap-3 {
gap: 0.75rem;
}
.gap-6 {
gap: 1.5rem;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mt-2 {
margin-top: 0.5rem;
}
@media (min-width: 768px) {
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
+524 -22
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.16.4",
"version": "0.16.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.16.4",
"version": "0.16.5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -30,6 +30,7 @@
"@kenyip/backoff-strategies": "^1.0.4",
"@keyv/valkey": "^1.0.8",
"@lukehagar/plexjs": "^0.39.0",
"@minisylar/express-typed-router": "^1.9.8",
"@pierre/diffs": "^1.2.4",
"@rocksky/sdk": "^0.3.0",
"@supercharge/promise-pool": "^3.0.0",
@@ -132,6 +133,8 @@
"@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",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.14.3",
@@ -159,6 +162,7 @@
"@types/sinon": "^21.0.0",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^8.1.9",
"@types/supertest": "^7.2.1",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^6.0.2",
"@welldone-software/why-did-you-render": "^10.0.1",
@@ -173,7 +177,7 @@
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-boundaries": "^7.0.2",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-mocha": "^11.3.0",
"eslint-plugin-mocha": "^12.0.2",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-storybook": "^10.3.6",
@@ -202,6 +206,7 @@
"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",
@@ -209,6 +214,7 @@
"typescript-eslint": "^8.59.3",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.12",
"vite-plugin-svgr": "^5.2.0",
"with-local-tmp-dir": "^7.0.1",
"zocker": "^3.0.0",
"zod": "^4.4.3"
@@ -2270,9 +2276,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3000,6 +3006,22 @@
"react": ">=16"
}
},
"node_modules/@minisylar/express-typed-router": {
"version": "1.9.8",
"resolved": "https://registry.npmjs.org/@minisylar/express-typed-router/-/express-typed-router-1.9.8.tgz",
"integrity": "sha512-C0VW62eAPNFYP/pPqpepVQLitsJLPI1/8CFSVGIfZO4uaPkJZchN9eDpcCE/S2Rc1RXC+71tZ1A/A/yCu8kp7A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@standard-schema/utils": "^0.3.0"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"express": "^4.18.0 || ^5.0.0"
}
},
"node_modules/@mjackson/form-data-parser": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz",
@@ -5329,7 +5351,6 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"dev": true,
"license": "MIT"
},
"node_modules/@stdlib/error-tools-fmtprodmsg": {
@@ -5813,6 +5834,258 @@
"node": ">=8"
}
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-svg-component": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-preset": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/core": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"camelcase": "^6.2.0",
"cosmiconfig": "^8.1.3",
"snake-case": "^3.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/core/node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@svgr/hast-util-to-babel-ast": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.21.3",
"entities": "^4.4.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/plugin-jsx": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"@svgr/hast-util-to-babel-ast": "8.0.0",
"svg-parser": "^2.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svrooij/sonos": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz",
@@ -6144,6 +6417,38 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/form-core": {
"version": "2.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-2.0.0-alpha.2.tgz",
"integrity": "sha512-u/2s1rJ1pSdFgU/oKz2zt/Wdb3XQK9LY1IIL2pLMu0AVYK5f8Ue5UwaCjcUxEAI8g6XJpbmEuy2wLIwHZbZnwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/pacer-lite": "^0.2.2",
"@tanstack/store": "^0.11.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/pacer-lite": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.2.tgz",
"integrity": "sha512-eQ1MyLKCHyXiH7NbdmB80W77OhiMgGBUb+qDx/8WMGbwg5Lf/NlfD0TfNYAqY77i8V3AxoDoYdICrQE5ADw4Yw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
@@ -6166,6 +6471,27 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-form": {
"version": "2.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-2.0.0-alpha.2.tgz",
"integrity": "sha512-rIm6kRhpK9Fbb5p3xiwKKofOKHXAK3K28NeR4sM5T0h8CU0TpCLMz27SAHY2xjvG2iSgx8n4mLVEyTxw7iHJdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/form-core": "2.0.0-alpha.2",
"@tanstack/react-store": "^0.11.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
@@ -6201,6 +6527,25 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
"integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/store": "0.11.1",
"use-sync-external-store": "^1.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
@@ -6219,6 +6564,17 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
"integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
@@ -6860,6 +7216,17 @@
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz",
"integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -10317,6 +10684,17 @@
"csstype": "^3.0.2"
}
},
"node_modules/dot-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/dotenv": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz",
@@ -11490,27 +11868,37 @@
}
},
"node_modules/eslint-plugin-mocha": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-11.3.0.tgz",
"integrity": "sha512-anENwrIwmdvunmmssjMn5a4nTd+mYMkqBlwjksxOECcIThLNhefWJIiTWY7pY/arMQFjNwHQjVOZb6pQ9PrLjg==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-12.0.2.tgz",
"integrity": "sha512-RzjnzVBoidGFjVcvzQShLbfWug5pL/6d6LZFbBZk1b5duZ0ooq6dasmpu6qLyNowUZBqaWvQbnR5U1hsKX/+5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.1",
"globals": "^15.14.0"
"@eslint-community/eslint-utils": "4.10.1",
"@eslint/core": "1.2.1",
"@types/estree": "1.0.9",
"globals": "17.8.0",
"json-schema-to-ts": "3.1.1",
"type-fest": "5.8.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
"eslint": ">=10.2.0"
}
},
"node_modules/eslint-plugin-mocha/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"node_modules/eslint-plugin-mocha/node_modules/type-fest": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
"dev": true,
"license": "MIT",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -13201,9 +13589,9 @@
}
},
"node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
"version": "17.8.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14480,6 +14868,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
@@ -15100,6 +15502,16 @@
"get-func-name": "^2.0.1"
}
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -15996,6 +16408,17 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lower-case": "^2.0.2",
"tslib": "^2.0.3"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -18556,6 +18979,17 @@
"node": ">=6"
}
},
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -18996,6 +19430,42 @@
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supertest/node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -19021,6 +19491,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-parser": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.5.tgz",
"integrity": "sha512-FuT74puvVMJQ8sgFxgOKPex5MbxPeltqCXJV3wR9Xq+vPHaF+tTdE1lhJcwspUxjtjvXy1NwqcSLZx9UNwHawg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
@@ -19258,6 +19738,13 @@
"integrity": "sha512-0s7O4OFi0f8DpD03spao04Osrt2b4p1v2uags7JBMLsAe36pUI0mQU6XMgQuQSv5jOTqTFS0W50wTPrlzST80w==",
"license": "MIT"
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"dev": true,
"license": "MIT"
},
"node_modules/ts-dedent": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
@@ -20174,6 +20661,21 @@
"picocolors": "^1.1.1"
}
},
"node_modules/vite-plugin-svgr": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
"integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.3.0",
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0"
},
"peerDependencies": {
"vite": ">=3.0.0"
}
},
"node_modules/vlc-client": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vlc-client/-/vlc-client-1.1.1.tgz",
+10 -3
View File
@@ -1,11 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.16.4",
"version": "0.16.5",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
"schema": "npm run -s schema:docs",
"schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts",
"openapi:docs": "node src/backend/utils/OpenApiUtils.ts",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "npm run -s test:backend",
"test:backend": "mocha --reporter spec",
@@ -18,7 +19,7 @@
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
"docs:install": "cd docsite && npm install --no-audit",
"docs:start": "cd docsite && npm start",
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
"docs:build": "npm run -s schema:docs && npm run -s openapi:docs && cd docsite && npm run build",
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
"postinstall": "patch-package",
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
@@ -70,6 +71,7 @@
"@kenyip/backoff-strategies": "^1.0.4",
"@keyv/valkey": "^1.0.8",
"@lukehagar/plexjs": "^0.39.0",
"@minisylar/express-typed-router": "^1.9.8",
"@pierre/diffs": "^1.2.4",
"@rocksky/sdk": "^0.3.0",
"@supercharge/promise-pool": "^3.0.0",
@@ -172,6 +174,8 @@
"@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",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.14.3",
@@ -199,6 +203,7 @@
"@types/sinon": "^21.0.0",
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^8.1.9",
"@types/supertest": "^7.2.1",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^6.0.2",
"@welldone-software/why-did-you-render": "^10.0.1",
@@ -213,7 +218,7 @@
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-boundaries": "^7.0.2",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-mocha": "^11.3.0",
"eslint-plugin-mocha": "^12.0.2",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-storybook": "^10.3.6",
@@ -242,6 +247,7 @@
"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",
@@ -249,6 +255,7 @@
"typescript-eslint": "^8.59.3",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.12",
"vite-plugin-svgr": "^5.2.0",
"with-local-tmp-dir": "^7.0.1",
"zocker": "^3.0.0",
"zod": "^4.4.3"
+101 -34
View File
@@ -7,7 +7,7 @@ import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, ty
import { buildTrackString } from "../../core/StringUtils.ts";
import type {CommonClientConfig} from "./infrastructure/config/client/index.ts";
import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts";
import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
import { mergeSimpleError, SimpleError, SkipTransformStageError, StageChangeError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
import {
FLOW_CONTROL_TERM,
type PlayTransformRules,
@@ -33,13 +33,13 @@ import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isComp
import type {DbConcrete} from "./database/drizzle/drizzleUtils.ts";
import type {ComponentSelect} from "./database/drizzle/drizzleTypes.ts";
import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.ts";
import type {ClientType, MonitoringStatus} from "../../core/Atomic.ts";
import type {ClientType, MonitoringStatus, OptionalCacheUsage} from "../../core/Atomic.ts";
import type {SourceType} from "../../core/Atomic.ts";
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts";
import dayjs, { type Dayjs } from "dayjs";
import { COMPONENT_STATE, type ComponentCommonApi, type ComponentCommonApiJson, type ComponentState, type PlayApiCommonDetailed } from "../../core/Api.ts";
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
import type { MarkRequired } from "ts-essentials";
import type { ElementOf, MarkRequired } from "ts-essentials";
import { serializeError } from "serialize-error";
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
@@ -67,7 +67,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
type: ClientType | SourceType;
name: string;
lastActiveAt?: Dayjs
lastActiveAt?: Dayjs;
lastReadyAt?: Dayjs;
protected lastUpdatedComponentDatesAt?: Dayjs
@@ -116,8 +116,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
name: this.config?.name ?? this.name
});
this.componentId = this.dbComponent.id;
this.lastActiveAt = this.dbComponent.lastActiveAt;
this.lastReadyAt = this.dbComponent.lastReadyAt;
this.lastActiveAt = this.dbComponent.lastActiveAt ?? undefined;
this.lastReadyAt = this.dbComponent.lastReadyAt ?? undefined;
return true;
}
@@ -221,6 +221,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
public abstract stop(opts?: {reason?: string | Error}): Promise<void>
public abstract start(opts?: {forceInit?: boolean}): Promise<boolean>
public async restart(opts: { reason?: string | Error, forceInit?: boolean } = {}): Promise<void> {
try {
await this.stop(opts);
await this.start(opts);
} catch (e) {
const err = new StageChangeError('Failed to restart', { cause: e });
this.replaceErrors(err);
throw err;
}
}
public retentionCleanup = async () => {
if(this.databaseOK !== true) {
this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
@@ -233,7 +247,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
this.setStatus('Retention cleanup finished');
} catch (e) {
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
this.warning = retentionErr;
this.warnings.push(retentionErr);
this.logger.warn(retentionErr);
this.setStatus('Retention cleanup failed');
}
@@ -248,7 +262,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
}
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
public transformPlay = async (play: PlayObject, hookType: TransformHook, transformOpts: {log?: boolean | 'all'} & OptionalCacheUsage = {}) => {
const {
log,
useCachedResult = true
} = transformOpts;
let logger: Logger;
@@ -280,7 +299,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
const cachedSteps = await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash);
const cachedSteps = useCachedResult ? await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash) : undefined;
if(cachedSteps !== undefined) {
logger.trace(`Cache hit for Steps => ${transformHash}`);
//return cachedTransformPlay;
@@ -294,7 +313,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const opts = {
logger,
asyncId
asyncId,
useCachedResult
}
if(cachedSteps !== undefined) {
@@ -343,7 +363,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const historyToDiff: {name: string, data?: PlayData}[] = [
{name: 'Pre Transform', data: play.data}
];
let isNew = false;
let isNew = false,
diffFailure = false;
if(steps.length > 0 && transformedPlay.lifecycle === undefined) {
transformedPlay.lifecycle = steps;
isNew = true;
@@ -352,6 +373,11 @@ export default abstract class AbstractComponent extends AbstractInitializable {
lifecycle = []
} = transformedPlay;
steps.forEach((s, index) => {
if(diffFailure) {
return;
}
const stepIdHint = `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`;
try {
if(!isNew) {
const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier());
if(existingStepIndex !== -1) {
@@ -363,12 +389,24 @@ export default abstract class AbstractComponent extends AbstractInitializable {
if(shouldLog === 'all') {
if(s.patch === undefined) {
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`});
historyToDiff.push({name: `${stepIdHint}`});
} else {
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName} ${s.cached ? ' (Cached)' : ''}`, data: patched});
const lastTransformedData = historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data;
const patched = patchObject(lastTransformedData, s.patch);
historyToDiff.push({name: `${stepIdHint} ${s.cached ? ' (Cached)' : ''}`, data: patched});
}
}
} catch (e) {
if(`patch` in s) {
this.logger.debug({
patch: s.patch,
playData: historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data,
cached: s.cached
}, 'Patch and Play data context');
}
this.logger.warn(new SimpleError(`Error occurred while trying to generate diffed Plays for console logging on step ${stepIdHint} but will continue`, {cause: e}));
diffFailure = true;
}
});
if(shouldLog !== false) {
if(steps.filter(x => x.patch !== undefined).length === 0) {
@@ -376,25 +414,45 @@ export default abstract class AbstractComponent extends AbstractInitializable {
} else {
const diffs: string[] = [];
try {
let lastTransformed: ElementOf<typeof historyToDiff>;
historyToDiff.forEach((curr, index) => {
if(index === 0) {
lastTransformed = curr;
return;
}
const last = historyToDiff[index - 1];
if(curr.data === undefined) {
diffs.push(`${last.name} => ${curr.name} -- No Change`);
} else {
const formattedDiff = diffObjectsConsoleOutput(last.data, curr.data);
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
try {
const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data, curr.data);
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
lastTransformed = curr;
} catch(e) {
this.logger.debug({pre: lastTransformed.data, post: curr.data}, 'Compared Pre and Post play data');
throw e;
}
}
});
} catch (e) {
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
diffFailure = true;
}
if(shouldLog === true || steps.filter(x => x.patch !== undefined).length > 2) {
const finalData = transformedPlay.data;
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
diffs.push(`Original => Final\n${formattedDiff}`);
try {
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
diffs.push(`Original => Final\n${formattedDiff}`);
} catch (e) {
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
diffFailure = true;
}
}
if(diffFailure) {
diffs.push('A failure during diff generation occurred but the transform was successful.');
}
logger.debug(`Transform Diff\n${diffs.join('\n')}`);
}
}
@@ -415,17 +473,18 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } = {}): Promise<[LifecycleStep, PlayObject]> => {
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } & OptionalCacheUsage = {}): Promise<[LifecycleStep, PlayObject]> => {
const {
onSuccess = 'continue',
onFailure = 'stop',
onSkip = 'continue',
failureReturnPartial = false
failureReturnPartial = false,
} = hookItem;
const {
logger = loggerNoop,
asyncId = nanoid(6)
asyncId = nanoid(6),
useCachedResult
} = opts;
const {
@@ -446,7 +505,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
stageName: string = 'Unnamed',
err: Error;
try {
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, asyncId);
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, {asyncId, useCachedResult});
newTransformedPlay = clone(newTransformedPlay);
} catch (e) {
err = e;
@@ -533,7 +592,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
public abstract getRunningState(): ComponentState
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players' | 'deadLetterPlays' | 'deadLetterPlaysTotal' | 'queued'> & Pick<ComponentCommonApi, 'state' | 'errors' | 'warnings'> {
let state: ComponentState;
if(!this.initializedOnce || this.initializing) {
state = COMPONENT_STATE.INITIALIZING;
@@ -551,10 +610,10 @@ export default abstract class AbstractComponent extends AbstractInitializable {
monitoringStatus: this.getMonitoringStatus(),
countNonLive: this.dbComponent.countNonLive,
createdAt: this.dbComponent.createdAt?.toISOString(),
lastReadyAt: this.lastReadyAt?.toISOString(),
lastActiveAt: this.lastActiveAt?.toISOString(),
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
lastReadyAt: this.lastActiveAt !== undefined ? this.lastReadyAt.toISOString() : undefined,
lastActiveAt: this.lastActiveAt !== undefined ? this.lastActiveAt?.toISOString() : undefined,
errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x),
warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x),
...this.additionalApiData()
}
}
@@ -569,12 +628,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
});
}
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
if('error' in payload && payload.error instanceof Error) {
payload.error = serializeError(payload.error);
public emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
if('errors' in payload) {
if(payload.errors.length > 0) {
payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x);
} else {
payload.errors = [];
}
}
if('warning' in payload && payload.warning instanceof Error) {
payload.warning = serializeError(payload.warning);
if('warnings' in payload) {
if(payload.warnings.length > 0) {
payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x);
} else {
payload.warnings = [];
}
}
this.emitEvent('componentUpdate', payload);
}
@@ -590,7 +657,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
this.setStatus(payload.title);
}
protected setStatus = (status: string) => {
public setStatus = (status: string) => {
this.status = status;
this.emitComponentUpdate({status});
}
+74 -21
View File
@@ -3,13 +3,14 @@ import {truncateStringToLength } from "../../core/StringUtils.ts";
import { hasNodeNetworkException } from "./errors/NodeErrors.ts";
import { hasUpstreamError } from "./errors/UpstreamError.ts";
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError } from "./errors/MSErrors.ts";
import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.ts";
import { AuthCheckError, AuthError, BuildDataError, ConnectionCheckError, findAuthIssue, ParseCacheError, PostInitError, StageError, type AuthErrorMap } from "./errors/MSErrors.ts";
import { generateErrorTruthyTest, messageWithCausesTruncatedDefault, type TruthyErrorsOpts } from "../../core/ErrorUtils.ts";
import { spawn } from 'abort-controller-x';
import { COMPONENT_AUTH_TYPE, type ComponentAuthType } from "../../core/Atomic.ts";
export default abstract class AbstractInitializable {
requiresAuth: boolean = false;
requiresAuthInteraction: boolean = false;
authType: ComponentAuthType = COMPONENT_AUTH_TYPE.none;
authed: boolean = false;
authFailure?: boolean;
@@ -19,8 +20,8 @@ export default abstract class AbstractInitializable {
databaseOK?: boolean | null;
connectionOK?: boolean | null;
cacheOK?: boolean | null;
error?: Error;
warning?: Error;
errors?: Error[] = [];
warnings?: Error[] = [];
protected initializedOnce: boolean = false;
initializing: boolean = false;
@@ -88,15 +89,15 @@ export default abstract class AbstractInitializable {
} catch (e) {
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
}
this.error = undefined;
this.warning = undefined;
this.errors = [];
this.warnings = [];
return true;
} catch(e) {
if(notify) {
await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
}
const initError = new Error('Initialization failed', {cause: e});
this.error = initError;
this.errors = [initError];
throw initError;
} finally {
this.initializing = false;
@@ -283,17 +284,31 @@ export default abstract class AbstractInitializable {
return;
}
authGated = () => this.requiresAuth && !this.authed
authGated = () => this.authType !== COMPONENT_AUTH_TYPE.none && !this.authed
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
canTryAuth = () => this.isUsable() && this.authGated() && !this.hasUnrecoverableAuthFailure()
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(opts: Parameters<typeof findAuthIssue<T>>[1] = {}): AuthErrorMap[T] | undefined => {
for(const err of this.errors) {
const authError = findAuthIssue(err, opts);
if(authError !== undefined) {
return authError;
}
}
return undefined;
}
hasAuthIssue = () => this.findAuthIssue() !== undefined;
hasUnrecoverableAuthFailure = () => this.findAuthIssue({unrecoverable: true}) !== undefined;
canAuthUnattended = () => !this.authGated() || this.authType === COMPONENT_AUTH_TYPE.unattended || (this.authType === COMPONENT_AUTH_TYPE.interactive && !this.hasUnrecoverableAuthFailure()) ;
protected doAuthentication = async (): Promise<boolean> => this.authed
// default init function, should be overridden if auth stage is required
testAuth = async (force: boolean = false) => {
if(!this.requiresAuth) {
if(this.authType === COMPONENT_AUTH_TYPE.none) {
return;
}
if(this.authed) {
@@ -303,24 +318,46 @@ export default abstract class AbstractInitializable {
this.logger.debug('Auth OK but step was forced');
}
if(this.authFailure) {
// only throw *before* testing if we have previously tested and the error was unrecoverable
// there is no reason to constantly retry auth when we already know it won't succeed
//
// test retries should be forced by api calls and auth callback flows *after* we have made changes to component credentials
const unrecoverableAuth = this.findAuthIssue({unrecoverable: true});
if(unrecoverableAuth !== undefined) {
let unrecoverable: boolean | undefined,
cause: Error;
if(!force) {
if(this.requiresAuthInteraction) {
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
if(unrecoverableAuth instanceof AuthCheckError) {
// we threw a fallback AuthCheckError
unrecoverable = true;
cause = unrecoverableAuth.cause as Error;
} else {
cause = unrecoverableAuth;
}
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
if(this.authType === COMPONENT_AUTH_TYPE.interactive) {
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication', {cause, unrecoverable});
}
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated', {cause, unrecoverable});
}
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
}
try {
this.authed = await this.doAuthentication();
this.authFailure = !this.authed;
await this.doAuthentication();
this.authed = true;
} catch (e) {
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
let unrecoverableMsg: boolean,
unrecoverable: boolean | undefined;
if(e instanceof AuthError) {
unrecoverableMsg = e.unrecoverable;
} else {
// if component auth test didn't throw AuthError (it should have!)
// then fallback teo determining by these conditions
unrecoverable = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
unrecoverableMsg = unrecoverable;
}
this.authed = false;
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
throw new AuthCheckError(`Authentication test failed!${unrecoverableMsg === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e, unrecoverable});
}
}
@@ -349,4 +386,20 @@ export default abstract class AbstractInitializable {
public additionalApiData(): Record<string, any> {
return {};
}
public clearErrors(opts?: TruthyErrorsOpts) {
if (!opts) { this.errors = []; return; }
const test = generateErrorTruthyTest(opts);
this.errors = this.errors.filter(x => !test(x));
}
public replaceErrors(e: Error, opts?: TruthyErrorsOpts) {
if(opts !== undefined) {
this.clearErrors(opts);
} else {
this.clearErrors({instance: e});
}
this.errors.push(e);
}
}
@@ -0,0 +1,73 @@
import type { SqliteDatabase, Migration } from 'sqlite-up';
import type { MigrateBaseContext } from '../appMigrator.ts';
import { playInputs, plays as drizzlePlays } from '../drizzle/schema/schema.ts';
import { eq } from 'drizzle-orm';
import { playContentBasicInvariantTransform } from '../../../utils/PlayComparisonUtils.ts';
import { hashObject } from '../../../utils/StringUtils.ts';
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
ctx.logger.info('Generating hashes for Play input data...');
let more = true;
let offset = 0,
processed = 0,
updated = 0;
// first update input hashes
while (more) {
const inputRows = await ctx.db.select().from(playInputs).limit(100).offset(offset);
for (const row of inputRows) {
if (row.playHash !== null) {
processed++;
}
try {
await ctx.db.update(playInputs).set({
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
}).where(eq(playInputs.id, row.id));
updated++;
processed++;
} catch (e) {
ctx.logger.warn(new Error(`Failed to generate hash for Play Input ${row.id}`, { cause: e }));
}
}
offset += 100;
ctx.logger.verbose(`Play Input Hash Generation Progress: Processed ${processed} | Updated ${updated}`);
if (inputRows.length < 100) {
more = false;
}
}
ctx.logger.info('Regenerating hashes for Play data...');
offset = 0;
processed = 0;
updated = 0;
more = true;
// then update Plays so hashes reflect Plays after transforms are done
while (more) {
const playsRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset);
for (const row of playsRows) {
try {
await ctx.db.update(drizzlePlays).set({
playHash: hashObject(playContentBasicInvariantTransform(row.play).data)
}).where(eq(drizzlePlays.id, row.id));
updated++;
processed++;
} catch (e) {
ctx.logger.warn(new Error(`Failed to generate hash for Play ${row.id} (${row.uid})`, { cause: e }));
}
}
offset += 100;
ctx.logger.verbose(`Play Hash Regeneration Progress: Processed ${processed} | Updated ${updated}`);
if (playsRows.length < 100) {
more = false;
}
}
};
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
// Rollback code here
// context is passed as ctx
};
@@ -0,0 +1,76 @@
import type { SqliteDatabase, Migration } from 'sqlite-up';
import type { MigrateBaseContext } from '../appMigrator.ts';
import { queueStates } from '../drizzle/schema/schema.ts';
import { eq } from 'drizzle-orm';
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
ctx.logger.info('Beginning queue entities consolidation.');
ctx.logger.verbose('Deleting (now) unused completed queue states...');
ctx.db.delete(queueStates).where(eq(queueStates.queueStatus,'completed'));
ctx.logger.verbose('Done with completed queue state deletions');
ctx.logger.info('Converting dead queue states to ingress states with failure + retries...');
let more = true;
let offset = 0,
updated = 0;
while (more) {
const plays = await ctx.db.query.plays.findMany({
where: {
queueStates: {
queueName: 'dead'
}
},
with: {
queueStates: true
},
limit: 100,
offset
});
for(const p of plays) {
const ingress = p.queueStates.find(x => x.queueName === 'ingress');
const dead = p.queueStates.find(x => x.queueName === 'dead');
if(ingress === undefined) {
await ctx.db.insert(queueStates).values([{
componentId: p.componentId,
playId: p.id,
queueName: 'ingress',
queueStatus: 'failed',
retries: dead.retries,
error: dead.error,
createdAt: dead.updatedAt,
updatedAt: dead.updatedAt
}]);
} else {
await ctx.db.update(queueStates).set({
queueStatus: 'failed',
retries: dead.retries,
error: dead.error,
updatedAt: dead.updatedAt}).where(eq(queueStates.id, ingress.id));
}
updated++;
await ctx.db.delete(queueStates).where(eq(queueStates.id, dead.id));
}
offset += 100;
ctx.logger.verbose(`Conversion Progress: Updated ${updated}`);
if (plays.length < 100) {
more = false;
}
}
ctx.logger.info('Done.');
};
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
// Rollback code here
// context is passed as ctx
};
@@ -1,5 +1,5 @@
import type {DBQueryConfig, DBQueryConfigWith, KnownKeysOnly, RelationFieldsFilterInternals, BuildQueryResult, RelationsFilter} from "drizzle-orm";
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical } from "./schema/schema.ts";
import type { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical, playEvents } from "./schema/schema.ts";
import type {TSchema, TableName} from "./schema/schema.ts";
@@ -24,6 +24,9 @@ export type PlayNew = typeof plays.$inferInsert;
export type PlayHistoricalSelect = typeof playsHistorical.$inferSelect;
export type PlayHistoricalNew = typeof playsHistorical.$inferInsert;
export type PlayEventSelect = typeof playEvents.$inferSelect;
export type PlayEventNew = typeof playEvents.$inferInsert;
// useful references for building types
// https://github.com/drizzle-team/drizzle-orm/discussions/2596
// https://github.com/drizzle-team/drizzle-orm/discussions/1539
@@ -1,14 +1,15 @@
import assert from "node:assert";
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates} from "./drizzleTypes.ts";
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates, QueueStateSelect} from "./drizzleTypes.ts";
import type {PlayInputNew} from "./drizzleTypes.ts";
import type {QueueStateNew} from "./drizzleTypes.ts";
import type {ComponentNew} from "./drizzleTypes.ts";
import type { MarkOptional } from "ts-essentials";
import { CLIENT_DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type PlayObject } from "../../../../core/Atomic.ts";
import type { MarkOptional, MarkRequired } from "ts-essentials";
import { DEAD_QUEUE, type DeadLetterScrobble, type ErrorLike, type LifecycleStep, type PlayObject } from "../../../../core/Atomic.ts";
import dayjs from "dayjs";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.ts";
import { hashObject } from "../../../utils/StringUtils.ts";
import { serializeError } from "serialize-error";
import { PLAY_EVENT_TYPE, type PlayEventDupeCheck, type PlayEventDupeCheckData, type PlayEventPlayStateChange, type PlayEventPlayStateChangeData, type PlayEventQueueStateChange, type PlayEventQueueStateChangeData, type PlayEventScrobbleResult, type PlayEventScrobbleResultData, type PlayEventTransform } from "../../../../core/PlayEvent.ts";
export const generateComponentEntity = (data: MarkOptional<ComponentNew, 'uid'>): ComponentNew => {
assert(data.name !== undefined, 'Must provide name');
@@ -72,7 +73,7 @@ export const hydratePlaySelect = <T extends PlaySelect | PlayHistoricalSelect>(s
}
export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, serializedError: boolean = false): DeadLetterScrobble<PlayObject> => {
const deadQueue = select.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE);
const deadQueue = select.queueStates.find(x => x.queueName === DEAD_QUEUE);
return {
play: select.play,
id: select.uid,
@@ -85,9 +86,82 @@ export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates, seri
}
export const generateInputEntity = (data: PlayInputNew): PlayInputNew => {
return data;
const {
playHash = hashObject(playContentBasicInvariantTransform(data.play).data)
} = data;
return {...data, playHash};
}
export const generateQueueStateEntity = (data: QueueStateNew): QueueStateNew => {
return data;
}
}
export const queueStateToEventData = (qs: QueueStateSelect): PlayEventQueueStateChangeData => {
const {
queueName,
queueStatus,
error,
retries
} = qs;
return {
queueName,
queueStatus,
error,
retries
}
}
export const transformToPlayEvent = (lifecycle: LifecycleStep[]): Omit<PlayEventTransform, 'playId'> => ({
eventName: PLAY_EVENT_TYPE.transform,
createdAt: dayjs(lifecycle[0].createdAt),
data: lifecycle
});
export const stateChangeToPlayEvent = (partial: PlayEventPlayStateChangeData): Omit<PlayEventPlayStateChange, 'playId'> => ({
eventName: PLAY_EVENT_TYPE.playStateChange,
createdAt: dayjs(),
data: partial
})
export const queueStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context'>): Omit<PlayEventQueueStateChange, 'playId'> => ({
eventName: PLAY_EVENT_TYPE.queueStateChange,
createdAt: dayjs(),
data: partial
});
export const queueCompletionStateToPlayEvent = (partial: MarkOptional<QueueStateSelect, 'context' | 'retries'>): Omit<PlayEventQueueStateChange, 'playId'> => {
const {
context,
retries,
...rest
} = partial;
return {
eventName: PLAY_EVENT_TYPE.queueStateChange,
createdAt: dayjs(),
data: rest
}
}
export const dupeCheckToPlayEvent = (partial: MarkRequired<Partial<PlayEventDupeCheckData>, 'match'>): Omit<PlayEventDupeCheck, 'playId'> => {
const {match, ...rest} = partial;
const data: PlayEventDupeCheckData = {
match,
score: match ? 1 : 0,
breakdowns: [],
createdAt: dayjs().toISOString(),
...rest
};
return {
eventName: PLAY_EVENT_TYPE.dupeCheck,
createdAt: dayjs(),
data
}
}
export const scrobbleToPlayEvent = (data: PlayEventScrobbleResultData): Omit<PlayEventScrobbleResult, 'playId'> => ({
eventName: PLAY_EVENT_TYPE.scrobbleResult,
createdAt: data.createdAt ?? dayjs(),
data
});
export const entityIsPlayEntity = (obj: object): obj is PlaySelectWithQueueStates => `play` in obj && typeof obj.play === 'object'
&& `componentId` in obj && typeof obj.componentId === 'number'
@@ -0,0 +1 @@
ALTER TABLE `play_inputs` ADD `playHash` text;
@@ -0,0 +1,11 @@
CREATE TABLE `play_events` (
`id` integer PRIMARY KEY,
`playId` integer NOT NULL,
`eventName` text(50) NOT NULL,
`data` text,
`error` text,
`createdAt` number NOT NULL,
CONSTRAINT `fk_play_events_playId_plays_id_fk` FOREIGN KEY (`playId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `play_event_id_idx` ON `play_events` (`playId`);
@@ -0,0 +1 @@
ALTER TABLE `play_queue_states` ADD `context` text;
@@ -45,9 +45,9 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
await this.db.delete(this.table).where(inArray(this.table.id, ids));
}
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<void> {
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<typeof this.table.$inferSelect> {
assert(id !== null && id !== undefined, `${id === null ? 'null' : 'undefined'} given for entity id`);
await this.db.update(this.table).set(data).where(eq(this.table.id, id));
return (await this.db.update(this.table).set(data).where(eq(this.table.id, id)).returning())[0];
}
async create(data: typeof this.table.$inferInsert): Promise<typeof this.table.$inferSelect> {
@@ -0,0 +1,8 @@
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
import type {DbConcrete} from "../drizzleUtils.ts";
export class DrizzlePlayEventsRepository extends DrizzleBaseRepository<'playEvents'> {
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
super(db, 'playEvents', 'Play Events', opts);
}
}
@@ -3,28 +3,29 @@ import dayjs, { type Dayjs } from "dayjs";
import { eq, inArray, relationsFilterToSQL, sql } from "drizzle-orm";
import assert from "node:assert";
import type { MarkOptional } from "ts-essentials";
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, type QueueName, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
import { type DateLike, type DeepReplaceValue, type PlayObject, type PlayState, QUEUE_STATUS_QUEUED, type QueueName, SCROBBLE_TS_SOC_END, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
import { removeUndefinedKeys } from '../../../../../core/DataUtils.ts';
import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.ts";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.ts";
import { hashObject } from "../../../../utils/StringUtils.ts";
import { comparePlayTemporally, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
import { comparePlayTemporally, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
import { type CompactableProperty, type RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.ts";
import type {SourceType} from "../../../../../core/Atomic.ts";
import type {ErrorLike, SourceType} from "../../../../../core/Atomic.ts";
import type {FindMany, FindWhere, FindWith, PlayInputNew, PlayNew, PlaySelect, PlaySelectWithQueueStates, PlayWith, QueueStateSelect, WhereClause} from "../drizzleTypes.ts";
import { type DbConcrete, runTransaction } from "../drizzleUtils.ts";
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
import { playInputs, plays, relations } from "../schema/schema.ts";
import { generateInputEntity, generatePlayEntity, hydratePlaySelect, stateChangeToPlayEvent, transformToPlayEvent, type PlayEntityOpts, type PlayHydateOptions } from "../entityUtils.ts";
import { playEvents, playInputs, plays, queueStates, relations, type TSchema } from "../schema/schema.ts";
import { buildDateCompare, type CompareDateOp, type ComponentConstrainedRepoOpts, DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
import type {PaginatedResponse} from "../../../../../core/Api.ts";
import type {PaginatedQueryResponse} from "../../../../../core/Api.ts";
;
import { type PlayEventTransform } from "../../../../../core/PlayEvent.ts";
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
export interface QueueCriteria {
queueName: QueueName
queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
retries?: number
}
export interface PlayWhereOpts<D extends DateLike = Dayjs> {
@@ -38,7 +39,7 @@ export interface PlayWhereOpts<D extends DateLike = Dayjs> {
text?: string[]
}
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues';
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues' | 'events' | 'children';
export interface QueryPlaysOpts<D extends DateLike = Dayjs> extends PlayWhereOpts<D> {
sort?: 'seenAt' | 'playedAt'
order?: 'asc' | 'desc'
@@ -92,6 +93,26 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
return res as PlaySelectWithQueueStates;
}
findByIdWith = async <K extends keyof TSchema['plays']["relations"]>(id: number, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
const res = await this.db.query.plays.findFirst({
where: {
id
},
with: buildPlayWith(args)
});
return res as unknown as PlayWith<K> | undefined;
}
findByUidWith = async <K extends keyof TSchema['plays']["relations"]>(uid: string, args: WithPlayRelation[]): Promise<PlayWith<K> | undefined> => {
const res = await this.db.query.plays.findFirst({
where: {
uid
},
with: buildPlayWith(args)
});
return res as unknown as PlayWith<K> | undefined;
}
createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}): Promise<PlayWith<'input'>[]> => {
const {
@@ -103,11 +124,14 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
const entitiesData = entitiesOpts.map((data) => {
const {
play,
play: {
lifecycle,
...playRest
},
input,
...rest
} = data;
return generatePlayEntity(play, { componentId: this.componentId, ...rest });
return generatePlayEntity(playRest, { componentId: this.componentId, ...rest });
});
const nakedPlays = await this.db.insert(plays).values(entitiesData).returning();
@@ -126,6 +150,23 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
});
const inputRow = await this.db.insert(playInputs).values(inputDatas);
const eventData = nakedPlays.map((x, index) => {
const {
play: {
lifecycle = [],
} = {}
} = entitiesOpts[index];
if(lifecycle.length > 0) {
return {...transformToPlayEvent(lifecycle), playId: x.id}
}
return undefined;
}).filter(x => x !== undefined);
if(eventData.length > 0) {
await this.db.insert(playEvents).values(eventData);
}
playRows = nakedPlays.map((x, index) => ({...x, play: hydratePlaySelect(x, hydrate), input: inputRow[index]}));
});
@@ -182,6 +223,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
case 'queues':
query.with.queueStates = true;
break;
case 'events':
query.with.events = true;
break;
default:
throw new Error(`Unknown relation ${w}`);
}
@@ -408,28 +452,28 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`);
} else {
for(const id of ids) {
let compactedPlay: PlayObject;
if(compactTypes.includes('input')) {
await this.db.update(playInputs).set({
data: {removedReason: 'Removed by compaction'}
}).where(eq(playInputs.playId, id));
}
if(compactTypes.includes('transform')) {
const playRow = await this.db.query.plays.findFirst({where: {id: id}});
if(playRow === undefined) {
// uhh shouldn't be
loggerCom.warn(`No Play found with ID ${id}, but it should have been...`);
continue;
}
compactedPlay = playRow.play;
if(compactedPlay.lifecycle !== undefined) {
compactedPlay.lifecycle = compactedPlay.lifecycle.map(x => {
if(x.inputs == undefined) {
const events = await this.db.query.playEvents.findMany({where: {playId: id}});
for(const ev of events) {
if(ev.eventName === 'transform') {
const transformEvent = ev as PlayEventTransform;
let compactedInput = false;
transformEvent.data = transformEvent.data.map(x => {
if(x.inputs !== undefined && x.inputs.length > 0) {
compactedInput = true;
return {...x, inputs: x.inputs.map((y) => ({type: y.type, input: 'Removed by compaction'}))};
}
return x;
});
if(compactedInput) {
await this.db.update(playEvents).set({data: transformEvent.data}).where(eq(playEvents.id, transformEvent.id));
}
return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
});
}
}
}
@@ -437,9 +481,6 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
const vals: Parameters<typeof updater.set>[0] = {
compacted: compactedFlags.join('-')
};
if(compactedPlay !== undefined) {
vals.play = compactedPlay;
}
await this.db.update(plays).set(vals).where(eq(plays.id, id));
}
loggerCom.trace(`Compacted ${ids.length} '${retentionType}' plays`);
@@ -454,65 +495,75 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
}
protected prepareGetQueueNext = () => this.db.query.plays.findFirst({
// https://stackoverflow.com/a/78551796
// cannot bind arrays in sqlite
where: {
componentId: sql.placeholder('componentId'),
queueStates: {
queueName: sql.placeholder('queueName'),
queueStatus: 'queued',
queueStatus: sql.placeholder('status'),
retries: {
lte: sql.placeholder('retries')
}
},
},
with: {
queueStates: true
},
orderBy: {
seenAt: 'asc'
queueStates: {
orderBy: {
updatedAt: 'asc'
}
}
},
// orderBy: {
// seenAt: 'asc'
// },
}).prepare()
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number, notIds?: number[], status?: QueueStateSelect['queueStatus']} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
const {
retries = 0,
retries = 1000,
notIds,
order = 'asc',
status = QUEUE_STATUS_QUEUED,
componentId = this.componentId
} = opts;
// let where: FindWhere<'plays'> = {
// componentId
// }
let res: PlaySelectWithQueueStates | undefined;
// if(retries !== undefined) {
// where.queueStates = {
// queueName,
// queueStatus: 'queued',
// retries: {
// lte: retries
// }
// }
// } else {
// where.queueStates = {
// queueName,
// queueStatus: 'queued'
// }
// }
if (notIds === undefined) {
if (this.getQueueNextPrepared === undefined) {
this.getQueueNextPrepared = this.prepareGetQueueNext();
}
// const res = await this.db.query.plays.findFirst({
// where: where,
// orderBy: {
// seenAt: order
// },
// with: {
// queueStates: true
// }
// });
if(this.getQueueNextPrepared === undefined) {
this.getQueueNextPrepared = this.prepareGetQueueNext();
res = await this.getQueueNextPrepared.execute({ queueName, retries, componentId, status });
} else {
// cannot bind arrays in sqlite so this has to be non-prepared
res = await this.db.query.plays.findFirst({
where: {
id: {
notIn: notIds
},
componentId: componentId,
queueStates: {
queueName: queueName,
queueStatus: status,
retries: {
lte: retries
}
},
},
with: {
queueStates: {
orderBy: {
updatedAt: 'asc'
}
}
},
// orderBy: {
// seenAt: 'asc'
// },
});
}
const res = await this.getQueueNextPrepared.execute({queueName, retries, componentId});
if(res === undefined) {
return undefined;
@@ -613,12 +664,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
return {data: res.map(x => ({...x, play: hydratePlaySelect(x, hydrate)})), meta: {limit, offset}};
}
public checkExisting = async (play: PlayObject, opts: {queueName?: string, states?: PlaySelect['state'][], taAccuracy?: TemporalAccuracy[]} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
public checkExisting = async (play: PlayObject, opts: {
queueName?: string,
states?: PlaySelect['state'][],
taAccuracy?: TemporalAccuracy[],
inputHash?: string | PlayObject,
notId?: number
} & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
const {
queueName,
componentId = this.componentId,
taAccuracy = TA_DEFAULT_ACCURACY,
states
states,
inputHash,
notId,
} = opts;
const hash = hashObject(playContentBasicInvariantTransform(play).data);
@@ -626,19 +685,25 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
// which we can then use with temporal comparison to make sure we are comparing the correct dates
//
// this isn't as fast as just comparing playDate directly but its still much faster/cheaper than paginating plays and doing everything in-memory
const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
let endRange: Dayjs;
if(play.data.playDateCompleted !== undefined) {
// this will be present if source reports it
// or we tracked it live with MemorySource
endRange = play.data.playDateCompleted.add(dateGranularity, 's');
} else {
endRange = play.data.playDate.add(dateGranularity, 's');
}
// const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
// let endRange: Dayjs;
// if(play.data.playDateCompleted !== undefined) {
// // this will be present if source reports it
// // or we tracked it live with MemorySource
// endRange = play.data.playDateCompleted.add(dateGranularity, 's');
// } else {
// endRange = play.data.playDate.add(dateGranularity, 's');
// }
const where: FindWhere<'plays'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {useDuration: true})),
};
if(notId !== undefined) {
where.NOT = {
id: notId
}
}
if(queueName !== undefined) {
where.queueStates = {
@@ -653,19 +718,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
}
const mbidId = playMbidIdentifier(play);
if(mbidId !== undefined) {
where.AND = [
{
OR: [
{
playHash: hash
},
{
mbidIdentifier: mbidId
}
]
}
]
if (mbidId !== undefined || inputHash !== undefined) {
where.AND = [{
OR: [
{
playHash: hash
}
]
}];
if (mbidId !== undefined) {
where.AND[0].OR.push({ mbidIdentifier: mbidId });
}
if (inputHash !== undefined) {
where.AND[0].OR.push({ input: { playHash: typeof inputHash === 'string' ? inputHash : hashObject(playContentBasicInvariantTransform(inputHash).data) } });
}
} else {
where.playHash = hash;
}
@@ -673,7 +739,8 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
const res = await this.db.query.plays.findMany({
where,
with: {
queueStates: true
queueStates: true,
input: true
}
});
if(res.length === 0) {
@@ -685,12 +752,13 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
})
}
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[] } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[], notId?: number } & ComponentConstrainedRepoOpts = {}): Promise<PlayWith<'queueStates'>[]> => {
const {
componentId = this.componentId,
bufferTime,
states,
with: qWith
with: qWith,
notId
} = opts;
const query: FindMany<'plays'> = {};
@@ -704,6 +772,11 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
in: states
}
}
if(notId !== undefined) {
where.id = {
NOT: notId
}
}
query.where = where;
return ((await this.db.query.plays.findMany({
@@ -734,26 +807,75 @@ where compacted IS NOT NULL
group by componentId,compacted;`);
return res;
}
async updateById(id: number, data: Partial<PlayNew> & {event?: boolean, reason?: string, error?: ErrorLike}): Promise<typeof this.table.$inferSelect> {
const res = await super.updateById(id, data) as PlaySelect;
if(data.event === true) {
if(data.state !== undefined) {
try {
await this.db.insert(playEvents).values({...stateChangeToPlayEvent(removeUndefinedKeys({state: data.state, reason: data.reason, error: data.error})), playId: id});
} catch (e) {
this.logger.warn(new Error(`Failed to create Play Event for state change ${data.state} on Play ${id}`));
}
}
}
return res;
}
async deleteById(id: number): Promise<void> {
await runTransaction(this.db, async () => {
await this.db.delete(playInputs).where(eq(playInputs.playId, id));
await this.db.delete(playEvents).where(eq(playEvents.playId, id));
await this.db.delete(queueStates).where(eq(queueStates.playId, id));
await this.db.update(plays).set({parentId: null}).where(eq(plays.parentId, id));
await this.db.delete(plays).where(eq(plays.id, id));
});
}
async deleteByIds(ids: number[]): Promise<void> {
await runTransaction(this.db, async () => {
await this.db.delete(playInputs).where(inArray(playInputs.playId, ids));
await this.db.delete(playEvents).where(inArray(playEvents.playId, ids));
await this.db.delete(queueStates).where(inArray(queueStates.playId, ids));
await this.db.update(plays).set({parentId: null}).where(inArray(plays.parentId, ids));
await this.db.delete(plays).where(inArray(plays.id, ids));
});
}
}
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean, useDuration?: boolean} = {}): CompareDateOp => {
const {
// use either provided arg or default to using source granularity
bufferTime = getTemporalAccuracyCloseVal(play.meta.source as SourceType),
useCompleted = true
useCompleted,
useDuration,
} = opts;
// we get all plays with a play date between playdate - (buffer) AND (playDateCompleted or playDate) + (buffer)
let endRange: Dayjs;
if(play.data.playDateCompleted !== undefined && useCompleted) {
let startRange: Dayjs,
endRange: Dayjs;
// make sure we use the
const [sotPlayDate, SOT] = getScrobbleTsSOCDateWithContext(play);
if(useDuration && play.data.duration !== undefined) {
if(SOT === SCROBBLE_TS_SOC_END) {
endRange = play.data.playDate.add(bufferTime, 's');
startRange = play.data.playDate.subtract(play.data.duration + bufferTime,'s')
} else {
endRange = play.data.playDate.add(play.data.duration + bufferTime, 's');
startRange = play.data.playDate.subtract(bufferTime,'s')
}
} else if(play.data.playDateCompleted !== undefined && useCompleted) {
startRange = play.data.playDate.subtract(bufferTime, 's');
// this will be present if source reports it
// or we tracked it live with MemorySource
endRange = play.data.playDateCompleted.add(bufferTime, 's');
} else {
endRange = play.data.playDate.add(bufferTime, 's');
startRange = sotPlayDate.subtract(bufferTime, 's');
endRange = sotPlayDate.add(bufferTime, 's');
}
return {
type: 'between',
range: [play.data.playDate.subtract(bufferTime, 's'), endRange]
range: [startRange, endRange]
}
}
@@ -780,6 +902,12 @@ export const buildPlayWith = (args: WithPlayRelation[] | undefined): FindWith<'p
case 'queues':
qWith.queueStates = true;
break;
case 'events':
qWith.events = true;
break;
case 'children':
qWith.children = true;
break;
default:
throw new Error(`Unknown relation ${w}`);
}
@@ -829,14 +957,26 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
// or else assigning an array to OR using only `typeof where.queueStates` causes a type error
const queueWhere: typeof where.queueStates.OR[0][] = [];
for(const q of queues) {
queueWhere.push(
{
queueName: q.queueName,
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
in: q.queueStatus
}
const qWhereCriteria: typeof where.queueStates.OR[0] = {
queueName: q.queueName,
queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
in: q.queueStatus
}
)
};
if(q.retries !== undefined) {
qWhereCriteria.retries = {
lte: q.retries
}
}
queueWhere.push(qWhereCriteria);
// queueWhere.push(
// {
// queueName: q.queueName,
// queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
// in: q.queueStatus
// }
// }
// )
}
if(queueWhere.length === 1) {
where.queueStates = queueWhere[0];
@@ -1,9 +1,10 @@
import { eq, and, lte, inArray } from "drizzle-orm";
import { eq, and, gte, lte, inArray } from "drizzle-orm";
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
import type {DbConcrete} from "../drizzleUtils.ts";
import type {QueueStateSelect} from "../drizzleTypes.ts";
import { queueStates } from "../schema/schema.ts";
import { CLIENT_DEAD_QUEUE } from "../../../../../core/Atomic.ts";
import type {PlaySelect, QueueStateSelect} from "../drizzleTypes.ts";
import { playEvents, queueStates } from "../schema/schema.ts";
import { DEAD_QUEUE, INGRESS_QUEUE } from "../../../../../core/Atomic.ts";
import { queueStateToPlayEvent } from "../entityUtils.ts";
export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'> {
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
@@ -17,7 +18,7 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
eq(queueStates.componentId, componentId),
lte(queueStates.retries, retries),
eq(queueStates.queueStatus, 'failed'),
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
eq(queueStates.queueName, INGRESS_QUEUE)
));
}
@@ -27,15 +28,56 @@ export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'>
}).where(and(
eq(queueStates.componentId, componentId),
eq(queueStates.queueStatus, 'queued'),
eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
eq(queueStates.queueName, DEAD_QUEUE)
));
}
public getQueueCount = async (componentId: number, queueNames: string[], queueStatus: QueueStateSelect['queueStatus'][] = ['queued']): Promise<number> => {
public getQueueCount = async (componentId: number, queueNames: string[], opts: {
queueStatus?: QueueStateSelect['queueStatus'][],
retries?: number
retryEq?: 'lte' | 'gte'
} = {}): Promise<number> => {
const {
queueStatus = ['queued'],
retries,
retryEq = 'lte'
} = opts
if(retries === undefined) {
return await this.db.$count(queueStates, and(
eq(queueStates.componentId, componentId),
inArray(queueStates.queueName, queueNames),
inArray(queueStates.queueStatus, queueStatus)
));
}
return await this.db.$count(queueStates, and(
eq(queueStates.componentId, componentId),
inArray(queueStates.queueName, queueNames),
inArray(queueStates.queueStatus, queueStatus)
));
inArray(queueStates.queueStatus, queueStatus),
retryEq === 'lte' ? lte(queueStates.retries, retries) : gte(queueStates.retries, retries)
));
}
async create(data: typeof this.table.$inferInsert & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
const res = await super.create(data) as QueueStateSelect;
if(data.event === true && data.playId !== undefined) {
try {
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
} catch (e) {
this.logger.warn(new Error(`Failed to create Play Event for new queue creation on Play ${data.playId}`));
}
}
return res;
}
async updateById(id: number, data: Partial<typeof this.table.$inferInsert> & {playId?: PlaySelect['id'], event?: boolean}): Promise<typeof this.table.$inferSelect> {
const res = await super.updateById(id, data) as QueueStateSelect;
if(data.event === true && data.playId !== undefined) {
try {
await this.db.insert(playEvents).values({...queueStateToPlayEvent(res), playId: data.playId});
} catch (e) {
this.logger.warn(new Error(`Failed to create Play Event for queue ${res.queueName} on Play ${data.playId}`));
}
}
return res;
}
}
@@ -1,7 +1,7 @@
import { integer, sqliteTable, text, index, uniqueIndex, customType, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { defineRelations } from 'drizzle-orm';
import dayjs, { type Dayjs } from "dayjs";
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject } from "../../../../../core/Atomic.ts";
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject, type QueueContext } from "../../../../../core/Atomic.ts";
import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.ts";
import type {ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm} from "../../../../../core/Transform.ts";
import type {JobRangeCount, JobRangeTime} from "../../../infrastructure/Job.ts";
@@ -94,6 +94,7 @@ export const playInputs = sqliteTable("play_inputs", {
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
data: text({ mode: 'json' }).$type<object>(),
play: PlayJson('play').notNull(),//text({ mode: 'json' }).notNull().$type<PlayObject>(),
playHash: text(),
createdAt: DayjsTimestamp('createdAt').$defaultFn(() => dayjs())
}, (table) => [
uniqueIndex('play_input_id_idx').on(table.playId)
@@ -143,6 +144,7 @@ export const queueStates = sqliteTable("play_queue_states", {
queueStatus: text({enum: ['queued','completed','failed']}).notNull().default('queued'),
retries: integer().notNull().default(0),
error: ErrorLikeJson('error'),
context: text({mode: 'json'}).$type<QueueContext & {isRetry?: boolean}>(),
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs())
}, (table) => [
@@ -211,7 +213,18 @@ export const jobs = sqliteTable("jobs", {
completedAt: DayjsTimestamp('completedAt')
});
const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
export const playEvents = sqliteTable("play_events", {
id: integer({ mode: 'number' }).primaryKey(),
playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
eventName: text({length: 50}).notNull(),
data: text({ mode: 'json' }),
error: ErrorLikeJson('error'),
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
}, (table) => [
index('play_event_id_idx').on(table.playId)
]);
const playRelations = defineRelations({ plays, queueStates, playEvents, playInputs, components, jobs, componentMigrations,playsHistorical }, (r) => ({
plays: {
queueStates: r.many.queueStates(),
input: r.one.playInputs({
@@ -233,7 +246,8 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
from: r.plays.jobId,
to: r.jobs.id,
optional: true
})
}),
events: r.many.playEvents(),
},
playsHistorical: {
component: r.one.components({
@@ -265,7 +279,13 @@ const playRelations = defineRelations({ plays, queueStates, playInputs, componen
},
jobs: {
plays: r.many.plays()
}
},
playEvents: {
play: r.one.plays({
from: r.playEvents.playId,
to: r.plays.id
}),
},
}));
export const relations = playRelations;
@@ -280,6 +300,8 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
return components;
case 'playInputs':
return playInputs;
case 'playEvents':
return playEvents;
case 'queueStates':
return queueStates;
case 'componentMigrations':
@@ -289,7 +311,7 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
}
}
export const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
export const schema = {playInputs, plays, playEvents, components, componentMigrations, queueStates, jobs};
export type TSchema = typeof relations;
export type Schema = typeof schema;
+53 -1
View File
@@ -33,16 +33,38 @@ export class ConnectionCheckError extends StageError {
}
addKnownErrorConstructor(ConnectionCheckError);
export class AuthError extends NamedError {
override name = 'Authentication Error';
unrecoverable: boolean = false;
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
super(msg, opts);
this.unrecoverable = opts.unrecoverable ?? false;
}
}
addKnownErrorConstructor(AuthError);
export const isAuthError = (e: Error): e is AuthError => e.name === 'Authentication Error' || e instanceof AuthError;
export class AuthCheckError extends StageError {
name = 'Authentication Check';
override name = 'Authentication Check';
unrecoverable?: boolean;
public constructor(msg?: string, opts: ErrorOptions & {unrecoverable?: boolean} = {}) {
super(msg, opts);
this.unrecoverable = opts.unrecoverable;
}
}
addKnownErrorConstructor(AuthCheckError);
export const isAuthCheckError = (e: Error): e is AuthCheckError => e.name === 'Authentication Check' || e instanceof AuthCheckError;
export class PostInitError extends StageError {
name = 'Post Initialization';
}
addKnownErrorConstructor(PostInitError);
export class StageChangeError extends NamedError {
override name = 'Stage Change';
}
const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/);
export class SimpleError extends Error implements HasSimpleError {
@@ -200,3 +222,33 @@ export class InvalidRegexError extends SimpleError {
}
}
addKnownErrorConstructor(InvalidRegexError, () => new InvalidRegexError(new RegExp(/1/)));
export type AuthErrorMap = {
root: AuthError
check: AuthCheckError
auth: AuthError | AuthCheckError
parent: Error
}
export const findAuthIssue = <T extends keyof AuthErrorMap = 'auth'>(err: Error, opts: { type?: T, unrecoverable?: boolean } = {}): AuthErrorMap[T] | undefined => {
const {
type = 'auth',
unrecoverable
} = opts;
const authError = findCauseByFunc(err, (e) => {
if (['check','auth'].includes(type) && isAuthCheckError(e)) {
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
}
if (['root','auth'].includes(type) && isAuthError(e)) {
return unrecoverable === undefined || e.unrecoverable === unrecoverable;
}
return false;
});
if (authError !== undefined) {
if (type === 'parent') {
return err as AuthErrorMap[T];
}
return authError as AuthErrorMap[T];
}
return undefined;
}
@@ -0,0 +1,18 @@
import { NamedError } from "./MSErrors.ts";
import type { PlayProcessingResult } from "../infrastructure/PlayProcessing.ts";
export interface PlayProcessingErrorData extends PlayProcessingResult {
showStopping: boolean
}
export class PlayProcessingError extends NamedError {
override name = 'Play Processing Error';
result: PlayProcessingResult
showStopping: boolean
constructor(error: Error, data: PlayProcessingErrorData, opts: ErrorOptions = {}) {
super('Error occured while processing Play', {...opts, cause: error});
this.result = data;
this.showStopping = data.showStopping;
}
}
@@ -16,7 +16,7 @@ export interface MSBackendEventMap extends Omit<MSCoreEvents, 'playerUpdate'> {
scrobbleTo: string[]
}
}>]
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[] } },{},SourceType>]
playerUpdate: [EmittedMSEvent<SourcePlayerObj<Dayjs> & { options: { scrobbleTo: string[], wasMonitored?: boolean } },{},SourceType>]
configError: [Error | string]
instantiateError: [Error]
}
@@ -0,0 +1,8 @@
import type { PlayEvent } from "../../../core/PlayEvent.ts"
import type { PlaySelectWithQueueStates, QueueStateSelect } from "../database/drizzle/drizzleTypes.ts"
export interface PlayProcessingResult {
playEntity: PlaySelectWithQueueStates,
events: Omit<PlayEvent, 'playId'>[],
queue: QueueStateSelect
}
@@ -1,6 +1,6 @@
import * as z from "zod";
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape} from "../common.ts";
import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema, type CommonComponentEnvShape, deadLetterOptionsSchema} from "../common.ts";
import {retentionConfigDurationValueSchema} from "../database.ts";
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
@@ -124,6 +124,7 @@ export const commonClientOptionsSchema = z.object({
...monitorOptionsSchema.shape,
...requestRetryOptionsSchema.shape,
...upstreamRefreshOptionsSchema.shape,
...deadLetterOptionsSchema.shape,
/**
* Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.
@@ -143,17 +144,6 @@ export const commonClientOptionsSchema = z.object({
}).optional().meta({
description: "Options used for increasing verbosity of logging in MS (used for debugging)"
}),
/**
* Number of times MS should automatically retry scrobbles in dead letter queue
*
* @default 3
* @examples [3]
* */
deadLetterRetries: z.number().optional().meta({
description: "Number of times MS should automatically retry scrobbles in dead letter queue",
default: 3,
examples: [3]
}),
/** Enhance/correct Play data by applying a transform pipeline */
playTransform: playTransformOptionsSchema.optional().meta({description: 'Enhance/correct Play data by applying a transform pipeline'}),
@@ -155,6 +155,21 @@ export const monitorOptionsSchema = z.object({
})
export type MonitorOptions = z.infer<typeof monitorOptionsSchema>;
export const deadLetterOptionsSchema = z.object({
/**
* Number of times MS should automatically retry Plays in dead letter queue
*
* @default 3
* @examples [3]
* */
deadLetterRetries: z.number().optional().meta({
description: "Number of times MS should automatically retry Plays in dead letter queue",
default: 3,
examples: [3]
})
})
export type DeadLetterOptions = z.infer<typeof deadLetterOptionsSchema>;
export type UnparsedConfig<T extends (SourceType | ClientType)> = {config: object, type: T, source?: 'file' | 'aio' | 'env', pos: string};
export const generateConfigLocation = (configType: string, config: UnparsedConfig<any>): string => {
@@ -81,6 +81,14 @@ export type DeezerInternalData = z.infer<typeof deezerInternalDataSchema>;
export const deezerInternalSourceOptionsSchema = z.object({
...commonSourceOptionsSchema.shape,
/**
* Set the heuristic level of duplicate/repeated track pruning from listening history.
*
* Some third party integrations (Sonos) cause Deezer to report the same track many times in listening history
*
* * `true` => detect plays with play times at exact, close to, or fuzzy (beginning/end of predicated play time + duration)
* * `aggressive` => above AND check for plays during previous play times that occurred during the track (50% +/- duration plus playedAt date)
*/
fuzzyDiscoveryIgnore: z.union([z.boolean(), z.literal('aggressive')]).optional(),
});
@@ -1,5 +1,5 @@
import * as z from "zod";
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema} from "../common.ts";
import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema, deadLetterOptionsSchema} from "../common.ts";
import {retentionConfigDurationValueSchema} from "../database.ts";
import {playTransformOptionsSchema} from "../../../../../core/Transform.ts";
import type { PipeUnwrapDirection } from "../../../../utils/ZodUtils.ts";
@@ -73,6 +73,7 @@ export const fileLogOptionsSchema = z.object({
export const commonSourceOptionsSchema = z.object({
...monitorOptionsSchema.shape,
...sourceRetryOptionsSchema.shape,
...deadLetterOptionsSchema.shape,
/**
* * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output
* * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen
@@ -1,5 +1,5 @@
import { childLogger, type Logger } from "@foxxmd/logging";
import type { PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
import type { OptionalCacheUsage, PlayObject, TransformerCommon, TransformerCommonConfig } from "../../../core/Atomic.ts";
import { isStageTyped, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
import AbstractInitializable from "../AbstractInitializable.ts";
import type { StageConfig } from "../../../core/Transform.ts";
@@ -61,13 +61,16 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
protected abstract doParseConfig(data: StageConfig): Y;
public async handle(data: Y, play: PlayObject): Promise<PlayObject> {
public async handle(data: Y, play: PlayObject, opts?: OptionalCacheUsage): Promise<PlayObject> {
const {
useCachedResult = true,
} = (opts ?? {});
const cacheKey = `transformResult-${this.configHash}-${hashObject(data)}-${hashObject(playContentInvariantTransform(play))}`
try {
const cachedTransformData = await this.cache.get<T>(cacheKey);
const cachedTransformData = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
if(cachedTransformData !== undefined) {
this.logger.debug('Transform cache hit');
const transformed = await this.doHandle(data, play, cachedTransformData);
const transformed = await this.doHandle(data, play, cachedTransformData, opts);
return transformed;
}
} catch (e) {
@@ -82,7 +85,7 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
}
try {
await this.handlePreFetch(play, data);
await this.handlePreFetch(play, data, opts);
} catch (e) {
if(e instanceof SkipTransformStageError) {
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
@@ -93,13 +96,13 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
let transformData: T;
let fetchedTransformData: any;
try {
fetchedTransformData = await this.getTransformerData(play, data);
fetchedTransformData = await this.getTransformerData(play, data, opts);
} catch (e) {
throw new Error(`Could not fetch transformer data`, { cause: e });
}
try {
transformData = await this.handlePostFetch(play, fetchedTransformData, data);
transformData = await this.handlePostFetch(play, fetchedTransformData, data, opts);
} catch (e) {
if(e instanceof StagePrerequisiteError) {
await this.cache.set(cacheKey, play, this.config.options?.ttl ?? '15s');
@@ -107,22 +110,22 @@ export default abstract class AbstractTransformer<T = any, Y extends StageConfig
throw new Error('postFetch did not pass', { cause: e });
}
const transformed = await this.doHandle(data, play, transformData);
const transformed = await this.doHandle(data, play, transformData, opts);
await this.cache.set(cacheKey, transformData, this.config.options?.ttl ?? '15s');
return transformed;
}
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T): Promise<PlayObject>;
protected abstract doHandle(data: StageConfig, play: PlayObject, transformData: T, opts?: OptionalCacheUsage): Promise<PlayObject>;
public async getTransformerData(play: PlayObject, stageConfig: Y): Promise<any> {
public async getTransformerData(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<any> {
return undefined;
}
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y): Promise<T> {
public async handlePostFetch(play: PlayObject, transformData: any, stageConfig: Y, opts?: OptionalCacheUsage): Promise<T> {
return transformData;
}
public async handlePreFetch(play: PlayObject, stageConfig: Y): Promise<void> {
public async handlePreFetch(play: PlayObject, stageConfig: Y, opts?: OptionalCacheUsage): Promise<void> {
return
}
}
@@ -1,4 +1,4 @@
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
import { type ArtistCredit, asMBReleasePrimaryGroupType, asMBReleaseSecondaryGroupType, asMBReleaseStatus, DEFAULT_MISSING_TYPES, type LifecycleInput, type MBReleaseGroupPrimaryType, type MBReleaseGroupSecondaryType, type MBReleaseStatus, type MissingMbidType, type OptionalCacheUsage, type PlayObject, type TrackMeta, type TransformerCommon, type TransformOptions } from "../../../core/Atomic.ts";
import { isWhenCondition, testWhenConditions } from "../../utils/PlayTransformUtils.ts";
import type {WebhookPayload} from "../infrastructure/config/health/webhooks.ts";
import type {ExternalMetadataTerm, PlayTransformMetadataStage} from "../../../core/Transform.ts";
@@ -429,7 +429,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
}
}
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async getTransformerData(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts?: OptionalCacheUsage): Promise<IRecordingMSList> {
const {
// preserve order of search from before searchOrder
@@ -443,25 +443,25 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
try {
switch(searchType) {
case 'isrc':
results = await this.searchByIsrc(play, stageConfig);
results = await this.searchByIsrc(play, stageConfig, opts);
break;
case 'album':
results = await this.searchByAlbum(play, stageConfig);
results = await this.searchByAlbum(play, stageConfig, opts);
break;
case 'artist':
results = await this.searchByArtist(play, stageConfig);
results = await this.searchByArtist(play, stageConfig, opts);
break;
case 'basic':
results = await this.searchByBasicFields(play, stageConfig);
results = await this.searchByBasicFields(play, stageConfig, opts);
break;
case 'freetext':
results = await this.searchByFreeText(play, stageConfig);
results = await this.searchByFreeText(play, stageConfig, opts);
break;
case 'basicorids':
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig);
results = await this.searchByBasicFieldsOrMBIDs(play, stageConfig, opts);
break;
case 'mbidrecording':
results = await this.searchByRecordingMbid(play, stageConfig);
results = await this.searchByRecordingMbid(play, stageConfig, opts);
break;
}
queries.push({type: `mbQuery-${searchType}${results.recordings.length === 0 ? '-empty' : ''}`, input: results.requestQuery});
@@ -486,12 +486,12 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
return {...results, requestQueries: queries};
}
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByBasicFields(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
this.logger.debug({labels: ['Basic Search']}, 'Searching by artist/album/track');
return await this.api.searchByRecording(play);
return await this.api.searchByRecording(play, opts);
}
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByBasicFieldsOrMBIDs(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
const using: UsingTypes[] = [];
const {
data: {
@@ -512,31 +512,31 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
using.push((brainz.artist ?? []).length > 0 ? 'mbidartist' : 'artist');
this.logger.debug({labels: ['Basic Or MBID Search']}, `Searching using ${using.join(', ')}}`);
return await this.api.searchByRecording(play, {using});
return await this.api.searchByRecording(play, {using, ...opts});
}
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByIsrc(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
if(play.data.isrc !== undefined) {
this.logger.debug({labels: ['ISRC Search']},'Searching with ISRC');
return await this.api.searchByRecording(play, {using: ['isrc']});
return await this.api.searchByRecording(play, {using: ['isrc'], ...opts});
}
throw new SearchPrerequisiteError('Play does not have ISRC');
}
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByRecordingMbid(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
if(play.data.meta?.brainz?.recording !== undefined) {
this.logger.debug({labels: ['MBID Search']},'Searching with Recording MBID');
return await this.api.searchByRecording(play, {using: ['mbidrecording']});
return await this.api.searchByRecording(play, {using: ['mbidrecording'], ...opts});
}
throw new SearchPrerequisiteError('Play does not have recording MBID');
}
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByAlbum(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
// possibly the artist is incorrect (may be combined as one string)
// if we have an album we can likely still get a decent hit w/o using artist
if(play.data.album !== undefined && play.data.artists !== undefined && play.data.artists.length > 0) {
this.logger.debug({labels: ['Album Search']},'Searching with only track+album');
return await this.api.searchByRecording(play, {using: ['title','album']});
return await this.api.searchByRecording(play, {using: ['title','album'], ...opts});
}
if(play.data.album === undefined) {
throw new SearchPrerequisiteError('Play does not have an album');
@@ -549,7 +549,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
}
}
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByArtist(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
const {
searchArtistMethod = this.defaults.searchArtistMethod,
} = stageConfig;
@@ -582,7 +582,7 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
// so we split out all artists by all found delimiters
const nativePlay = nativeParse(play, {titleClean: true, delimiters: DELIMITERS});
this.logger.debug({labels: ['Parsed Artist Search']},'Searching with aggressive native parsing');
return await this.api.searchByRecording(nativePlay, {using: ['title','artist']});
return await this.api.searchByRecording(nativePlay, {using: ['title','artist'], ...opts});
}
}
@@ -594,9 +594,9 @@ export default class MusicbrainzTransformer extends AtomicPartsTransformer<Exter
}
}
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage): Promise<IRecordingMSList> {
public async searchByFreeText(play: PlayObject, stageConfig: MusicbrainzTransformerDataStage, opts: OptionalCacheUsage = {}): Promise<IRecordingMSList> {
this.logger.debug({labels: ['Freetext Search']},'Trying freetext search');
const results = await this.api.searchByRecording(play, {freetext: true}) as IRecordingMSList;
const results = await this.api.searchByRecording(play, {freetext: true, ...opts}) as IRecordingMSList;
results.freeText = true;
return results;
}
@@ -1,6 +1,6 @@
import { childLogger, type Logger } from "@foxxmd/logging";
import type AbstractTransformer from "./AbstractTransformer.ts";
import type {TransformerCommonConfig} from "../../../core/Atomic.ts";
import type {OptionalCacheUsage, TransformerCommonConfig} from "../../../core/Atomic.ts";
import UserTransformer from "./UserTransformer.ts";
import type {StageConfig} from "../../../core/Transform.ts";
import type {PlayObject} from "../../../core/Atomic.ts";
@@ -146,10 +146,10 @@ export default class TransformerManager {
return config;
}
public async handleStage(data: StageConfig, play: PlayObject, asyncId: string = nanoid(6)): Promise<[PlayObject, string]> {
public async handleStage(data: StageConfig, play: PlayObject, opts: {asyncId?: string} & OptionalCacheUsage): Promise<[PlayObject, string]> {
const t: AbstractTransformer = this.getTransformerByStage(data);
try {
const transformedPlay = await this.asyncStore.run(asyncId, async () => {
const transformedPlay = await this.asyncStore.run(opts.asyncId ?? nanoid(6), async () => {
return await t.handle(data, play);
});
return [transformedPlay, t.name];
+2 -1
View File
@@ -4,6 +4,7 @@ import xml2js from 'xml2js';
import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER } from "../infrastructure/Atomic.ts";
import type {JRiverData} from "../infrastructure/config/source/jriver.ts";
import AbstractApiClient from "./AbstractApiClient.ts";
import { AuthError } from '../errors/MSErrors.ts';
const parser = new xml2js.Parser({'async': true});
@@ -155,7 +156,7 @@ export class JRiverApiClient extends AbstractApiClient {
if(this.config.username === undefined || this.config.password === undefined) {
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
}
throw new Error(msg, {cause: e});
throw new AuthError(msg, {cause: e});
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import type {KodiData} from "../infrastructure/config/source/kodi.ts";
import AbstractApiClient from "./AbstractApiClient.ts";
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
import { artistNamesToCredits } from "../../../core/StringUtils.ts";
import { AuthError } from "../errors/MSErrors.ts";
interface KodiDuration {
hours: number
@@ -150,7 +151,7 @@ export class KodiApiClient extends AbstractApiClient {
if(this.config.username === undefined || this.config.password === undefined) {
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
}
throw new Error(msg, {cause: e});
throw new AuthError(msg, {cause: e});
}
}
+113 -18
View File
@@ -13,14 +13,16 @@ import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjec
import type {LastfmData} from "../infrastructure/config/client/lastfm.ts";
import AbstractApiClient from "./AbstractApiClient.ts";
import { normalizeStr, parseArtistCredits } from "../../utils/StringUtils.ts";
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams } from 'lastfm-ts-api';
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams, LastFMResponseError } from 'lastfm-ts-api';
import clone from 'clone';
import type { IncomingMessage } from "http";
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
import { AuthError, ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
import { redactString } from "@foxxmd/redact-string";
import dns from 'node:dns/promises';
import xml2js from 'xml2js';
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
import * as z from 'zod';
const badErrors = [
'api key suspended',
@@ -30,6 +32,12 @@ const badErrors = [
'invalid parameters'
];
const unrecoverableAuthErrorCodes = [
10, // invalid api key
13, // invalid method signature
26, // api key suspended
]
const retryErrors = [
'operation failed',
'service offline',
@@ -247,7 +255,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
if (this.sessionKey === undefined) {
this.logger.warn('No session key found. User interaction for authentication required.');
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
throw new SimpleError('No session key found. User interaction for authentication required.');
throw new AuthError('No session key found. User interaction for authentication required.', {unrecoverable: false});
}
try {
// existing lastfm clients are ok with getting user from getInfo
@@ -269,11 +277,32 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
this.logger.info(`Client authorized for user ${this.user}`)
return true;
} catch (e) {
if(e instanceof AuthError) {
throw e;
}
this.logger.error('Testing auth failed');
if(isNodeNetworkException(e)) {
this.logger.error(`Could not communicate with ${this.upstreamName} API`);
throw new AuthError('Testing auth failed', {cause: e, unrecoverable: false});
}
throw e;
let unrecoverable: boolean;
if(e instanceof LastFMResponseError) {
try {
const errorContent = JSON.parse(e.content);
if(`error` in errorContent) {
unrecoverable = unrecoverableAuthErrorCodes.includes(errorContent.error);
}
} catch (e) {
// swallow
}
}
if(unrecoverable === undefined) {
const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined;
if(errorWithMessage !== undefined) {
unrecoverable = [401,403].includes(errorWithMessage.response.statusCode);
}
}
throw new AuthError('Testing auth failed', {cause: e, unrecoverable});
}
}
@@ -541,7 +570,7 @@ export class LastFMIgnoredScrobble extends UpstreamError {
}
export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayObject => {
export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObject => {
const {
artist,
track,
@@ -812,6 +841,72 @@ export interface LastFMScrobblePayload {
mbid?: string
}
// bodies arrive as urlencoded form data, so numeric fields are always strings (or string[] for batch keys) on the wire
const lastfmNumericStringSchema = z.coerce.number().int().positive();
export const lastfmSubmitPayloadSchema = z.object({
artist: z.string(),
track: z.string(),
album: z.string().optional(),
albumArtist: z.string().optional(),
mbid: z.string().optional(),
duration: lastfmNumericStringSchema.optional(),
sk: z.string().optional(),
api_key: z.string().optional()
});
export const lastfmSubmitMultiPayloadSchema = z.object({
artist: z.array(z.string()),
track: z.array(z.string()),
album: z.array(z.string()).optional(),
albumArtist: z.array(z.string()).optional(),
mbid: z.array(z.string()).optional(),
duration: z.array(lastfmNumericStringSchema).optional(),
sk: z.string().optional(),
api_key: z.string().optional()
});
export const lastfmScrobblePayloadSchema = z.object({
method: z.literal('track.scrobble'),
...lastfmSubmitPayloadSchema.shape,
timestamp: lastfmNumericStringSchema,
}).meta({title: 'Single Scrobble'});
export type LastfmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
export const lastfmScrobbleMultiPayloadSchema = z.object({
method: z.literal('track.scrobble'),
...lastfmSubmitMultiPayloadSchema.shape,
timestamp: z.array(lastfmNumericStringSchema),
}).meta({title: 'Bulk Scrobbles'});
export const lastfmScrobbleMaybeMultiPayloadSchema = z.union([lastfmScrobblePayloadSchema,lastfmScrobbleMultiPayloadSchema]).meta({title: 'Single or Bulk Scrobble'});
export type LastfmScrobbleMaybeMultiPayload = z.infer<typeof lastfmScrobbleMaybeMultiPayloadSchema>;
export type LastFmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
export const lastfmNowPlayingPayloadSchema = z.object({
method: z.literal('track.updateNowPlaying'),
...lastfmSubmitPayloadSchema.shape,
}).meta({title: 'Update Now Playing'});
export type LastFmNowPlayingPayload = z.infer<typeof lastfmNowPlayingPayloadSchema>;
export type LastFmSubmitPayload = LastfmScrobbleMaybeMultiPayload | LastFmNowPlayingPayload;
export type LastFmSingleSubmitPayload = LastfmScrobblePayload | LastFmNowPlayingPayload;
export const lastfmAuthRequestPayloadSchema = z.object({
method: z.literal('auth.getMobileSession'),
username: z.string().optional(),
password: z.string().optional(),
api_key: z.string().optional(),
}).meta({title: 'Get Mobile Auth Session'});
export const lastfmRequestSchema = z.union([
lastfmScrobbleMaybeMultiPayloadSchema,
z.discriminatedUnion("method", [
lastfmNowPlayingPayloadSchema,
lastfmAuthRequestPayloadSchema
]).meta({title: 'Update Now Playing or Mobile Auth Session'})
]);
export type LastFmRequest = z.infer<typeof lastfmRequestSchema>;
export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload {
method: string
}
@@ -821,7 +916,7 @@ const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist'];
//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid'];
//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional];
export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMScrobbleRequestPayload[] => {
export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScrobblePayload[] => {
const keys = Object.keys(obj);
let allObject = true;
for(const k of lfmPayloadKeysRequired) {
@@ -834,10 +929,10 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
throw new Error('Payload is an unexpected mix of arrays and objects');
}
}
const payloads: LastFMScrobbleRequestPayload[] = [];
const payloads: LastfmScrobblePayload[] = [];
if(allObject) {
payloads.push(obj as LastFMScrobbleRequestPayload);
payloads.push(obj as LastfmScrobblePayload);
} else {
let index = 0;
for(const t of (obj.track as string[])) {
@@ -849,14 +944,14 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined,
duration: obj.duration !== undefined ? obj.duration[index] : undefined,
albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined,
method: obj.method as string
method: obj.method
})
index++;
}
}
return payloads.map(x => {
const cleaned: LastFMScrobbleRequestPayload = x;
const cleaned: LastfmScrobblePayload = x;
if(typeof cleaned.duration === 'string') {
cleaned.duration = Number.parseInt(cleaned.duration);
}
@@ -946,19 +1041,19 @@ export const playToScrobbleApiResponseXml = (play: PlayObject) => {
scrobble: {
track: {
$: {corrected: 0},
_: play.data.track
_: play.data.track ?? ''
},
artist: {
$: {corrected: 0},
_: play.data.artists?.join(',')
_: play.data.artists?.join(',') ?? ''
},
album: {
$: {corrected: 0},
_: play.data.album
_: play.data.album ?? ''
},
albumArtist: {
$: {corrected: 0},
_: play.data.albumArtists?.join(',')
_: play.data.albumArtists?.join(',') ?? ''
},
timestamp: {
_: dayjs().unix(),
@@ -981,19 +1076,19 @@ export const playToNowPlayingApiResponseXml = (play: PlayObject) => {
nowplaying: {
track: {
$: { corrected: 0 },
_: play.data.track
_: play.data.track ?? ''
},
artist: {
$: { corrected: 0 },
_: play.data.artists?.join(',')
_: play.data.artists?.join(',') ?? ''
},
album: {
$: { corrected: 0 },
_: play.data.album
_: play.data.album ?? ''
},
albumArtist: {
$: { corrected: 0 },
_: play.data.albumArtists?.join(',')
_: play.data.albumArtists?.join(',') ?? ''
},
ignoredMessage: {
$: { code: 0 }
+11 -3
View File
@@ -23,12 +23,13 @@ import { unique } from '../../utils.ts';
import { removeUndefinedKeys } from '../../../core/DataUtils.ts';
import type {ListenPayload, ListenResponse, ListenType, SubmitPayload} from '../../../core/vendor/listenbrainz/interfaces.ts';
import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.ts';
import { ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
import { AuthError, ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
import pRetry from 'p-retry';
import { findCauseByFunc } from '../../utils/ErrorUtils.ts';
import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts';
import { playToSubmitPayload } from './listenbrainz/lzUtils.ts';
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
import { getRoot } from '../../ioc.ts';
export interface SubmitOptions {
@@ -59,6 +60,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
declare config: ListenBrainzClientData;
url: URLData;
userAgent: string
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
super('ListenBrainz', name, config, options);
@@ -66,6 +68,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
url = 'https://api.listenbrainz.org/'
} = config;
let cleanUrl = url;
this.userAgent = `multi-scrobbler/${getRoot().items.version}`
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
if(pathedUrl !== undefined) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
@@ -84,6 +87,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
try {
req.set('Authorization', `Token ${this.config.token}`);
req.set('User-Agent', this.userAgent);
return await req as T;
} catch (e) {
const {
@@ -183,8 +187,12 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
try {
const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
return true;
} catch (e) {
throw e;
} catch (err) {
const cause = findCauseByFunc<request.ResponseError>(err, (e) => isSuperAgentResponseError(e));
if(cause !== undefined && [401,403,400].includes(cause.status)) {
throw new AuthError('Failed to validate token', {cause: err, unrecoverable: true});
}
throw new AuthError('Failed to validate token due to non-auth error', {cause: err, unrecoverable: false});
}
}
+8 -3
View File
@@ -14,7 +14,7 @@ import type {RockskyScrobble} from './rocksky/interfaces.ts';
import type {Handle} from "@atcute/lexicons";
import { getATProtoIdentifier, identifierToAtProtoHandle } from './atproto/atUtils.ts';
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
import { ScrobbleSubmitError } from "../errors/MSErrors.ts";
import { AuthError, ScrobbleSubmitError } from "../errors/MSErrors.ts";
import { tryApiCall } from "../../utils/RequestUtils.ts";
import { type CreateScrobbleInput, RockskyClient } from "@rocksky/sdk";
import { getRoot } from "../../ioc.ts";
@@ -23,6 +23,8 @@ import type {HandleData} from "../infrastructure/config/client/atproto.ts";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { removeUndefinedKeys } from "../../../core/DataUtils.ts";
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
import { isSuperAgentResponseError } from "../errors/ErrorUtils.ts";
interface SubmitOptions {
log?: boolean
@@ -166,7 +168,8 @@ export class RockSkyApiClient extends AbstractApiClient {
const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`));
return true;
} catch (e) {
throw e;
const cause = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
throw new AuthError('Failed to validate token', {cause: e, unrecoverable: cause !== undefined && [401,403].includes(cause.status)});
}
} else {
try {
@@ -174,7 +177,9 @@ export class RockSkyApiClient extends AbstractApiClient {
await req;
return true;
} catch (e) {
throw new UpstreamError('Failed to get /profile with given token', {cause: e});
const upstreamErr = new UpstreamError('Failed to get /profile with given token', {cause: e});
const cause = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause !== undefined && [401,403].includes(cause.status)});
}
}
}
+5 -2
View File
@@ -11,10 +11,12 @@ import { playToListenPayload } from '../listenbrainz/lzUtils.ts';
import type {SubmitPayload} from '../../../../core/vendor/listenbrainz/interfaces.ts';
import type {ListenType} from '../../../../core/vendor/listenbrainz/interfaces.ts';
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
import { ScrobbleSubmitError } from "../../errors/MSErrors.ts";
import { AuthError, ScrobbleSubmitError } from "../../errors/MSErrors.ts";
import { tryApiCall } from "../../../utils/RequestUtils.ts";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { artistNamesToCredits } from "../../../../core/StringUtils.ts";
import { findCauseByFunc } from "../../../utils/ErrorUtils.ts";
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.ts";
interface SubmitOptions {
log?: boolean
@@ -133,7 +135,8 @@ export class KoitoApiClient extends AbstractApiClient implements PaginatedTimeRa
const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url, '/apis/listenbrainz/1/validate-token')}`));
return true;
} catch (e) {
throw new Error('Could not validate Koito API Key', { cause: e });
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
throw new AuthError('Could not validate Koito API Key', { cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)});
}
}
+5 -3
View File
@@ -16,8 +16,9 @@ import { getMalojaResponseError, isMalojaAPIErrorBody, type MalojaResponseV3Comm
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.ts';
import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.ts';
import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts';
import { ScrobbleSubmitError } from '../../errors/MSErrors.ts';
import { AuthError, ScrobbleSubmitError } from '../../errors/MSErrors.ts';
import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.ts';
import { findCauseByFunc } from '../../../utils/ErrorUtils.ts';
@@ -175,10 +176,11 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR
body,
text: text.slice(0, 50)
},'Maloja API Response');
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
throw new UpstreamError('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new UpstreamError(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
}
} catch (e) {
throw e;
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)});
}
}
+11 -10
View File
@@ -1,5 +1,5 @@
import type { Response } from 'superagent';
import type {ArtistCredit, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
import type {ArtistCredit, OptionalCacheUsage, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
import { UpstreamError } from "../../errors/UpstreamError.ts";
import { type AbstractApiOptions, type FormatPlayObjectOptions, MUSICBRAINZ_URL, type MusicbrainzApiConfigData } from "../../infrastructure/Atomic.ts";
import AbstractApiClient from "../AbstractApiClient.ts";
@@ -9,10 +9,8 @@ import { difference, sleep } from "../../../utils.ts";
import {SequentialRoundRobin} from 'round-robin-js';
import type { Cacheable } from "cacheable";
import { getRoot } from "../../../ioc.ts";
import { version } from "../../../version.ts";
import { hashObject } from "../../../utils/StringUtils.ts";
import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.ts";
;
import { AsyncLocalStorage } from "async_hooks";
import { nanoid } from "nanoid";
import { stripIndents } from "common-tags";
@@ -79,7 +77,7 @@ export class MusicbrainzApiClient extends AbstractApiClient {
if(mb === undefined) {
const api = new MusicBrainzApi({
appName: 'multi-scrobbler',
appVersion: version,
appVersion: getRoot().items.version,
appContactInfo: mbConfig.contact,
baseUrl: u.url.toString(),
preRequest: (method, url, headers) => {
@@ -114,17 +112,18 @@ export class MusicbrainzApiClient extends AbstractApiClient {
return 'API';
}
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string }): Promise<T> => {
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string } & OptionalCacheUsage): Promise<T> => {
let apiConfig = this.rrApis.next().value;
const {
timeout = 30000,
cacheKey
cacheKey,
useCachedResult = true
} = options || {};
try {
const cachedTransform = await this.cache.get<T>(cacheKey);
const cachedTransform = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
if(cachedTransform !== undefined) {
const cacheUrl = await this.cache.get<string>(`${cacheKey}-url`);
const cacheQs = await this.cache.get<string>(`${cacheKey}-qs`);
@@ -218,13 +217,14 @@ export class MusicbrainzApiClient extends AbstractApiClient {
}
}
searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise<IRecordingMSList | undefined> => {
searchByRecording = async(play: PlayObject, options?: SearchOptions & OptionalCacheUsage): Promise<IRecordingMSList | undefined> => {
const {
escapeCharacters = true,
removeCharacters = false,
using = ['album','artist','title'],
freetext
freetext,
useCachedResult
} = options || {};
const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`;
@@ -347,7 +347,8 @@ export class MusicbrainzApiClient extends AbstractApiClient {
query: q
});
}, {
cacheKey
cacheKey,
useCachedResult
});
if(res === undefined) {
+2 -2
View File
@@ -1,5 +1,5 @@
import dayjs, { type Dayjs, type ManipulateType } from "dayjs";
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult} from "../../../../core/Atomic.ts";
import {type PlayObject, type PlayObjectMinimal, type BrainzMeta, type MBID, type ScrobbleActionResult, PARSED_FROM} from "../../../../core/Atomic.ts";
import { getRoot } from "../../../ioc.ts";
import { removeUndefinedKeys } from '../../../../core/DataUtils.ts';
import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.ts";
@@ -159,7 +159,7 @@ export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}
},
meta: {
source: 'tealfm',
parsedFrom: 'history',
parsedFrom: PARSED_FROM.history,
musicService,
playId: options.playId,
url: {
+81 -72
View File
@@ -1,78 +1,87 @@
export type WebScrobblerHookEvent = 'scrobble' | 'paused' | 'resumedplaying' | 'nowplaying' | string;
export interface WebScrobblerPayload {
eventName: WebScrobblerHookEvent
time?: number
data: {
song: WebScrobblerSong
songs?: WebScrobblerSong[]
currentlyPlaying?: boolean
}
}
import * as z from 'zod';
interface ProcessedSongData {
artist?: string | null;
album?: string | null;
albumArtist?: string | null;
track?: string | null;
duration?: number | null;
}
//export type WebScrobblerHookEvent = 'scrobble' | 'paused' | 'resumedplaying' | 'nowplaying' | string;
const webScrobblerHookEventSchema = z.union([z.enum(['scrobble','paused','resumedplaying','nowplaying']), z.string()]);
export type WebScrobblerHookEvent = z.infer<typeof webScrobblerHookEventSchema>;
interface ParsedSongData extends ProcessedSongData {
trackArt?: string | null;
uniqueID?: string | null;
originUrl?: string | null;
isPodcast?: boolean | null;
isPlaying?: boolean | null;
currentTime?: number | null;
isScrobblingAllowed?: boolean | null;
}
const processedSongDataSchema = z.object({
artist: z.string().nullish(),
album: z.string().nullish(),
albumArtist: z.string().nullish(),
track: z.string().nullish(),
duration: z.number().positive().nullish(),
});
export type ProcessedSongData = z.output<typeof processedSongDataSchema>;
export type Flags =
| {
isScrobbled: boolean;
isCorrectedByUser: boolean;
isRegexEditedByUser: {
track: boolean;
artist: boolean;
album: boolean;
albumArtist: boolean;
};
isAlbumFetched: boolean;
isValid: boolean;
isMarkedAsPlaying: boolean;
isSkipped: boolean;
isReplaying: boolean;
}
| Record<string, never>;
const parsedSongDataSchema = processedSongDataSchema.extend({
trackArt: z.string().nullish(),
uniqueID: z.string().nullish(),
originUrl: z.string().nullish(),
isPodcast: z.boolean().nullish(),
isPlaying: z.boolean().nullish(),
currentTime: z.number().nullish(),
isScrobblingAllowed: z.boolean().nullish(),
});
export type ParsedSongData = z.output<typeof parsedSongDataSchema>;
export type Metadata =
| {
label: string;
startTimestamp: number;
// Record<string, never> means "no keys allowed" -> empty object with no extra props
export const flagsSchema = z.looseObject({
isScrobbled: z.boolean(),
isCorrectedByUser: z.boolean(),
isRegexEditedByUser: z.object({
track: z.boolean(),
artist: z.boolean(),
album: z.boolean(),
albumArtist: z.boolean(),
}),
isAlbumFetched: z.boolean(),
isValid: z.boolean(),
isMarkedAsPlaying: z.boolean(),
isSkipped: z.boolean(),
isReplaying: z.boolean(),
});
export type Flags = z.infer<typeof flagsSchema>;
albumMbId?: string;
albumUrl?: string;
artistUrl?: string;
notificationId?: string;
trackArtUrl?: string;
trackUrl?: string;
userPlayCount?: number;
userloved?: boolean;
}
| Record<string, never>;
export const metadataSchema = z.looseObject({
label: z.string(),
startTimestamp: z.int().positive(),
albumMbId: z.string().optional(),
albumUrl: z.string().optional(),
artistUrl: z.string().optional(),
notificationId: z.string().optional(),
trackArtUrl: z.string().optional(),
trackUrl: z.string().optional(),
userPlayCount: z.int().positive().optional(),
userloved: z.boolean().optional(),
});
export type Metadata = z.infer<typeof metadataSchema>;
export interface Connector {
id: string
js: string
label: string
}
export interface WebScrobblerSong {
controllerTabId: string | number;
connector: Connector
parsed: ParsedSongData;
processed: ProcessedSongData;
noRegex: ProcessedSongData;
flags: Flags;
metadata: Metadata;
connectorLabel: string;
}
export const connectorSchema = z.object({
id: z.string(),
js: z.string(),
label: z.string(),
});
export type Connector = z.infer<typeof connectorSchema>;
export const webScrobblerSongSchema = z.object({
controllerTabId: z.union([z.string(),z.int().positive()]),
connector: connectorSchema,
parsed: parsedSongDataSchema,
processed: processedSongDataSchema,
noRegex: processedSongDataSchema,
flags: flagsSchema,
metadata: metadataSchema,
connectorLabel: z.string()
});
export type WebScrobblerSong = z.infer<typeof webScrobblerSongSchema>;
export const webScrobblePayloadSchema = z.object({
eventName: webScrobblerHookEventSchema,
time: z.int().positive().optional(),
data: z.object({
song: webScrobblerSongSchema,
songs: z.array(webScrobblerSongSchema).optional(),
currentlyPlaying: z.boolean().optional()
})
});
export type WebScrobblerPayload = z.infer<typeof webScrobblePayloadSchema>;
+4 -4
View File
@@ -173,7 +173,7 @@ const dataDir = getDataDir();
await root.items.cache().init(true);
initServer(logger, appLoggerStream, output, scrobbleSources, scrobbleClients);
initServer({sources: scrobbleSources, clients: scrobbleClients},{logger, appLoggerStream, initialOutput: output});
if(process.env.IS_LOCAL === 'true') {
logger.info('multi-scrobbler can be run as a background service! See: https://docs.multi-scrobbler.app/installation/service');
@@ -209,9 +209,9 @@ const dataDir = getDataDir();
if(nameColl.length > 0) {
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
}
const clientInitOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined};
const initOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined};
for(const c of scrobbleClients.clients) {
c.initTasks(clientInitOptions);
c.initTasks(initOptions);
const res = await Promise.race([
sleep(2200),
(async () => {
@@ -227,7 +227,7 @@ const dataDir = getDataDir();
}
for(const c of scrobbleSources.sources) {
c.initTasks();
c.initTasks(initOptions);
const res = await Promise.race([
sleep(2200),
(async () => {
+24 -2
View File
@@ -12,7 +12,7 @@ import TransformerManager from "./common/transforms/TransformerManager.ts";
import type {TransformerCommonConfig} from "../core/Atomic.ts";
import prom from 'prom-client';
import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.ts";
import { version } from "./version.ts";
import { version, stable } from "./version.ts";
import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts";
import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts";
@@ -35,11 +35,21 @@ const discovered = new prom.Counter({
help: 'Number of discovered plays for a Source',
labelNames: ['name', 'type']
});
const sourceDead = new prom.Gauge({
name: 'multiscrobbler_source_dead',
help: 'Number of dead letter plays for a Source',
labelNames: ['name', 'type']
});
const queuedGauge = new prom.Gauge({
name: 'multiscrobbler_client_queued',
help: 'Number of queued plays for a Client',
labelNames: ['name', 'type']
});
const queuedSourceGauge = new prom.Gauge({
name: 'multiscrobbler_source_queued',
help: 'Number of queued plays for a Source',
labelNames: ['name', 'type']
});
const deadLetterGauge = new prom.Gauge({
name: 'multiscrobbler_client_deadletter',
help: 'Number of deadletter plays for a Client',
@@ -130,8 +140,18 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
const portVal: number | string = process.env.PORT ?? port;
const versionParts: string[] = [];
if(stable === version) {
versionParts.push(stable);
} else {
if(stable !== 'unknown') {
versionParts.push(stable);
}
versionParts.push(version);
}
return createContainer().add({
version,
version: versionParts.join('-'),
configDir: getConfigDir(),
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
// @ts-ignore
@@ -144,6 +164,8 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
loggingConfig,
sourceMetics: {
discovered: discovered,
queued: queuedSourceGauge,
deadLetter: sourceDead
//issues: sourceIssues
},
clientMetrics: {
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -1,8 +1,8 @@
import type {Logger, LogLevel} from "@foxxmd/logging";
import type EventEmitter from "events";
import type {PlayMatchResult, PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import type {ComponentAuthType, PlayMatchResult, PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import { type FormatPlayObjectOptions } from "../common/infrastructure/Atomic.ts";
import { SINGLE_USER_PLATFORM_ID_STR } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID_STR } from '../../core/Atomic.ts';
import { CALCULATED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
@@ -18,6 +18,7 @@ import dayjs from "dayjs";
export default class DiscordScrobbler extends AbstractScrobbleClient {
api: DiscordWSClient | DiscordIPCClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
override nowPlayingIsRealtime: boolean = true;
@@ -71,7 +72,11 @@ export default class DiscordScrobbler extends AbstractScrobbleClient {
this.authed = false;
this.connectionOK = false;
}
await this.tryStopScrobbling();
try {
await this.tryStopScrobbling('API emitted stop event');
} catch (e) {
this.logger.warn(new Error('Failed to stop scrobbling loop after emitter stopped', {cause: e}));
}
});
if(typeof artwork === 'boolean') {
@@ -119,7 +124,7 @@ export default class DiscordScrobbler extends AbstractScrobbleClient {
}
}
queueScrobble = async (data: PlayObject | PlayObject[], source: string) => {
queueScrobble = async (data: PlayObject | PlayObject[]) => {
// discord does not handle scrobbles, only Now Playing
// so don't bother queueing any scrobbles as we don't want to cache them
// or give the user the impression they are used (in UI as a number of queued scrobbles)
+2 -1
View File
@@ -1,6 +1,6 @@
import type {Logger} from "@foxxmd/logging";
import type EventEmitter from "events";
import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
@@ -15,6 +15,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class KoitoScrobbler extends AbstractScrobbleClient {
api: KoitoApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
getScrobblesForTimeRange: TimeRangeListensFetcher
+2 -1
View File
@@ -1,6 +1,6 @@
import type {Logger} from "@foxxmd/logging";
import type EventEmitter from "events";
import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfigOptional, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
@@ -13,6 +13,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class LastfmScrobbler extends AbstractScrobbleClient {
api: LastfmApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.interactive;
requiresAuth = true;
requiresAuthInteraction = true;
upstreamType: string = 'Last.fm';
@@ -1,6 +1,6 @@
import type {Logger} from "@foxxmd/logging";
import type EventEmitter from "events";
import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
@@ -16,6 +16,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
api: ListenbrainzApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
getScrobblesForTimeRange: TimeRangeListensFetcher
+2 -1
View File
@@ -1,7 +1,7 @@
import { childLogger, type Logger } from "@foxxmd/logging";
import type EventEmitter from "events";
import normalizeUrl from "normalize-url";
import type {PlayObject} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
@@ -15,6 +15,7 @@ const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "
export default class MalojaScrobbler extends AbstractScrobbleClient {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
serverVersion: any;
webUrl: string;
+2 -1
View File
@@ -1,6 +1,6 @@
import { childLogger, type Logger } from "@foxxmd/logging";
import type EventEmitter from "events";
import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfigOptional} from "../common/infrastructure/Atomic.ts";
@@ -27,6 +27,7 @@ import { isAbortError } from "abort-controller-x";
export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient {
api: RockSkyApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
+6 -4
View File
@@ -57,9 +57,11 @@ export default class ScrobbleClients {
}
this.sourceEmitter.on('playerUpdate', async (payload) => {
// agressively update Now Playing so scrobblers that display based on duration are mostly synced
// but aggressively *stop* updating if state becomes stale/orphaned
this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
if(payload.data.options.wasMonitored) {
// agressively update Now Playing so scrobblers that display based on duration are mostly synced
// but aggressively *stop* updating if state becomes stale/orphaned
this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
}
});
this.sourceEmitter.on('discoveredToScrobble', async (payload) => {
@@ -451,7 +453,7 @@ export default class ScrobbleClients {
}
}
for (const playObj of playObjs) {
await client.queueScrobble(clone(playObj), scrobbleFrom);
await client.queueScrobble(clone(playObj));
}
}
if(excluded.length > 0) {
+11 -3
View File
@@ -5,7 +5,7 @@ import fs from 'node:fs';
import path from 'path';
import { Readable } from 'stream';
import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts";
import { buildTrackString, capitalize } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfigOptional} from "../common/infrastructure/Atomic.ts";
@@ -25,9 +25,13 @@ import { fromStream } from '@atcute/repo';
import { playToRepositoryCreatePlayHistoricalOpts, type RepositoryCreatePlayHistoricalOpts } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";
import { isAbortError } from "abort-controller-x";
import type { FmTealAlphaFeedPlay, FmTealFeedPlay } from "../common/vendor/teal/lexicons/index.ts";
import { AuthError } from "../common/errors/MSErrors.ts";
import { findCauseByReference } from "../utils/ErrorUtils.ts";
import { ClientResponseError } from "@atcute/client";
export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
override nowPlayingIsRealtime: boolean = true;
@@ -97,10 +101,14 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
return res;
}
} catch (e) {
if(isNodeNetworkException(e)) {
const nodeNetError = isNodeNetworkException(e);
if(nodeNetError) {
this.logger.error('Could not communicate with ATProto API');
throw new AuthError(`Failed to validate session due to network issues`, {cause: e, unrecoverable: false});
}
throw e;
const clientError = findCauseByReference(e, ClientResponseError);
const authIssue = clientError !== undefined && [401,403].includes(clientError.status);
throw new AuthError(`Failed to validate session${!authIssue ? ' due to network issues' : ''}`, {cause: e, unrecoverable: authIssue});
}
}
+346 -216
View File
@@ -1,28 +1,28 @@
import type {LogDataPretty, Logger, LogLevel} from "@foxxmd/logging";
import {loggerTest, type LogDataPretty, type Logger, type LogLevel} from "@foxxmd/logging";
import type {Express} from 'express';
import bsseDef from 'better-sse';
import bodyParser from "body-parser";
import { FixedSizeList } from 'fixed-size-list';
import type { PassThrough } from "node:stream";
import { PassThrough } from "node:stream";
import { Transform } from "stream";
import {
CLIENT_DEAD_QUEUE,
DEAD_QUEUE,
type ClientStatusData,
type DeadLetterScrobble,
type LogOutputConfig,
PLAY_CLIENT_STATE,
PLAY_SOURCE_STATE,
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 {ExpressHandler, LeveledLogData} from "../common/infrastructure/Atomic.ts";
import type {LeveledLogData} from "../common/infrastructure/Atomic.ts";
import { getRoot } from "../ioc.ts";
import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts";
import type AbstractSource from "../sources/AbstractSource.ts";
import AbstractSource from "../sources/AbstractSource.ts";
import MemorySource from "../sources/MemorySource.ts";
import { parseBool } from "../utils.ts";
import { sortByNewestPlayDate } from '../../core/PlayUtils.ts';
@@ -30,20 +30,27 @@ import { setupAuthRoutes } from "./auth.ts";
import { setupDeezerRoutes } from "./deezerRoutes.ts";
import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.ts";
import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.ts";
import { type ComponentAwareRequest, makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle, type SourceAwareRequest } from "./middleware.ts";
import { makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle } 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 { SimpleError } from "../common/errors/MSErrors.ts";
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 type {ComponentClientApiJson, ComponentSourceApiJson} from "../../core/Api.ts";
import {componentStateBodySchema, type ComponentClientApiJson, type ComponentSourceApiJson, type PlayApiCommonDetailed} 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";
const maxBufferSize = 300;
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
@@ -65,7 +72,34 @@ const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' |
return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit);
}
export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
export interface ApiArgs {
app: Express
router: ReturnType<typeof createTypedRouter>
scrobbleSources: ScrobbleSources
scrobbleClients: ScrobbleClients
}
export interface ApiOptions {
logger?: Logger
appLoggerStream?: PassThrough
initialLogOutput?: LogDataPretty[]
testMode?: boolean
}
export const setupApi = (args: ApiArgs, opts: ApiOptions = {}) => {
const {
app,
router,
scrobbleSources,
scrobbleClients
} = args;
const {
logger = loggerTest,
appLoggerStream = new PassThrough(),
initialLogOutput = [],
testMode
} = opts;
for(const level of Object.keys(logger.levels.labels)) {
output[level] = new FixedSizeList<LeveledLogData>(maxBufferSize);
}
@@ -114,7 +148,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
const sourceAwareMiddle = makeSourceNextMiddle(scrobbleSources);
const clientAwareMiddle = makeClientNextMiddle(scrobbleClients);
const setLogWebSettings: ExpressHandler = async (req, res, next) => {
const setLogWebSettings: TypedMiddleware = async (req, res, next) => {
// @ts-expect-error logLevel not part of session
const sessionLevel: LogLevel | undefined = req.session.logLevel as LogLevel | undefined;
if(sessionLevel !== undefined && logConfig.level !== sessionLevel) {
@@ -128,17 +162,21 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
next();
}
app.get('/api/logs/stream', setLogWebSettings, async (req, res) => {
router.get('/api/logs/stream', {middleware: [setLogWebSettings], tags: ['Events'], summary: 'SSE Logs'}, async (req, res) => {
const session = await bsseDef.createSession(req, res);
await session.stream(logObjectStream);
});
app.get('/api/logs', setLogWebSettings, async (req, res) => {
router.get('/api/logs', {middleware: [setLogWebSettings], tags: ['Events'], summary: 'Get Logs'}, async (req, res) => {
const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
return res.json({data: slicedLog, settings: logConfig});
});
app.put('/api/logs', async (req, res) => {
router.put('/api/logs', {
bodySchema: z.object({level: logLevelStandaloneSchema, limit: z.int().positive().max(500)}),
tags: ['Events'],
summary: 'Update Log Settings'
}, async (req, res) => {
logConfig.level = req.body.level as LogLevel | undefined ?? logConfig.level;
logConfig.limit = req.body.limit ?? logConfig.limit;
const slicedLog = getLogs(logger.levels.values[logConfig.level], logConfig.limit + 1, logConfig.sort === 'ascending' ? 'asc' : 'desc');
@@ -149,7 +187,9 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json({data: slicedLog, settings: logConfig});
});
app.get('/api/events', async (req, res) => {
router.get('/api/events', {querySchema: z.object({
next: z.literal('true').optional().meta({description: 'When used events are sent in new ui format'})
}), tags: ['Events'], summary: 'SSE Events'}, async (req, res) => {
const {
query: {
next: nextQs
@@ -160,7 +200,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
const session = await bsseDef.createSession(req, res);
scrobbleSources.emitter.onAny((eventName: string, payload: any) => {
if(payload.from !== undefined) {
if(payload !== undefined && payload.from !== undefined) {
if(isNextapi) {
session.push({event: eventName, ...payload}, eventName);
} else {
@@ -169,7 +209,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
});
scrobbleClients.emitter.onAny((eventName: string, payload: any) => {
if(payload.from !== undefined) {
if(payload !== undefined && payload.from !== undefined) {
if(isNextapi) {
session.push({event: eventName, ...payload}, eventName);
} else {
@@ -180,22 +220,12 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
});
setupDeezerRoutes(app, logger, scrobbleSources);
setupWebscrobblerRoutes(app, logger, scrobbleSources);
setupLZEndpointRoutes(app, logger, scrobbleSources, scrobbleClients);
setupLastfmEndpointRoutes(app, logger, scrobbleSources);
setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
setupWebscrobblerRoutes(app, router, logger, scrobbleSources);
setupLZEndpointRoutes(app, router, logger, scrobbleSources, scrobbleClients);
setupLastfmEndpointRoutes(app, router, logger, scrobbleSources);
setupAuthRoutes(app, router, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients);
app.put('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
logger.info(req.body);
res.sendStatus(200);
});
app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => {
logger.info(req.body);
res.sendStatus(200);
});
app.get('/api/components', async (req, res, next) => {
router.get('/api/components', {tags: ['Source/Client'], summary: 'Get All Sources/Clients'}, async (req, res, next) => {
const sourceData = scrobbleSources.sources.filter(x => x.databaseOK).map((x) => {
const {
@@ -255,13 +285,13 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json([...sourceData, ...clientData]);
});
app.get('/api/sources/:componentVal/players', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => {
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({});
});
app.get('/api/components/:componentVal/players', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => {
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());
} else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) {
@@ -270,7 +300,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json({});
});
app.get('/api/sources/:componentVal/players/:platformId', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => {
router.get('/api/sources/:id/players/:platformId', {middleware: [sourceAwareMiddle], hidden: true}, async (req, res, next) => {
if(req.component instanceof MemorySource) {
const {
params: {
@@ -285,7 +315,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
return res.json({});
});
app.get('/api/components/:componentVal/players/:platformId', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => {
router.get('/api/components/:id/players/:platformId', {middleware: [componentAwareMiddle], tags: ['Source/Client'], summary: 'Get Specific Source/Client Player'}, async (req, res, next) => {
const {
params: {
platformId
@@ -308,14 +338,97 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.status(400).json({error: `Component does not support players`});
});
app.get('/api/components/:componentVal', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => {
router.get('/api/components/:id', {middleware: [componentAwareMiddle], tags: ['Source/Client'], summary: 'Get Source/Client'}, async (req, res) => {
const {
component,
} = req;
return res.json(component.getApiData());
});
router.post('/api/components/:id/state',
{
middleware: [componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] })],
bodySchema: componentStateBodySchema,
tags: ['Source/Client'],
summary: 'Update Source/Client State'
},
async (req, res) => {
const {
component,
body: {
state,
reason = 'invoked by api'
}
} = req;
switch (state) {
case 'stop':
try {
await component.stop({ reason: new SimpleError(reason, {simple: true, shortStack: true}) })
} catch (e) {
return res.status(500).json({ error: serializeError(e) });
}
break;
case 'start':
try {
await component.start({ forceInit: true })
} catch (e) {
return res.status(500).json({ error: serializeError(e) });
}
break;
case 'restart':
try {
await component.restart({ forceInit: true, reason: new SimpleError(reason, {simple: true, shortStack: true}) })
} catch (e) {
return res.status(500).json({ error: serializeError(e) });
}
break;
case 'ignore':
component.monitoringActivity = component.getSystemMonitoring() === false ? undefined : false;
component.emitComponentUpdate({state: component.getRunningState()});
break;
case 'monitor':
component.monitoringActivity = component.getSystemMonitoring() === true ? undefined : true;
component.emitComponentUpdate({state: component.getRunningState()});
break;
default:
return res.status(400).json({ error: { message: `'state' type ${state} was not handled` } });
}
return res.sendStatus(200);
});
app.get('/api/components/:componentVal/plays', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => {
router.post('/api/components/:id/auth', {
middleware: [componentAwareMiddle],
tags: ['Source/Client'],
summary: 'Test Source/Client Authentication'
}, async (req, res, next) => {
const {
component,
} = req;
let didAuth = false;
try {
logger.verbose('User requested auth test');
await component.testAuth(true);
component.clearErrors({predicate: x => findAuthIssue(x) !== undefined});
didAuth = true;
return res.sendStatus(200);
} catch (e) {
component.replaceErrors(e, {predicate: x => findAuthIssue(x) !== undefined});
return res.status(500).json({error: serializeError(e)});
} finally {
const data = component.getApiData();
component.emitComponentUpdate({
errors: data.errors,
state: data.state,
status: didAuth ? 'Authenticated successfully' : data.status
});
}
});
router.get('/api/components/:id/plays', {
middleware: [componentAwareMiddle],
tags: ['Plays'],
summary: 'Get Paginated Plays'
}, async (req, res, next) => {
const {
component,
query
@@ -324,19 +437,22 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
const hydratedQuery = asDayjsHydratedObject<QueryPlaysOptsJson, QueryPlaysOpts<Dayjs>>(query);
const playRes = await component.getPlaysPaginated(hydratedQuery);
// @ts-expect-error
// @ts-expect-error its fine
playRes.data = playRes.data.map(x => asSerializablePlaySelect(x))
//PlayApiCommonDetailed
// plus paginatioon
return res.json(playRes);
});
app.get('/api/components/:componentVal/plays/:playUid', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => {
router.get('/api/components/:id/plays/:uid', {
middleware: [componentAwareMiddle],
tags: ['Plays'],
summary: 'Get Play'
}, async (req, res, next) => {
const {
component,
query,
params: {
playUid
uid: playUid
}
} = req;
@@ -346,7 +462,138 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json(asSerializablePlaySelect(playRes));
});
app.delete('/api/cache/:cacheType', async (req, res) => {
router.delete('/api/components/:id/plays/:uid', {
middleware: [componentAwareMiddle],
querySchema: z.object({children: z.stringbool().optional()}),
tags: ['Plays'],
summary: 'Delete Play'
}, async (req, res, next) => {
const {
component,
query: {
children
},
params: {
uid: playUid
}
} = req;
const play = await component.playRepo.findByUidWith<'children'>(playUid, ['children']);
if(play === undefined) {
return res.sendStatus(404);
}
await component.deletePlay(play);
return res.sendStatus(200);
});
router.post('/api/components/:id/plays/queue', {
middleware: [componentAwareMiddle,bodyParser.json({ type: ['text/*', 'application/json'] })],
bodySchema: z.object({
context: queueContextSchema.optional(),
filters: z.looseObject({})
}),
tags: ['Plays'],
summary: 'Requeue Bulk Plays'
}, async (req, res, next) => {
const {
component,
body
} = req;
const hydratedQuery = asDayjsHydratedObject<QueryPlaysOptsJson, QueryPlaysOpts<Dayjs>>(body.filters);
res.sendStatus(200);
const queueFunc = component instanceof AbstractSource ?
async (p: PlayWith<'queueStates'>) => await component.queuePlay([p], {...body.context, isRetry: true, reason: 'User requested reprocessing'})
: async (p: PlayWith<'queueStates'>) => await component.queueScrobble([p], {...body.context, isRetry: true, reason: 'User requested reprocessing'});
const currentFilters = hydratedQuery;
let more = true;
while(more) {
const res = await component.getPlaysPaginatedInternal(currentFilters);
pMap(res.data, async (x) => await queueFunc(x), {concurrency: 5});
more = res.data.length === res.meta.limit;
if(more) {
currentFilters.offset += res.meta.limit
}
}
});
router.post('/api/components/:id/plays/:uid/queue', {
middleware: [componentAwareMiddle],
bodySchema: queueContextSchema.optional(),
tags: ['Plays'],
summary: 'Requeue a Play'
}, async (req, res, next) => {
const {
component,
params: {
uid: playUid
},
body = {}
} = req;
const play = await component.playRepo.findByUid(playUid);
if(play === undefined) {
return res.sendStatus(404);
}
if(component instanceof AbstractSource) {
await component.queuePlay([play], {...body, isRetry: true, reason: 'User requested reprocessing'});
} else {
await component.queueScrobble([play], {...body, isRetry: true, reason: 'User requested reprocessing'});
}
return res.sendStatus(200);
});
router.delete('/api/components/:id/plays/:uid/queue', {
middleware: [componentAwareMiddle],
tags: ['Plays'],
summary: 'Dequeue a Play'
}, async (req, res, next) => {
const {
component,
params: {
uid: playUid
}
} = req;
const play = await component.playRepo.findByUid(playUid);
if(play === undefined) {
return res.sendStatus(404);
}
await component.cancelQueuedPlay(play);
return res.sendStatus(200);
});
router.delete('/api/components/:id/plays/:uid/dead', {
middleware: [componentAwareMiddle],
tags: ['Plays'],
summary: 'Mark Dead Play as Completed'
}, async (req, res, next) => {
const {
component,
params: {
uid: playUid
}
} = req;
const play = await component.playRepo.findByUid(playUid);
if(play === undefined) {
return res.sendStatus(404);
}
await component.removeDeadLetterScrobble(play);
return res.sendStatus(200);
});
router.delete('/api/cache/:cacheType', {
tags: ['Cache'],
summary: 'Delete Cache By Type'
}, async (req, res) => {
const cache = await getRoot().items.cache();
logger.verbose(`User request cache deletion for ${req.params.cacheType}`);
switch(req.params.cacheType) {
@@ -371,7 +618,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
*
* */
app.get('/api/status', async (req, res, next) => {
router.get('/api/status', {hidden: true}, async (req, res, next) => {
const sourceData = scrobbleSources.sources.map((x) => {
const {
@@ -465,9 +712,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json({sources: sourceData, clients: clientData});
});
app.get('/api/recent', sourceMiddleFunc(false), async (req, res, next) => {
router.get('/api/recent', {middleware: [sourceMiddleFunc(false)], querySchema: z.any(), hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
query: {
upstream = 'false',
@@ -499,9 +745,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json(result);
});
app.get('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => {
router.get('/api/source/art', {middleware: [sourceMiddleFunc(false)], querySchema: z.object({data: z.number()}).optional(), hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource,
query: {
data
@@ -527,9 +772,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
});
app.get('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
router.get('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
query = {}
} = req;
@@ -538,21 +782,19 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
...query as Partial<QueryPlaysOpts>,
queues: [
{
queueName: CLIENT_DEAD_QUEUE,
queueName: DEAD_QUEUE,
queueStatus: ['queued','failed']
}
]
}
// @ts-ignore
const result: DeadLetterScrobble<PlayObject>[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true));
return res.json(result);
});
app.put('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
router.put('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
} = req;
@@ -560,12 +802,11 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
res.status(200).send('OK');
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000));
await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000, 'Reprocessing bulk dead Plays initiated by user'));
});
app.put('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
router.put('/api/dead/:id', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
params: {
id
@@ -574,14 +815,24 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
const deadId = id as string;
(client as AbstractScrobbleClient).logger.verbose(`User requested processing of dead letter scrobble ${deadId} via API call`)
(client as AbstractScrobbleClient).logger.verbose(`User requested processing of dead letter scrobble ${deadId} via API call`);
try {
const [scrobbled, dead] = await (client as AbstractScrobbleClient).processDeadLetterScrobble(deadId);
if(scrobbled) {
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));
}
return res.json(playSelectToDeadScrobble(dead, true));
} catch (e) {
if(e.message.includes(`Play ${deadId} does not exist`)) {
logger.warn(e);
@@ -592,22 +843,20 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
});
app.delete('/api/dead', clientMiddleFunc(true), async (req, res, next) => {
router.delete('/api/dead', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
} = req;
(client as AbstractScrobbleClient).logger.verbose('User requested deletion of all dead letter scrobbles via API');
(client as AbstractScrobbleClient).removeDeadLetterScrobbles(['queued', 'failed'], 'failed', false).then(() => null).catch((e) => logger.error(e));
(client as AbstractScrobbleClient).removeDeadLetterScrobbles().then(() => null).catch((e) => logger.error(e));
return res.sendStatus(200);
});
app.delete('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => {
router.delete('/api/dead/:id', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleClient: client,
params: {
id
@@ -618,22 +867,22 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
(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(deadId,'failed', false);
await (client as AbstractScrobbleClient).removeDeadLetterScrobble(playEntity);
return res.status(200).send();
} 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});
}
});
app.get('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => {
router.get('/api/scrobbled', {middleware: [clientMiddleFunc(true)], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error scrobbleClient not part of req
scrobbleClient: client,
query
} = req;
@@ -650,9 +899,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.json(result);
});
app.use('/api/source/init', sourceRequiredMiddle);
app.post('/api/source/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
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 {
@@ -678,9 +925,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
});
app.use('/api/source/listen', sourceRequiredMiddle);
app.post('/api/source/listen', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
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 {
@@ -700,9 +945,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
res.status(200).json({listening});
});
app.use('/api/client/listen', clientRequiredMiddle);
app.post('/api/client/listen', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
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 {
@@ -722,9 +965,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
res.status(200).json({listening});
});
app.use('/api/client/init', clientRequiredMiddle);
app.post('/api/client/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
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 {
@@ -747,8 +988,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
res.status(200).send('OK');
});
app.post('/api/client/historical', clientRequiredMiddle, async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
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');
@@ -760,8 +1000,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
}
});
app.get('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
app.get('/api/health', async (req, res) => {
router.get('/health', {hidden: true}, async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`));
router.get('/api/health', {querySchema: z.object({type: z.string(), name: z.string()}).optional(), tags: ['App Meta']}, async (req, res) => {
const {
type,
name
@@ -774,134 +1014,18 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
return res.status((clientsReady && sourcesReady) ? 200 : 500).json({messages: sourceMessages.concat(clientMessages)});
});
const issuesClientGauge = new prom.Gauge({
name: 'multiscrobbler_client_issues',
help: 'Number of errors/issues with Client',
labelNames: ['name', 'type'],
async collect() {
for(const client of scrobbleClients.clients) {
let issues = 0;
if(!(await client.isReady())) {
issues++;
}
this.labels({name: client.getSafeExternalName(), type: client.type}).set(issues);
}
}
});
const sourceIssues = new prom.Gauge({
name: 'multiscrobbler_source_issues',
help: 'Number of errors/issues with Source',
labelNames: ['name', 'type'],
async collect() {
for(const source of scrobbleSources.sources) {
let issues = 0;
if(source.requiresAuth && !source.authed) {
issues++;
}
if(source.canPoll && !source.polling) {
issues++;
}
this.labels({name: source.getSafeExternalName(), type: source.type}).set(issues);
}
}
});
let playRepo: DrizzlePlayRepository,
playHistoricalRepo: DrizzlePlayHistoricalRepository;
const sourcePlays = new prom.Gauge({
name: 'multiscrobbler_source_plays',
help: 'Count of stored plays by state for Sources',
labelNames: ['name', 'type', 'state'],
async collect() {
const res = await playRepo.getPlayCountByState();
for(const source of scrobbleSources.sources) {
const relevant = res.filter(x => x['componentId'] === source.componentId);
for(const s of PLAY_SOURCE_STATE) {
const rel = relevant.find(x => x['state'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({name: source.getSafeExternalName(), type: source.type, state: s}).set(count);
}
}
}
});
const sourceRetention = new prom.Gauge({
name: 'multiscrobbler_source_plays_compacted',
help: 'Count of compacted, stored plays by compaction type for Sources',
labelNames: ['name', 'type', 'compactionType'],
async collect() {
const res = await playRepo.getCompactedPlayCountByComponent();
for(const source of scrobbleSources.sources) {
const relevant = res.filter(x => x['componentId'] === source.componentId);
for(const s of ['input','transform','input-transform']) {
const rel = relevant.find(x => x['compacted'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({name: source.getSafeExternalName(), type: source.type, compactionType: s}).set(count);
}
}
}
});
const clientPlays = new prom.Gauge({
name: 'multiscrobbler_client_plays',
help: 'Count of stored plays by state for Clients',
labelNames: ['name', 'type', 'state'],
async collect() {
const res = await playRepo.getPlayCountByState();
for(const client of scrobbleClients.clients) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for(const s of PLAY_CLIENT_STATE) {
const rel = relevant.find(x => x['state'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({name: client.getSafeExternalName(), type: client.type, state: s}).set(count);
}
}
}
});
const clientRetention = new prom.Gauge({
name: 'multiscrobbler_client_plays_compacted',
help: 'Count of compacted, stored plays by compaction type for Clients',
labelNames: ['name', 'type', 'compactionType'],
async collect() {
const res = await playRepo.getCompactedPlayCountByComponent();
for(const client of scrobbleClients.clients) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for(const s of ['input','transform','input-transform']) {
const rel = relevant.find(x => x['compacted'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({name: client.getSafeExternalName(), type: client.type, compactionType: s}).set(count);
}
}
}
});
const clientHistoricalPlays = new prom.Gauge({
name: 'multiscrobbler_client_historical_plays',
help: 'Count of stored historical plays for Clients',
labelNames: ['name', 'type'],
async collect() {
const res = await playHistoricalRepo.getPlayCountByComponent();
for(const client of scrobbleClients.clients) {
if(client instanceof AbstractHistoricalScrobbleClient) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for(const rel of relevant) {
this.labels({name: client.getSafeExternalName(), type: client.type}).set(rel['count(*)']);
}
}
}
}
});
if(process.env.PROMETHEUS_FULL === 'true') {
prom.collectDefaultMetrics();
if(testMode !== true) {
registerMetrics(scrobbleSources, scrobbleClients);
if(process.env.PROMETHEUS_FULL === 'true') {
prom.collectDefaultMetrics();
}
}
app.get('/api/metrics', async (req, res) => {
router.get('/api/metrics', {tags: ['App Meta']}, async (req, res) => {
if(playRepo === undefined) {
if(!hasMetricRepositories()) {
const db = await getRoot().items.db();
playRepo = new DrizzlePlayRepository(db);
playHistoricalRepo = new DrizzlePlayHistoricalRepository(db);
setMetricRepositories(new DrizzlePlayRepository(db),new DrizzlePlayHistoricalRepository(db))
}
const metricsString = await prom.register.metrics();
@@ -912,11 +1036,17 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro
});
app.get('/api/version', async (req, res) => {
router.get('/api/version', {tags: ['App Meta']}, async (req, res) => {
return res.json({version: root.get('version')});
});
app.use('/api/*path', async (req, res) => {
router.use('/api/docs', router.docs({
title: "Multi-Scrobbler API",
version: "0.1.0",
description: "Public API docs",
}))
router.all('/api/*path', {hidden: true}, async (req, res) => {
const remote = req.connection.remoteAddress;
const proxyRemote = req.headers["x-forwarded-for"];
const ua = req.headers["user-agent"];
+64 -26
View File
@@ -1,7 +1,6 @@
import type {Express} from 'express';
import type {Logger} from "@foxxmd/logging";
import passport from "passport";
import type {ExpressHandler} from "../common/infrastructure/Atomic.ts";
import type LastfmScrobbler from "../scrobblers/LastfmScrobbler.ts";
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
import type LastfmSource from "../sources/LastfmSource.ts";
@@ -11,10 +10,33 @@ 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 { 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, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
app.use('/api/client/auth', clientMiddle);
app.get('/api/client/auth', async (req, res) => {
export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, logger: Logger, sourceMiddle: SourceCheckMiddleTypedMiddleware, clientMiddle: ClientCheckedMiddleTypedMiddleware, 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) => {
switch(req.component.type) {
case 'lastfm':
case 'librefm':
res.status(200).send((req.component as LastfmScrobbler | LastfmSource | LibrefmScrobbler | LibrefmSource ).api.getAuthUrl());
break;
case 'spotify': {
const source = req.component as SpotifySource;
if (source.spotifyApi === undefined) {
res.status(400).send('Spotify configuration is not valid');
} else {
logger.info('Redirecting to spotify authorization url');
res.status(200).send(source.createAuthUrl());
}
} break;
}
});
router.get('/api/client/auth', {middleware: [clientMiddle], hidden: true}, async (req, res) => {
const {
scrobbleClient,
} = req as any;
@@ -25,35 +47,31 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
res.redirect(scrobbleClient.api.getAuthUrl());
break;
case 'tealfm':
const url = await scrobbleClient.getAuthorizeUrl();
res.redirect(url);
res.redirect(await scrobbleClient.getAuthorizeUrl());
break;
default:
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
}
});
app.use('/api/source/auth', sourceMiddle);
app.get('/api/source/auth', async (req, res, next) => {
router.get('/api/source/auth', {middleware: [sourceMiddle], hidden: true}, async (req, res, next) => {
const {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
scrobbleSource: source,
// @ts-expect-error TS(2339): Property 'sourceName' does not exist on type 'Requ... Remove this comment to see the full error message
sourceName: name,
} = req;
switch (source.type) {
case 'spotify':
if (source.spotifyApi === undefined) {
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.createAuthUrl());
res.redirect((source as SpotifySource).createAuthUrl());
}
break;
case 'lastfm':
case 'librefm':
res.redirect(source.api.getAuthUrl());
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
@@ -68,7 +86,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
}
});
app.get(/.*callback$/, async (req, res, next) => {
app.get(/.*callback$/, async (req, res) => {
if(req.url.indexOf('/api') !== 0) {
return res.redirect(307, `/api${req.url}`);
}
@@ -103,7 +121,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
throw error;
}
await entity.api.authenticate(token);
entity.authFailure = false;
entity.clearErrors({predicate: x => findAuthIssue(x) !== undefined});
if(entity instanceof AbstractSource) {
entity.poll().catch((e) => logger.error(e));
} else {
@@ -113,10 +131,16 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
entity.initScrobbleMonitoring().catch((e) => logger.error(e));
})
}
return res.send('OK');
} catch (e) {
return res.send(e.message);
if(entity !== undefined) {
entity.errors.push(e);
entity.logger.error(e);
} else {
logger.error(e);
}
}
res.redirect('/next');
return;
} else if(req.url.includes('ytmusic')) {
const entity: YTMusicSource | undefined = scrobbleSources.getByName(name) as (YTMusicSource | undefined);
if(entity === undefined) {
@@ -130,22 +154,36 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr
} else {
responseContent = result;
}
return res.send(responseContent);
res.send(responseContent);
return;
} else {
// TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here)
// but eventually should update all source callbacks to url specific URLS to avoid ambiguity...
// wish we could use state param to identify name/source but not all auth strategies and auth provides may provide access to that
logger.info({label: 'Spotify'}, 'Received auth code callback from Spotify');
const source = scrobbleSources.getByNameAndType(state as string, 'spotify', true) as SpotifySource;
const tokenResult = await source.handleAuthCodeCallback(req.query);
let responseContent = 'OK';
if (tokenResult === true) {
source.authFailure = false;
source.poll().catch((e) => logger.error(e));
} else {
responseContent = tokenResult;
try {
const tokenResult = await source.handleAuthCodeCallback(req.query);
if (tokenResult === true) {
source.clearErrors({predicate: x => findAuthIssue(x) !== undefined});
source.poll().catch((e) => logger.error(e));
} else {
if (tokenResult instanceof Error) {
source.replaceErrors(tokenResult, {predicate: (x) => x.message === tokenResult.message});
source.logger.error(tokenResult);
} else if (typeof tokenResult === 'string') {
const e = new SimpleError(`Token result was unexpected: ${tokenResult}`);
source.replaceErrors(e, {predicate: (x) => x.message === e.message});
source.logger.error(e);
}
}
} catch (e) {
const err = new SimpleError('Unexpected error while trying to authorize code, or save file', { cause: e });
source.replaceErrors(err, {predicate: (x) => err.message === x.message});
source.logger.error(err);
}
return res.send(responseContent);
res.redirect('/next');
return;
}
});
}
+1 -3
View File
@@ -27,9 +27,7 @@ export const setupDeezerRoutes = (app: Express, logger: Logger, scrobbleSources:
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
const entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
for(let i = 0; i < 3; i++) {
if(entity.error !== undefined) {
return res.send('Error with deezer credentials storage');
} else if(entity.config.data.accessToken !== undefined) {
if(entity.config.data.accessToken !== undefined) {
// start polling
await entity.doAuthentication();
entity.poll()
+85 -39
View File
@@ -1,38 +1,52 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import type {Express} from 'express';
import type {Express, Request} from 'express';
import { childLogger, type Logger } from "@foxxmd/logging";
import type {Writable} from 'ts-essentials'
import bodyParser from "body-parser";
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import { nonEmptyBody } from "./middleware.ts";
import { LFMEndpointNotifier } from "../sources/ingressNotifiers/LFMEndpointNotifier.ts";
import type { EndpointLastfmSource} from "../sources/EndpointLastfmSource.ts";
import { playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointLastfmSource.ts";
import {playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml, type LastFMScrobbleRequestPayload} from "../common/vendor/LastfmApiClient.ts";
import { playStateFromRequest, requestMatchers } from "../sources/EndpointLastfmSource.ts";
import {lastfmRequestSchema, playToNowPlayingApiResponseJson, playToNowPlayingApiResponseXml, playToScrobbleApiResponseJson, playToScrobbleApiResponseXml} from "../common/vendor/LastfmApiClient.ts";
import xml2js from 'xml2js';
import crypto from 'node:crypto';
import type { createTypedRouter, TypedMiddleware, InferSchemaHandler } from "@minisylar/express-typed-router";
import { parseDisplayIdentifiersFromRequest } from '../utils/RequestUtils.ts';
import * as z from 'zod';
const unmatchIdentifierWarn: string[] = [];
export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const looseQuery = z.looseObject({format: z.string().optional()});
type LooseQuery = typeof looseQuery;
export const setupLastfmEndpointRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'LFM']);
const nonEmptyCheck = nonEmptyBody(logger, 'LFM Endpoint');
const webhookIngress = new LFMEndpointNotifier(logger);
app.use(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
webhookIngress.trackIngress(req, true);
if (req.method !== 'POST') {
return res.sendStatus(405);
}
next();
},
bodyParser.urlencoded({ extended: true }),
nonEmptyCheck, async function (req, res) {
webhookIngress.trackIngress(req, false);
const rawIngress: TypedMiddleware = (req, res, next) => {
webhookIngress.trackIngress(req, true);
if (req.method !== 'POST') {
res.sendStatus(405);
return;
}
next();
};
const middleware = [rawIngress,bodyParser.urlencoded({ extended: true }),nonEmptyCheck] as const;
type SubmitHandler = InferSchemaHandler<{
bodySchema: typeof lastfmRequestSchema,
querySchema: LooseQuery
middleware: typeof middleware,
}>;
const submitRoute: SubmitHandler = async (req, res) => {
webhookIngress.trackIngress(req as Request, false);
const sources = scrobbleSources.getByType('endpointlfm') as EndpointLastfmSource[];
if (sources.length === 0) {
@@ -41,15 +55,15 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug] = parseDisplayIdentifiersFromRequest(req);
const [slug] = parseDisplayIdentifiersFromRequest(req, requestMatchers);
logger.warn(`No Lastfm endpoint config matched => Slug: ${slug}`);
return res.sendStatus(409);
res.sendStatus(409);
return;
}
if(!('method' in req.body)) {
return res.status(400).json({error: `Missing 'method' param`});
}
const method = (req.body as LastFMScrobbleRequestPayload).method;
let wantsJson: boolean = false;
if(req.query.format === 'json') {
@@ -72,19 +86,25 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
logger[level](`No LFM Endpoint Source has the apiKey '${req.body.api_key}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.api_key);
}
} else if(req.body.username !== undefined) {
source = validSources.find(x => x.config.data?.username === req.body.username);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.username) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has the username '${req.body.username}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.username);
}
} else if(req.body.sk !== undefined) {
source = validSources.find(x => crypto.createHash('md5').update(x.getUid()).digest('hex') === req.body.sk);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.sk) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has an ID md5 that matches the provided session key (sk) '${req.body.sk}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.sk);
} else {
if (req.body.method === 'auth.getMobileSession') {
if (`username` in req.body && req.body.username !== undefined) {
const u = req.body.username;
source = validSources.find(x => x.config.data?.username === u);
if (source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.username) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has the username '${req.body.username}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.username);
}
}
} else if(source === undefined && `sk` in req.body && req.body.sk !== undefined) {
const sk = req.body.sk;
source = validSources.find(x => crypto.createHash('md5').update(x.getUid()).digest('hex') === sk);
if(source === undefined) {
const level = unmatchIdentifierWarn.includes(req.body.sk) ? 'trace' : 'warn';
logger[level](`No LFM Endpoint Source has an ID md5 that matches the provided session key (sk) '${req.body.sk}' configured so will use the first Endpoint Source listed instead.`);
unmatchIdentifierWarn.push(req.body.sk);
}
}
}
@@ -92,18 +112,20 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
source = validSources[0];
}
switch (method) {
switch (req.body.method) {
case 'auth.getMobileSession': {
const resp = {
session: {
name: req.body.name ?? source.getUid(),
// @ts-expect-error idk sometimes they send this
name: req.body.name ?? req.body.username ?? source.getUid(),
key: crypto.createHash('md5').update(source.getUid()).digest('hex'),
subscriber: 0
}
};
source.logger.info(`Authenticating with username ${resp.session.name}`);
if (wantsJson) {
return res.status(200).json(resp);
res.status(200).json(resp);
return;
}
const builder = new xml2js.Builder();
const xml = builder.buildObject({ lfm: { $: { status: "ok" }, ...resp } });
@@ -112,7 +134,7 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
case 'track.updateNowPlaying':
case 'track.scrobble': {
const playerState = playStateFromRequest(req.body);
if (method === 'track.scrobble') {
if (req.body.method === 'track.scrobble') {
if (wantsJson) {
res.status(200).json(playToScrobbleApiResponseJson(playerState[0].play))
} else {
@@ -127,9 +149,33 @@ export const setupLastfmEndpointRoutes = (app: Express, parentLogger: Logger, sc
}
await source.handle(playerState)
} break;
default:
return res.status(400).json({ error: `Unexpected 'method' param value '${method}', expected one of: track.updateNowPlaying | track.scrobble | auth.getMobileSession` });
// default:
// return res.status(400).json({ error: `Unexpected 'method' param value '${req.body.method}', expected one of: track.updateNowPlaying | track.scrobble | auth.getMobileSession` });
}
}
router.post('/api/lastfm{*splat}', {
middleware: middleware as Writable<typeof middleware>,
bodySchema: lastfmRequestSchema,
querySchema: looseQuery,
tags: ['Lastfm Ingress'],
summary: 'Accept a Last.fm Scrobble (Slug)',
description: 'Accepts the standard Last.fm `track.scrobble` payload at this endpoint.'
}, submitRoute);
router.post('/2.0/', {
middleware: middleware as Writable<typeof middleware>,
bodySchema: lastfmRequestSchema,
querySchema: looseQuery,
tags: ['Lastfm Ingress'],
summary: 'Accept a Last.fm Scrobble (Standard)',
description: 'Accepts the standard Last.fm `track.scrobble` payload at this endpoint.'
}, submitRoute);
app.use(/(\/api\/lastfm(?!\/callback))|(\/2.0\/?)$/,
async function (req, res) {
logger.warn(`Received what looks like a Last.fm Endpoint request but it was to an invalid URL route: ${req.originalUrl}\nMake sure base URL path to MS endpoint is correct.`);
res.sendStatus(404);
});
}
@@ -1,21 +1,25 @@
/* eslint-disable prefer-arrow-functions/prefer-arrow-functions */
import type {Express} from 'express';
import type {Express, Request} from 'express';
import { childLogger, type Logger } from "@foxxmd/logging";
import type {Writable} from 'ts-essentials';
import bodyParser from "body-parser";
import type { EndpointListenbrainzSource} from "../sources/EndpointListenbrainzSource.ts";
import { playStateFromRequest, parseDisplayIdentifiersFromRequest } from "../sources/EndpointListenbrainzSource.ts";
import { playStateFromRequest, requestMatchers } from "../sources/EndpointListenbrainzSource.ts";
import { LZEndpointNotifier } from "../sources/ingressNotifiers/LZEndpointNotifier.ts";
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import { nonEmptyBody } from "./middleware.ts";
import type {PlayingNowPayload} from '../../core/vendor/listenbrainz/interfaces.ts';
import {submitPayloadSchema, type PlayingNowPayload, type SubmitPayload} from '../../core/vendor/listenbrainz/interfaces.ts';
import type ScrobbleClients from '../scrobblers/ScrobbleClients.ts';
import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.ts';
import { stringToDeterministicNumber } from '../utils/StringUtils.ts';
import { messageWithCauses } from '../../core/ErrorUtils.ts';
import type { createTypedRouter, TypedMiddleware, InferSchemaHandler } from "@minisylar/express-typed-router";
import { stripIndents } from 'common-tags';
import { parseDisplayIdentifiersFromRequest } from '../utils/RequestUtils.ts';
const TEXT_WILDCARD_REGEX = new RegExp(/text\/.+/);
export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
export const setupLZEndpointRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, parentLogger: Logger, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
const logger = childLogger(parentLogger, ['Ingress', 'Listenbrainz']);
@@ -35,52 +39,79 @@ export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobb
const nonEmptyCheck = nonEmptyBody(logger, 'LZ Endpoint');
const webhookIngress = new LZEndpointNotifier(logger);
app.use(/(\/api\/listenbrainz.*)|(\/1\/submit-listens\/?$)/,
async function (req, res, next) {
// track request before parsing body to ensure we at least log that something is happening
// (in the event body parsing does not work or request is not POST/PATCH)
webhookIngress.trackIngress(req, true);
if (req.method !== 'POST') {
logger.warn(`Expected request to this endpoint to be POST but got ${req.method} instead. URL: ${req.originalUrl}\nMake sure base URL path to MS endpoint is correct.`);
return res.sendStatus(405);
const rawIngress: TypedMiddleware = (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
};
const middleware = [rawIngress,lzJsonParser,nonEmptyCheck] as const;
type SubmitHandler = InferSchemaHandler<{
bodySchema: typeof submitPayloadSchema,
middleware: typeof middleware,
}>;
const submitRoute: SubmitHandler = async (req, res) => {
webhookIngress.trackIngress(req as Request, false);
logger.trace({body: req.body}, "Recieved request Body");
//req.body.payload[0].track_metadata.additional_info.recording_mbid
const playerStates = playStateFromRequest(req.body as unknown as SubmitPayload);
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
res.status(409).json({error: `Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured`, code: 409});
return;
}
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug, token] = parseDisplayIdentifiersFromRequest(req, requestMatchers);
logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`);
res.status(409).json({error: `No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`, code: 409});
return;
}
try {
for (const source of validSources) {
await source.handle(playerStates);
}
next();
},
lzJsonParser, nonEmptyCheck, async function (req, res) {
webhookIngress.trackIngress(req, false);
} catch (e) {
const submitListenError = new Error('Unexpected error occurred while processing submit-listens request', {cause: e});
const errMsg = messageWithCauses(submitListenError);
logger.error(submitListenError);
res.status(500).json({error: errMsg, code: 500});
return;
}
logger.trace({body: req.body}, "Recieved request Body");
res.status(200).json({status: "ok"});
}
const playerStates = playStateFromRequest(req.body);
router.post('/api/listenbrainz{*splat}', {
bodySchema: submitPayloadSchema,
middleware: middleware as Writable<typeof middleware>,
tags: ['Listenbrainz Ingress'],
summary: 'Accept a Listenbrainz Scrobble (Slug)',
description: 'Accepts the standard Listenbrainz `submit-listens` payload at this endpoint.',
}, submitRoute);
router.post('/1/submit-listens', {
middleware: middleware as Writable<typeof middleware>,
bodySchema: submitPayloadSchema,
tags: ['Listenbrainz Ingress'],
summary: 'Accept a Listenbrainz Scrobble (Standard)',
description: 'Accepts the standard Listenbrainz `submit-listens` payload at this endpoint.'
}, submitRoute);
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
return res.status(409).json({error: `Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured`, code: 409});
}
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug, token] = parseDisplayIdentifiersFromRequest(req);
logger.warn(`No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`);
return res.status(409).json({error: `No Listenbrainz endpoint config matched => Slug: ${slug} | Token: ${token}`, code: 409});
}
try {
for (const source of validSources) {
await source.handle(playerStates);
}
} catch (e) {
const submitListenError = new Error('Unexpected error occurred while processing submit-listens request', {cause: e});
const errMsg = messageWithCauses(submitListenError);
logger.error(submitListenError);
return res.status(500).json({error: errMsg, code: 500});
}
return res.status(200).json({status: "ok"});
});
app.get('/1/user/:username/playing-now', async function (req, res) {
router.get('/1/user/:username/playing-now',{
tags: ['Listenbrainz Ingress'],
summary: 'Get Playing Now',
description: stripIndents`Tries to match username with the username set in the config of an LZ Endpoint Source.
If no match then returns Playing Now for the first LZ Endpoint configured.`
}, async function (req, res) {
// TODO need to implement user names for endpoint configs
// so we can identify playing now calls by user
// and then determine actual playing now by clients that are able to be scrobbled to from this source
@@ -88,7 +119,6 @@ export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobb
// but for now just stub out empty response so panoscrobbler doesn't complain
const user = req.params.username;
let listens: PlayingNowPayload[];
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
@@ -101,7 +131,7 @@ export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobb
}
const playObjs = scrobbleClients.getPlayingNow(matchedSource.name, matchedSource.clients);
listens = playObjs.map(x => ({playing_now: true, track_metadata: playToListenPayload(x).track_metadata}));
const listens: PlayingNowPayload[] = playObjs.map(x => ({playing_now: true, track_metadata: playToListenPayload(x).track_metadata}));
return res.status(200).json({
payload: {
@@ -113,16 +143,24 @@ export const setupLZEndpointRoutes = (app: Express, parentLogger: Logger, scrobb
});
});
app.get('/1/validate-token', async function (req, res) {
router.get('/1/validate-token', {
tags: ['Listenbrainz Ingress'],
summary: 'Validate Token',
description: stripIndents`Always returns valid as long as any endpointlz source is configured.
For \`username\` in the response it will return the Source \`config.data.username\` or fallback to the Source's \`name\` or fallback to \`Multi-Scrobbler\` if no valid Sources.`
}, async function (req, res) {
//https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-validate-token
const sources = scrobbleSources.getByType('endpointlz') as EndpointListenbrainzSource[];
if (sources.length === 0) {
logger.warn('Received Listenbrainz endpoint payload but no Listenbrainz endpoint sources are configured');
res.sendStatus(404);
return;
}
const validSources = sources.filter(x => x.matchRequest(req));
if (validSources.length === 0) {
const [slug, token] = parseDisplayIdentifiersFromRequest(req);
const [slug, token] = parseDisplayIdentifiersFromRequest(req, requestMatchers);
logger.warn(`No Listenbrainz endpoint config matched => Token: ${token}`);
}
+39 -10
View File
@@ -1,5 +1,6 @@
import express, { Router } from 'express';
import { childLogger, type LogDataPretty, type Logger } from "@foxxmd/logging";
import express from 'express';
import type { Express } from 'express';
import { childLogger, loggerTest, type LogDataPretty, type Logger } from "@foxxmd/logging";
import bodyParser from 'body-parser';
import { stripIndents } from "common-tags";
import session from 'express-session';
@@ -16,17 +17,39 @@ import type ScrobbleSources from '../sources/ScrobbleSources.ts';
import type ScrobbleClients from '../scrobblers/ScrobbleClients.ts';
import { qsOptions } from '../../core/Atomic.ts';
import { projectRootDir } from "../common/infrastructure/Atomic.ts";
import { createTypedRouter } from "@minisylar/express-typed-router";
const app = express();
const router = Router();
app.set('query parser', (str: string) => qs.parse(str, qsOptions));
export interface ServerArgs {
sources: ScrobbleSources
clients: ScrobbleClients
}
export interface ServerOptions {
logger?: Logger
appLoggerStream?: PassThrough
initialOutput?: LogDataPretty[]
testMode?: boolean
}
export const initServer = async (parentLogger: Logger, appLoggerStream: PassThrough, initialOutput: LogDataPretty[] = [], sources: ScrobbleSources, clients: ScrobbleClients) => {
export const initServer = async (args: ServerArgs, opts: ServerOptions = {}): Promise<[Express, ReturnType<typeof createTypedRouter>]> => {
const logger = childLogger(parentLogger, 'API'); // parentLogger.child({labels: ['API']}, mergeArr);
const app = express();
const router = createTypedRouter();
app.set('query parser', (str: string) => qs.parse(str, qsOptions));
const {
sources,
clients
} = args;
const {
logger: parentLogger = loggerTest,
} = opts;
const logger = childLogger(parentLogger, 'API');
try {
app.use(router);
app.use(router.getRouter());
app.use(bodyParser.json());
app.use(
bodyParser.urlencoded({
@@ -36,6 +59,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
//app.use(express.static(buildDir));
router.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false}));
app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false}));
app.use(passport.initialize());
app.use(passport.session());
@@ -47,13 +71,17 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
return;
}
setupApi({app, router, scrobbleSources: sources, scrobbleClients: clients}, {...opts, logger});
if(opts.testMode === true) {
return [app, router];
}
const isProd = root.get('isProd');
const port = root.get('port');
const local = root.get('localUrl');
const localDefined = root.get('hasDefinedBaseUrl');
setupApi(app, logger, appLoggerStream, initialOutput, sources, clients);
const addy = getAddress();
const addresses: string[] = [];
let dockerHint = '';
@@ -119,6 +147,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
}).on('error', (err) => {
throw new Error('Server encountered unrecoverable error', {cause: err});
});
return [app, router];
} catch (e) {
throw new Error('Server encountered unrecoverable error', {cause: e});
}
+31 -23
View File
@@ -1,12 +1,14 @@
import type {Logger} from "@foxxmd/logging";
import type {ExpressHandler} from "../common/infrastructure/Atomic.ts";
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import type {Request, Response, NextFunction} from "express";
import type AbstractSource from "../sources/AbstractSource.ts";
import type AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts";
import type { TypedMiddleware } from "@minisylar/express-typed-router";
export const makeSourceCheckMiddle = (sources: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => {
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,
@@ -30,7 +32,9 @@ export const makeSourceCheckMiddle = (sources: any) => (required: boolean): Expr
next();
}
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => {
export type ClientCheckedMiddleTypedMiddleware = TypedMiddleware<{scrobbleClient: AbstractScrobbleClient}>;
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ClientCheckedMiddleTypedMiddleware => (req: any, res: any, next: any) => {
const {
query: {
name
@@ -52,7 +56,7 @@ export const makeClientCheckMiddle = (clients: any) => (required: boolean): Expr
next();
}
export const nonEmptyBody = (logger: Logger, origin: string = 'Origin'): ExpressHandler => async (req, res, 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) {
const length = req.header('content-length') !== undefined ? Number.parseInt(req.header('content-length')) : undefined;
@@ -75,16 +79,17 @@ export interface ComponentAwareRequest extends Request {
component: AbstractSource | AbstractScrobbleClient
}
export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => {
export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): TypedMiddleware<{component: ComponentAwareRequest['component']}> => (req, res, next) => {
const {
params: {
componentVal
id
}
} = req;
const componentId = Number.parseInt(componentVal as string);
const componentId = Number.parseInt(id as string);
if (isNaN(componentId)) {
return res.status(400).json({ error: 'Component id must be a number' });
res.status(400).json({ error: 'Component id must be a number' });
return;
}
let component: AbstractSource | AbstractScrobbleClient;
@@ -94,7 +99,8 @@ export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleC
}
if(component === undefined) {
return res.status(404).json({error: `No Component with the Id ${componentId} exists`});
res.status(404).json({error: `No Component with the Id ${componentId} exists`});
return;
}
(req as ComponentAwareRequest).component = component;
@@ -109,22 +115,23 @@ export interface ClientAwareRequest extends Request {
component: AbstractScrobbleClient
}
export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => {
export const makeSourceNextMiddle = (sources: ScrobbleSources): TypedMiddleware<{component: AbstractSource}> => async (req: Request, res: Response, next: NextFunction) => {
const {
params: {
componentVal
id
}
} = req;
const componentId = Number.parseInt(componentVal as string);
const componentId = Number.parseInt(id as string);
if (isNaN(componentId)) {
return res.status(400).json({ error: 'Source Id must be a number' });
res.status(400).json({ error: 'Source Id must be a number' });
return;
}
let component: AbstractSource;
component = sources.sources.find(x => x.componentId === componentId);
const component: AbstractSource = sources.sources.find(x => x.componentId === componentId);
if(component === undefined) {
return res.status(404).json({error: `No Source with the Id ${componentId} exists`});
res.status(404).json({error: `No Source with the Id ${componentId} exists`});
return;
}
(req as SourceAwareRequest).component = component;
@@ -132,22 +139,23 @@ export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler =
next();
}
export const makeClientNextMiddle = (clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => {
export const makeClientNextMiddle = (clients: ScrobbleClients): TypedMiddleware<{component: AbstractScrobbleClient}> => async (req: Request, res: Response, next: NextFunction) => {
const {
params: {
componentVal
id
}
} = req;
const componentId = Number.parseInt(componentVal as string);
const componentId = Number.parseInt(id as string);
if (isNaN(componentId)) {
return res.status(400).json({ error: 'Source Id must be a number' });
res.status(400).json({ error: 'Source Id must be a number' });
return;
}
let component: AbstractScrobbleClient;
component = clients.clients.find(x => x.componentId === componentId);
const component: AbstractScrobbleClient = clients.clients.find(x => x.componentId === componentId);
if(component === undefined) {
return res.status(404).json({error: `No Client with the Id ${componentId} exists`});
res.status(404).json({error: `No Client with the Id ${componentId} exists`});
return;
}
(req as ClientAwareRequest).component = component;
+145
View File
@@ -0,0 +1,145 @@
import type { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts";
import type { DrizzlePlayRepository } from "../common/database/drizzle/repositories/PlayRepository.ts";
import prom from 'prom-client';
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts";
import { PLAY_CLIENT_STATE, PLAY_SOURCE_STATE } from "../../core/Atomic.ts";
import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.ts";
let playRepo: DrizzlePlayRepository,
playHistoricalRepo: DrizzlePlayHistoricalRepository;
export const setMetricRepositories = (play: DrizzlePlayRepository, playHistorical: DrizzlePlayHistoricalRepository) => {
playRepo = play;
playHistoricalRepo = playHistorical;
}
export const hasMetricRepositories = () => playRepo !== undefined;
export const registerMetrics = (scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
if (prom.register.getSingleMetric('multiscrobbler_client_issues') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_client_issues',
help: 'Number of errors/issues with Client',
labelNames: ['name', 'type'],
async collect() {
for (const client of scrobbleClients.clients) {
let issues = 0;
if (!(await client.isReady())) {
issues++;
}
this.labels({ name: client.getSafeExternalName(), type: client.type }).set(issues);
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_source_issues') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_source_issues',
help: 'Number of errors/issues with Source',
labelNames: ['name', 'type'],
async collect() {
for (const source of scrobbleSources.sources) {
let issues = 0;
if (source.requiresAuth && !source.authed) {
issues++;
}
if (source.canPoll && !source.polling) {
issues++;
}
this.labels({ name: source.getSafeExternalName(), type: source.type }).set(issues);
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_source_plays') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_source_plays',
help: 'Count of stored plays by state for Sources',
labelNames: ['name', 'type', 'state'],
async collect() {
const res = await playRepo.getPlayCountByState();
for (const source of scrobbleSources.sources) {
const relevant = res.filter(x => x['componentId'] === source.componentId);
for (const s of PLAY_SOURCE_STATE) {
const rel = relevant.find(x => x['state'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({ name: source.getSafeExternalName(), type: source.type, state: s }).set(count);
}
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_source_plays_compacted') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_source_plays_compacted',
help: 'Count of compacted, stored plays by compaction type for Sources',
labelNames: ['name', 'type', 'compactionType'],
async collect() {
const res = await playRepo.getCompactedPlayCountByComponent();
for (const source of scrobbleSources.sources) {
const relevant = res.filter(x => x['componentId'] === source.componentId);
for (const s of ['input', 'transform', 'input-transform']) {
const rel = relevant.find(x => x['compacted'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({ name: source.getSafeExternalName(), type: source.type, compactionType: s }).set(count);
}
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_client_plays') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_client_plays',
help: 'Count of stored plays by state for Clients',
labelNames: ['name', 'type', 'state'],
async collect() {
const res = await playRepo.getPlayCountByState();
for (const client of scrobbleClients.clients) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for (const s of PLAY_CLIENT_STATE) {
const rel = relevant.find(x => x['state'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({ name: client.getSafeExternalName(), type: client.type, state: s }).set(count);
}
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_client_plays_compacted') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_client_plays_compacted',
help: 'Count of compacted, stored plays by compaction type for Clients',
labelNames: ['name', 'type', 'compactionType'],
async collect() {
const res = await playRepo.getCompactedPlayCountByComponent();
for (const client of scrobbleClients.clients) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for (const s of ['input', 'transform', 'input-transform']) {
const rel = relevant.find(x => x['compacted'] === s);
const count = rel === undefined ? 0 : rel['count(*)'];
this.labels({ name: client.getSafeExternalName(), type: client.type, compactionType: s }).set(count);
}
}
}
});
}
if (prom.register.getSingleMetric('multiscrobbler_client_historical_plays') === undefined) {
new prom.Gauge({
name: 'multiscrobbler_client_historical_plays',
help: 'Count of stored historical plays for Clients',
labelNames: ['name', 'type'],
async collect() {
const res = await playHistoricalRepo.getPlayCountByComponent();
for (const client of scrobbleClients.clients) {
if (client instanceof AbstractHistoricalScrobbleClient) {
const relevant = res.filter(x => x['componentId'] === client.componentId);
for (const rel of relevant) {
this.labels({ name: client.getSafeExternalName(), type: client.type }).set(rel['count(*)']);
}
}
}
}
});
}
}
+18 -11
View File
@@ -1,4 +1,4 @@
import type {Express} from 'express';
import type {Express, Request} from 'express';
import { childLogger, type Logger } from "@foxxmd/logging";
import bodyParser from "body-parser";
import cors from 'cors';
@@ -7,12 +7,14 @@ import { WebhookNotifier } from "../sources/ingressNotifiers/WebhookNotifier.ts"
import type ScrobbleSources from "../sources/ScrobbleSources.ts";
import { WebScrobblerSource } from "../sources/WebScrobblerSource.ts";
import { nonEmptyBody } from "./middleware.ts";
import type { createTypedRouter, TypedMiddleware } from "@minisylar/express-typed-router";
import { webScrobblePayloadSchema, type WebScrobblerPayload } from '../common/vendor/webscrobbler/interfaces.ts';
const corsOpts: cors.CorsOptions = {
methods: ['POST']
}
export const setupWebscrobblerRoutes = (app: Express, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
export const setupWebscrobblerRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, parentLogger: Logger, scrobbleSources: ScrobbleSources) => {
const logger = childLogger(parentLogger, ['Ingress', 'WebScrobbler']);
@@ -25,20 +27,25 @@ export const setupWebscrobblerRoutes = (app: Express, parentLogger: Logger, scro
// }
});
const webhookIngress = new WebhookNotifier(logger);
const rawIngress: TypedMiddleware = (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
};
app.options('/api/webscrobbler*path', async (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
},
cors(corsOpts));
cors(corsOpts));
app.post(/api\/webscrobbler\/?.*/,
async (req, res, next) => {
webhookIngress.trackIngress(req, true);
next();
router.post('/api/webscrobbler{*splat}',
{
middleware: [rawIngress,cors(corsOpts) as TypedMiddleware,webScrobblerJsonParser,nonEmptyBody(logger, 'WebScrobbler Extension')],
bodySchema: webScrobblePayloadSchema,
tags: ['WebScrobbler Ingress'],
summary: 'Accept a Webscrobbler native scrobble'
},
cors(corsOpts),
webScrobblerJsonParser, nonEmptyBody(logger, 'WebScrobbler Extension'), async (req, res) => {
webhookIngress.trackIngress(req, false);
async (req, res) => {
webhookIngress.trackIngress(req as Request, false);
res.sendStatus(200);
@@ -52,7 +59,7 @@ export const setupWebscrobblerRoutes = (app: Express, parentLogger: Logger, scro
// const splitPath = cleanPath.split('/');
// const slug = splitPath[splitPath.length - 1];
const playerState = WebScrobblerSource.playStateFromRequest(req.body);
const playerState = WebScrobblerSource.playStateFromRequest(req.body as unknown as WebScrobblerPayload);
const sources = scrobbleSources.getByType('webscrobbler') as WebScrobblerSource[];
if (sources.length === 0) {
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -1,7 +1,7 @@
import dayjs, { type Dayjs } from "dayjs";
import type EventEmitter from "events";
import { MusicKit, type MeHistoryRecentlyPlayedTracksProps, type Song } from "node-musickit-api";
import type { PlayObject, PlayObjectMinimal } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts";
import type { InternalConfig } from "../common/infrastructure/Atomic.ts";
import type { AppleMusicSourceConfig } from "../common/infrastructure/config/source/applemusic.ts";
import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts";
@@ -33,6 +33,7 @@ export default class AppleMusicSource extends AbstractSource {
private readonly POLL_TRACK_LIMIT = 20;
private readonly UPSTREAM_TRACK_LIMIT = 30;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
@@ -355,13 +356,7 @@ export default class AppleMusicSource extends AbstractSource {
onPollPostAuthCheck = async () => {
if(!this.polling) {
this.logger.verbose('Hydrating initial recently played tracks for reference.');
const referencePlays = await this.getRecentlyPlayed();
const reversedPlays = [...referencePlays];
reversedPlays.reverse();
for(const refPlay of reversedPlays) {
await this.addPlayToDB(refPlay);
}
await this.getRecentlyPlayed();
}
return true;
}
+42 -8
View File
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import type EventEmitter from "events";
import type { Request } from 'superagent';
import request from 'superagent';
import { type PlayObject, type PlayObjectMinimal, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayMatchResult, type PlayObject, type PlayObjectMinimal, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, type TemporalAccuracy } from "../../core/Atomic.ts";
import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig } from "../common/infrastructure/Atomic.ts";
import type {DeezerInternalSourceConfig, DeezerInternalTrackData} from "../common/infrastructure/config/source/deezer.ts";
import { TRANSFORM_HOOK } from "../../core/Transform.ts";
@@ -63,6 +63,7 @@ interface DeezerAuthedUserData {
}
export default class DeezerInternalSource extends MemorySource {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
isSubAccount: boolean = false;
@@ -332,15 +333,22 @@ export default class DeezerInternalSource extends MemorySource {
protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})
existingDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<PlayObject | undefined> => {
const list: PlayObject[] = await this.getRecentlyDiscoveredPlays();
async existingDiscovered(play: PlayObject): Promise<PlayMatchResult | undefined> {
const list: PlayObject[] = await this.getRecentPlays();
const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate);
const existing = await findAsync(list, async x => {
const e = await this.transformPlay(x, TRANSFORM_HOOK.existing);
return genericSourcePlayMatch(e, candidate);
});
if(existing) {
return existing;
return {
match: true,
score: 1,
breakdowns: [],
reason: 'Has matching data with very close timestamps',
closestMatchedPlay: existing,
createdAt: dayjs().toISOString()
}
}
if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
const fuzzyIndex = await findIndexAsync(list, async x => {
@@ -361,19 +369,45 @@ export default class DeezerInternalSource extends MemorySource {
if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
// always return fuzzy match as existing
// likely will make MS miss scrobbles for repeated plays
return list[fuzzyIndex];
return {
match: true,
score: 1,
breakdowns: [],
reason: 'Has matching data and timestamp is during the duration of a previous play',
closestMatchedPlay: list[fuzzyIndex],
createdAt: dayjs().toISOString()
}
}
if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
// last discovered play was this one, or next played play was also this one
// so we'll assume this means the play is on repeat, don't count as existing
return undefined;
return {
match: false,
score: 0.5,
breakdowns: [],
reason: 'Has matching data for previous play but assuming its on repeat',
closestMatchedPlay: list[fuzzyIndex],
createdAt: dayjs().toISOString()
}
}
// next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
// so this is likely a duplicate deezer should not have reported
return list[fuzzyIndex];
return {
match: true,
score: 1,
breakdowns: [],
reason: 'Has matching data and looks like a misreported play',
closestMatchedPlay: list[fuzzyIndex],
createdAt: dayjs().toISOString()
}
}
}
return undefined;
return {
match: false,
score: 0,
breakdowns: [],
createdAt: dayjs().toISOString()
}
}
}
+4 -2
View File
@@ -3,7 +3,7 @@ import type EventEmitter from "events";
import passport from "passport";
import { Strategy as DeezerStrategy } from 'passport-deezer';
import request from 'superagent';
import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts";
import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig } from "../common/infrastructure/Atomic.ts";
import type {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.ts";
import { parseRetryAfterSecsFromObj, sleep, sortByOldestPlayDate, } from "../utils.ts";
@@ -12,10 +12,12 @@ import { readJson } from '../utils/DataUtils.ts';
import { joinedUrl } from "../utils/NetworkUtils.ts";
import AbstractSource, { type RecentlyPlayedOptions } from "./AbstractSource.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { SimpleError } from "../common/errors/MSErrors.ts";
export default class DeezerSource extends AbstractSource {
workingCredsPath;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = true;
@@ -242,7 +244,7 @@ export default class DeezerSource extends AbstractSource {
return true;
} else {
this.logger.warn('Callback contained an error! User may have denied access?')
this.error = error;
this.errors.push(error);
this.logger.error(error);
return error;
}
+26 -32
View File
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import {
type ExpressRequest,
type FormatPlayObjectOptions,
@@ -11,17 +11,21 @@ import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {PlayPlatformId} from '../../core/Atomic.ts';
import MemorySource from "./MemorySource.ts";
import type {LastFMEndpointSourceConfig} from "../common/infrastructure/config/source/endpointlfm.ts";
import { ingressPayloads, type LastFMPayloadkey, type LastFMScrobbleRequestPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts";
import { ingressPayloads, type LastFmSingleSubmitPayload, type LastFmSubmitPayload, scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.ts";
import type {Logger} from "@foxxmd/logging";
import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { AUTH_HEADER_DEFAULT_REGEX, parseSlugFromRequest, type RequestIdentifierRegexes } from "../utils/RequestUtils.ts";
const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:^\/1\/?|^\/2.0\/?)$/i);
const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)(\/|\/2.0\/)?$/i);
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
export const requestMatchers: RequestIdentifierRegexes = {
slug: slugMatch,
noSlug: noSlugMatch,
token: AUTH_HEADER_DEFAULT_REGEX
}
export class EndpointLastfmSource extends MemorySource {
declare config: LastFMEndpointSourceConfig;
@@ -43,8 +47,8 @@ export class EndpointLastfmSource extends MemorySource {
};
}
matchRequest(req: ExpressRequest): boolean {
const slug = parseSlugFromRequest(req);
matchRequest(req: Pick<ExpressRequest, 'baseUrl' | 'originalUrl' | 'header'>): boolean {
const slug = parseSlugFromRequest(req, requestMatchers);
if (slug === false) {
return false;
}
@@ -52,7 +56,7 @@ export class EndpointLastfmSource extends MemorySource {
return (this.config.data.slug === undefined && slug === undefined) || (slug !== undefined && this.config.data.slug !== undefined && this.config.data.slug.toLowerCase().trim() === slug.toLocaleLowerCase().trim());
}
static formatPlayObj(obj: LastFMScrobbleRequestPayload, options: FormatPlayObjectOptions = {}): PlayObject {
static formatPlayObj(obj: LastFmSingleSubmitPayload, options: FormatPlayObjectOptions = {}): PlayObject {
return scrobblePayloadToPlay(obj);
}
@@ -78,10 +82,11 @@ export class EndpointLastfmSource extends MemorySource {
}
const discoverable = stateData.filter(x => x.play.meta.nowPlaying === false);
const discovered = await this.discover(discoverable.map(x => x.play));
if (discovered.length > 0) {
await this.scrobble(discovered);
}
await this.queuePlay(discoverable.map(x => ({...x.play, meta: {...x.play.meta, parsedFrom: PARSED_FROM.ingress}})));
// const discovered = await this.discover(discoverable.map(x => x.play));
// if (discovered.length > 0) {
// await this.scrobble(discovered);
// }
this.componentRepo.updateById(this.dbComponent.id, {lastActiveAt: dayjs()});
this.setStatus('Waiting for Plays');
}
@@ -93,7 +98,14 @@ export class EndpointLastfmSource extends MemorySource {
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts);
}
export const playStateFromRequest = (obj: Record<LastFMPayloadkey, unknown>): PlayerStateData[] => ingressPayloads(obj).map(x => {
export const playStateFromRequest = (obj: LastFmSubmitPayload): PlayerStateData[] => {
let payloads: LastFmSingleSubmitPayload[];
if(obj.method === 'track.updateNowPlaying') {
payloads = [obj];
} else {
payloads = ingressPayloads(obj);
}
return payloads.map(x => {
const play = scrobblePayloadToPlay(x);
play.meta.sourceSOT = SOURCE_SOT.INGRESS;
return {
@@ -102,7 +114,8 @@ export const playStateFromRequest = (obj: Record<LastFMPayloadkey, unknown>): Pl
status: obj.method === 'track.updateNowPlaying' ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.unknown,
stateUpdatedAt: dayjs()
}
})
});
}
export const parseSlugFromString = (path: string): string | false | undefined => {
const noSlug = parseRegexSingle(noSlugMatch, path);
@@ -114,23 +127,4 @@ export const parseSlugFromString = (path: string): string | false | undefined =>
return slugResult.groups[0];
}
return false;
}
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.originalUrl);
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined] => {
const slug = parseSlugFromRequest(req);
return [slug];
}
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string] => {
const [slug] = parseIdentifiersFromRequest(req);
let slugStr = '(no slug)';
if (slug === false) {
slugStr = '(invalid slug)';
} else if (slug !== undefined) {
slugStr = slug;
}
return [slugStr];
}
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import {
type ExpressRequest,
type FormatPlayObjectOptions,
@@ -18,11 +18,17 @@ import MemorySource from "./MemorySource.ts";
import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.ts";
import type {Logger} from "@foxxmd/logging";
import type {PlayerStateOptions} from "./PlayerState/AbstractPlayerState.ts";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { AUTH_HEADER_DEFAULT_REGEX, parseSlugFromRequest, parseTokenFromRequest, type RequestIdentifierRegexes } from "../utils/RequestUtils.ts";
const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?\/1\/validate-token\/|)$/i);
const noSlugMatch = new RegExp(/\/api\/listenbrainz\/?$|(\/1\/?|\/1\/.+)$/i);
const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i);
export const requestMatchers: RequestIdentifierRegexes = {
slug: slugMatch,
noSlug: noSlugMatch,
token: AUTH_HEADER_DEFAULT_REGEX
}
export const authHeaderRegex = new RegExp(/Token (.+)$/i);
export class EndpointListenbrainzSource extends MemorySource {
@@ -47,9 +53,9 @@ export class EndpointListenbrainzSource extends MemorySource {
};
}
matchRequest(req: ExpressRequest): boolean {
matchRequest(req: Pick<ExpressRequest, 'baseUrl' | 'originalUrl' | 'header'>): boolean {
let matchesToken = this.config.data.token === undefined;
const reqToken = parseTokenFromRequest(req);
const reqToken = parseTokenFromRequest(req, requestMatchers);
if (reqToken === false) {
return false;
}
@@ -62,7 +68,7 @@ export class EndpointListenbrainzSource extends MemorySource {
}
let matchesPath = false;
const slug = parseSlugFromRequest(req);
const slug = parseSlugFromRequest(req, requestMatchers);
if (slug === false) {
return false;
} else {
@@ -104,10 +110,11 @@ export class EndpointListenbrainzSource extends MemorySource {
}
const discoverable = stateData.filter(x => x.play.meta.nowPlaying === false && this.isValidScrobble(x.play));
const discovered = await this.discover(discoverable.map(x => x.play));
if (discovered.length > 0) {
await this.scrobble(discovered);
}
await this.queuePlay(discoverable.map(x => ({...x.play, meta: {...x.play.meta, parsedFrom: PARSED_FROM.ingress}})))
// const discovered = await this.discover(discoverable.map(x => x.play));
// if (discovered.length > 0) {
// await this.scrobble(discovered);
// }
this.componentRepo.updateById(this.dbComponent.id, {lastActiveAt: dayjs()});
this.setStatus('Waiting for Plays');
}
@@ -146,63 +153,4 @@ export const listenTypeAsPlayerStatus = (event: string): ReportedPlayerStatus =>
default:
return REPORTED_PLAYER_STATUSES.unknown;
}
}
export const parseTokenFromString = (str: string): string | undefined => {
const tokenMatch = parseRegexSingle(authHeaderRegex, str);
if(tokenMatch !== undefined) {
return tokenMatch.groups[0];
}
return undefined;
}
export const parseTokenFromRequest = (req: ExpressRequest): string | false | undefined => {
const auth = req.header('Authorization');
if(typeof auth === 'string' && auth !== '') {
const matchedToken = parseTokenFromString(auth);
if(matchedToken === undefined) {
return false;
}
return matchedToken;
}
return undefined;
}
export const parseSlugFromString = (path: string): string | false | undefined => {
const noSlug = parseRegexSingle(noSlugMatch, path);
if (noSlug !== undefined) {
return undefined;
}
const slugResult = parseRegexSingle(slugMatch, path);
if (slugResult !== undefined) {
return slugResult.groups[0];
}
return false;
}
export const parseSlugFromRequest = (req: ExpressRequest): string | false | undefined => parseSlugFromString(req.baseUrl);
export const parseIdentifiersFromRequest = (req: ExpressRequest): [string | false | undefined, false | string | undefined] => {
const slug = parseSlugFromRequest(req);
const token = parseTokenFromRequest(req);
return [slug, token];
}
export const parseDisplayIdentifiersFromRequest = (req: ExpressRequest): [string, string] => {
const [slug, token] = parseIdentifiersFromRequest(req);
let slugStr = '(no slug)';
if (slug === false) {
slugStr = '(invalid slug)';
} else if (slug !== undefined) {
slugStr = slug;
}
let tokenStr = '(no token)';
if (token === false) {
tokenStr = '(invalid token)';
} else if (token !== undefined) {
tokenStr = `${token.substring(0,3)}****`
}
return [slugStr, tokenStr];
}
+2 -1
View File
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import type { EventEmitter } from "events";
import normalizeUrl from 'normalize-url';
import { URL } from "url";
import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts";
import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {JRiverSourceConfig} from "../common/infrastructure/config/source/jriver.ts";
import { type Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.ts";
@@ -18,6 +18,7 @@ export class JRiverSource extends MemoryPositionalSource {
client: JRiverApiClient;
clientReady: boolean = false;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor(name: any, config: JRiverSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
+7 -3
View File
@@ -37,11 +37,11 @@ import {
import dayjs from "dayjs";
import type EventEmitter from "events";
import { FixedSizeList } from "fixed-size-list";
import type {ArtistCredit, BrainzMeta, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import type {ArtistCredit, BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import { genGroupIdStr } from '../../core/PlayUtils.ts';
import { artistNameToCredit, buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts";
import type {FormatPlayObjectOptions, InternalConfig, PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts";
import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {JellyApiSourceConfig} from "../common/infrastructure/config/source/jellyfin.ts";
import { getPlatformIdFromData, isDebugMode } from "../utils.ts";
import { noCasePropObj } from "../utils/DataUtils.ts";
@@ -49,6 +49,8 @@ import { joinedUrl } from "../utils/NetworkUtils.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts";
import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";
import * as axios from 'axios';
import { AuthError } from "../common/errors/MSErrors.ts";
const shortDeviceId = truncateStringToLength(10, '');
@@ -81,6 +83,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
libraries: {name: string, paths: string[], collectionType: CollectionType}[] = [];
declare config: JellyApiSourceConfig;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor(name: any, config: JellyApiSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
super('jellyfin', name, config, internal, emitter);
@@ -251,7 +254,8 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
}
return true;
} catch (e) {
throw e;
const unrecoverable = axios.isAxiosError(e) && [401,403].includes(e.status);
throw new AuthError('API Key failed to authenticate', {cause: e, unrecoverable});
}
}
+2 -1
View File
@@ -1,5 +1,5 @@
import type { EventEmitter } from "events";
import type {PlayObject} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../core/Atomic.ts";
import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {KodiSourceConfig} from "../common/infrastructure/config/source/kodi.ts";
import { KodiApiClient } from "../common/vendor/KodiApiClient.ts";
@@ -11,6 +11,7 @@ export class KodiSource extends MemoryPositionalSource {
client: KodiApiClient;
clientReady: boolean = false;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor(name: any, config: KodiSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
+3 -2
View File
@@ -1,5 +1,5 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfig, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -11,6 +11,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class KoitoSource extends MemorySource {
api: KoitoApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
getScrobblesForTimeRange: TimeRangeListensFetcher
@@ -63,7 +64,7 @@ export default class KoitoSource extends MemorySource {
this.setStatus('Checking for new Plays');
await this.processRecentPlays([]);
const resp = await this.getScrobblesForTimeRange({limit, cursor: 0 });
return resp;
return resp.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
+4 -3
View File
@@ -1,7 +1,7 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import type {FormatPlayObjectOptions, InternalConfig, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
import type {PlayPlatformId} from '../../core/Atomic.ts';
import type {ComponentAuthType, PlayPlatformId} from '../../core/Atomic.ts';
import type {SourceType} from "../../core/Atomic.ts";
import type {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm.ts";
import LastfmApiClient, { formatPlayObj } from "../common/vendor/LastfmApiClient.ts";
@@ -16,6 +16,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class LastfmSource extends MemorySource {
api: LastfmApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.interactive;
requiresAuth = true;
requiresAuthInteraction = true;
upstreamType: string = 'Last.fm';
@@ -101,7 +102,7 @@ export default class LastfmSource extends MemorySource {
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
try {
const [history, now] = await this.getLastfmRecentTrack(options);
return history;
return history.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
} catch (e) {
throw e;
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type {LastfmSourceConfig} from "../common/infrastructure/config/source/la
export default class LibrefmSource extends LastfmSource {
// @ts-expect-error
// @ts-expect-error its fine
declare config: LibrefmSourceConfig;
constructor(name: any, config: LibrefmSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
+4 -3
View File
@@ -1,8 +1,8 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfig, TimeRangeListensFetcher} from "../common/infrastructure/Atomic.ts";
import type {PlayPlatformId} from '../../core/Atomic.ts';
import type {ComponentAuthType, PlayPlatformId} from '../../core/Atomic.ts';
import type {ListenBrainzSourceConfig} from "../common/infrastructure/config/source/listenbrainz.ts";
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -17,6 +17,7 @@ import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts
export default class ListenbrainzSource extends MemorySource {
api: ListenbrainzApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
getScrobblesForTimeRange: TimeRangeListensFetcher
@@ -85,7 +86,7 @@ export default class ListenbrainzSource extends MemorySource {
const {limit = 20} = options;
const now = await this.api.getPlayingNow();
await this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
return await this.getScrobblesForTimeRange({limit});
return (await this.getScrobblesForTimeRange({limit})).map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
+6 -3
View File
@@ -2,13 +2,13 @@ import dayjs from "dayjs";
import type { EventEmitter } from "events";
import path from 'path';
import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js';
import type {BrainzMeta, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import {
type FormatPlayObjectOptions,
type InternalConfig,
type PlayerStateData,
} from "../common/infrastructure/Atomic.ts";
import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {ReportedPlayerStatus} from '../../core/Atomic.ts';
import type {MPDSourceConfig, PlayerState} from "../common/infrastructure/config/source/mpd.ts";
@@ -18,6 +18,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { isDebugMode, sleep } from "../utils.ts";
import { artistNamesToCredits } from "../../core/StringUtils.ts";
import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
'play': REPORTED_PLAYER_STATUSES.playing,
@@ -36,6 +37,8 @@ export class MPDSource extends MemoryPositionalSource {
protected currentPlayPath: string;
protected currentPlaySong?: Song;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data = {}
@@ -139,7 +142,7 @@ export class MPDSource extends MemoryPositionalSource {
// break;
// }
// }
throw new Error(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e});
throw new AuthError(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e, unrecoverable: false});
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -10,6 +10,7 @@ import type {MalojaSourceConfig} from "../common/infrastructure/config/source/ma
export default class MalojaSource extends MemorySource {
api: MalojaApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
@@ -58,7 +59,7 @@ export default class MalojaSource extends MemorySource {
this.setStatus('Checking for new Plays');
await this.processRecentPlays([]);
const resp = await this.api.getPaginatedTimeRangeListens({limit, cursor: 0});
return resp.data;
return resp.data.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
+7 -5
View File
@@ -2,7 +2,7 @@ import type {Logger} from "@foxxmd/logging";
import dayjs, { type Dayjs } from "dayjs";
import type { EventEmitter } from "events";
import { AsyncTask, SimpleIntervalJob, ToadScheduler } from "toad-scheduler";
import { type PlayObject, SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson, type SourcePlayerObj } from "../../core/Atomic.ts";
import { PARSED_FROM, type PlayObject, SOURCE_SOT, type SOURCE_SOT_TYPES, type SourcePlayerJson, type SourcePlayerObj } from "../../core/Atomic.ts";
import { buildTrackString } from "../../core/StringUtils.ts";
import {
asPlayerStateDataMaybePlay,
@@ -115,6 +115,7 @@ export default class MemorySource extends AbstractSource {
this.emitEvent('playerUpdate', {
...state,
options: {
wasMonitored: this.isMonitoring(),
scrobbleTo: this.clients
}
});
@@ -331,6 +332,7 @@ export default class MemorySource extends AbstractSource {
this.emitEvent('playerUpdate', {
...apiState,
options: {
wasMonitored: this.isMonitoring(),
scrobbleTo: this.clients
}
});
@@ -350,7 +352,7 @@ export default class MemorySource extends AbstractSource {
}
}
return newStatefulPlays;
return newStatefulPlays.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.player}}));
}
protected isListenedPlayDiscoverable = async (candidate: PlayObject): Promise<[boolean, string]> => {
@@ -366,11 +368,11 @@ export default class MemorySource extends AbstractSource {
if (thresholdResults.passes) {
const matchingRecent = await this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
if (matchingRecent === undefined) {
if (matchingRecent.match === false) {
return [true,`${stPrefix} added after ${thresholdResultSummary(thresholdResults)} and not matching any prior plays`];
} else {
const {data: {playDate, duration}} = candidate;
const {data: {playDate: rplayDate}} = matchingRecent;
const {closestMatchedPlay: {data: {playDate: rplayDate}} = {}} = matchingRecent;
if (!playDate.isSame(rplayDate)) {
if (duration !== undefined) {
if (playDate.isAfter(rplayDate.add(duration, 's'))) {
@@ -386,7 +388,7 @@ export default class MemorySource extends AbstractSource {
return [false, `${stPrefix} not added because it matched the last discovered play and could not determine time frame of play`];
}
} else {
return [false, `${stPrefix} ${EXPECTED_NON_DISCOVERED_REASON}`]
return [false, `${stPrefix} ${EXPECTED_NON_DISCOVERED_REASON}`];
}
}
}
+6 -4
View File
@@ -4,14 +4,14 @@ import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket';
import { WS } from 'iso-websocket'
import { randomUUID } from "node:crypto";
import pEvent from 'p-event';
import type {PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";
import type {ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";
import { UpstreamError } from "../common/errors/UpstreamError.ts";
import {
type FormatPlayObjectOptions,
type InternalConfig,
type PlayerStateData,
} from "../common/infrastructure/Atomic.ts";
import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import type {MCAuthenticateRequest, MCAuthenticateResponse, MCPlaybackOverviewRequest, MCPlaybackOverviewResponse, MusikcubeSourceConfig} from "../common/infrastructure/config/source/musikcube.ts";
import { sleep } from "../utils.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -19,6 +19,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";
import { normalizeWSAddress } from "../utils/NetworkUtils.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { artistNamesToCredits } from "../../core/StringUtils.ts";
import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_STATE = {
0: 'connecting',
@@ -29,6 +30,7 @@ const CLIENT_STATE = {
export class MusikcubeSource extends MemoryPositionalSource {
declare config: MusikcubeSourceConfig;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
url: URLData;
@@ -149,9 +151,9 @@ export class MusikcubeSource extends MemoryPositionalSource {
if(authE === undefined) {
throw new Error('Musikcube did not respond to auth message after 2000 ms');
} else if(isCloseEvent(authE)) {
throw new Error(`Password is not correct: ${authE.code} => ${authE.reason}`);
throw new AuthError(`Password is not correct: ${authE.code} => ${authE.reason}`, {unrecoverable: true});
} else if(isErrorEvent(authE)) {
throw new Error(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error});
throw new AuthError(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error, unrecoverable: false});
}
return true;
@@ -84,7 +84,7 @@ export abstract class AbstractPlayerState {
currentListenRange?: ListenRange
listenRanges: ListenRange[] = [];
createdAt: Dayjs = dayjs();
stateLastUpdatedAt: Dayjs = dayjs(0);
stateLastUpdatedAt: Dayjs = dayjs(1);
lastPlay?: PlayObject
lastPlayUpdatedAt?: Dayjs
@@ -173,6 +173,9 @@ export abstract class AbstractPlayerState {
update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) {
this.stateLastUpdatedAt = state.stateUpdatedAt ?? dayjs();
if(!this.stateLastUpdatedAt.isValid()) {
this.stateLastUpdatedAt = dayjs();
}
const {play, status} = state;
+4 -2
View File
@@ -1,5 +1,5 @@
import type EventEmitter from "events";
import type {BrainzMeta, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";
import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";
import { artistNamesToCredits, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts";
import {
asPlayerStateDataMaybePlay,
@@ -7,7 +7,7 @@ import {
type InternalConfig,
MBID_VARIOUS_ARTISTS,
type PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts";
import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {PlayPlatformId} from '../../core/Atomic.ts';
import { getFirstNonEmptyString, isDebugMode, } from "../utils.ts";
import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts";
@@ -41,6 +41,8 @@ const THUMB_REGEX = new RegExp(/\/library\/metadata\/(?<ratingkey>\d+)\/thumb\/\
export default class PlexApiSource extends MemoryPositionalSource {
users: string[] = [];
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
plexApi: PlexAPI;
plexUser: string;
+3 -2
View File
@@ -1,5 +1,5 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -10,6 +10,7 @@ import type {RockskySourceConfig} from "../common/infrastructure/config/source/r
export default class RockskySource extends MemorySource {
api: RockSkyApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
@@ -70,7 +71,7 @@ export default class RockskySource extends MemorySource {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
this.setStatus('Checking for new Plays');
return await this.api.getRecentlyPlayed(limit);
return (await this.api.getRecentlyPlayed(limit)).map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
+9 -6
View File
@@ -2,7 +2,7 @@ import dayjs, { type Dayjs } from "dayjs";
import type EventEmitter from "events";
import SpotifyWebApi from "spotify-web-api-node";
import request from 'superagent';
import { type BrainzMeta, type PlayObject, type PlayObjectMinimal, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC } from "../../core/Atomic.ts";
import { type BrainzMeta, COMPONENT_AUTH_TYPE, type PlayObject, type PlayObjectMinimal, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, type ScrobbleTsSOC } from "../../core/Atomic.ts";
import { artistNameToCredit, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.ts";
@@ -19,7 +19,7 @@ import {
} from "../common/infrastructure/Atomic.ts";
import { NO_USER } from '../../core/Atomic.ts';
import { NO_DEVICE } from '../../core/Atomic.ts';
import type {ReportedPlayerStatus} from '../../core/Atomic.ts';
import type {ComponentAuthType, ReportedPlayerStatus} from '../../core/Atomic.ts';
import type {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.ts";
import {
parseRetryAfterSecsFromObj,
@@ -40,7 +40,7 @@ import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { createGetScrobblesForTimeRangeFunc } from "../utils/ListenFetchUtils.ts";
import { SimpleError } from "../common/errors/MSErrors.ts";
import { AuthError, SimpleError } from "../common/errors/MSErrors.ts";
const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position'];
const state = 'random';
@@ -52,6 +52,7 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag
spotifyApi: SpotifyWebApi;
workingCredsPath: string;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.interactive;
requiresAuth = true;
requiresAuthInteraction = true;
@@ -342,16 +343,18 @@ export default class SpotifySource extends MemoryPositionalSource implements Pag
doAuthentication = async () => {
try {
if(undefined === this.spotifyApi.getAccessToken()) {
this.logger.warn('Cannot use API until an access token has been received from the authorization flow. See the dashboard.');
return false;
throw new AuthError('Cannot use API until an access token has been received from the authorization flow.', {unrecoverable: false});
}
await this.callApi<ReturnType<typeof this.spotifyApi.getMe>>(((api: any) => api.getMe()));
return true;
} catch (e) {
if(e instanceof AuthError) {
throw e;
}
if(isNodeNetworkException(e)) {
this.logger.error('Could not communicate with Spotify API');
}
throw e;
throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: 'statusCode' in e && [401,403].includes(e.statusCode)})
}
}
+8 -4
View File
@@ -4,11 +4,11 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import type EventEmitter from "events";
import type { Request } from 'superagent';
import request from 'superagent';
import { REPORTED_PLAYER_STATUSES, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import { UpstreamError } from "../common/errors/UpstreamError.ts";
import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig, type PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.ts";
import type {PlayPlatformId} from '../../core/Atomic.ts';
import type {ComponentAuthType, PlayPlatformId} from '../../core/Atomic.ts';
import type {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.ts";
import { getSubsonicResponse, type EntryData, type OpenSubsonicExtensionsResponse, type SubsonicNowPlayingResponse, type SubsonicResponse, type SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.ts";
import { removeDuplicates } from "../utils.ts";
@@ -22,7 +22,9 @@ import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts';
import { noRetryOnUpstreamError, tryApiCall } from '../utils/RequestUtils.ts';
import { artistNameToCredit } from '../../core/StringUtils.ts';
import { timeToHumanTimestamp, todayAwareFormat } from '../../core/TimeUtils.ts';
import type { ComponentSourceApiJson, SubsonicSourceApiJson } from '../../core/Api.ts';
import type { SubsonicSourceApiJson } from '../../core/Api.ts';
import { isSuperAgentResponseError } from '../common/errors/ErrorUtils.ts';
import { AuthError } from '../common/errors/MSErrors.ts';
dayjs.extend(isSameOrAfter);
@@ -39,6 +41,7 @@ interface SourceIdentifierData {
export class SubsonicSource extends MemoryPositionalSource {
requiresAuth = true;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
multiPlatform: boolean = true;
@@ -341,7 +344,8 @@ export class SubsonicSource extends MemoryPositionalSource {
this.logger.info('Subsonic API Status: ok');
return true;
} catch (e) {
throw e;
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
throw new AuthError('Failed to authenticate', {cause: e, unrecoverable: superagentError !== undefined && [403,401].includes(superagentError.status)})
}
}
+3 -2
View File
@@ -1,5 +1,5 @@
import type EventEmitter from "events";
import { type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { COMPONENT_AUTH_TYPE, type ComponentAuthType, PARSED_FROM, type PlayObject, SOURCE_SOT } from "../../core/Atomic.ts";
import { isNodeNetworkException } from "../common/errors/NodeErrors.ts";
import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {RecentlyPlayedOptions} from "./AbstractSource.ts";
@@ -13,6 +13,7 @@ import { parseArrayFromMaybeString } from "../utils/StringUtils.ts";
export default class TealfmSource extends MemorySource {
client: TealApiClient;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = false;
@@ -119,7 +120,7 @@ export default class TealfmSource extends MemorySource {
|| (x.meta.musicService === undefined && !this.serviceAllow.includes('unknown'))
);
}
return plays;
return plays.map((x) => ({...x, meta: {...x.meta, parsedFrom: PARSED_FROM.history}}));
}
getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => {
+5 -3
View File
@@ -2,13 +2,13 @@ import { parseRegexSingle, parseToRegex } from "@foxxmd/regex-buddy-core";
import type { EventEmitter } from "events";
import * as VLC from "vlc-client"
import type {VlcMeta, VlcStatus} from "vlc-client/dist/Types.js";
import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import type {ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import {
type FormatPlayObjectOptions,
type InternalConfig,
type PlayerStateData,
} from "../common/infrastructure/Atomic.ts";
import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';
import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';
import type {ReportedPlayerStatus} from '../../core/Atomic.ts';
import type {VlcAudioMeta, VLCSourceConfig, PlayerState} from "../common/infrastructure/config/source/vlc.ts";
@@ -19,6 +19,7 @@ import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";
import { isDebugMode } from "../utils.ts";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
import { artistNamesToCredits } from "../../core/StringUtils.ts";
import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
'playing': REPORTED_PLAYER_STATUSES.playing,
@@ -28,6 +29,7 @@ const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
export class VLCSource extends MemoryPositionalSource {
declare config: VLCSourceConfig;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
host?: string
port?: number
@@ -117,7 +119,7 @@ export class VLCSource extends MemoryPositionalSource {
return true;
} catch (e) {
let friendlyError: string | undefined;
throw new Error(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e});
throw new AuthError(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e});
}
}
+17 -16
View File
@@ -1,6 +1,6 @@
import dayjs, { type Dayjs } from "dayjs";
import type EventEmitter from "events";
import { type PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.ts";
import { PARSED_FROM, type PlayObjectMinimal, SOURCE_SOT } from "../../core/Atomic.ts";
import {
type FormatPlayObjectOptions,
type InternalConfig,
@@ -81,7 +81,7 @@ export class WebScrobblerSource extends MemorySource {
}
}
static playStateFromRequest(obj: WebScrobblerPayload,): PlayerStateData {
static playStateFromRequest(obj: WebScrobblerPayload): PlayerStateData {
const {
eventName,
time = dayjs().unix(),
@@ -121,18 +121,18 @@ export class WebScrobblerSource extends MemorySource {
}
} = obj;
const track = processed.track ?? parsed.track;
const artist = processed.artist ?? parsed.artist;
const album = processed.album ?? parsed.album;
const albumArtist = processed.albumArtist ?? parsed.albumArtist;
const duration = parsed.duration ?? processed.duration;
const track = processed.track ?? parsed.track ?? undefined;
const artist = processed.artist ?? parsed.artist ?? undefined;
const album = processed.album ?? parsed.album ?? undefined;
const albumArtist = processed.albumArtist ?? parsed.albumArtist ?? undefined;
const duration = parsed.duration ?? processed.duration ?? undefined;
const play: PlayObjectMinimal<Dayjs, WebScrobbleMeta> = {
data: {
track,
artists: [artistNameToCredit(artist)],
artists: artist !== undefined ? [artistNameToCredit(artist)] : [],
album: album === null ? undefined : album,
albumArtists: albumArtist === null ? undefined : [artistNameToCredit(albumArtist)],
albumArtists: albumArtist === null ? undefined : albumArtist === undefined ? undefined : [artistNameToCredit(albumArtist)],
playDate: dayjs.unix(startTimestamp),
duration: duration === null ? undefined : duration,
meta: {
@@ -143,7 +143,7 @@ export class WebScrobblerSource extends MemorySource {
},
meta: {
trackId: uniqueID,
parsedFrom: connectorLabel,
parsedFrom: PARSED_FROM.ingress,
url: {
web: trackUrl,
origin: originUrl
@@ -166,8 +166,8 @@ export class WebScrobblerSource extends MemorySource {
return false;
}
if (playObj.meta.parsedFrom !== undefined) {
const lowerSource = playObj.meta.parsedFrom.toLowerCase();
if (playObj.meta.musicService !== undefined) {
const lowerSource = playObj.meta.musicService.toLowerCase();
if (Array.isArray(this.config.data.blacklist) && this.config.data.blacklist.length > 0) {
if (this.config.data.blacklist.some(x => x === lowerSource)) {
this.logger.debug(`Will not scrobble play because it is from a blacklisted connector '${lowerSource}'`);
@@ -196,10 +196,11 @@ export class WebScrobblerSource extends MemorySource {
this.setStatus('Received Play');
}
if (stateData.play.meta.nowPlaying === false) {
const discovered = await this.discover([stateData.play]);
if (discovered.length > 0) {
await this.scrobble(discovered);
}
await this.queuePlay([stateData.play]);
// const discovered = await this.discover([stateData.play]);
// if (discovered.length > 0) {
// await this.scrobble(discovered);
// }
}
}
+8 -4
View File
@@ -1,6 +1,6 @@
import dayjs, { type Dayjs } from "dayjs";
import type EventEmitter from "events";
import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject, type PlayObjectMinimal} from "../../core/Atomic.ts";
import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";
import type {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.ts";
import { Innertube, UniversalCache, Parser, YTNodes, type ApiResponse, type IBrowseResponse, Log, type SessionOptions } from 'youtubei.js';
@@ -113,6 +113,7 @@ const getGoogleOauthOpts = (): GenerateAuthUrlOpts => {
export default class YTMusicSource extends AbstractSource {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
requiresAuth = true;
requiresAuthInteraction = true;
@@ -279,7 +280,11 @@ Redirect URI : ${this.redirectUri}`);
}
reauthenticate = async () => {
await this.tryStopPolling();
try {
await this.tryStopPolling('Reauthenticating yt');
} catch (e) {
this.logger.warn(new Error('Failed to stop polling but will try to reauth anyway', {cause: e}));
}
if(this.authed) {
await this.clearCredentials();
this.authed = false;
@@ -690,8 +695,7 @@ ${humanDiff}`;
if(this.transientDiscovered.data.length === 0) {
// and add to discovered since its empty
for(const refPlay of reversedPlays) {
//this.transientDiscovered.add(refPlay);
await this.addPlayToDB(refPlay);
this.transientDiscovered.add(refPlay);
}
}
}
@@ -1,7 +1,8 @@
import type {Logger} from "@foxxmd/logging";
import type {Request} from "express";
import { parseIdentifiersFromRequest } from "../EndpointLastfmSource.ts";
import { IngressNotifier } from "./IngressNotifier.ts";
import { parseIdentifiersFromRequest } from "../../utils/RequestUtils.ts";
import { requestMatchers } from "../EndpointLastfmSource.ts";
export class LFMEndpointNotifier extends IngressNotifier {
@@ -14,7 +15,7 @@ export class LFMEndpointNotifier extends IngressNotifier {
if(!isRaw) {
const [slug] = parseIdentifiersFromRequest(req);
const [slug] = parseIdentifiersFromRequest(req, requestMatchers);
if(slug === false) {
return [false, `Request URL was not valid: ${req.baseUrl}`];
}
@@ -1,7 +1,8 @@
import type {Logger} from "@foxxmd/logging";
import type {Request} from "express";
import { parseIdentifiersFromRequest } from "../EndpointListenbrainzSource.ts";
import { IngressNotifier } from "./IngressNotifier.ts";
import { parseIdentifiersFromRequest } from "../../utils/RequestUtils.ts";
import { requestMatchers } from "../EndpointListenbrainzSource.ts";
export class LZEndpointNotifier extends IngressNotifier {
@@ -14,7 +15,7 @@ export class LZEndpointNotifier extends IngressNotifier {
if(!isRaw) {
const [slug, token] = parseIdentifiersFromRequest(req);
const [slug, token] = parseIdentifiersFromRequest(req, requestMatchers);
if(slug === false) {
return [false, `Request URL was not valid: ${req.baseUrl}`];
}
@@ -34,6 +34,12 @@ class TestComponent extends AbstractComponent {
protected getIdentifier(): string {
return 'test';
}
public async start(opts?: { forceInit?: boolean; }): Promise<boolean> {
return true;
}
public async stop(opts?: { reason?: string | Error; }): Promise<void> {
return;
}
constructor(config?: Omit<AbstractComponentConfig, 'id'>) {
super({id: `test-${Date.now()}`, ...(config ?? {})});
}
@@ -700,14 +706,14 @@ describe('Play Transforms', function () {
component.buildTransformRules();
const play = generatePlay({ track: 'My cool something track', artists: artistNamesToCredits([str]), playDate: dayjs().subtract(10, 'm') });
const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, 'all');
const transformed = await component.transformPlay(play, TRANSFORM_HOOK.preCompare, {log: 'all'});
expect(transformed.data.track).equal('My cool bar track');
expect(artistCreditsToNames(transformed.data.artists)).eql(primaries.concat(secondaries));
const cachablePlay = clone(play);
const laterDate = dayjs().subtract(5, 'm');
cachablePlay.data.playDate = laterDate;
const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, 'all');
const cacheTransformed = await component.transformPlay(cachablePlay, TRANSFORM_HOOK.preCompare, {log: 'all'});
expect(cacheTransformed.data.track).equal('My cool bar track');
expect(artistCreditsToNames(cacheTransformed.data.artists)).eql(primaries.concat(secondaries));
expect(cacheTransformed.data.playDate.isSame(cachablePlay.data.playDate));
+2 -2
View File
@@ -125,7 +125,7 @@ describe('Migrations', function () {
try {
await fs.cp(path.resolve(projectRootDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true });
const mf = path.resolve('./migrations');
const [db, _] = await getMigratedDb(dbPath, {migrationsFolder: mf});
const [db, _] = await getMigratedDb(dbPath, {migrationsFolder: mf, migrationsAppFolder: process.cwd()});
await migrateDb(db, { migrationsFolder: mf });
const res = await x('drizzle-kit', [
'generate',
@@ -144,7 +144,7 @@ describe('Migrations', function () {
const newMigrationFolder = (await fs.readdir(path.resolve('./migrations/'))).find(x => x.includes('newMigration'));
await fs.appendFile(path.resolve('./migrations/',newMigrationFolder, 'migration.sql'),`\nselect count(*) from plays;`);
await getMigratedDb(dbPath, {migrationsFolder: mf});
await getMigratedDb(dbPath, {migrationsFolder: mf, migrationsAppFolder: process.cwd()});
const contents = await fs.readdir(path.resolve('./'));
const bakPattern = new RegExp(/ms\.db\.\d+\.bak/);
expect(contents.some(x => bakPattern.test(x))).is.true;
+313
View File
@@ -0,0 +1,313 @@
import chai, { expect } from 'chai';
import asPromised from 'chai-as-promised';
import { describe, it } from 'mocha';
import request from 'supertest';
import ScrobbleSources from '../../sources/ScrobbleSources.ts';
import { WildcardEmitter } from '../../common/WildcardEmitter.ts';
import { loggerDebug, loggerTest } from '@foxxmd/logging';
import type { ListenbrainzEndpointSourceConfig } from '../../common/infrastructure/config/source/endpointlz.ts';
import { initServer } from '../../server/index.ts';
import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';
import { zocker } from "zocker";
import { webScrobblePayloadSchema } from '../../common/vendor/webscrobbler/interfaces.ts';
import type { WebScrobblerSourceConfig } from '../../common/infrastructure/config/source/webscrobbler.ts';
import { playToSubmitPayload } from '../../common/vendor/listenbrainz/lzUtils.ts';
import { generatePlay } from '../../../core/tests/utils/PlayTestUtils.ts';
import dayjs from 'dayjs';
import { faker } from '@faker-js/faker';
import * as z from 'zod';
import pEvent from 'p-event';
import type { LastFMEndpointSourceConfig } from '../../common/infrastructure/config/source/endpointlfm.ts';
import { lastfmNowPlayingPayloadSchema, lastfmScrobblePayloadSchema } from '../../common/vendor/LastfmApiClient.ts';
import { removeUndefinedKeys } from '../../../core/DataUtils.ts';
chai.use(asPromised);
const internalConfig = { localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test' };
const defaultLzConfig: ListenbrainzEndpointSourceConfig & {source: string} = {
id: 'test',
enable: true,
data: {},
source: 'file'
};
const defaultWebscrobblerConfig: WebScrobblerSourceConfig & {source: string} = {
id: 'test',
enable: true,
data: {},
source: 'file'
};
const defaultLfmConfig: LastFMEndpointSourceConfig & {source: string} = {
id: 'test',
enable: true,
data: {},
source: 'file'
};
const generateSources = () => new ScrobbleSources(new WildcardEmitter(), internalConfig, loggerTest);
describe('Listenbrainz Endpoint', function() {
let clients: ScrobbleClients;
before(function() {
clients = new ScrobbleClients(new WildcardEmitter(), new WildcardEmitter(), internalConfig, loggerTest);
});
describe('Accepts requests on standard endpoints', function() {
it('accepts requests to /1/validate-token with a config without token', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
const [app] = await initServer({sources, clients}, {testMode: true});
const response = await request(app).get('/1/validate-token')
expect(response.status).eq(200);
expect(response.body.user_name).eq(defaultLzConfig.id);
});
it('accepts requests to /1/validate-token with a config with token', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
await sources.addSource('endpointlz', [{...defaultLzConfig, data: {token: 'foo'}, id: 'tokenTest'}]);
const [app] = await initServer({sources, clients}, {testMode: true});
const response = await request(app)
.get('/1/validate-token')
.set('Authorization', 'Token foo');
expect(response.status).eq(200);
expect(response.body.user_name).eq('tokenTest');
});
it('accepts requests to /1/submit-listens', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
const [app] = await initServer({sources, clients}, {testMode: true});
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [response, _] = await Promise.all([
request(app).post('/1/submit-listens')
.set('Content-Type', 'application/json')
.send(JSON.stringify(playToSubmitPayload(generatePlay()))),
pEvent(source.emitter, 'playInsert', {timeout: 1000})
]);
expect(response.status).eq(200);
expect( source.getApiData().queued).eq(1);
});
it('accepts requests to /1/playing-now', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
const [app] = await initServer({sources, clients}, {testMode: true});
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
await Promise.all([
request(app).post('/1/submit-listens')
.set('Content-Type', 'application/json')
.send(JSON.stringify({...playToSubmitPayload(generatePlay()), listen_type: 'playing_now'})),
//pEvent(source.emitter, 'playInsert', {timeout: 1000})
]);
const response = await request(app).get('/1/user/test/playing-now');
expect(response.body.payload.listens).to.exist;
});
});
describe('Accepts requests on slug endpoints', function() {
it('accepts requests for submit-listens payload on /api/listenbrainz with no slug', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
const [app] = await initServer({sources, clients}, {testMode: true});
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [response, _] = await Promise.all([
request(app).post('/api/listenbrainz')
.set('Content-Type', 'application/json')
.send(JSON.stringify(playToSubmitPayload(generatePlay()))),
pEvent(source.emitter, 'playInsert', {timeout: 1000})
]);
expect(response.status).eq(200);
expect( source.getApiData().queued).eq(1);
});
it('accepts requests for submit-listens payload on /api/listenbrainz with slug', async function() {
const sources = generateSources();
await sources.addSource('endpointlz', [defaultLzConfig]);
await sources.addSource('endpointlz', [{...defaultLzConfig, id: 'foo', data: {slug: 'foobar'}}]);
const [app] = await initServer({sources, clients}, {testMode: true});
const source = sources.sources[1];
source.queueIdleMs = 2;
await source.initialize();
const [response, _] = await Promise.all([
request(app).post('/api/listenbrainz/foobar')
.set('Content-Type', 'application/json')
.send(JSON.stringify(playToSubmitPayload(generatePlay()))),
pEvent(source.emitter, 'playInsert', {timeout: 1000})
])
expect(response.status).eq(200);
expect( source.getApiData().queued).eq(1);
});
});
});
describe('Webscrobbler Endpoint', function() {
let clients: ScrobbleClients;
before(function() {
clients = new ScrobbleClients(new WildcardEmitter(), new WildcardEmitter(), internalConfig, loggerTest);
});
it('accepts request to /api/webscrobbler', async function () {
const sources = generateSources();
await sources.addSource('webscrobbler', [defaultWebscrobblerConfig]);
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [app] = await initServer({ sources, clients }, { testMode: true });
const payload = zocker(webScrobblePayloadSchema)
.override(z.ZodString,() => faker.word.words({ count: { min: 1, max: 5 } }))
.supply(webScrobblePayloadSchema.shape.data.shape.currentlyPlaying, true)
.supply(webScrobblePayloadSchema.shape.time, dayjs().unix())
.supply(webScrobblePayloadSchema.shape.data.shape.song.shape.processed.shape.duration, faker.number.int({ min: 30, max: 400 }))
.supply(webScrobblePayloadSchema.shape.data.shape.song.shape.parsed.shape.duration, faker.number.int({ min: 30, max: 400 }))
.supply(webScrobblePayloadSchema.shape.data.shape.song.shape.parsed.shape.isScrobblingAllowed, true)
.supply(webScrobblePayloadSchema.shape.eventName, 'scrobble').generate()
try {
const [response, _] = await Promise.all([
request(app).post('/api/webscrobbler')
.set('Content-Type', 'application/json')
.send(JSON.stringify(payload)),
pEvent(source.emitter, 'playInsert', { timeout: 1000 })
]);
expect(response.status).eq(200);
expect(source.getApiData().queued, JSON.stringify(payload)).eq(1);
} catch (e) {
console.log(JSON.stringify(payload));
throw e;
}
});
});
describe('Last.fm Endpoint', function () {
let clients: ScrobbleClients;
before(function () {
clients = new ScrobbleClients(new WildcardEmitter(), new WildcardEmitter(), internalConfig, loggerTest);
});
describe('Accepts requests on slug endpoints', function () {
it('accepts request to /api/lastfm with no slug', async function () {
const sources = generateSources();
await sources.addSource('endpointlfm', [defaultLfmConfig]);
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [app] = await initServer({ sources, clients }, { testMode: true, logger: loggerDebug });
const payload = removeUndefinedKeys(zocker(lastfmScrobblePayloadSchema)
.override(z.ZodString, () => faker.word.words({ count: { min: 1, max: 5 } }))
.supply(lastfmScrobblePayloadSchema.shape.duration, faker.number.int({ min: 30, max: 400 }))
.supply(lastfmScrobblePayloadSchema.shape.timestamp, dayjs(faker.date.recent()).unix())
.generate());
try {
const [response] = await Promise.all([
request(app).post('/api/lastfm')
// @ts-expect-error its fine
.send(new URLSearchParams(payload).toString()),
]);
expect(response.status).eq(200);
expect(source.getApiData().queued, JSON.stringify(payload)).eq(1);
} catch (e) {
console.log(JSON.stringify(payload));
throw e;
}
});
});
it('accepts request to /2.0 for auth.getMobileSession', async function () {
const sources = generateSources();
await sources.addSource('endpointlfm', [defaultLfmConfig]);
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [app] = await initServer({ sources, clients }, { testMode: true });
const response = await request(app).post('/2.0')
.set('Accept', 'application/json')
.send(new URLSearchParams({username: 'atest', password: 'anything', api_key: '1234', api_sig: '5678', method: 'auth.getMobileSession'}).toString());
expect(response.status).eq(200);
expect(response.body.session.key).exist;
});
it('accepts request to /2.0 for track.scrobble', async function () {
const sources = generateSources();
await sources.addSource('endpointlfm', [defaultLfmConfig]);
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [app] = await initServer({ sources, clients }, { testMode: true });
const payload = removeUndefinedKeys(zocker(lastfmScrobblePayloadSchema)
.override(z.ZodString, () => faker.word.words({ count: { min: 1, max: 5 } }))
.supply(lastfmScrobblePayloadSchema.shape.duration, faker.number.int({ min: 30, max: 400 }))
.supply(lastfmScrobblePayloadSchema.shape.timestamp, dayjs(faker.date.recent()).unix())
.generate());
try {
const [response] = await Promise.all([
request(app).post('/api/lastfm')
// @ts-expect-error its fine
.send(new URLSearchParams(payload).toString()),
]);
expect(response.status).eq(200);
expect(source.getApiData().queued, JSON.stringify(payload)).eq(1);
} catch (e) {
console.log(JSON.stringify(payload));
throw e;
}
});
it('accepts request to /2.0 for track.updateNowPlaying', async function () {
const sources = generateSources();
await sources.addSource('endpointlfm', [defaultLfmConfig]);
const source = sources.sources[0];
source.queueIdleMs = 2;
await source.initialize();
const [app] = await initServer({ sources, clients }, { testMode: true });
const payload = removeUndefinedKeys(zocker(lastfmNowPlayingPayloadSchema)
.override(z.ZodString, () => faker.word.words({ count: { min: 1, max: 5 } }))
.supply(lastfmScrobblePayloadSchema.shape.duration, faker.number.int({ min: 30, max: 400 }))
.supply(lastfmScrobblePayloadSchema.shape.timestamp, dayjs(faker.date.recent()).unix())
.generate());
try {
const [response] = await Promise.all([
request(app).post('/api/lastfm')
// @ts-expect-error its fine
.send(new URLSearchParams(payload).toString()),
]);
// TODO more accurate verification this is updating now playing
expect(response.status).eq(200);
} catch (e) {
console.log(JSON.stringify(payload));
throw e;
}
});
});
+7 -2
View File
@@ -1,6 +1,6 @@
import EventEmitter from "events";
import request from "superagent";
import type {PlayObject} from "../../../core/Atomic.ts";
import {COMPONENT_AUTH_TYPE, type ComponentAuthType, type PlayObject} from "../../../core/Atomic.ts";
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.ts";
import type {CommonClientConfig, CommonClientOptions, NowPlayingOptions} from "../../common/infrastructure/config/client/index.ts";
import clone from "clone";
@@ -12,6 +12,8 @@ import type { DrizzleQueueRepository } from "../../common/database/drizzle/repos
import type {PlaySelect} from "../../common/database/drizzle/drizzleTypes.ts";
import dayjs from "dayjs";
import type { MarkOptional, MarkRequired } from "ts-essentials";
import { AuthError } from "../../common/errors/MSErrors.ts";
import { isSuperAgentResponseError } from "../../common/errors/ErrorUtils.ts";
export class TestScrobbler extends AbstractScrobbleClient {
@@ -60,8 +62,11 @@ export class TestScrobbler extends AbstractScrobbleClient {
}
export class TestAuthScrobbler extends TestScrobbler {
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor() {
super();
this.requiresAuth = true;
}
doAuthentication = async() => {
@@ -69,7 +74,7 @@ export class TestAuthScrobbler extends TestScrobbler {
await request.get('http://example.com');
return true;
} catch (e) {
throw e;
throw new AuthError('Failed to auth', {cause: e, unrecoverable: isSuperAgentResponseError(e) && [401,403].includes(e.status)});
}
}
}
+32 -32
View File
@@ -6,7 +6,7 @@ import dayjs from "dayjs";
import { after, describe, it } from 'mocha';
import { http, HttpResponse } from 'msw';
import pEvent from 'p-event';
import { CLIENT_INGRESS_QUEUE, type PlayObject, SOURCE_SOT } from "../../../core/Atomic.ts";
import { INGRESS_QUEUE, type PlayObject, QUEUE_STATUS_FAILED, SOURCE_SOT } from "../../../core/Atomic.ts";
import { sleep, sortByOldestPlayDate } from "../../utils.ts";
import { genGroupIdStr } from '../../../core/PlayUtils.ts';
import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' };
@@ -91,10 +91,10 @@ describe('Networking', function () {
async function() {
const authScrobbler = new TestAuthScrobbler();
try {
await authScrobbler.testAuth();
await authScrobbler.initialize();
} catch (e) { /* empty */ }
assert.isTrue(authScrobbler.authGated());
assert.isFalse(authScrobbler.authFailure);
assert.isFalse(authScrobbler.hasUnrecoverableAuthFailure());
}
));
@@ -109,10 +109,10 @@ describe('Networking', function () {
async function() {
const authScrobbler = new TestAuthScrobbler();
try {
await authScrobbler.testAuth();
await authScrobbler.initialize();
} catch (e) { /* empty */ }
assert.isTrue(authScrobbler.authGated());
assert.isTrue(authScrobbler.authFailure);
assert.isTrue(authScrobbler.hasUnrecoverableAuthFailure());
}
));
});
@@ -528,7 +528,7 @@ describe('Upstream Scrobbles', function() {
const sp = spy(scrobbler, 'getScrobblesForTimeRange');
const play = generatePlay({playDate: dayjs().subtract(60, 's')});
await scrobbler.queueScrobble(play, 'test');
await scrobbler.queueScrobble(play);
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
@@ -548,7 +548,7 @@ describe('Upstream Scrobbles', function() {
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
await scrobbler.queueScrobble([play1, play2], 'test');
await scrobbler.queueScrobble([play1, play2]);
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
@@ -569,7 +569,7 @@ describe('Upstream Scrobbles', function() {
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
const play3 = generatePlay({playDate: dayjs().subtract(DEFAULT_CONSOLIDATE_DURATION.add(4, 'm'))});
await scrobbler.queueScrobble([play1, play2, play3], 'test');
await scrobbler.queueScrobble([play1, play2, play3]);
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
@@ -589,7 +589,7 @@ describe('Upstream Scrobbles', function() {
const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
await scrobbler.queueScrobble([play1], 'test');
await scrobbler.queueScrobble([play1]);
const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
scrobbler.startScrobbling(new AbortController().signal).then(() => null);
await emptied;
@@ -597,7 +597,7 @@ describe('Upstream Scrobbles', function() {
MockDate.set(dayjs().add(REFRESH_STALE_DEFAULT + 1, 's').toDate());
const emptied2 = pEvent(scrobbler.emitter, 'queueEmptied');
await scrobbler.queueScrobble([play2], 'test');
await scrobbler.queueScrobble([play2]);
await emptied2;
scrobbler.tryStopScrobbling().then(() => null);
expect(sp.calledTwice).is.true;
@@ -613,16 +613,16 @@ describe('Dead Scrobbles', function() {
await testScrobbler.initialize();
testScrobbler.testRecentScrobbles = [];
const queuedPlayed = await testScrobbler.playRepoTest.createPlays(generateArray<RepositoryCreatePlayOpts>(3, () => ({ ...fixtureCreatePlay(), state: 'queued', input: {} })))
const queuedPlays = await testScrobbler.playRepoTest.createPlays(generateArray<RepositoryCreatePlayOpts>(3, () => ({ ...fixtureCreatePlay(), state: 'failed', input: {} })));
for(const dead of queuedPlayed) {
await testScrobbler.addDeadLetterScrobble(dead);
await testScrobbler.queueRepoTest.createMany(queuedPlays.map(x => ({queueName: INGRESS_QUEUE, queueStatus: QUEUE_STATUS_FAILED, retries: 1, playId: x.id, componentId: testScrobbler.componentId})));
await testScrobbler.processDeadLetterQueue(undefined, undefined, true);
const updatedPlays = await testScrobbler.playRepoTest.findPlays({uid: queuedPlays.map(x => x.uid)});
for(const p of updatedPlays) {
expect(p.state === 'scrobbled');
}
await testScrobbler.processDeadLetterQueue();
await pEvent(testScrobbler.emitter, 'queueState');
expect(testScrobbler.deadLetterQueued).eq(0);
});
});
@@ -677,8 +677,8 @@ describe('Scrobble client uses transform plays correctly', function() {
const newScrobble = generatePlay({
track: 'my cool track'
});
await testScrobbler.queueScrobble(newScrobble, 'test');
const queuedPlayedData = await testScrobbler.playRepoTest.getQueued(CLIENT_INGRESS_QUEUE);
await testScrobbler.queueScrobble(newScrobble);
const queuedPlayedData = await testScrobbler.playRepoTest.getQueued(INGRESS_QUEUE);
expect(queuedPlayedData.data[0].play.data.track).is.eq('my cool track');
testScrobbler.scrobbleSleep = 100;
testScrobbler.initScrobbleMonitoring().catch(console.error);
@@ -763,7 +763,7 @@ describe('Manages scrobble queue', function() {
const newScrobble = generatePlay({
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
});
await testScrobbler.queueScrobble(newScrobble, 'test');
await testScrobbler.queueScrobble(newScrobble);
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(3000)]);
assert.isDefined(res);
@@ -780,13 +780,13 @@ describe('Manages scrobble queue', function() {
const dupScrobble = clone(newScrobble);
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
await testScrobbler.queueScrobble(newScrobble, 'test');
await testScrobbler.queueScrobble(newScrobble);
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res);
assert.isDefined(res.data);
await testScrobbler.queueScrobble(dupScrobble, 'test');
await testScrobbler.queueScrobble(dupScrobble);
const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);
assert.isUndefined(resDup);
@@ -802,8 +802,8 @@ describe('Manages scrobble queue', function() {
const dupScrobble = clone(newScrobble);
dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');
await testScrobbler.queueScrobble(newScrobble, 'test');
await testScrobbler.queueScrobble(dupScrobble, 'test');
await testScrobbler.queueScrobble(newScrobble);
await testScrobbler.queueScrobble(dupScrobble);
const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);
assert.isDefined(res);
@@ -830,7 +830,7 @@ describe('Manages scrobble queue', function() {
testScrobbler.scrobbleDelay = 600;
await testScrobbler.queueScrobble([newScrobble1, newScrobble2, newScrobble3], 'test');
await testScrobbler.queueScrobble([newScrobble1, newScrobble2, newScrobble3]);
await pEvent(testScrobbler.emitter, 'scrobble');
const initial = dayjs();
await pEvent(testScrobbler.emitter, 'scrobble');
@@ -1187,7 +1187,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(10)
]);
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.empty;
});
@@ -1214,7 +1214,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(100)
])
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.not.empty;
});
@@ -1241,7 +1241,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(50)
])
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.not.empty;
});
@@ -1268,7 +1268,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(50)
])
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.not.empty;
});
@@ -1295,7 +1295,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(50)
])
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.not.empty;
});
@@ -1322,7 +1322,7 @@ describe('Scrobble Clients Behavior', function() {
pEvent(testClient.emitter, 'scrobbleQueued'),
sleep(50)
])
const queued = await testClient.getQueued(CLIENT_INGRESS_QUEUE);
const queued = await testClient.getQueued(INGRESS_QUEUE);
expect(queued.data).is.not.empty;
});

Some files were not shown because too many files have changed in this diff Show More