mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54159f9b9f | ||
|
|
9b9176ccf1 | ||
|
|
4a974a83d9 | ||
|
|
232c802b44 | ||
|
|
e335b44abd | ||
|
|
115911c63a | ||
|
|
f45493d10e | ||
|
|
574b723fa4 | ||
|
|
02f1eca649 | ||
|
|
212afaa5e5 | ||
|
|
006fbe1079 | ||
|
|
5cecac937b | ||
|
|
297c7dee95 | ||
|
|
42a6887141 | ||
|
|
cbdb58b36e | ||
|
|
ae20d46456 | ||
|
|
6b12bd2ef2 | ||
|
|
dde19cfc83 | ||
|
|
0938e95e1d | ||
|
|
3a40478cb1 | ||
|
|
935845a976 | ||
|
|
b494e8f93f | ||
|
|
9e13ead40f | ||
|
|
3b2f5ee1c8 | ||
|
|
51de79b92f | ||
|
|
b6f7e0e586 | ||
|
|
c69af47978 | ||
|
|
edde8f9e74 | ||
|
|
72d702acf0 | ||
|
|
67b13ffe40 | ||
|
|
b464c7507f | ||
|
|
3cf5b82247 | ||
|
|
1571f06662 | ||
|
|
b3fb04a6ae | ||
|
|
cb2a6168fd | ||
|
|
56fcbedff9 | ||
|
|
548c614b0a | ||
|
|
d3469aee7a | ||
|
|
0109cc6fcd | ||
|
|
1988fc5f54 | ||
|
|
aa93600166 | ||
|
|
09e9d0f79a | ||
|
|
9c84e2d8e3 | ||
|
|
4b8d4932aa | ||
|
|
970e200e76 | ||
|
|
3e05b3f964 | ||
|
|
86b8b4ad90 | ||
|
|
64d160782b | ||
|
|
39f8367392 | ||
|
|
62905e8010 | ||
|
|
203f4c343e | ||
|
|
3b93073694 | ||
|
|
649280582e | ||
|
|
0ad86ad692 | ||
|
|
e80f35f1b1 | ||
|
|
a885079a07 | ||
|
|
ab994770ab | ||
|
|
17d7882710 | ||
|
|
1dcbf92fed | ||
|
|
8686309993 | ||
|
|
b83f8872c2 | ||
|
|
9f22c56e87 | ||
|
|
6b86ef8629 | ||
|
|
d0001d2f62 | ||
|
|
e381be2f7e | ||
|
|
c1e9757aed | ||
|
|
61fbc39a91 | ||
|
|
4c764879d7 | ||
|
|
692b2160bf | ||
|
|
344c0a15ad | ||
|
|
f44835dfba | ||
|
|
204cc0bdb8 | ||
|
|
4b7542ddd9 | ||
|
|
1fc3ad1bd4 | ||
|
|
9f34929140 | ||
|
|
1178b0b6fb | ||
|
|
cad42cba04 | ||
|
|
68b7385f31 | ||
|
|
9a93f13a7d | ||
|
|
649ccde43d | ||
|
|
fc06c6a8a9 | ||
|
|
d089b960f9 | ||
|
|
c210590c8f | ||
|
|
78aa418b7d | ||
|
|
48cc18d373 | ||
|
|
258cd61b4b | ||
|
|
185ccdf71c | ||
|
|
b9dad6149b | ||
|
|
9ae74a193f | ||
|
|
99b30956ba | ||
|
|
93a5775363 | ||
|
|
e8a998ddda | ||
|
|
1b02dc684e | ||
|
|
23813a65ef | ||
|
|
0583c66743 | ||
|
|
cd4e88b232 | ||
|
|
8be739efb4 | ||
|
|
3f2e29c5c8 | ||
|
|
b109157350 | ||
|
|
ce15c4049c | ||
|
|
5a7c01a6d9 | ||
|
|
befd1ac043 | ||
|
|
714678fdab | ||
|
|
2a58edd9b2 | ||
|
|
abfceeb5f8 | ||
|
|
d7c88d4191 | ||
|
|
a44fe48314 | ||
|
|
29ffc6b804 | ||
|
|
3bdc9447b8 | ||
|
|
4a27806825 | ||
|
|
b8cd53f554 | ||
|
|
e7218074f8 | ||
|
|
a48ada9ac3 | ||
|
|
bc28de66b1 | ||
|
|
bc18993cd3 | ||
|
|
d280ca5dd2 | ||
|
|
155d8a3252 | ||
|
|
68d9417cf7 |
@@ -29,7 +29,6 @@
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"TakumiI.markdowntable",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"bowlerr.sqlite-intelliview-vscode"
|
||||
|
||||
+1
-1
@@ -159,4 +159,4 @@ storybook-static
|
||||
|
||||
bun.lock
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
Vendored
+1
-6
@@ -6,13 +6,8 @@
|
||||
"**/build": true,
|
||||
"**/schema": true
|
||||
},
|
||||
"tailwindCSS.files.exclude": [
|
||||
"docsite/**"
|
||||
],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss",
|
||||
"**/config/*.json": "jsonc"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "src/client/index.css",
|
||||
}
|
||||
|
||||
}
|
||||
+4
-1
@@ -18,4 +18,7 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lib
|
||||
lib
|
||||
|
||||
docs/api/
|
||||
static/openapi.json
|
||||
@@ -3,6 +3,7 @@ import type { Config } from '@docusaurus/types';
|
||||
import * as themes from 'prism-react-renderer';
|
||||
import * as Renderers from './lib/socialCard/ImageRenderers.js';
|
||||
import path from 'node:path';
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
let baseSite: string = 'https://foxxmd.github.io';
|
||||
@@ -32,7 +33,7 @@ const config: Config = {
|
||||
v4: {
|
||||
removeLegacyPostBuildHeadAttribute: true, // required
|
||||
},
|
||||
experimental_faster: {
|
||||
faster: {
|
||||
ssgWorkerThreads: true,
|
||||
rspackBundler: true, // required flag
|
||||
rspackPersistentCache: true, // new flag
|
||||
@@ -75,20 +76,29 @@ const config: Config = {
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
docItemComponent: "@theme/ApiItem",
|
||||
routeBasePath: '/',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: (params: {
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `${path.join('https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
}
|
||||
version: string;
|
||||
versionDocsDirPath: string;
|
||||
docPath: string;
|
||||
permalink: string;
|
||||
locale: string;
|
||||
}) => {
|
||||
return `https://github.com/FoxxMD/multi-scrobbler/blob/master${path.join('/docsite', params.versionDocsDirPath, params.docPath)}?plain=1`;
|
||||
},
|
||||
// based on this https://github.com/facebook/docusaurus/issues/6127#issuecomment-3937124499
|
||||
// to filter out the autogenerated api folder sidebar item
|
||||
sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
const items = await defaultSidebarItemsGenerator(args);
|
||||
const filteredItems = items.filter(item => !(item.type === 'category' && item.label === 'api'));
|
||||
return filteredItems
|
||||
},
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
@@ -100,7 +110,7 @@ const config: Config = {
|
||||
blog: false,
|
||||
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
customCss: ['./src/css/custom.css','./src/css/openapiBase.css','./src/css/evergreen.css'],
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
@@ -122,10 +132,11 @@ const config: Config = {
|
||||
// ```
|
||||
},
|
||||
],
|
||||
'docusaurus-theme-github-codeblock'
|
||||
'docusaurus-theme-github-codeblock',
|
||||
"docusaurus-theme-openapi-docs"
|
||||
],
|
||||
plugins: [
|
||||
'docusaurus-plugin-sass',
|
||||
'docusaurus-plugin-sass',
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
@@ -167,7 +178,38 @@ const config: Config = {
|
||||
'docusaurus-plugin-content-pages': Renderers.docs,
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
[
|
||||
'docusaurus-plugin-openapi-docs',
|
||||
{
|
||||
id: "api", // plugin id
|
||||
docsPluginId: "classic", // configured for preset-classic
|
||||
config: {
|
||||
ms: {
|
||||
specPath: "static/openapi.json",
|
||||
outputDir: "docs/api",
|
||||
showInfoPage: true,
|
||||
maskCredentials: false, // Disable credential masking in code snippets
|
||||
sidebarOptions: {
|
||||
groupPathsBy: "tag",
|
||||
},
|
||||
} satisfies OpenApiPlugin.Options,
|
||||
}
|
||||
},
|
||||
],
|
||||
// [
|
||||
// '@docusaurus/plugin-content-docs',
|
||||
// {
|
||||
// sidebarItemsGenerator: async function ({ defaultSidebarItemsGenerator, ...args }) {
|
||||
// const items = await defaultSidebarItemsGenerator(args);
|
||||
// for(const i of items) {
|
||||
// console.log(i);
|
||||
// }
|
||||
// //const filteredItems = items.filter(item => item.type !== 'doc' || !item.id.endsWith('/index'));
|
||||
// //return filteredItems
|
||||
// },
|
||||
// }
|
||||
// ]
|
||||
],
|
||||
themeConfig:
|
||||
{
|
||||
@@ -193,6 +235,11 @@ const config: Config = {
|
||||
position: 'left',
|
||||
label: 'Config Playground',
|
||||
},
|
||||
{
|
||||
to: 'api/multi-scrobbler-api',
|
||||
position: 'left',
|
||||
label: 'API',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
label: 'GitHub',
|
||||
|
||||
Generated
+2796
-904
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -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"
|
||||
},
|
||||
@@ -69,6 +71,7 @@
|
||||
"overrides": {
|
||||
"lodash": "4.18.1",
|
||||
"satori": "0.25.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.6"
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ const validator = createAjvValidator({
|
||||
onCreateAjv: (ajv) => {
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function SchemaEditorInner(props: SchemaEditorProps): JSX.Element {
|
||||
onCreateAjv: (ajv) => {
|
||||
const a = new Ajv2020();
|
||||
a.addKeyword('deprecationMessage');
|
||||
a.addKeyword('zockerId');
|
||||
f.default(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Dashboard for Multi Scrobbler"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app. aaaa</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index-next.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+513
-1085
File diff suppressed because it is too large
Load Diff
+10
-18
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.16.4",
|
||||
"version": "0.17.0-RC1",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema:docs",
|
||||
"schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts",
|
||||
"openapi:docs": "node src/backend/utils/OpenApiUtils.ts",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "mocha --reporter spec",
|
||||
@@ -18,7 +19,7 @@
|
||||
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
|
||||
"docs:install": "cd docsite && npm install --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
|
||||
"docs:build": "npm run -s schema:docs && npm run -s openapi:docs && cd docsite && npm run build",
|
||||
"docs:netlify": "npm run -s docs:install && npm run -s docs:build",
|
||||
"postinstall": "patch-package",
|
||||
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@keyv/valkey": "^1.0.8",
|
||||
"@lukehagar/plexjs": "^0.39.0",
|
||||
"@minisylar/express-typed-router": "^1.9.8",
|
||||
"@pierre/diffs": "^1.2.4",
|
||||
"@rocksky/sdk": "^0.3.0",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
@@ -157,21 +159,17 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@flamefrontend/sse-runtime-react": "^0.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@lukemorales/query-key-factory": "^1.3.4",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"@react-hook/window-size": "^3.1.1",
|
||||
"@react-nano/use-event-source": "^0.16.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@storybook/addon-a11y": "^10.4.3",
|
||||
"@storybook/addon-docs": "^10.4.3",
|
||||
"@storybook/react-vite": "^10.4.3",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/form-core": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-form": "^2.0.0-alpha.2",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
@@ -195,25 +193,23 @@
|
||||
"@types/qs": "^6.15.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/spotify-web-api-node": "^5.0.7",
|
||||
"@types/superagent": "^8.1.9",
|
||||
"@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",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^8.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^1.0.0-rc.2-c5a84d1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"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",
|
||||
@@ -228,27 +224,23 @@
|
||||
"msw": "^2.14.6",
|
||||
"msw-storybook-addon": "^2.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemon": "^3.0.3",
|
||||
"playwright": "^1.58.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sass": "^1.99.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sinon": "^21.0.2",
|
||||
"storybook": "^10.4.3",
|
||||
"storybook-addon-remix-react-router": "^6.1.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"supertest": "^7.2.2",
|
||||
"tinyexec": "^1.1.1",
|
||||
"ts-essentials": "^10.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"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"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/node_modules/spotify-web-api-node/src/http-manager.js b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
index 6c3991f..89e7a99 100644
|
||||
--- a/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
+++ b/node_modules/spotify-web-api-node/src/http-manager.js
|
||||
@@ -63,6 +63,11 @@ HttpManager._makeRequest = function(method, options, uri, callback) {
|
||||
req.send(options.data);
|
||||
}
|
||||
|
||||
+ req.timeout({
|
||||
+ response: 5000, // Wait 5 seconds for the server to start sending,
|
||||
+ deadline: 9000, // but allow 9 seconds for entire response to complete
|
||||
+ });
|
||||
+
|
||||
req.end(function(err, response) {
|
||||
if (err) {
|
||||
if (err.timeout) {
|
||||
@@ -7,7 +7,7 @@ import {MONITORING_ORIGIN_SYSTEM, MONITORING_ORIGIN_USER, type ComponentType, ty
|
||||
import { buildTrackString } from "../../core/StringUtils.ts";
|
||||
import type {CommonClientConfig} from "./infrastructure/config/client/index.ts";
|
||||
import type {CommonSourceConfig} from "./infrastructure/config/source/index.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import { mergeSimpleError, SimpleError, SkipTransformStageError, StageChangeError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.ts";
|
||||
import {
|
||||
FLOW_CONTROL_TERM,
|
||||
type PlayTransformRules,
|
||||
@@ -33,13 +33,13 @@ import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isComp
|
||||
import type {DbConcrete} from "./database/drizzle/drizzleUtils.ts";
|
||||
import type {ComponentSelect} from "./database/drizzle/drizzleTypes.ts";
|
||||
import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.ts";
|
||||
import type {ClientType, MonitoringStatus} from "../../core/Atomic.ts";
|
||||
import type {ClientType, MonitoringStatus, OptionalCacheUsage} from "../../core/Atomic.ts";
|
||||
import type {SourceType} from "../../core/Atomic.ts";
|
||||
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { COMPONENT_STATE, type ComponentCommonApi, type ComponentCommonApiJson, type ComponentState, type PlayApiCommonDetailed } from "../../core/Api.ts";
|
||||
import type {WebhookPayload} from "./infrastructure/config/health/webhooks.ts";
|
||||
import type { MarkRequired } from "ts-essentials";
|
||||
import type { ElementOf, MarkRequired } from "ts-essentials";
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
|
||||
@@ -67,7 +67,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
type: ClientType | SourceType;
|
||||
name: string;
|
||||
|
||||
lastActiveAt?: Dayjs
|
||||
lastActiveAt?: Dayjs;
|
||||
lastReadyAt?: Dayjs;
|
||||
protected lastUpdatedComponentDatesAt?: Dayjs
|
||||
|
||||
@@ -116,8 +116,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
name: this.config?.name ?? this.name
|
||||
});
|
||||
this.componentId = this.dbComponent.id;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt;
|
||||
this.lastActiveAt = this.dbComponent.lastActiveAt ?? undefined;
|
||||
this.lastReadyAt = this.dbComponent.lastReadyAt ?? undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -221,6 +221,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
public abstract stop(opts?: {reason?: string | Error}): Promise<void>
|
||||
public abstract start(opts?: {forceInit?: boolean}): Promise<boolean>
|
||||
|
||||
public async restart(opts: { reason?: string | Error, forceInit?: boolean } = {}): Promise<void> {
|
||||
try {
|
||||
await this.stop(opts);
|
||||
await this.start(opts);
|
||||
} catch (e) {
|
||||
const err = new StageChangeError('Failed to restart', { cause: e });
|
||||
this.replaceErrors(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public retentionCleanup = async () => {
|
||||
if(this.databaseOK !== true) {
|
||||
this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
|
||||
@@ -233,7 +247,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus('Retention cleanup finished');
|
||||
} catch (e) {
|
||||
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
|
||||
this.warning = retentionErr;
|
||||
this.warnings.push(retentionErr);
|
||||
this.logger.warn(retentionErr);
|
||||
this.setStatus('Retention cleanup failed');
|
||||
}
|
||||
@@ -248,7 +262,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
return partArr.map(x => this.transformManager.parseTransformerConfig(x));
|
||||
}
|
||||
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, log?: boolean | 'all') => {
|
||||
public transformPlay = async (play: PlayObject, hookType: TransformHook, transformOpts: {log?: boolean | 'all'} & OptionalCacheUsage = {}) => {
|
||||
|
||||
const {
|
||||
log,
|
||||
useCachedResult = true
|
||||
} = transformOpts;
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
@@ -280,7 +299,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? isDebugMode();
|
||||
|
||||
const transformHash = `playTransform-${hashObject(hook)}-${hashObject(playContentInvariantTransform(play))}`;
|
||||
const cachedSteps = await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash);
|
||||
const cachedSteps = useCachedResult ? await this.cache.cacheTransform.get<LifecycleStep[]>(transformHash) : undefined;
|
||||
if(cachedSteps !== undefined) {
|
||||
logger.trace(`Cache hit for Steps => ${transformHash}`);
|
||||
//return cachedTransformPlay;
|
||||
@@ -294,7 +313,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
const opts = {
|
||||
logger,
|
||||
asyncId
|
||||
asyncId,
|
||||
useCachedResult
|
||||
}
|
||||
|
||||
if(cachedSteps !== undefined) {
|
||||
@@ -343,7 +363,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
const historyToDiff: {name: string, data?: PlayData}[] = [
|
||||
{name: 'Pre Transform', data: play.data}
|
||||
];
|
||||
let isNew = false;
|
||||
let isNew = false,
|
||||
diffFailure = false;
|
||||
if(steps.length > 0 && transformedPlay.lifecycle === undefined) {
|
||||
transformedPlay.lifecycle = steps;
|
||||
isNew = true;
|
||||
@@ -352,6 +373,11 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
lifecycle = []
|
||||
} = transformedPlay;
|
||||
steps.forEach((s, index) => {
|
||||
if(diffFailure) {
|
||||
return;
|
||||
}
|
||||
const stepIdHint = `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`;
|
||||
try {
|
||||
if(!isNew) {
|
||||
const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier());
|
||||
if(existingStepIndex !== -1) {
|
||||
@@ -363,12 +389,24 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
if(shouldLog === 'all') {
|
||||
if(s.patch === undefined) {
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`});
|
||||
historyToDiff.push({name: `${stepIdHint}`});
|
||||
} else {
|
||||
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
|
||||
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
const lastTransformedData = historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data;
|
||||
const patched = patchObject(lastTransformedData, s.patch);
|
||||
historyToDiff.push({name: `${stepIdHint} ${s.cached ? ' (Cached)' : ''}`, data: patched});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if(`patch` in s) {
|
||||
this.logger.debug({
|
||||
patch: s.patch,
|
||||
playData: historyToDiff[historyToDiff.findLastIndex(x => x.data !== undefined)].data,
|
||||
cached: s.cached
|
||||
}, 'Patch and Play data context');
|
||||
}
|
||||
this.logger.warn(new SimpleError(`Error occurred while trying to generate diffed Plays for console logging on step ${stepIdHint} but will continue`, {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
});
|
||||
if(shouldLog !== false) {
|
||||
if(steps.filter(x => x.patch !== undefined).length === 0) {
|
||||
@@ -376,25 +414,45 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
} else {
|
||||
const diffs: string[] = [];
|
||||
|
||||
try {
|
||||
let lastTransformed: ElementOf<typeof historyToDiff>;
|
||||
historyToDiff.forEach((curr, index) => {
|
||||
if(index === 0) {
|
||||
lastTransformed = curr;
|
||||
return;
|
||||
}
|
||||
const last = historyToDiff[index - 1];
|
||||
if(curr.data === undefined) {
|
||||
diffs.push(`${last.name} => ${curr.name} -- No Change`);
|
||||
} else {
|
||||
const formattedDiff = diffObjectsConsoleOutput(last.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(lastTransformed.data, curr.data);
|
||||
diffs.push(`${last.name} => ${curr.name}\n${formattedDiff}`);
|
||||
lastTransformed = curr;
|
||||
} catch(e) {
|
||||
this.logger.debug({pre: lastTransformed.data, post: curr.data}, 'Compared Pre and Post play data');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
|
||||
if(shouldLog === true || steps.filter(x => x.patch !== undefined).length > 2) {
|
||||
const finalData = transformedPlay.data;
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
try {
|
||||
const formattedDiff = diffObjectsConsoleOutput(play.data, finalData, true);
|
||||
diffs.push(`Original => Final\n${formattedDiff}`);
|
||||
} catch (e) {
|
||||
this.logger.warn(new SimpleError('Error occurred while trying to generate Play diffs for console but will continue', {cause: e}));
|
||||
diffFailure = true;
|
||||
}
|
||||
}
|
||||
if(diffFailure) {
|
||||
diffs.push('A failure during diff generation occurred but the transform was successful.');
|
||||
}
|
||||
|
||||
logger.debug(`Transform Diff\n${diffs.join('\n')}`);
|
||||
}
|
||||
}
|
||||
@@ -415,17 +473,18 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
}
|
||||
}
|
||||
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
protected generateStepFromStage = async (playTruth: PlayObject, hookItem: StageConfig, hookType: TransformHook, opts: { logger?: Logger, asyncId?: string } & OptionalCacheUsage = {}): Promise<[LifecycleStep, PlayObject]> => {
|
||||
const {
|
||||
onSuccess = 'continue',
|
||||
onFailure = 'stop',
|
||||
onSkip = 'continue',
|
||||
failureReturnPartial = false
|
||||
failureReturnPartial = false,
|
||||
} = hookItem;
|
||||
|
||||
const {
|
||||
logger = loggerNoop,
|
||||
asyncId = nanoid(6)
|
||||
asyncId = nanoid(6),
|
||||
useCachedResult
|
||||
} = opts;
|
||||
|
||||
const {
|
||||
@@ -446,7 +505,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
stageName: string = 'Unnamed',
|
||||
err: Error;
|
||||
try {
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, asyncId);
|
||||
[newTransformedPlay, stageName] = await this.transformManager.handleStage(hookItem, playTruth, {asyncId, useCachedResult});
|
||||
newTransformedPlay = clone(newTransformedPlay);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
@@ -533,7 +592,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
|
||||
public abstract getRunningState(): ComponentState
|
||||
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
|
||||
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players' | 'deadLetterPlays' | 'deadLetterPlaysTotal' | 'queued'> & Pick<ComponentCommonApi, 'state' | 'errors' | 'warnings'> {
|
||||
let state: ComponentState;
|
||||
if(!this.initializedOnce || this.initializing) {
|
||||
state = COMPONENT_STATE.INITIALIZING;
|
||||
@@ -551,10 +610,10 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
monitoringStatus: this.getMonitoringStatus(),
|
||||
countNonLive: this.dbComponent.countNonLive,
|
||||
createdAt: this.dbComponent.createdAt?.toISOString(),
|
||||
lastReadyAt: this.lastReadyAt?.toISOString(),
|
||||
lastActiveAt: this.lastActiveAt?.toISOString(),
|
||||
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
|
||||
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
|
||||
lastReadyAt: this.lastActiveAt !== undefined ? this.lastReadyAt.toISOString() : undefined,
|
||||
lastActiveAt: this.lastActiveAt !== undefined ? this.lastActiveAt?.toISOString() : undefined,
|
||||
errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x),
|
||||
...this.additionalApiData()
|
||||
}
|
||||
}
|
||||
@@ -569,12 +628,20 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
});
|
||||
}
|
||||
|
||||
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('error' in payload && payload.error instanceof Error) {
|
||||
payload.error = serializeError(payload.error);
|
||||
public emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
|
||||
if('errors' in payload) {
|
||||
if(payload.errors.length > 0) {
|
||||
payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.errors = [];
|
||||
}
|
||||
}
|
||||
if('warning' in payload && payload.warning instanceof Error) {
|
||||
payload.warning = serializeError(payload.warning);
|
||||
if('warnings' in payload) {
|
||||
if(payload.warnings.length > 0) {
|
||||
payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x);
|
||||
} else {
|
||||
payload.warnings = [];
|
||||
}
|
||||
}
|
||||
this.emitEvent('componentUpdate', payload);
|
||||
}
|
||||
@@ -590,7 +657,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
|
||||
this.setStatus(payload.title);
|
||||
}
|
||||
|
||||
protected setStatus = (status: string) => {
|
||||
public setStatus = (status: string) => {
|
||||
this.status = status;
|
||||
this.emitComponentUpdate({status});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_inputs` ADD `playHash` text;
|
||||
+1104
File diff suppressed because it is too large
Load Diff
+11
@@ -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`);
|
||||
+1206
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE `play_queue_states` ADD `context` text;
|
||||
+1216
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -13,14 +13,16 @@ import { type AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjec
|
||||
import type {LastfmData} from "../infrastructure/config/client/lastfm.ts";
|
||||
import AbstractApiClient from "./AbstractApiClient.ts";
|
||||
import { normalizeStr, parseArtistCredits } from "../../utils/StringUtils.ts";
|
||||
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams } from 'lastfm-ts-api';
|
||||
import { LastFMUser, LastFMAuth, LastFMTrack, type LastFMUserGetRecentTracksResponse, type LastFMBooleanNumber, type LastFMUpdateNowPlayingResponse, type LastFMUserGetInfoResponse, type LastFMUserGetRecentTracksParams, LastFMResponseError } from 'lastfm-ts-api';
|
||||
import clone from 'clone';
|
||||
import type { IncomingMessage } from "http";
|
||||
import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts";
|
||||
import { ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
|
||||
import { AuthError, ScrobbleSubmitError, SimpleError } from "../errors/MSErrors.ts";
|
||||
import { redactString } from "@foxxmd/redact-string";
|
||||
import dns from 'node:dns/promises';
|
||||
import xml2js from 'xml2js';
|
||||
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
|
||||
import * as z from 'zod';
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -30,6 +32,12 @@ const badErrors = [
|
||||
'invalid parameters'
|
||||
];
|
||||
|
||||
const unrecoverableAuthErrorCodes = [
|
||||
10, // invalid api key
|
||||
13, // invalid method signature
|
||||
26, // api key suspended
|
||||
]
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
@@ -247,7 +255,7 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
if (this.sessionKey === undefined) {
|
||||
this.logger.warn('No session key found. User interaction for authentication required.');
|
||||
this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`);
|
||||
throw new SimpleError('No session key found. User interaction for authentication required.');
|
||||
throw new AuthError('No session key found. User interaction for authentication required.', {unrecoverable: false});
|
||||
}
|
||||
try {
|
||||
// existing lastfm clients are ok with getting user from getInfo
|
||||
@@ -269,11 +277,32 @@ export default class LastfmApiClient extends AbstractApiClient implements Pagina
|
||||
this.logger.info(`Client authorized for user ${this.user}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
if(e instanceof AuthError) {
|
||||
throw e;
|
||||
}
|
||||
this.logger.error('Testing auth failed');
|
||||
if(isNodeNetworkException(e)) {
|
||||
this.logger.error(`Could not communicate with ${this.upstreamName} API`);
|
||||
throw new AuthError('Testing auth failed', {cause: e, unrecoverable: false});
|
||||
}
|
||||
throw e;
|
||||
let unrecoverable: boolean;
|
||||
if(e instanceof LastFMResponseError) {
|
||||
try {
|
||||
const errorContent = JSON.parse(e.content);
|
||||
if(`error` in errorContent) {
|
||||
unrecoverable = unrecoverableAuthErrorCodes.includes(errorContent.error);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
if(unrecoverable === undefined) {
|
||||
const errorWithMessage = findCauseByFunc(e, (ee) => `response` in ee) as Error & {response: IncomingMessage} | undefined;
|
||||
if(errorWithMessage !== undefined) {
|
||||
unrecoverable = [401,403].includes(errorWithMessage.response.statusCode);
|
||||
}
|
||||
}
|
||||
throw new AuthError('Testing auth failed', {cause: e, unrecoverable});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +570,7 @@ export class LastFMIgnoredScrobble extends UpstreamError {
|
||||
|
||||
}
|
||||
|
||||
export const scrobblePayloadToPlay = (obj: LastFMScrobbleRequestPayload): PlayObject => {
|
||||
export const scrobblePayloadToPlay = (obj: LastFmSingleSubmitPayload): PlayObject => {
|
||||
const {
|
||||
artist,
|
||||
track,
|
||||
@@ -812,6 +841,72 @@ export interface LastFMScrobblePayload {
|
||||
mbid?: string
|
||||
}
|
||||
|
||||
// bodies arrive as urlencoded form data, so numeric fields are always strings (or string[] for batch keys) on the wire
|
||||
const lastfmNumericStringSchema = z.coerce.number().int().positive();
|
||||
|
||||
export const lastfmSubmitPayloadSchema = z.object({
|
||||
artist: z.string(),
|
||||
track: z.string(),
|
||||
album: z.string().optional(),
|
||||
albumArtist: z.string().optional(),
|
||||
mbid: z.string().optional(),
|
||||
duration: lastfmNumericStringSchema.optional(),
|
||||
sk: z.string().optional(),
|
||||
api_key: z.string().optional()
|
||||
});
|
||||
|
||||
export const lastfmSubmitMultiPayloadSchema = z.object({
|
||||
artist: z.array(z.string()),
|
||||
track: z.array(z.string()),
|
||||
album: z.array(z.string()).optional(),
|
||||
albumArtist: z.array(z.string()).optional(),
|
||||
mbid: z.array(z.string()).optional(),
|
||||
duration: z.array(lastfmNumericStringSchema).optional(),
|
||||
sk: z.string().optional(),
|
||||
api_key: z.string().optional()
|
||||
});
|
||||
|
||||
export const lastfmScrobblePayloadSchema = z.object({
|
||||
method: z.literal('track.scrobble'),
|
||||
...lastfmSubmitPayloadSchema.shape,
|
||||
timestamp: lastfmNumericStringSchema,
|
||||
}).meta({title: 'Single Scrobble'});
|
||||
export type LastfmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
|
||||
export const lastfmScrobbleMultiPayloadSchema = z.object({
|
||||
method: z.literal('track.scrobble'),
|
||||
...lastfmSubmitMultiPayloadSchema.shape,
|
||||
timestamp: z.array(lastfmNumericStringSchema),
|
||||
}).meta({title: 'Bulk Scrobbles'});
|
||||
export const lastfmScrobbleMaybeMultiPayloadSchema = z.union([lastfmScrobblePayloadSchema,lastfmScrobbleMultiPayloadSchema]).meta({title: 'Single or Bulk Scrobble'});
|
||||
export type LastfmScrobbleMaybeMultiPayload = z.infer<typeof lastfmScrobbleMaybeMultiPayloadSchema>;
|
||||
|
||||
export type LastFmScrobblePayload = z.infer<typeof lastfmScrobblePayloadSchema>;
|
||||
|
||||
export const lastfmNowPlayingPayloadSchema = z.object({
|
||||
method: z.literal('track.updateNowPlaying'),
|
||||
...lastfmSubmitPayloadSchema.shape,
|
||||
}).meta({title: 'Update Now Playing'});
|
||||
export type LastFmNowPlayingPayload = z.infer<typeof lastfmNowPlayingPayloadSchema>;
|
||||
|
||||
export type LastFmSubmitPayload = LastfmScrobbleMaybeMultiPayload | LastFmNowPlayingPayload;
|
||||
export type LastFmSingleSubmitPayload = LastfmScrobblePayload | LastFmNowPlayingPayload;
|
||||
|
||||
export const lastfmAuthRequestPayloadSchema = z.object({
|
||||
method: z.literal('auth.getMobileSession'),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
}).meta({title: 'Get Mobile Auth Session'});
|
||||
|
||||
export const lastfmRequestSchema = z.union([
|
||||
lastfmScrobbleMaybeMultiPayloadSchema,
|
||||
z.discriminatedUnion("method", [
|
||||
lastfmNowPlayingPayloadSchema,
|
||||
lastfmAuthRequestPayloadSchema
|
||||
]).meta({title: 'Update Now Playing or Mobile Auth Session'})
|
||||
]);
|
||||
export type LastFmRequest = z.infer<typeof lastfmRequestSchema>;
|
||||
|
||||
export interface LastFMScrobbleRequestPayload extends LastFMScrobblePayload {
|
||||
method: string
|
||||
}
|
||||
@@ -821,7 +916,7 @@ const lfmPayloadKeysRequired: LastFMPayloadkey[] = ['track','artist'];
|
||||
//const lfmPayloadKeysOptional: LastFMPayloadkey[] = ['duration','album','albumArtist','mbid'];
|
||||
//const lfmPayloadKeys: LastFMPayloadkey[] = [...lfmPayloadKeysRequired, ...lfmPayloadKeysOptional];
|
||||
|
||||
export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMScrobbleRequestPayload[] => {
|
||||
export const ingressPayloads = (obj: LastfmScrobbleMaybeMultiPayload): LastfmScrobblePayload[] => {
|
||||
const keys = Object.keys(obj);
|
||||
let allObject = true;
|
||||
for(const k of lfmPayloadKeysRequired) {
|
||||
@@ -834,10 +929,10 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
|
||||
throw new Error('Payload is an unexpected mix of arrays and objects');
|
||||
}
|
||||
}
|
||||
const payloads: LastFMScrobbleRequestPayload[] = [];
|
||||
const payloads: LastfmScrobblePayload[] = [];
|
||||
|
||||
if(allObject) {
|
||||
payloads.push(obj as LastFMScrobbleRequestPayload);
|
||||
payloads.push(obj as LastfmScrobblePayload);
|
||||
} else {
|
||||
let index = 0;
|
||||
for(const t of (obj.track as string[])) {
|
||||
@@ -849,14 +944,14 @@ export const ingressPayloads = (obj: Record<LastFMPayloadkey, unknown>): LastFMS
|
||||
mbid: obj.mbid !== undefined ? obj.mbid[index] : undefined,
|
||||
duration: obj.duration !== undefined ? obj.duration[index] : undefined,
|
||||
albumArtist: obj.albumArtist !== undefined ? obj.albumArtist[index] : undefined,
|
||||
method: obj.method as string
|
||||
method: obj.method
|
||||
})
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return payloads.map(x => {
|
||||
const cleaned: LastFMScrobbleRequestPayload = x;
|
||||
const cleaned: LastfmScrobblePayload = x;
|
||||
if(typeof cleaned.duration === 'string') {
|
||||
cleaned.duration = Number.parseInt(cleaned.duration);
|
||||
}
|
||||
@@ -946,19 +1041,19 @@ export const playToScrobbleApiResponseXml = (play: PlayObject) => {
|
||||
scrobble: {
|
||||
track: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.track
|
||||
_: play.data.track ?? ''
|
||||
},
|
||||
artist: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.artists?.join(',')
|
||||
_: play.data.artists?.join(',') ?? ''
|
||||
},
|
||||
album: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.album
|
||||
_: play.data.album ?? ''
|
||||
},
|
||||
albumArtist: {
|
||||
$: {corrected: 0},
|
||||
_: play.data.albumArtists?.join(',')
|
||||
_: play.data.albumArtists?.join(',') ?? ''
|
||||
},
|
||||
timestamp: {
|
||||
_: dayjs().unix(),
|
||||
@@ -981,19 +1076,19 @@ export const playToNowPlayingApiResponseXml = (play: PlayObject) => {
|
||||
nowplaying: {
|
||||
track: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.track
|
||||
_: play.data.track ?? ''
|
||||
},
|
||||
artist: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.artists?.join(',')
|
||||
_: play.data.artists?.join(',') ?? ''
|
||||
},
|
||||
album: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.album
|
||||
_: play.data.album ?? ''
|
||||
},
|
||||
albumArtist: {
|
||||
$: { corrected: 0 },
|
||||
_: play.data.albumArtists?.join(',')
|
||||
_: play.data.albumArtists?.join(',') ?? ''
|
||||
},
|
||||
ignoredMessage: {
|
||||
$: { code: 0 }
|
||||
|
||||
+11
-3
@@ -23,12 +23,13 @@ import { unique } from '../../utils.ts';
|
||||
import { removeUndefinedKeys } from '../../../core/DataUtils.ts';
|
||||
import type {ListenPayload, ListenResponse, ListenType, SubmitPayload} from '../../../core/vendor/listenbrainz/interfaces.ts';
|
||||
import { baseFormatPlayObj } from '../../utils/PlayTransformUtils.ts';
|
||||
import { ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
|
||||
import { AuthError, ScrobbleSubmitError, SimpleError } from '../errors/MSErrors.ts';
|
||||
import pRetry from 'p-retry';
|
||||
import { findCauseByFunc } from '../../utils/ErrorUtils.ts';
|
||||
import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts';
|
||||
import { playToSubmitPayload } from './listenbrainz/lzUtils.ts';
|
||||
import { isrcNoHyphens } from '../../../core/PlayUtils.ts';
|
||||
import { getRoot } from '../../ioc.ts';
|
||||
|
||||
|
||||
export interface SubmitOptions {
|
||||
@@ -59,6 +60,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
|
||||
declare config: ListenBrainzClientData;
|
||||
url: URLData;
|
||||
userAgent: string
|
||||
|
||||
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
|
||||
super('ListenBrainz', name, config, options);
|
||||
@@ -66,6 +68,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
url = 'https://api.listenbrainz.org/'
|
||||
} = config;
|
||||
let cleanUrl = url;
|
||||
this.userAgent = `multi-scrobbler/${getRoot().items.version}`
|
||||
const pathedUrl = normalizeListenbrainzUrl(cleanUrl);
|
||||
if(pathedUrl !== undefined) {
|
||||
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
|
||||
@@ -84,6 +87,7 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
|
||||
try {
|
||||
req.set('Authorization', `Token ${this.config.token}`);
|
||||
req.set('User-Agent', this.userAgent);
|
||||
return await req as T;
|
||||
} catch (e) {
|
||||
const {
|
||||
@@ -183,8 +187,12 @@ export class ListenbrainzApiClient extends AbstractApiClient implements Pageless
|
||||
try {
|
||||
const resp = await this.callApi(() => request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} catch (err) {
|
||||
const cause = findCauseByFunc<request.ResponseError>(err, (e) => isSuperAgentResponseError(e));
|
||||
if(cause !== undefined && [401,403,400].includes(cause.status)) {
|
||||
throw new AuthError('Failed to validate token', {cause: err, unrecoverable: true});
|
||||
}
|
||||
throw new AuthError('Failed to validate token due to non-auth error', {cause: err, unrecoverable: false});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -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
@@ -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
@@ -16,8 +16,9 @@ import { getMalojaResponseError, isMalojaAPIErrorBody, type MalojaResponseV3Comm
|
||||
import { getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext } from '../../../utils/TimeUtils.ts';
|
||||
import { artistCreditsToNames, artistNamesToCredits, buildTrackString } from '../../../../core/StringUtils.ts';
|
||||
import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts';
|
||||
import { ScrobbleSubmitError } from '../../errors/MSErrors.ts';
|
||||
import { AuthError, ScrobbleSubmitError } from '../../errors/MSErrors.ts';
|
||||
import { NO_RETRY_HTTP_STATUS, tryApiCall } from '../../../utils/RequestUtils.ts';
|
||||
import { findCauseByFunc } from '../../../utils/ErrorUtils.ts';
|
||||
|
||||
|
||||
|
||||
@@ -175,10 +176,11 @@ export class MalojaApiClient extends AbstractApiClient implements PaginatedTimeR
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
},'Maloja API Response');
|
||||
throw new Error('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new Error(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
throw new UpstreamError('Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', { cause: new UpstreamError(`Maloja API Response was ${status}: ${text.slice(0, 50)}`) })
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
const superagentError = findCauseByFunc<request.ResponseError>(e, (ee) => isSuperAgentResponseError(ee));
|
||||
throw new AuthError('Failed to test Maloja API with apikey', {cause: e, unrecoverable: superagentError !== undefined && [401,403].includes(superagentError.status)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -1,5 +1,5 @@
|
||||
import type { Response } from 'superagent';
|
||||
import type {ArtistCredit, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
|
||||
import type {ArtistCredit, OptionalCacheUsage, PlayObject, PlayObjectMinimal, URLData} from "../../../../core/Atomic.ts";
|
||||
import { UpstreamError } from "../../errors/UpstreamError.ts";
|
||||
import { type AbstractApiOptions, type FormatPlayObjectOptions, MUSICBRAINZ_URL, type MusicbrainzApiConfigData } from "../../infrastructure/Atomic.ts";
|
||||
import AbstractApiClient from "../AbstractApiClient.ts";
|
||||
@@ -9,10 +9,8 @@ import { difference, sleep } from "../../../utils.ts";
|
||||
import {SequentialRoundRobin} from 'round-robin-js';
|
||||
import type { Cacheable } from "cacheable";
|
||||
import { getRoot } from "../../../ioc.ts";
|
||||
import { version } from "../../../version.ts";
|
||||
import { hashObject } from "../../../utils/StringUtils.ts";
|
||||
import { playContentInvariantTransform } from "../../../utils/PlayComparisonUtils.ts";
|
||||
;
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { nanoid } from "nanoid";
|
||||
import { stripIndents } from "common-tags";
|
||||
@@ -79,7 +77,7 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
if(mb === undefined) {
|
||||
const api = new MusicBrainzApi({
|
||||
appName: 'multi-scrobbler',
|
||||
appVersion: version,
|
||||
appVersion: getRoot().items.version,
|
||||
appContactInfo: mbConfig.contact,
|
||||
baseUrl: u.url.toString(),
|
||||
preRequest: (method, url, headers) => {
|
||||
@@ -114,17 +112,18 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
return 'API';
|
||||
}
|
||||
|
||||
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string }): Promise<T> => {
|
||||
callApi = async <T = Response>(func: (mb: MusicBrainzApi) => Promise<any>, options?: { timeout?: number, cacheKey?: string } & OptionalCacheUsage): Promise<T> => {
|
||||
|
||||
let apiConfig = this.rrApis.next().value;
|
||||
|
||||
const {
|
||||
timeout = 30000,
|
||||
cacheKey
|
||||
cacheKey,
|
||||
useCachedResult = true
|
||||
} = options || {};
|
||||
|
||||
try {
|
||||
const cachedTransform = await this.cache.get<T>(cacheKey);
|
||||
const cachedTransform = useCachedResult ? await this.cache.get<T>(cacheKey) : undefined;
|
||||
if(cachedTransform !== undefined) {
|
||||
const cacheUrl = await this.cache.get<string>(`${cacheKey}-url`);
|
||||
const cacheQs = await this.cache.get<string>(`${cacheKey}-qs`);
|
||||
@@ -218,13 +217,14 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
searchByRecording = async(play: PlayObject, options?: SearchOptions): Promise<IRecordingMSList | undefined> => {
|
||||
searchByRecording = async(play: PlayObject, options?: SearchOptions & OptionalCacheUsage): Promise<IRecordingMSList | undefined> => {
|
||||
|
||||
const {
|
||||
escapeCharacters = true,
|
||||
removeCharacters = false,
|
||||
using = ['album','artist','title'],
|
||||
freetext
|
||||
freetext,
|
||||
useCachedResult
|
||||
} = options || {};
|
||||
|
||||
const cacheKey = `mb-recSearch-${hashObject({...playContentInvariantTransform(play), using})}`;
|
||||
@@ -347,7 +347,8 @@ export class MusicbrainzApiClient extends AbstractApiClient {
|
||||
query: q
|
||||
});
|
||||
}, {
|
||||
cacheKey
|
||||
cacheKey,
|
||||
useCachedResult
|
||||
});
|
||||
|
||||
if(res === undefined) {
|
||||
|
||||
+83
-38
@@ -1,5 +1,5 @@
|
||||
import dayjs, { type Dayjs, type ManipulateType } from "dayjs";
|
||||
import type {PlayObject, PlayObjectMinimal, BrainzMeta, MBID, ScrobbleActionResult, UnixTimestamp} 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";
|
||||
@@ -8,7 +8,7 @@ import type {AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRa
|
||||
import type {ListRecord, RecordOptions, TealClientData} from "../../infrastructure/config/client/tealfm.ts";
|
||||
import AbstractApiClient from "../AbstractApiClient.ts";
|
||||
import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.ts";
|
||||
import type { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.ts";
|
||||
import type { FmTealActorStatus, FmTealAlphaActorStatus, FmTealAlphaFeedPlay, FmTealFeedPlay } from "./lexicons/index.ts";
|
||||
import { ScrobbleSubmitError } from "../../errors/MSErrors.ts";
|
||||
import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.ts";
|
||||
import { musicServiceToCononical } from "../listenbrainz/lzUtils.ts";
|
||||
@@ -18,7 +18,19 @@ import type { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthentica
|
||||
import { UpstreamError } from "../../errors/UpstreamError.ts";
|
||||
import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto';
|
||||
import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts";
|
||||
import { isrcNoHyphens } from "../../../../core/PlayUtils.ts";
|
||||
import { isrcNoHyphens, sortByNewestPlayDate } from "../../../../core/PlayUtils.ts";
|
||||
import { isGenericUri } from "@atcute/lexicons/syntax";
|
||||
|
||||
type TealPlayRecord = FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main;
|
||||
|
||||
const TEAL_PLAY_COLLECTIONS = ['fm.teal.feed.play', 'fm.teal.alpha.feed.play'] as const;
|
||||
|
||||
const asMusicServiceUri = (musicService?: string): `${string}:${string}` | undefined => {
|
||||
if (musicService === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return isGenericUri(musicService) ? musicService : `https://${musicService}`;
|
||||
};
|
||||
|
||||
export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
|
||||
|
||||
@@ -43,10 +55,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
}
|
||||
|
||||
|
||||
async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
|
||||
async createScrobbleRecord(record: FmTealFeedPlay.Main): Promise<ScrobbleActionResult> {
|
||||
const input: ComAtprotoRepoCreateRecord.$input = {
|
||||
repo: this.client.userData.did,
|
||||
collection: 'fm.teal.alpha.feed.play',
|
||||
collection: 'fm.teal.feed.play',
|
||||
record
|
||||
};
|
||||
try {
|
||||
@@ -62,10 +74,10 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
|
||||
async updateStatusRecord(record: FmTealActorStatus.Main): Promise<ScrobbleActionResult> {
|
||||
const input: ComAtprotoRepoPutRecord.$input = {
|
||||
repo: this.client.userData.did,
|
||||
collection: "fm.teal.alpha.actor.status",
|
||||
collection: "fm.teal.actor.status",
|
||||
rkey: "self",
|
||||
record
|
||||
};
|
||||
@@ -92,37 +104,54 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
|
||||
cursor = generateTID(dayjs.unix(to).toISOString());
|
||||
}
|
||||
|
||||
const resp = await this.client.get((client) => client.get('com.atproto.repo.listRecords', {
|
||||
const responses = await Promise.all(TEAL_PLAY_COLLECTIONS.map(collection => this.client.get((client) => client.get('com.atproto.repo.listRecords', {
|
||||
params: {
|
||||
repo: this.client.userData.did,
|
||||
collection: "fm.teal.alpha.feed.play",
|
||||
collection,
|
||||
limit,
|
||||
cursor
|
||||
}
|
||||
}));
|
||||
}))));
|
||||
|
||||
if(!resp.ok) {
|
||||
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
|
||||
const fromTimestamps: number[] = [],
|
||||
playSets: PlayObject[][] = [];
|
||||
|
||||
for (const resp of responses) {
|
||||
if (!resp.ok) {
|
||||
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
|
||||
}
|
||||
if(resp.data.cursor !== undefined) {
|
||||
fromTimestamps.push(usecToUnix(decodeTid(resp.data.cursor).timestampUs));
|
||||
}
|
||||
playSets.push((resp.data.records as ListRecord<TealPlayRecord>[]).map(x => listRecordToPlay(x)));
|
||||
}
|
||||
|
||||
let fromTS: UnixTimestamp;
|
||||
if(resp.data.cursor !== undefined) {
|
||||
const { timestampUs } = decodeTid(resp.data.cursor);
|
||||
fromTS = usecToUnix(timestampUs);
|
||||
}
|
||||
const from = fromTimestamps.length > 0 ? fromTimestamps.reduce((earliest, current) => current < earliest ? current : earliest) : undefined;
|
||||
const plays = playSets.flat().sort(sortByNewestPlayDate)
|
||||
|
||||
const plays = (resp.data.records as unknown as ListRecord<FmTealAlphaFeedPlay.Main>[]).map(x => listRecordToPlay(x));
|
||||
|
||||
return {data: plays, meta: {to, from: fromTS, limit}};
|
||||
return {data: plays, meta: {to, from, limit, more: false, order: 'desc'}};
|
||||
}
|
||||
}
|
||||
|
||||
export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOptions = {}): PlayObject => {
|
||||
export const recordToPlay = (record: TealPlayRecord, options: RecordOptions = {}): PlayObject => {
|
||||
const artists = record.artists ?? [];
|
||||
let musicService: string;
|
||||
if('musicServiceUri' in record) {
|
||||
musicService = record.musicServiceUri
|
||||
} else if(`musicServiceBaseDomain` in record) {
|
||||
musicService = record.musicServiceBaseDomain;
|
||||
}
|
||||
let origin: string;
|
||||
if('originUri' in record) {
|
||||
origin = record.originUri;
|
||||
} else if(`originUrl` in record) {
|
||||
origin = record.originUrl;
|
||||
}
|
||||
|
||||
const play: PlayObjectMinimal = {
|
||||
data: {
|
||||
track: record.trackName,
|
||||
artists: record.artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
|
||||
artists: artists.filter(x => x.artistName !== undefined).map(x => ({ name: x.artistName, mbid: x.artistMbId })),
|
||||
duration: record.duration,
|
||||
playDate: dayjs(record.playedTime),
|
||||
album: record.releaseName,
|
||||
@@ -130,11 +159,12 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
},
|
||||
meta: {
|
||||
source: 'tealfm',
|
||||
parsedFrom: 'history',
|
||||
musicService: record.musicServiceBaseDomain,
|
||||
parsedFrom: PARSED_FROM.history,
|
||||
musicService,
|
||||
playId: options.playId,
|
||||
url: {
|
||||
web: options.web
|
||||
web: options.web,
|
||||
origin
|
||||
},
|
||||
user: options.user
|
||||
}
|
||||
@@ -143,7 +173,7 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
const brainz = removeUndefinedKeys<BrainzMeta>({
|
||||
recording: record.recordingMbId,
|
||||
album: record.releaseMbId,
|
||||
artist: record.artists.filter(x => x.artistMbId !== undefined).length > 0 ? record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
|
||||
artist: artists.filter(x => x.artistMbId !== undefined).length > 0 ? artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined
|
||||
});
|
||||
|
||||
if (brainz !== undefined) {
|
||||
@@ -153,10 +183,24 @@ export const recordToPlay = (record: FmTealAlphaFeedPlay.Main, options: RecordOp
|
||||
return baseFormatPlayObj(record, play);
|
||||
}
|
||||
|
||||
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealAlphaActorStatus.Main => {
|
||||
const { $type, ...item } = notPlaying
|
||||
export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): FmTealActorStatus.Main => {
|
||||
const item = notPlaying
|
||||
? { trackName: "", artists: [] }
|
||||
: playToRecord(play);
|
||||
: (() => {
|
||||
const {
|
||||
$type,
|
||||
musicServiceUri,
|
||||
originUri,
|
||||
trackDiscriminant,
|
||||
releaseDiscriminant,
|
||||
...record
|
||||
} = playToRecord(play);
|
||||
return {
|
||||
...record,
|
||||
musicServiceBaseDomain: musicServiceUri,
|
||||
originUrl: originUri
|
||||
};
|
||||
})();
|
||||
|
||||
let expiry: Dayjs;
|
||||
if (notPlaying) {
|
||||
@@ -167,7 +211,7 @@ export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, positi
|
||||
}
|
||||
|
||||
return {
|
||||
$type: "fm.teal.alpha.actor.status",
|
||||
$type: "fm.teal.actor.status",
|
||||
time: dayjs().toISOString(),
|
||||
expiry: expiry.toISOString(),
|
||||
item: {
|
||||
@@ -186,17 +230,19 @@ export const mbidUriOrUndefined = (mbid?: MBID): undefined | MBIDURI => {
|
||||
return mbidToUri(mbid);
|
||||
};
|
||||
export type MBIDURI = `mbid:${MBID}`;
|
||||
export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
|
||||
export const playToRecord = (play: PlayObject): FmTealFeedPlay.Main => {
|
||||
const musicService = musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService;
|
||||
|
||||
const record: FmTealAlphaFeedPlay.Main = {
|
||||
$type: "fm.teal.alpha.feed.play",
|
||||
const record: FmTealFeedPlay.Main = {
|
||||
$type: "fm.teal.feed.play",
|
||||
trackName: play.data.track,
|
||||
artists: play.data.artists.map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
|
||||
artists: (play.data.artists ?? []).map(x => removeUndefinedKeys({ artistName: x.name, artistMbId: mbidUriOrUndefined(x.mbid as MBID) })),
|
||||
duration: Math.round(play.data.duration),
|
||||
playedTime: getScrobbleTsSOCDateWithContext(play)[0].toISOString(),
|
||||
releaseName: play.data.album,
|
||||
submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`,
|
||||
musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService,
|
||||
musicServiceUri: asMusicServiceUri(musicService),
|
||||
originUri: isGenericUri(play.meta.url?.origin) ? play.meta.url?.origin : undefined,
|
||||
isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined,
|
||||
trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID),
|
||||
recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID),
|
||||
@@ -205,7 +251,7 @@ export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => {
|
||||
|
||||
return record;
|
||||
};
|
||||
export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main>): PlayObject => {
|
||||
export const listRecordToPlay = (listRecord: ListRecord<TealPlayRecord>): PlayObject => {
|
||||
const opts: RecordOptions = {};
|
||||
const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri);
|
||||
if (uriRes !== undefined) {
|
||||
@@ -215,5 +261,4 @@ export const listRecordToPlay = (listRecord: ListRecord<FmTealAlphaFeedPlay.Main
|
||||
}
|
||||
return recordToPlay(listRecord.value, opts);
|
||||
};
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/);
|
||||
|
||||
export const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/fm.teal(?:\.alpha)?\.feed.play\/(?<tid>.*))/);
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ export default defineLexiconConfig({
|
||||
files: ['lexicons/**/*.json'],
|
||||
outdir: 'lexicons/',
|
||||
imports: ['@atcute/bluesky'],
|
||||
modules: { importSuffix: '.ts' },
|
||||
},
|
||||
pull: {
|
||||
outdir: path.resolve(__dirname, 'lexicons/'),
|
||||
@@ -19,8 +20,8 @@ export default defineLexiconConfig({
|
||||
type: 'git',
|
||||
remote: 'https://github.com/teal-fm/teal.git',
|
||||
ref: 'main',
|
||||
pattern: ['lexicons/fm.teal.alpha/feed/*.json','lexicons/fm.teal.alpha/actor/*.json'],
|
||||
pattern: ['lexicons/fm.teal/feed/*.json','lexicons/fm.teal/actor/*.json'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
this directory contains lexicon documents pulled from the following sources:
|
||||
|
||||
- https://github.com/teal-fm/teal.git (ref: main)
|
||||
- commit: f661868af2a56199f1b3cae1ad20cd5831ec4212
|
||||
- commit: 82ea66cadbde3f1aee9c72b05f372c6543a4b3f6
|
||||
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
export * as FmTealActorDefs from "./types/fm/teal/actor/defs.ts";
|
||||
export * as FmTealActorGetProfile from "./types/fm/teal/actor/getProfile.ts";
|
||||
export * as FmTealActorGetProfiles from "./types/fm/teal/actor/getProfiles.ts";
|
||||
export * as FmTealActorProfile from "./types/fm/teal/actor/profile.ts";
|
||||
export * as FmTealActorProfileStatus from "./types/fm/teal/actor/profileStatus.ts";
|
||||
export * as FmTealActorSearchActors from "./types/fm/teal/actor/searchActors.ts";
|
||||
export * as FmTealActorStatus from "./types/fm/teal/actor/status.ts";
|
||||
export * as FmTealFeedDefs from "./types/fm/teal/feed/defs.ts";
|
||||
export * as FmTealFeedGetActorFeed from "./types/fm/teal/feed/getActorFeed.ts";
|
||||
export * as FmTealFeedGetPlay from "./types/fm/teal/feed/getPlay.ts";
|
||||
export * as FmTealFeedPlay from "./types/fm/teal/feed/play.ts";
|
||||
export * as FmTealAlphaActorDefs from "./types/fm/teal/alpha/actor/defs.ts";
|
||||
export * as FmTealAlphaActorGetProfile from "./types/fm/teal/alpha/actor/getProfile.ts";
|
||||
export * as FmTealAlphaActorGetProfiles from "./types/fm/teal/alpha/actor/getProfiles.ts";
|
||||
@@ -8,4 +19,4 @@ export * as FmTealAlphaActorStatus from "./types/fm/teal/alpha/actor/status.ts";
|
||||
export * as FmTealAlphaFeedDefs from "./types/fm/teal/alpha/feed/defs.ts";
|
||||
export * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/getActorFeed.ts";
|
||||
export * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
export * as FmTealAlphaFeedPlay from "./types/fm/teal/alpha/feed/play.ts";
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
import * as FmTealActorProfile from "./profile.ts";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _miniProfileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#miniProfileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()),
|
||||
});
|
||||
const _profileViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#profileView"),
|
||||
),
|
||||
/**
|
||||
* IPLD of the avatar
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
/**
|
||||
* IPLD of the banner image
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc). May be changed to another (backwards compatible) lexicon.
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* The decentralized identifier of the actor
|
||||
*/
|
||||
did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()),
|
||||
displayName: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(FmTealActorProfile.featuredItemSchema);
|
||||
},
|
||||
get status() {
|
||||
return /*#__PURE__*/ v.optional(statusViewSchema);
|
||||
},
|
||||
});
|
||||
const _statusViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.defs#statusView"),
|
||||
),
|
||||
/**
|
||||
* The datetime after which the status is no longer current.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return /*#__PURE__*/ v.optional(FmTealFeedDefs.playViewSchema);
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
});
|
||||
|
||||
type miniProfileView$schematype = typeof _miniProfileViewSchema;
|
||||
type profileView$schematype = typeof _profileViewSchema;
|
||||
type statusView$schematype = typeof _statusViewSchema;
|
||||
|
||||
export interface miniProfileViewSchema extends miniProfileView$schematype {}
|
||||
export interface profileViewSchema extends profileView$schematype {}
|
||||
export interface statusViewSchema extends statusView$schematype {}
|
||||
|
||||
export const miniProfileViewSchema =
|
||||
_miniProfileViewSchema as miniProfileViewSchema;
|
||||
export const profileViewSchema = _profileViewSchema as profileViewSchema;
|
||||
export const statusViewSchema = _statusViewSchema as statusViewSchema;
|
||||
|
||||
export interface MiniProfileView extends v.InferInput<
|
||||
typeof miniProfileViewSchema
|
||||
> {}
|
||||
export interface ProfileView extends v.InferInput<typeof profileViewSchema> {}
|
||||
export interface StatusView extends v.InferInput<typeof statusViewSchema> {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfile", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The actor's DID or handle
|
||||
*/
|
||||
actor: /*#__PURE__*/ v.actorIdentifierString(),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actor() {
|
||||
return FmTealActorDefs.profileViewSchema;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.getProfiles", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Array of actor DIDs or handles
|
||||
* @minLength 1
|
||||
*/
|
||||
actors: /*#__PURE__*/ v.constrain(
|
||||
/*#__PURE__*/ v.array(/*#__PURE__*/ v.actorIdentifierString()),
|
||||
[/*#__PURE__*/ v.arrayLength(1)],
|
||||
),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.getProfiles": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet";
|
||||
|
||||
const _featuredItemSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.actor.profile#featuredItem"),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the item, formatted as mbid:<uuid>
|
||||
*/
|
||||
mbid: /*#__PURE__*/ v.genericUriString(),
|
||||
/**
|
||||
* The MusicBrainz entity type of the item.
|
||||
*/
|
||||
type: /*#__PURE__*/ v.string<
|
||||
"artist" | "recording" | "release" | (string & {})
|
||||
>(),
|
||||
});
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profile"),
|
||||
/**
|
||||
* Small image to be displayed next to posts from account. AKA, 'profile picture'
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
avatar: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Larger horizontal image to display behind profile view.
|
||||
* @accept image/jpeg, image/png
|
||||
* @maxSize 1000000
|
||||
*/
|
||||
banner: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [
|
||||
/*#__PURE__*/ v.blobSize(1000000),
|
||||
/*#__PURE__*/ v.blobAccept(["image/jpeg", "image/png"]),
|
||||
]),
|
||||
),
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* Free-form profile description text.
|
||||
* @maxLength 2560
|
||||
* @maxGraphemes 256
|
||||
*/
|
||||
description: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 2560),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 256),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Annotations of text in the profile description (mentions, URLs, hashtags, etc).
|
||||
*/
|
||||
get descriptionFacets() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(AppBskyRichtextFacet.mainSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 64
|
||||
*/
|
||||
displayName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 64),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The user's most recent item featured on their profile.
|
||||
*/
|
||||
get featuredItem() {
|
||||
return /*#__PURE__*/ v.optional(featuredItemSchema);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type featuredItem$schematype = typeof _featuredItemSchema;
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface featuredItemSchema extends featuredItem$schematype {}
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const featuredItemSchema = _featuredItemSchema as featuredItemSchema;
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface FeaturedItem extends v.InferInput<typeof featuredItemSchema> {}
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profile": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.profileStatus"),
|
||||
/**
|
||||
* The onboarding completion status
|
||||
*/
|
||||
completedOnboarding: /*#__PURE__*/ v.string<
|
||||
| "complete"
|
||||
| "none"
|
||||
| "playOnboarding"
|
||||
| "profileOnboarding"
|
||||
| (string & {})
|
||||
>(),
|
||||
/**
|
||||
* The timestamp when this status was created
|
||||
*/
|
||||
createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The timestamp when this status was last updated
|
||||
*/
|
||||
updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.profileStatus": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealActorDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.actor.searchActors", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The maximum number of actors to return
|
||||
* @minimum 1
|
||||
* @maximum 25
|
||||
*/
|
||||
limit: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
|
||||
/*#__PURE__*/ v.integerRange(1, 25),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The search query
|
||||
* @maxLength 640
|
||||
* @maxGraphemes 128
|
||||
*/
|
||||
q: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 640),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 128),
|
||||
]),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get actors() {
|
||||
return /*#__PURE__*/ v.array(FmTealActorDefs.miniProfileViewSchema);
|
||||
},
|
||||
/**
|
||||
* Cursor for pagination
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.actor.searchActors": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "../feed/defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.literal("self"),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.actor.status"),
|
||||
/**
|
||||
* The datetime after which the status is no longer current. If unavailable, default to 10 minutes after the start time.
|
||||
*/
|
||||
expiry: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
get item() {
|
||||
return FmTealFeedDefs.playViewSchema;
|
||||
},
|
||||
/**
|
||||
* The datetime at which the status was recorded.
|
||||
*/
|
||||
time: /*#__PURE__*/ v.datetimeString(),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.actor.status": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
|
||||
const _artistSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.feed.defs#artist"),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz artist ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
artistMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the artist
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
artistName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
});
|
||||
const _playViewSchema = /*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.literal("fm.teal.feed.defs#playView"),
|
||||
),
|
||||
/**
|
||||
* Array of artists in order of original appearance.
|
||||
*/
|
||||
get artists() {
|
||||
return /*#__PURE__*/ v.array(artistSchema);
|
||||
},
|
||||
/**
|
||||
* The length of the track in seconds
|
||||
*/
|
||||
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
/**
|
||||
* The ISRC code associated with the recording
|
||||
*/
|
||||
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The canonical URI identifying the listening surface or music service.
|
||||
*/
|
||||
musicServiceUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The exact URI where the listening event originated.
|
||||
*/
|
||||
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The datetime at which playback began.
|
||||
*/
|
||||
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the release/album
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
releaseName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided.
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
submissionClientAgent: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the track
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
});
|
||||
|
||||
type artist$schematype = typeof _artistSchema;
|
||||
type playView$schematype = typeof _playViewSchema;
|
||||
|
||||
export interface artistSchema extends artist$schematype {}
|
||||
export interface playViewSchema extends playView$schematype {}
|
||||
|
||||
export const artistSchema = _artistSchema as artistSchema;
|
||||
export const playViewSchema = _playViewSchema as playViewSchema;
|
||||
|
||||
export interface Artist extends v.InferInput<typeof artistSchema> {}
|
||||
export interface PlayView extends v.InferInput<typeof playViewSchema> {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getActorFeed", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The author's DID for the play
|
||||
*/
|
||||
authorDID: /*#__PURE__*/ v.didString(),
|
||||
/**
|
||||
* The cursor to start the query from
|
||||
*/
|
||||
cursor: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The upper limit of tracks to get per request. Default is 20, max is 50.
|
||||
*/
|
||||
limit: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get plays() {
|
||||
return /*#__PURE__*/ v.array(FmTealFeedDefs.playViewSchema);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.feed.getActorFeed": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.query("fm.teal.feed.getPlay", {
|
||||
params: /*#__PURE__*/ v.object({
|
||||
/**
|
||||
* The author's DID for the play
|
||||
*/
|
||||
authorDID: /*#__PURE__*/ v.didString(),
|
||||
/**
|
||||
* The record key of the play
|
||||
*/
|
||||
rkey: /*#__PURE__*/ v.recordKeyString(),
|
||||
}),
|
||||
output: {
|
||||
type: "lex",
|
||||
schema: /*#__PURE__*/ v.object({
|
||||
get play() {
|
||||
return FmTealFeedDefs.playViewSchema;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface $params extends v.InferInput<mainSchema["params"]> {}
|
||||
export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface XRPCQueries {
|
||||
"fm.teal.feed.getPlay": mainSchema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {} from "@atcute/lexicons";
|
||||
import * as v from "@atcute/lexicons/validations";
|
||||
import type {} from "@atcute/lexicons/ambient";
|
||||
import * as FmTealFeedDefs from "./defs.ts";
|
||||
|
||||
const _mainSchema = /*#__PURE__*/ v.record(
|
||||
/*#__PURE__*/ v.tidString(),
|
||||
/*#__PURE__*/ v.object({
|
||||
$type: /*#__PURE__*/ v.literal("fm.teal.feed.play"),
|
||||
/**
|
||||
* DEPRECATED: USE 'artists' INSTEAD. Array of Musicbrainz artist IDs.
|
||||
* @deprecated
|
||||
*/
|
||||
artistMbIds: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(/*#__PURE__*/ v.string()),
|
||||
),
|
||||
/**
|
||||
* DEPRECATED: USE 'artists' INSTEAD. Array of artist names in order of original appearance.
|
||||
* @deprecated
|
||||
*/
|
||||
artistNames: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
),
|
||||
/**
|
||||
* Array of artists in order of original appearance.
|
||||
*/
|
||||
get artists() {
|
||||
return /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.array(FmTealFeedDefs.artistSchema),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* The length of the track in seconds
|
||||
*/
|
||||
duration: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.integer()),
|
||||
/**
|
||||
* The ISRC code associated with the recording
|
||||
*/
|
||||
isrc: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
|
||||
/**
|
||||
* The canonical URI identifying the listening surface or music service.
|
||||
*/
|
||||
musicServiceUri: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.genericUriString(),
|
||||
),
|
||||
/**
|
||||
* The exact URI where the listening event originated.
|
||||
*/
|
||||
originUri: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The datetime at which playback began.
|
||||
*/
|
||||
playedTime: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()),
|
||||
/**
|
||||
* The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
recordingMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* Distinguishing information for release variants (e.g. 'Deluxe Edition', 'Remastered', '2023 Remaster', 'Special Edition'). Used to differentiate between different versions of the same base release while maintaining grouping capabilities.
|
||||
* @maxLength 128
|
||||
* @maxGraphemes 1280
|
||||
*/
|
||||
releaseDiscriminant: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 128),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 1280),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz release ID URI, formatted as mbid:<uuid>
|
||||
*/
|
||||
releaseMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the release/album
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
releaseName: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided.
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
submissionClientAgent: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* Distinguishing information for track variants (e.g. 'Acoustic Version', 'Live at Wembley', 'Radio Edit', 'Demo'). Used to differentiate between different versions of the same base track while maintaining grouping capabilities.
|
||||
* @maxLength 128
|
||||
* @maxGraphemes 1280
|
||||
*/
|
||||
trackDiscriminant: /*#__PURE__*/ v.optional(
|
||||
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(0, 128),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 1280),
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* The MusicBrainz ID URI of the track, formatted as mbid:<uuid>
|
||||
*/
|
||||
trackMbId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()),
|
||||
/**
|
||||
* The name of the track
|
||||
* @minLength 1
|
||||
* @maxLength 256
|
||||
* @maxGraphemes 2560
|
||||
*/
|
||||
trackName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
|
||||
/*#__PURE__*/ v.stringLength(1, 256),
|
||||
/*#__PURE__*/ v.stringGraphemes(0, 2560),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
type main$schematype = typeof _mainSchema;
|
||||
|
||||
export interface mainSchema extends main$schematype {}
|
||||
|
||||
export const mainSchema = _mainSchema as mainSchema;
|
||||
|
||||
export interface Main extends v.InferInput<typeof mainSchema> {}
|
||||
|
||||
declare module "@atcute/lexicons/ambient" {
|
||||
interface Records {
|
||||
"fm.teal.feed.play": mainSchema;
|
||||
}
|
||||
}
|
||||
+81
-72
@@ -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>;
|
||||
@@ -173,7 +173,7 @@ const dataDir = getDataDir();
|
||||
|
||||
await root.items.cache().init(true);
|
||||
|
||||
initServer(logger, appLoggerStream, output, scrobbleSources, scrobbleClients);
|
||||
initServer({sources: scrobbleSources, clients: scrobbleClients},{logger, appLoggerStream, initialOutput: output});
|
||||
|
||||
if(process.env.IS_LOCAL === 'true') {
|
||||
logger.info('multi-scrobbler can be run as a background service! See: https://docs.multi-scrobbler.app/installation/service');
|
||||
@@ -209,9 +209,9 @@ const dataDir = getDataDir();
|
||||
if(nameColl.length > 0) {
|
||||
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
|
||||
}
|
||||
const clientInitOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined};
|
||||
const initOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined};
|
||||
for(const c of scrobbleClients.clients) {
|
||||
c.initTasks(clientInitOptions);
|
||||
c.initTasks(initOptions);
|
||||
const res = await Promise.race([
|
||||
sleep(2200),
|
||||
(async () => {
|
||||
@@ -227,7 +227,7 @@ const dataDir = getDataDir();
|
||||
}
|
||||
|
||||
for(const c of scrobbleSources.sources) {
|
||||
c.initTasks();
|
||||
c.initTasks(initOptions);
|
||||
const res = await Promise.race([
|
||||
sleep(2200),
|
||||
(async () => {
|
||||
|
||||
+24
-2
@@ -12,7 +12,7 @@ import TransformerManager from "./common/transforms/TransformerManager.ts";
|
||||
import type {TransformerCommonConfig} from "../core/Atomic.ts";
|
||||
import prom from 'prom-client';
|
||||
import { CoverArtApiClient } from "./common/vendor/musicbrainz/CoverArtApiClient.ts";
|
||||
import { version } from "./version.ts";
|
||||
import { version, stable } from "./version.ts";
|
||||
import type {DbConcrete} from "./common/database/drizzle/drizzleUtils.ts";
|
||||
import type { MSBackendEventMap } from "./common/infrastructure/MSBackendEventMap.ts";
|
||||
|
||||
@@ -35,11 +35,21 @@ const discovered = new prom.Counter({
|
||||
help: 'Number of discovered plays for a Source',
|
||||
labelNames: ['name', 'type']
|
||||
});
|
||||
const sourceDead = new prom.Gauge({
|
||||
name: 'multiscrobbler_source_dead',
|
||||
help: 'Number of dead letter plays for a Source',
|
||||
labelNames: ['name', 'type']
|
||||
});
|
||||
const queuedGauge = new prom.Gauge({
|
||||
name: 'multiscrobbler_client_queued',
|
||||
help: 'Number of queued plays for a Client',
|
||||
labelNames: ['name', 'type']
|
||||
});
|
||||
const queuedSourceGauge = new prom.Gauge({
|
||||
name: 'multiscrobbler_source_queued',
|
||||
help: 'Number of queued plays for a Source',
|
||||
labelNames: ['name', 'type']
|
||||
});
|
||||
const deadLetterGauge = new prom.Gauge({
|
||||
name: 'multiscrobbler_client_deadletter',
|
||||
help: 'Number of deadletter plays for a Client',
|
||||
@@ -130,8 +140,18 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
|
||||
|
||||
const portVal: number | string = process.env.PORT ?? port;
|
||||
|
||||
const versionParts: string[] = [];
|
||||
if(stable === version) {
|
||||
versionParts.push(stable);
|
||||
} else {
|
||||
if(stable !== 'unknown') {
|
||||
versionParts.push(stable);
|
||||
}
|
||||
versionParts.push(version);
|
||||
}
|
||||
|
||||
return createContainer().add({
|
||||
version,
|
||||
version: versionParts.join('-'),
|
||||
configDir: getConfigDir(),
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
// @ts-ignore
|
||||
@@ -144,6 +164,8 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => {
|
||||
loggingConfig,
|
||||
sourceMetics: {
|
||||
discovered: discovered,
|
||||
queued: queuedSourceGauge,
|
||||
deadLetter: sourceDead
|
||||
//issues: sourceIssues
|
||||
},
|
||||
clientMetrics: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -24,10 +24,14 @@ import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient
|
||||
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 } from "../common/vendor/teal/lexicons/index.ts";
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +217,11 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
|
||||
logger.info('Starting CAR conversion to historical plays...');
|
||||
|
||||
for await (const entry of repo) {
|
||||
if(entry.collection === 'fm.teal.alpha.feed.play') {
|
||||
if(entry.collection === 'fm.teal.feed.play' || entry.collection === 'fm.teal.alpha.feed.play') {
|
||||
let play: PlayObject;
|
||||
try {
|
||||
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main, {
|
||||
web: did !== undefined ? `at://did:plc:${did}/fm.teal.alpha.feed.play/${entry.rkey}` : undefined,
|
||||
play = recordToPlay(entry.record as FmTealAlphaFeedPlay.Main | FmTealFeedPlay.Main, {
|
||||
web: did !== undefined ? `at://did:plc:${did}/${entry.collection}/${entry.rkey}` : undefined,
|
||||
playId: entry.rkey,
|
||||
user: did
|
||||
});
|
||||
@@ -287,4 +295,3 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+317
-599
File diff suppressed because it is too large
Load Diff
+45
-59
@@ -1,7 +1,5 @@
|
||||
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,64 +9,32 @@ 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 } from './middleware.ts';
|
||||
import { findAuthIssue, SimpleError } from '../common/errors/MSErrors.ts';
|
||||
import type { createTypedRouter } from '@minisylar/express-typed-router';
|
||||
|
||||
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) => {
|
||||
const {
|
||||
scrobbleClient,
|
||||
} = req as any;
|
||||
export const setupAuthRoutes = (app: Express, router: ReturnType<typeof createTypedRouter>, logger: Logger, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => {
|
||||
const componentAwareMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients);
|
||||
|
||||
switch (scrobbleClient.type) {
|
||||
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.redirect(scrobbleClient.api.getAuthUrl());
|
||||
res.status(200).send((req.component as LastfmScrobbler | LastfmSource | LibrefmScrobbler | LibrefmSource ).api.getAuthUrl());
|
||||
break;
|
||||
case 'tealfm':
|
||||
const url = await scrobbleClient.getAuthorizeUrl();
|
||||
res.redirect(url);
|
||||
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) => {
|
||||
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':
|
||||
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.redirect(source.createAuthUrl());
|
||||
res.status(200).send(source.createAuthUrl());
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
case 'librefm':
|
||||
res.redirect(source.api.getAuthUrl());
|
||||
break;
|
||||
case 'deezer':
|
||||
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
|
||||
req.session.deezerSource = name;
|
||||
return passport.authenticate(`deezer-${source.name}`)(req,res,next);
|
||||
case 'ytmusic':
|
||||
await (source as YTMusicSource).reauthenticate();
|
||||
res.redirect((source as YTMusicSource).verificationUrl);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
|
||||
} break;
|
||||
}
|
||||
});
|
||||
|
||||
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 +69,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 +79,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 +102,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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});
|
||||
}
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
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) => {
|
||||
const {
|
||||
query: {
|
||||
name,
|
||||
type
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Source name must be defined');
|
||||
} else if (name !== undefined) {
|
||||
const source = sources.getByNameAndType(name, type);
|
||||
|
||||
if (source === undefined) {
|
||||
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
|
||||
}
|
||||
|
||||
req.sourceName = name;
|
||||
req.scrobbleSource = source;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => {
|
||||
const {
|
||||
query: {
|
||||
name
|
||||
} = {}
|
||||
} = req;
|
||||
|
||||
if (required && name === undefined) {
|
||||
return res.status(404).send('Client name must be defined');
|
||||
} else if (name !== undefined) {
|
||||
const client = clients.getByName(name);
|
||||
|
||||
if (client === undefined) {
|
||||
return res.status(404).send(`No client with the name: ${name}`);
|
||||
}
|
||||
|
||||
req.scrobbleClient = client;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export const nonEmptyBody = (logger: Logger, origin: string = 'Origin'): 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 +29,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 +49,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 +65,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 +89,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;
|
||||
|
||||
@@ -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(*)']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user