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 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 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
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
146 changed files with 13351 additions and 3216 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 `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/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));
}
}
+522 -20
View File
@@ -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",
+9 -2
View File
@@ -6,6 +6,7 @@
"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"
+53 -24
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,7 +33,7 @@ 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";
@@ -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) {
@@ -453,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 {
@@ -484,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;
@@ -571,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;
@@ -589,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()
}
}
@@ -607,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);
}
@@ -628,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 }
+7 -3
View File
@@ -23,7 +23,7 @@ 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';
@@ -187,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)});
}
}
@@ -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";
@@ -112,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`);
@@ -216,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})}`;
@@ -345,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 () => {
+12
View File
@@ -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',
@@ -154,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