Compare commits

...
94 Commits
Author SHA1 Message Date
FoxxMD 1d7dcbd215 docs: Add preview UI callout to landing 2026-07-17 14:44:23 +00:00
Matt Foxx f3d7d59af6 Merge pull request #636 from Exerra/feat/apple-music
feat: Apple Music source
2026-07-16 15:45:04 -04:00
FoxxMD 2a4afefe64 feat(applemusic): Make album normalization user configurable and add guidance on usage 2026-07-16 16:41:36 +00:00
FoxxMD 1dba73ccb2 feat(client)!: Overhaul source scrobble filtering
* Use id OR name for matching client to scrobble to, during transition period
* Deprecate using name-only and warn when this is detected
* Consolidate logging filtered clients to one statement to reduce log noise
* Improve filter matching by making strings case and whitepsace insensitive
* On startup log `clients` options for sources
2026-07-16 15:51:54 +00:00
FoxxMD 3e82e035ac feat: Update string normalization with granular options 2026-07-16 15:49:01 +00:00
Exerra b12153fba9 docs(applemusic): improve formatting of developer tools shortcut
Updated the Apple Music configuration documentation to use <kbd> tags for keyboard shortcuts, improving readability.
2026-07-16 18:12:33 +03:00
Exerra 8c82412de4 docs: add detailed Apple Music authorization token extraction guide
Updated Apple Music configuration documentation with step-by-step instructions and screenshots for extracting authorization tokens in Chrome, Edge, Firefox, and Safari.
2026-07-16 17:15:58 +03:00
FoxxMD e804ce33ef fix(listenbrainz): Fix play fetch more indicator
latest_listen_ts is *always* the latest of *all-time*, not for the current min/max_ts range requested.

Instead, use count < than desired (and non-zero) to indicate more results
2026-07-15 20:49:37 +00:00
FoxxMD 3907fa5474 fix: Fix play fetch options logging data for pageless functions 2026-07-15 20:48:32 +00:00
FoxxMD a7b3593314 stage import rule from eslint-plugin-import-x 2026-07-15 19:08:48 +00:00
FoxxMD 76ef994067 fix: Implement unicorn eslint rules and fix them 2026-07-15 18:31:13 +00:00
FoxxMD c3cb3cf3a1 fix(client): Correct SOT scrobble api requests to not have the same from/to value 2026-07-15 17:34:37 +00:00
FoxxMD 8096219526 fix(client): Correct create/update timestamps for queues and scrobbles
* Ensure ingress queue always has an updated timestamp before dead creation
* Don't re-assign createdAt for scrobble after dead queue has already been updated
2026-07-15 17:02:29 +00:00
FoxxMD 67c1823195 feat(ui): Clean up scrobble timeline items
* Use timeline summary formatting to fix spacing
* Provide client name from fetched component data, if present, so scrobble summary is explicit where it sent/response scrobble from
2026-07-15 16:22:04 +00:00
FoxxMD 2014e78ade fix(ui): Stabilize play timeline item order 2026-07-15 14:51:42 +00:00
Exerra b3c332deeb refactor: replace Apple Music headers config with origin field
Replaces the generic `headers` configuration field with a specific `origin` field for Apple Music. Updates the config schema, environment variable handling, documentation, and source implementation to simplify origin header management.
2026-07-15 17:12:21 +03:00
Exerra 93a1042e03 test(applemusic): add tests for stripping EP/Single album suffixes
Added test cases to ensure that ' - EP' and ' - Single' suffixes are correctly stripped from Apple Music album names during the formatting process.
2026-07-15 16:21:10 +03:00
Exerra ba010240c0 feat(AppleMusicSource): include track URL in metadata
Added the web URL to the track metadata in AppleMusicSource to provide a direct link to the track.
2026-07-15 16:20:54 +03:00
FoxxMD 7f491d6974 fix: Fix hash router type 2026-07-15 13:08:04 +00:00
Exerra 74e67e0e41 feat(AppleMusicSource): include ISRC in track metadata
Added the isrc field to the track object in AppleMusicSource to improve track identification accuracy.
2026-07-15 15:59:49 +03:00
FoxxMD 1853f9abba fix(musicbrainz): Catch scenario where all search prereqs fail
#636
2026-07-15 12:58:40 +00:00
Exerra c13e7ebeec feat(applemusic): strip ' - EP' and ' - Single' from album names
Implemented album name normalization in AppleMusicSource to ensure consistency with other sources by removing standard suffixes from EPs and singles. Updated documentation to reflect this behavior.
2026-07-15 15:38:14 +03:00
Exerra 207354e799 docs(docsite): fix heading hierarchy in applemusic.mdx
Adjusted markdown heading levels to ensure proper document structure.
2026-07-15 15:36:51 +03:00
FoxxMD d7f6b17308 update boundary element linting to be more lenient
* allow core-tests from backend
* downgrade linting to warn
2026-07-15 12:23:53 +00:00
Exerra c069020aac docs(README.md): add Apple Music to supported sources list
Updated the README to include Apple Music in the list of supported scrobbling sources with a link to its documentation.
2026-07-15 13:52:53 +03:00
Exerra f84a0f190d test(applemusic): add integration tests for Apple Music module
Add new test suite for Apple Music backend logic
2026-07-15 13:39:27 +03:00
FoxxMD d0f43fbd2d feat(ui): Increase virtual play list height 2026-07-15 01:29:12 +00:00
FoxxMD b38168975e feat(ui): Compact filters
* move refresh/loading indicator into list type header
* Remove filter card title to trim filter real estate
2026-07-15 01:22:40 +00:00
Exerra a845ac10ce feat(applemusic): implement top-rebound recovery for duplicate plays
Added logic to detect and recover 'top-rebound' history patterns where Apple Music deduplicates re-listened tracks by bumping them to the top. This ensures intermediate tracks are captured and re-listened top tracks are processed correctly. Includes a new configuration option `recoverUnchangedTopHistory` to toggle this behavior.
2026-07-15 02:44:41 +03:00
Exerra 56fdb12683 feat(sources): update Apple Music configuration and environment variables
Refactored Apple Music source configuration to support token-based authentication via key files and custom headers. Renamed environment variable prefixes from 'AM' to 'APPLEMUSIC' for consistency.
2026-07-15 00:04:14 +03:00
Exerra 1f40e1e414 docs(applemusic): update configuration guide and add APPLEMUSIC_HEADERS
Added documentation for the APPLEMUSIC_HEADERS environment variable and updated code blocks with titles for better clarity in the Apple Music source configuration.
2026-07-15 00:03:06 +03:00
Exerra c1a260dc56 fix(AppleMusicSource): add authentication verification to connection checks
Implemented status code validation for Apple Music authentication and connection checks. The module now throws errors instead of failing silently when the API is unreachable or auth is invalid.
2026-07-14 21:56:59 +03:00
Exerra 5d34ea68af docs: update Apple Music configuration guide
Reorganized the Apple Music documentation to prioritize the Browser Token (Free) authentication method and clarify requirements for the paid MusicKit Key approach.
2026-07-14 21:27:23 +03:00
FoxxMD 41ea9e0aa7 feat(ui): Make timeline items more compact
#500
2026-07-14 15:23:37 +00:00
FoxxMD a9d91c2c8a fix circular buffer 2026-07-14 14:04:19 +00:00
FoxxMD a696436a75 fix: Fix missing early return for skipped stage 2026-07-14 13:47:57 +00:00
Exerra 4ae388676e chore(package-lock.json): update dependencies
Bump node-musickit-api to 3.1.0-next.5
2026-07-14 14:12:42 +03:00
Exerra 89b698d48d docs: add Apple Music source documentation
Add Apple Music to the sources overview table and the main documentation index.
2026-07-14 14:06:20 +03:00
Exerra b71a4cbbd6 build(package.json): bump node-musickit-api to v3.1.0-next.5
Updated node-musickit-api dependency to the latest next version.
2026-07-14 13:08:36 +03:00
Exerra a870cdc6f4 chore(package.json): update node-musickit-api to v3.1.0-next.4
Switched node-musickit-api dependency from a local path to version 3.1.0-next.4 from the registry.
2026-07-14 01:11:29 +03:00
Exerra c404dd5396 feat(applemusic): add support for custom headers in API requests
Introduced a `headers` configuration option to allow setting custom request headers, such as 'Origin', for Apple Music API requests. Updated configuration schema and source implementation to apply these headers.
2026-07-14 01:03:26 +03:00
Exerra cae845028d refactor(AppleMusicSource): use HistoryConsistencyResult type alias
Update parseRecentAgainstResponse return type signature to use the HistoryConsistencyResult alias instead of an inline object definition.
2026-07-14 00:50:58 +03:00
Exerra 1698c5d71e refactor(AppleMusicSource): clarify timestamp calculation logic
Updated comments in applyCalculatedTimestamps to reflect that Apple Music lacks track timestamps, necessitating a hacky estimation method.
2026-07-14 00:48:29 +03:00
Exerra 765d86e02b refactor(AppleMusicSource): extract timestamp calculation to method
Moved the reduceRight logic for calculating play timestamps into a dedicated private method 'applyCalculatedTimestamps' to improve code readability and maintainability.
2026-07-14 00:46:58 +03:00
Exerra f347b82d33 refactor(AppleMusicSource): remove redundant unknown type assertion
Cleaned up the type casting in the AppleMusicSource fetch logic by removing unnecessary unknown assertion.
2026-07-14 00:44:38 +03:00
Exerra 87aa9c461e refactor(AppleMusicSource): replace magic numbers with class constants
Introduced constants for history limits and track polling constraints to improve maintainability and readability within AppleMusicSource.
2026-07-14 00:44:25 +03:00
Exerra 417ce85bc7 refactor(AppleMusicSource): improve type safety and code readability
Refactored AppleMusicSource to use defined types for history consistency, updated the getRecentlyPlayedTracks limit type, and renamed variable for clarity.
2026-07-14 00:42:01 +03:00
Exerra c8d917cfe6 refactor(AppleMusicSource): replace AppleMusicTrack interface with Song type
Removed local AppleMusicTrack interface and updated formatPlayObj to utilize the shared Song type for improved type consistency.
2026-07-14 00:34:47 +03:00
Exerra 8c789123f9 refactor(AppleMusicSource): remove unused backlogged plays logic
Removed the unused `getBackloggedPlays` method and cleaned up the `getRecentlyDiscoveredPlays` logic in `AppleMusicSource` to streamline track processing.
2026-07-14 00:31:06 +03:00
Exerra 06ed3f4388 feat: add Apple Music source support
Implement Apple Music source, including configuration types, environment variable handling, and source initialization logic. Refactored SourceType definitions to improve maintainability.
2026-07-14 00:24:44 +03:00
FoxxMD fab2e4938d feat(ui): Persist log window state #500
Store opened state, and position/size, to local storage so logs stay where user wants them across refreshes/initial load.

Resets window to default position if any part of log window is outside viewport bounds on open.
2026-07-12 16:19:13 +00:00
FoxxMD fbcd1e1b9a refactor(ui): Fix conditional hook for SSE and add stories for header elements 2026-07-12 15:43:33 +00:00
FoxxMD d48f2bd4ac feat(ui): Replace now playing player with summary with reduced motion #500 2026-07-11 20:28:13 +00:00
FoxxMD e2dad463f6 feat: reduce now playing animation #500 2026-07-11 20:05:20 +00:00
FoxxMD 642af13f3c fix(ui): fix shiki instance creation 2026-07-10 21:26:51 +00:00
FoxxMD 4146b159bd feat(ui): Component list container max width based on selected grid width 2026-07-10 21:11:13 +00:00
FoxxMD 3a3a97132d fix jellyfin import? 2026-07-10 21:03:06 +00:00
FoxxMD 04a5b91694 feat(ui): Implement grid for desktop layout
Allow user-selected max grid width on non-mobile breakpoints

#500
2026-07-10 20:57:03 +00:00
FoxxMD 945f494a2e fix: top-level type import syntax
see eslint.config.js comments
2026-07-10 16:47:38 +00:00
FoxxMD 4b09909b33 update cacheable for per-operation ttl 2026-07-10 15:17:01 +00:00
FoxxMD a5a1365b72 chore: remove tsx from mocha extension 2026-07-10 15:16:42 +00:00
FoxxMD 6bc618c481 always cache mb url so we can log to transform input 2026-07-10 15:16:31 +00:00
FoxxMD 85d1a0e648 chore: fix code styling in musicbrainz transformer 2026-07-10 15:15:57 +00:00
FoxxMD 1ae45c6060 add npm script for generating esbuild bundle metadata 2026-07-10 13:34:15 +00:00
FoxxMD 7daa43a883 tree-shake shiki for chakra usage 2026-07-10 13:34:15 +00:00
FoxxMD f8501b2a2e chore: Remove tsx usage from vscode launch profiles 2026-07-10 13:34:15 +00:00
Matt Foxx 14c35edadf Merge pull request #633 from FoxxMD/node-ts-native
feat: Migrate to native node runtime everywhere
2026-07-09 16:25:27 -04:00
FoxxMD cbb9774a18 chore: remove tsx and usage in npm scripts 2026-07-09 20:24:16 +00:00
FoxxMD 7b1eb7c2c1 disable flaky test 2026-07-09 20:06:13 +00:00
FoxxMD 8c8d1e1673 chore: Configure no-unused-vars 2026-07-09 20:01:08 +00:00
FoxxMD fbdd504453 fxi: Fix linting rules related to functions and error causes 2026-07-09 19:52:13 +00:00
FoxxMD 612a90d7e7 fix: Fix many useless or missed variable assignments 2026-07-09 19:43:11 +00:00
FoxxMD de8ad60db8 allow no-empty-object-type with single extends interfaces 2026-07-09 19:34:24 +00:00
FoxxMD 43fd8ea807 refactor: autofix (eslint) prefer-const prefer-as-const and var rules 2026-07-09 19:33:54 +00:00
FoxxMD ec3a7e2d20 fix: Fix case block declarations 2026-07-09 19:20:26 +00:00
FoxxMD 981a211e56 refactor: remove all unused imports 2026-07-09 19:04:40 +00:00
FoxxMD c42b5cacaa refactor: Move files/imports to implement folder import isolation 2026-07-09 18:31:18 +00:00
FoxxMD 919907f1cb feat: Add eslint rules to enforce folder import boundaries 2026-07-09 18:14:31 +00:00
FoxxMD 9eac9fdfa1 chore: prevent vscode from reporting comments in config json are errors 2026-07-09 18:14:01 +00:00
FoxxMD 86cea49ed4 fix(ci): Use node for stanity run 2026-07-09 13:31:02 +00:00
FoxxMD 8fc892a738 test: Fix db error test using transient db 2026-07-09 13:27:55 +00:00
FoxxMD fd591be3d6 remove tsx from mocha setup 2026-07-09 13:27:39 +00:00
FoxxMD 63b23689a4 remove debug file 2026-07-09 13:27:27 +00:00
FoxxMD 3035d4c5e8 feat(docker): Remove tsx and typescript dependency from build 2026-07-09 12:58:15 +00:00
FoxxMD 7891133002 fix remaining type imports preventing docker container from running with tsx 2026-07-09 12:26:56 +00:00
FoxxMD 66db4d017e more fixes to get all tests passing 2026-07-09 12:17:03 +00:00
FoxxMD 15e55dc6c5 more refactoring to remove server packages from bundled vite build 2026-07-09 04:13:28 +00:00
FoxxMD 239fb5919e fix(ui): Exclude discord.js from vite deps 2026-07-09 02:54:44 +00:00
FoxxMD 7e53553a8e more fixes for file extensions 2026-07-09 02:36:51 +00:00
FoxxMD 4bd0345d1b replace file extensions and more import type fixes 2026-07-09 02:27:41 +00:00
FoxxMD b207109697 add allowImportingTsExtensions compiler option 2026-07-09 02:03:03 +00:00
FoxxMD 77de2a7526 fix remaining erasure issues 2026-07-09 01:59:58 +00:00
FoxxMD 69b19da6aa fix(eslint): ignore no-undef 2026-07-09 01:59:46 +00:00
FoxxMD f2e2d67668 initial work for using erasable types 2026-07-08 21:14:34 +00:00
378 changed files with 6816 additions and 4111 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
run: |
set +e
export NODE_ENV=production
timeout --preserve-status 10s node node_modules/.bin/tsx src/backend/index.ts
timeout --preserve-status 10s node src/backend/index.ts
exitcode="$?"
if [[ "$exitcode" -eq 143 ]] || [[ "$exitcode" -eq 137 ]]; then
echo "App stayed up long enough and exited with expected status"
+5 -1
View File
@@ -155,4 +155,8 @@ storybook-static
*.db
*.db*
**/lexicons/**/*.json
**/lexicons/**/*.json
bun.lock
.DS_Store
**/.DS_Store
-1
View File
@@ -1,7 +1,6 @@
{
"reporter": "dot",
"extension": "ts",
"import": "tsx/esm",
"spec": "./src/backend/tests/**/*.test.ts",
"file": [
"./src/backend/tests/setup.ts"
+5 -20
View File
@@ -20,7 +20,7 @@
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"runtimeExecutable": "node",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
@@ -35,7 +35,7 @@
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/index.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"runtimeExecutable": "node",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
@@ -48,28 +48,13 @@
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "tsx",
"type": "node",
"request": "launch",
// Debug current file in VSCode
"program": "${file}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
"<node_internals>/**",
// Ignore all dependencies (optional)
"${workspaceFolder}/node_modules/**",
],
},
{
"name": "schema",
"type": "node",
"request": "launch",
// Debug app in VSCode
"program": "${workspaceFolder}/src/backend/utils/SchemaStaticUtil.ts",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"runtimeExecutable": "node",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": [
@@ -96,7 +81,7 @@
"--recursive",
"${workspaceFolder}/src/backend/tests/**/*.test.ts"
],
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"runtimeExecutable": "node",
"internalConsoleOptions": "openOnSessionStart",
"name": "Mocha Tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
@@ -120,7 +105,7 @@
"--config", "${workspaceRoot}/.mocharc.json",
"${file}"
],
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
"runtimeExecutable": "node",
"internalConsoleOptions": "openOnSessionStart",
"name": "Mocha Test on File",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
+4 -3
View File
@@ -1,5 +1,4 @@
{
"mochaExplorer.require": "tsx/esm",
"mochaExplorer.timeout": 1200000,
"mochaExplorer.exit": true,
"search.exclude": {
@@ -11,7 +10,9 @@
"docsite/**"
],
"files.associations": {
"*.css": "tailwindcss"
"*.css": "tailwindcss",
"**/config/*.json": "jsonc"
},
"tailwindCSS.experimental.configFile": "src/client/index.css"
"tailwindCSS.experimental.configFile": "src/client/index.css",
}
+2
View File
@@ -118,6 +118,8 @@ RUN npm ci --omit=dev --no-audit \
&& npm uninstall ts-json-schema-generator \
&& npm cache clean --force \
&& chown -R abc:abc node_modules \
## peerDependency of validbot and can(?) be removed for production
&& rm -r node_modules/typescript \
&& npx @usex/prune-mod -w \
&& rm -rf /root/.cache
+1
View File
@@ -12,6 +12,7 @@ alt="multi-scrobbler logo" width="180" height="180">
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
* Supports scrobbling from many [**Sources**](https://docs.multi-scrobbler.app/configuration/sources)
* [Apple Music](https://docs.multi-scrobbler.app/configuration/sources/applemusic/?configType=file)
* [Azuracast](https://docs.multi-scrobbler.app/configuration/sources/azuracast)
* [Deezer](https://docs.multi-scrobbler.app/configuration/sources/deezer)
* [Google Cast (Chromecast)](https://docs.multi-scrobbler.app/configuration/sources/google-cast)
+29
View File
@@ -0,0 +1,29 @@
[
{
"id": "myAppleMusic",
"name": "My Apple Music",
"enable": true,
"clients": [],
"data": {
// either key or token needs to be provided
"key": {
"id": "2HPSNJZ88N",
"teamId": "SN6YASW8G4",
"p8": "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEG...-----END PRIVATE KEY-----"
},
"token": "your-jwt-token-here",
"mediaUserToken": "your-media-user-token-here",
// optional: Origin header for API requests (required when using a browser token)
"origin": "https://music.apple.com",
// optional: polling interval in seconds (default 60)
"interval": 60
},
"options": {
"logDiff": true,
"recoverUnchangedTopHistory": true,
"normalizeAlbum": true
}
}
]
@@ -7,4 +7,4 @@ echo -e "\nmulti-scrobbler is starting!"
export NODE_ENV=production
cd /app || exit
exec \
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/node_modules/.bin/tsx /app/src/backend/index.ts
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/src/backend/index.ts
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

@@ -0,0 +1,262 @@
---
title: Apple Music
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import AppleMusicConfig from '!!raw-loader!@site/../config/applemusic.json.example';
This Source **monitors your Apple Music listening history** via the official Apple Music API and scrobbles new activity to your configured [Clients](/configuration/clients).
Because of how the Apple Music API works, Multi-Scrobbler (MS) requires two different tokens to function: a **Media User Token** (identifies your personal account) and an **Authentication Token** (authorizes API access).
### 1. Getting a Media User Token
The `mediaUserToken` is required to access your recently played history. You can extract it directly from the Apple Music web app:
1. Visit [music.apple.com](https://music.apple.com) in your browser and log in.
2. Open your browser's Developer Tools (usually `F12` or <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd>).
3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Safari/Firefox).
4. Expand **Cookies** in the left sidebar and select `https://music.apple.com`.
5. Find the cookie named `media-user-token` and copy its value (it typically starts with `0.`).
:::caution[Token Expiry]
The `mediaUserToken` will eventually expire. If Multi-Scrobbler begins throwing authentication errors in the logs, simply repeat these steps to obtain and configure a fresh token.
:::
### 2. Authentication
To authorize API access, Multi-Scrobbler requires an Authentication Token (JWT). Because the official Apple Music API is designed for developers, there are two ways to provide this token depending on your situation:
* **Browser Token (Free & Most Common):** Best for 99% of users. You can easily extract a temporary token from the Apple Music web player without needing an Apple Developer account.
* **MusicKit Key (Requires Paid Apple Developer Account):** If you happen to be enrolled in the paid Apple Developer Program ($99/year), you can provide a private key to automatically generate permanent tokens.
Choose your preferred method below:
<Tabs groupId="appleMusicAuth" queryString>
<TabItem value="token" label="Browser Token (Free)">
Since most users don't have a paid developer account, extracting the token from the web player is the standard approach.
Behind the scenes, the Apple Music website makes a lot of background requests, which can make finding the right token confusing. To filter out the noise and grab the exact token we need, follow the steps for your browser below:
<details>
<summary>Chrome, Edge & Firefox</summary>
1. Visit [music.apple.com](https://music.apple.com) and log in.
2. Open Developer Tools (`F12` or `Ctrl+Shift+I` / <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd>) and navigate to the **Network** tab.
3. In the filter box at the top left, paste exactly: `https://amp-api.music.apple.com/v1/me/account` (**Label 1**).
4. Click the **Fetch/XHR** filter button to hide irrelevant requests (**Label 2**).
5. Reload the page (**Label 3**).
6. Under the "Name" column, click the request starting with `account?meta=` (**Label 4**).
7. In the panel that opens, scroll down to the **Request Headers** section.
8. Find the `authorization` header. Copy the long string of text **after** the `Bearer ` prefix (**Label 5**).
![Chrome Network Tab Instructions](../applemusic_chrome_devtools.png)
</details>
<details>
<summary>Safari</summary>
Before you can extract the token in Safari, you must enable developer tools.
{/* This weird formatting is because ⌘ is usually incredibly tiny */}
1. Open Safari Settings (<kbd>⌘</kbd> + <kbd>,</kbd>), navigate to the **Advanced** tab, and check **"Show features for web developers"** at the very bottom.
![Safari Advanced Settings](../applemusic_safari_settings.png)
2. Visit [music.apple.com](https://music.apple.com) and log in.
3. Open the Web Inspector (<kbd>⌘</kbd>+<kbd>⌥</kbd>+<kbd>I</kbd> or Develop > Show Web Inspector) and navigate to the **Network** tab.
4. In the filter bar on the left side of the inspector, paste exactly: `https://amp-api.music.apple.com/v1/me/account` (**Label 1**).
5. Reload the page (**Label 2**).
6. Click the `account` request that appears in the Name list (**Label 3**).
7. In the details sidebar, look under the **Request** section for the `Authorization` header. Copy the long string of text **after** the `Bearer ` prefix (**Label 4**).
![Safari Network Tab Instructions](../applemusic_safari_devtools.png)
</details>
<br />
Once you have your token, add it to your configuration file along with the origin header:
```json title="applemusic.json"
{
"data": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsIm...",
"mediaUserToken": "your-media-user-token-here",
"origin": "https://music.apple.com"
}
}
```
:::info[Origin Header Required]
When using a JWT extracted from the browser, Apple requires the request to match the domain it was issued to. You **must** include the `origin` field in your config as shown above.
:::
*Note: Browser-generated JWTs are valid for a maximum of 35 days and must be updated manually when they expire.*
</TabItem>
<TabItem value="key" label="MusicKit Key (Paid Account)">
:::warning[Apple Developer Account Required]
To generate JWTs automatically, you must be enrolled in the paid [Apple Developer Program](https://developer.apple.com/programs/) ($99/year) and create a MusicKit key. If you don't have an account, use the **Browser Token (Free)** tab instead.
:::
To generate JWTs automatically and avoid manual token refreshes, you need an Apple Music API key:
1. Go to the [Apple Developer portal](https://developer.apple.com/account/resources/authkeys/list) and create a **MusicKit** key.
2. Download the `.p8` file — this is your private key.
3. Note your **Key ID** and **Team ID** (found in your Apple Developer account Membership details).
Add these details to your config:
```json title="applemusic.json"
{
"data": {
"key": {
"id": "2HPSNJZ88N",
"teamId": "SN6YASW8G4",
"p8": "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEG...-----END PRIVATE KEY-----"
},
"mediaUserToken": "your-media-user-token-here"
}
}
```
:::tip
When pasting the contents of your `.p8` file into JSON, make sure to replace physical line breaks with `\n` so it remains a valid, single-line JSON string.
:::
</TabItem>
</Tabs>
---
### How Multi-Scrobbler handles Apple Music quirks
#### Timestamp Estimation
The Apple Music API **does not provide timestamps** for when tracks were played. Multi-Scrobbler estimates play times by taking the current time and subtracting track durations backwards:
* The most recent track is assumed to have finished playing **now**.
* Each older track is estimated to have played `duration` seconds before the previous one.
*For the most accurate scrobble timestamps, it is highly recommended to keep the polling interval (`APPLEMUSIC_INTERVAL`) low (the default is 60 seconds).*
#### Duplicate Play Recovery
The Apple Music history API strictly deduplicates tracks. Instead of showing the same song multiple times in a row, it simply bumps the re-played track back to the #1 spot and removes the older entry.
For example, if you listen to **Song A** → **Song B**, your history is `[Song B, Song A]`. If you then listen to **Song A** again, the API returns `[Song A, Song B]` (Song A was bumped to the top).
If Multi-Scrobbler polled when the top track was Song A, and polls again after it's bumped back to the top, the top track appears functionally unchanged. This usually confuses standard scrobblers into ignoring the update entirely. By default, MS correctly detects this "top-rebound" pattern, extracts the intermediate tracks that were squeezed in (Song B), and scrobbles Song A again as a re-listen.
*Note: Because of this strict deduplication, **"pure loops"** (listening to Song A continuously on repeat without playing anything else in between) cannot be tracked. The history list never changes shape, so MS has no way of knowing it was played again.*
If you ever experience false positives (tracks being scrobbled that you didn't actually re-listen to), you can disable this recovery behavior with `"recoverUnchangedTopHistory": false` in your config options or by setting the `APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY=false` env var.
#### Album name normalization
The Apple Music API appends ` - EP` or ` - Single` to the album name for EPs and singles. These suffixes are *not* part of the official names of the album and can cause issues when trying to scrobble or match metadata.
By default, Multi-Scrobbler strips these suffixes out so only the official name is used.
If you do not want this behavior it can be disabled with ENV `APPLEMUSIC_NORMALIZE_ALBUM=false` or File/AIO option `"normalizeAlbum": false`.
<DetailsAdmo type="tip" summary="Stripping Suffix with User Stage">
If you still want this stripping functionality but with more control over how it is applied you can use a [User Stage](/configuration/transforms/user).
Here is an example of creating a User Stage to only strip suffixes for a specific scrobble client:
<details>
<summary>Example</summary>
```json5 title="config.json"
{
// ...
"transformers": [
{
"type": "user",
"name": "AppleAlbumStip",
"defaults": {
"album": [
"/ - (EP|Single)$/i"
]
}
}
]
}
```
```json5 title="applemusic.json"
{
{
"id": "myAppleMusic",
"name": "My Apple Music",
"data": {
// ...
},
// highlight-start
"options": {
"normalizeAlbum": false
}
// highlight-end
}
}
```
```json5 title="lastfm.json"
[
{
"name": "Foxx LFM Client",
"id": "myLastFmClient",
"enable": true,
"configureAs": "client",
"data": {
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
"redirectUri": "http://localhost:9078/lastfm/callback"
},
// highlight-start
"options": {
"playTransform": {
"preCompare": [
{
"type": "user",
"name": "AppleAlbumStip",
}
]
}
}
// highlight-end
}
]
```
</details>
</DetailsAdmo>
## Configuration Reference
<Config config="AppleMusicSourceConfig" fileContent={AppleMusicConfig} name="applemusic">
| Environment Variable | Required? | Default | Description |
| ------------------------------------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APPLEMUSIC_ID` | Yes | | A unique ID for this source. |
| `APPLEMUSIC_MEDIA_USER_TOKEN` | Yes | | The media-user-token extracted from the browser. |
| `APPLEMUSIC_KEY_ID` | No | | Key ID from your MusicKit key. |
| `APPLEMUSIC_TEAM_ID` | No | | Team ID from your Apple Developer account. |
| `APPLEMUSIC_KEY_P8` | No | | The contents of your MusicKit `.p8` private key file. |
| `APPLEMUSIC_TOKEN` | No | | The authentication JWT extracted from the browser. |
| `APPLEMUSIC_INTERVAL` | No | `60` | Polling interval in seconds. |
| `APPLEMUSIC_ORIGIN_HEADER` | No | | Origin header for API requests, e.g. `https://music.apple.com`. Required when using a browser token. |
| `APPLEMUSIC_RECOVER_UNCHANGED_TOP_HISTORY` | No | `true` | Apple Music deduplicates its history by bumping re-played tracks to the top. If you listen to Song A → Song B → Song A, the history changes from `[A]` to `[A, B]` (bumping A). MS detects this "top-rebound" to recover the intermediate play (B) and the re-listen (A). Disable this only if you see incorrect duplicate scrobbles. |
| `APPLEMUSIC_NORMALIZE_ALBUM` | No | `true` | Strips extraneous `- EP` and `- Single` suffixes from album names |
| `APPLEMUSIC_NAME` | No | | A vanity name different than the ID. |
</Config>
@@ -15,6 +15,7 @@ A **Source** is a data source that contains information about tracks you are pla
| Name | Networking | Scrobble SOT | [Multi Device/User](#multi-deviceuser) | [Scrobble Destination](#limiting-scrobble-destination) | [Thresholds](#scrobble-thresholds) | [Should Scrobble](#should-scrobble-behavior) |
| :---------------------------------------------------------------------- | :------------------------------------------------------- | :-------------------------------------------------- | :------------------------------------- | :----------------------------------------------------- | :--------------------------------- | :------------------------------------------- |
| [Apple Music](/configuration/sources/applemusic) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ |
| [Azuracast](/configuration/sources/azuracast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ✅ |
| [Deezer](/configuration/sources/deezer) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ✅ | ✅ | ❌ | ❌ |
| [Google Cast (Chromecast)](/configuration/sources/google-cast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ✅ | ✅ | ✅ | ❌ |
+15
View File
@@ -3,6 +3,8 @@ slug: /
title: Overview
sidebar_position: 1
---
import Carousel from '@site/src/components/Carousel/NextCarousel';
import BrowserOnly from '@docusaurus/BrowserOnly';
![Latest Release](https://img.shields.io/github/v/release/foxxmd/multi-scrobbler)
![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
@@ -13,6 +15,7 @@ sidebar_position: 1
A dockerized app that monitors your music listening activity from *everywhere* and scrobbles it *anywhere*.
* Supports monitoring activity from many [**Sources**](/configuration/sources)
* [Apple Music](/configuration/sources/applemusic)
* [Azuracast](/configuration/sources/azuracast)
* [Deezer](/configuration/sources/deezer)
* [Google Cast (Chromecast)](/configuration/sources/google-cast)
@@ -64,6 +67,18 @@ A dockerized app that monitors your music listening activity from *everywhere* a
[**Quick Start Guide**](/quickstart)
<DetailsAdmo type="tip" summary="New UI Preview">
A **full rewrite** of the UI is now available for preview. The new UI modernizes Multi-Scrobbler and gives you in depth insight into how your scrobbles are handled end-to-end.
<Carousel/>
To try out the stable-release preview navigate to `http://yourMSDomain:9078/next`
Or to try out the **latest preview snapshot** see the docker image pinned [in this comment.](https://github.com/FoxxMD/multi-scrobbler/issues/500#issuecomment-4917525794)
</DetailsAdmo>
<img src={require('/img/status-ui.png').default} width="800"/>
**Why should I use this over a browser extension and/or mobile app scrobbler?**
+30
View File
@@ -22,6 +22,8 @@
"clsx": "^2.0.0",
"docusaurus-plugin-sass": "^0.2.6",
"docusaurus-theme-github-codeblock": "^2.0.2",
"embla-carousel": "^9.0.0-rc02",
"embla-carousel-react": "^9.0.0-rc02",
"json5": "^2.2.3",
"micromark-extension-directive": "^3.0.1",
"modern-react-json-editor": "^1.0.2",
@@ -10863,6 +10865,34 @@
"integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==",
"license": "ISC"
},
"node_modules/embla-carousel": {
"version": "9.0.0-rc02",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-9.0.0-rc02.tgz",
"integrity": "sha512-GOQBX6v5cNk/ptfuHUoHhW9rvl5KeSGsBoppaUqtihu2kDmQAyzvnH/95r3TaSScFSJTj68V9AM6E5nq3jUVcQ==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "9.0.0-rc02",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-9.0.0-rc02.tgz",
"integrity": "sha512-DpcD0kTv6L31KvBcrx5d57E6T2rP/YceTGAdq7llkpKFdfM1of8YotHVEQTNIU3I9o5mjE/QxLi1ndJmm1DikA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "9.0.0-rc02",
"embla-carousel-reactive-utils": "9.0.0-rc02"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "9.0.0-rc02",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-9.0.0-rc02.tgz",
"integrity": "sha512-H1c+yUbcVST9eJoGU+05L1vSDyBs0hf/Gf1OadHYIJ3asBfyvn6oaGCs2wXq1D8/f09y0wwCxeRxT9UZU/TxJA==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "9.0.0-rc02"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+2
View File
@@ -30,6 +30,8 @@
"clsx": "^2.0.0",
"docusaurus-plugin-sass": "^0.2.6",
"docusaurus-theme-github-codeblock": "^2.0.2",
"embla-carousel": "^9.0.0-rc02",
"embla-carousel-react": "^9.0.0-rc02",
"json5": "^2.2.3",
"micromark-extension-directive": "^3.0.1",
"modern-react-json-editor": "^1.0.2",
@@ -0,0 +1,96 @@
import React, {
ComponentPropsWithRef,
useCallback,
useEffect,
useState
} from 'react'
import { EmblaCarouselType } from 'embla-carousel'
type UsePrevNextButtonsType = {
prevBtnDisabled: boolean
nextBtnDisabled: boolean
onPrevButtonClick: () => void
onNextButtonClick: () => void
}
export const usePrevNextButtons = (
emblaApi: EmblaCarouselType | undefined
): UsePrevNextButtonsType => {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
const onPrevButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.goToPrev()
}, [emblaApi])
const onNextButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.goToNext()
}, [emblaApi])
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canGoToPrev())
setNextBtnDisabled(!emblaApi.canGoToNext())
}, [])
useEffect(() => {
if (!emblaApi) return
onSelect(emblaApi)
emblaApi.on('reinit', onSelect).on('select', onSelect)
}, [emblaApi, onSelect])
return {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick
}
}
type PropType = ComponentPropsWithRef<'button'>
export const PrevButton = (props: PropType) => {
const { children, disabled, ...restProps } = props
return (
<button
className={'embla__button embla__button--prev'.concat(
disabled ? ' embla__button--disabled' : ''
)}
type="button"
{...restProps}
>
<svg className="embla__button__svg" viewBox="0 0 532 532">
<path
fill="currentColor"
d="M355.66 11.354c13.793-13.805 36.208-13.805 50.001 0 13.785 13.804 13.785 36.238 0 50.034L201.22 266l204.442 204.61c13.785 13.805 13.785 36.239 0 50.044-13.793 13.796-36.208 13.796-50.002 0a5994246.277 5994246.277 0 0 0-229.332-229.454 35.065 35.065 0 0 1-10.326-25.126c0-9.2 3.393-18.26 10.326-25.2C172.192 194.973 332.731 34.31 355.66 11.354Z"
/>
</svg>
{children}
</button>
)
}
export const NextButton = (props: PropType) => {
const { children, disabled, ...restProps } = props
return (
<button
className={'embla__button embla__button--next'.concat(
disabled ? ' embla__button--disabled' : ''
)}
type="button"
{...restProps}
>
<svg className="embla__button__svg" viewBox="0 0 532 532">
<path
fill="currentColor"
d="M176.34 520.646c-13.793 13.805-36.208 13.805-50.001 0-13.785-13.804-13.785-36.238 0-50.034L330.78 266 126.34 61.391c-13.785-13.805-13.785-36.239 0-50.044 13.793-13.796 36.208-13.796 50.002 0 22.928 22.947 206.395 206.507 229.332 229.454a35.065 35.065 0 0 1 10.326 25.126c0 9.2-3.393 18.26-10.326 25.2-45.865 45.901-206.404 206.564-229.332 229.52Z"
/>
</svg>
{children}
</button>
)
}
@@ -0,0 +1,158 @@
:root[data-theme="light"] {
--text-body: rgb(54, 49, 61);
--text-comment: rgb(99, 94, 105);
--text-high-contrast: rgb(49, 49, 49);
--text-medium-contrast: rgb(99, 94, 105);
--text-low-contrast: rgb(116, 109, 118);
--detail-high-contrast: rgb(192, 192, 192);
--detail-medium-contrast: rgb(234, 234, 234);
--detail-low-contrast: rgb(240, 240, 242);
--text-body-rgb-value: 54, 49, 61;
--text-comment-rgb-value: 99, 94, 105;
--text-high-contrast-rgb-value: 49, 49, 49;
--text-medium-contrast-rgb-value: 99, 94, 105;
--text-low-contrast-rgb-value: 116, 109, 118;
--detail-high-contrast-rgb-value: 192, 192, 192;
--detail-medium-contrast-rgb-value: 234, 234, 234;
--detail-low-contrast-rgb-value: 240, 240, 242;
}
:root[data-theme="dark"] {
--text-body: rgb(222, 222, 222);
--text-comment: rgb(170, 170, 170);
--text-high-contrast: rgb(230, 230, 230);
--text-medium-contrast: rgb(202, 202, 202);
--text-low-contrast: rgb(170, 170, 170);
--detail-high-contrast: rgb(101, 101, 101);
--detail-medium-contrast: rgb(25, 25, 25);
--detail-low-contrast: rgb(21, 21, 21);
--text-body-rgb-value: 222, 222, 222;
--text-comment-rgb-value: 170, 170, 170;
--text-high-contrast-rgb-value: 230, 230, 230;
--text-medium-contrast-rgb-value: 202, 202, 202;
--text-low-contrast-rgb-value: 170, 170, 170;
--detail-high-contrast-rgb-value: 101, 101, 101;
--detail-medium-contrast-rgb-value: 25, 25, 25;
--detail-low-contrast-rgb-value: 21, 21, 21;
}
.embla {
max-width: 48rem;
margin: auto;
--slide-height: 33rem;
--slide-spacing: 1rem;
--slide-size: 100%;
margin-bottom: 1rem;
}
.embla__viewport {
overflow: hidden;
}
.embla__container {
display: flex;
touch-action: pan-y pinch-zoom;
margin-left: calc(var(--slide-spacing) * -1);
}
.embla__slide {
transform: translate3d(0, 0, 0);
flex: 0 0 var(--slide-size);
min-width: 0;
padding-left: var(--slide-spacing);
}
.embla__slide__container {
border: 0.15rem solid transparent;
border-radius: 1.2rem;
display: flex;
align-items: normal;
justify-content: center;
height: var(--slide-height);
user-select: none;
overflow:hidden;
flex-wrap: wrap;
}
.imageCaption {
font-style: italic;
font-size: 0.8em;
text-wrap-style: balance;
text-align: center;
}
.embla__controls {
display: grid;
grid-template-columns: auto 1fr;
justify-content: space-between;
gap: 1.2rem;
// margin-top: 1.8rem;
}
.embla__buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
align-items: center;
}
.embla__button {
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
-webkit-appearance: none;
appearance: none;
background-color: transparent;
touch-action: manipulation;
display: inline-flex;
text-decoration: none;
cursor: pointer;
border: 0;
padding: 0;
margin: 0;
border: 0.2rem solid var(--detail-medium-contrast);
width: 2.5rem;
height: 2.5rem;
z-index: 1;
border-radius: 50%;
color: var(--text-body);
display: flex;
align-items: center;
justify-content: center;
transform: rotate(0deg);
}
.embla__button--disabled {
color: var(--detail-high-contrast);
}
.embla__button__svg {
width: 35%;
height: 35%;
}
.embla__selected-snap-display {
justify-self: flex-end;
align-self: center;
color: var(--text-low-contrast);
font-weight: 600;
}
.embla__slide:nth-child(1) {
flex: 0 0 90%;
}
.embla__slide:nth-child(2) {
flex: 0 0 60%;
}
.embla__slide:nth-child(3) {
flex: 0 0 75%;
}
.embla__slide:nth-child(4) {
flex: 0 0 80%;
}
.embla__slide:nth-child(5) {
flex: 0 0 100%;
}
.embla__slide:nth-child(6) {
flex: 0 0 100%;
}
@@ -0,0 +1,100 @@
import React, {useEffect} from 'react'
import BrowserOnly from "@docusaurus/BrowserOnly"
import './NextCarousel.scss';
import { EmblaOptionsType } from 'embla-carousel'
import useEmblaCarousel from 'embla-carousel-react'
import {
NextButton,
PrevButton,
usePrevNextButtons
} from './Arrows'
import {
SelectedSnapDisplay,
useSelectedSnapDisplay
} from './SnapDisplay'
import ComponentList from '@site/static/img/next/ms-componentlist.png';
import ComponentListDesktop from '@site/static/img/next/ms-componentlistdesktop.png';
import ComponentDetailed from '@site/static/img/next/ms-componentdetailed.png';
import PlaysList from '@site/static/img/next/ms-playlist.jpg';
import Timeline from '@site/static/img/next/ms-mbemptyquery.png';
import Logs from '@site/static/img/next/ms-logs.jpg';
const EmblaCarousel = (props: {options?: EmblaOptionsType}) => {
const { options } = props
const [emblaRef, emblaApi] = useEmblaCarousel(options)
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick
} = usePrevNextButtons(emblaApi)
const { selectedSnap, snapCount } = useSelectedSnapDisplay(emblaApi)
return (
<div className="embla">
<div className="embla__viewport" ref={emblaRef}>
<div className="embla__container">
<div className="embla__slide" key="1">
<div className="embla__slide__container">
<img src={ComponentListDesktop} />
<div className="imageCaption">Source/Client List on desktop</div>
</div>
</div>
<div className="embla__slide" key="2">
<div className="embla__slide__container">
<img src={ComponentList} />
<div className="imageCaption">Source/Client List on mobile</div>
</div>
</div>
<div className="embla__slide" key="3">
<div className="embla__slide__container">
<img src={ComponentDetailed} />
<div className="imageCaption">Source/Client Details</div>
</div>
</div>
<div className="embla__slide" key="4">
<div className="embla__slide__container">
<img src={PlaysList} />
<div className="imageCaption">Search for Plays/Scrobbles with live updates</div>
</div>
</div>
<div className="embla__slide" key="5">
<div className="embla__slide__container">
<img src={Timeline} />
<div className="imageCaption">Scrobble timeline audit trail with detailed errors</div>
</div>
</div>
<div className="embla__slide" key="6">
<div className="embla__slide__container">
<img src={Logs} />
<div className="imageCaption">Floating logs for dev-level troubleshooting</div>
</div>
</div>
</div>
</div>
<div className="embla__controls">
<div className="embla__buttons">
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
</div>
<SelectedSnapDisplay
selectedSnap={selectedSnap}
snapCount={snapCount}
/>
</div>
</div>
)
}
const ClientCarousel = () => {
return (
<BrowserOnly>{() => {
return <EmblaCarousel/>
}}</BrowserOnly>
)
}
export default ClientCarousel;
@@ -0,0 +1,47 @@
import React, { useCallback, useEffect, useState } from 'react'
import { EmblaCarouselType } from 'embla-carousel'
type UseSelectedSnapDisplayType = {
selectedSnap: number
snapCount: number
}
export const useSelectedSnapDisplay = (
emblaApi: EmblaCarouselType | undefined
): UseSelectedSnapDisplayType => {
const [selectedSnap, setSelectedSnap] = useState(0)
const [snapCount, setSnapCount] = useState(0)
const updateScrollSnapState = useCallback((emblaApi: EmblaCarouselType) => {
setSnapCount(emblaApi.snapList().length)
setSelectedSnap(emblaApi.selectedSnap())
}, [])
useEffect(() => {
if (!emblaApi) return
updateScrollSnapState(emblaApi)
emblaApi.on('select', updateScrollSnapState)
emblaApi.on('reinit', updateScrollSnapState)
}, [emblaApi, updateScrollSnapState])
return {
selectedSnap,
snapCount
}
}
type PropType = {
selectedSnap: number
snapCount: number
}
export const SelectedSnapDisplay = (props: PropType) => {
const { selectedSnap, snapCount } = props
return (
<div className="embla__selected-snap-display">
{selectedSnap + 1} / {snapCount}
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+170 -6
View File
@@ -1,5 +1,8 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import boundaries from 'eslint-plugin-boundaries';
import unusedImports from 'eslint-plugin-unused-imports';
import { importX } from 'eslint-plugin-import-x'
import { defineConfig, globalIgnores } from "eslint/config";
@@ -11,11 +14,33 @@ import tsEslint from 'typescript-eslint';
import arrow from 'eslint-plugin-prefer-arrow-functions';
import hooks from 'eslint-plugin-react-hooks';
import mochaPlugin from 'eslint-plugin-mocha';
import unicorn from 'eslint-plugin-unicorn';
const defaultRules = {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'no-unused-vars': 'warn',
'no-unused-vars': [
'warn',
{
args: 'none',
caughtErrors: 'none',
destructuredArrayIgnorePattern: "^_",
"argsIgnorePattern": "^_",
ignoreRestSiblings: true,
ignoreUsingDeclarations: true
}
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: 'none',
caughtErrors: 'none',
destructuredArrayIgnorePattern: "^_",
"argsIgnorePattern": "^_",
ignoreRestSiblings: true,
ignoreUsingDeclarations: true
}
],
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
@@ -27,21 +52,87 @@ const defaultRules = {
}
],
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": [
"warn",
{
"allowInterfaces": 'with-single-extends'
}
]
};
export default defineConfig([
globalIgnores([
'docsite/build',
'docsite/.docusaurus',
'public/mockServiceWorker.js'
'public/mockServiceWorker.js',
'dist'
]),
{
plugins: {
"prefer-arrow-functions": arrow,
js
js,
'unused-imports': unusedImports,
unicorn,
'import-x': importX,
},
rules: {
...defaultRules,
// https://typescript-eslint.io/rules/consistent-type-imports/#comparison-with-importsnotusedasvalues--verbatimmodulesyntax
//
// when using tsconfig compiler verbatimModuleSyntax and EX import {type Foo, type Bar} from 'a';
// true => typescript will *still* import a module if all types are inline
// false => typescript will erase the entire module import
//
// we need to use verbatimModuleSyntax: true for nodejs type stripping compatibility so
// its important that we use top-level type imports so we don't accidentally import server-side modules into frontend
// but can still use types where necessary
//
// this rule *should* do this...it does detect imports that are typed but not explicitly declared
// but its not moving inline -> top-level
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: 'type-imports',
fixStyle: "separate-type-imports"
}
],
// however, import/consistent-type-specifier-style from eslint-plugin-import *does* move inline => top-level
// but it does not yet support eslint10 :(
//
// TODO eventually add this once plugin-import supports eslint10
// https://github.com/import-js/eslint-plugin-import
// https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/consistent-type-specifier-style.md
// -- this enforces that type-only imports are fixed to top-level type imports IE
// import {type Foo, type Bar} from 'a'; => import type { Foo, Bar} from 'a';
//
// USE eslint-plugin-import-x since its more actively developed?
// waiting on the status of this for supporting mixed inline types
// https://github.com/un-ts/eslint-plugin-import-x/issues/501
// "import-x/consistent-type-specifier-style": [
// "error", "prefer-top-level"
// ],
"unicorn/prefer-then-catch": "error",
"unicorn/consistent-destructuring": "warn",
"unicorn/consistent-function-scoping": "warn",
"unicorn/consistent-optional-chaining": "error",
"unicorn/no-array-callback-reference": "warn",
"unicorn/no-accidental-bitwise-operator": "warn",
"no-obj-calls": "error",
"unicorn/new-for-builtins": "error",
"unicorn/no-impossible-length-comparison": "error",
"unicorn/no-duplicate-loops": "warn",
"unicorn/no-duplicate-logical-operands": "warn",
"unicorn/no-declarations-before-early-exit": "warn",
"unicorn/prefer-negative-index": "warn",
"unicorn/prefer-import-meta-properties": "error",
"unicorn/prefer-array-from-async": "warn",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/no-useless-else": "warn",
"unicorn/no-unused-array-method-return": "error",
//"unicorn/no-unreadable-object-destructuring": "warn",
"unicorn/prefer-object-destructuring-defaults": "warn"
},
rules: defaultRules,
extends: [
tsEslint.configs.recommended,
"js/recommended"
@@ -49,7 +140,9 @@ export default defineConfig([
languageOptions: {
globals: {
...globals.node,
}
},
ecmaVersion: 'latest',
sourceType: 'module',
},
files: ['src/**/*.ts','src/**/*.tsx']
},
@@ -86,5 +179,76 @@ export default defineConfig([
"@typescript-eslint/no-unused-expressions": 'off',
'mocha/max-top-level-suites': 'off'
},
},
{
// https://typescript-eslint.io/troubleshooting/faqs/eslint#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
files: ['**/*.{ts,tsx,mts,cts}'],
plugins: {
'unused-imports': unusedImports
},
rules: {
'no-undef': 'off',
'unused-imports/no-unused-imports': 'error'
}
},
// this ruleset helps keep backend, frontend, and core keep imports isolated
// in order to prevent frontend (vite) from accidentally bundling backend files + backend packages when importing directly (backend) or transitively (through core)
//
// folder (module?) boundaries should be like
//
// backend <-- all node/server side code
// core <-- shared types, utils, and logic between backend and client
// client <-- frontend code to be bundled by vite, SHOULD NOT import directly/transitively from backend
{
files: ['src/**/*.{ts,tsx}'],
plugins: { boundaries },
settings: {
// Define your three architectural layers
'boundaries/elements': [
{ type: 'frontend', mode: 'file', pattern: 'src/client/**/*' },
{ type: 'config', mode: 'file', pattern: 'config/*.example' },
{ type: 'core', mode: 'file', pattern: ['src/core/!(tests)/**','src/core/!(tests)'] },
{ type: 'core-tests', mode: 'file', pattern: ['src/core/tests/**'] },
{ type: 'backend', mode: 'file', pattern: 'src/backend/**/*' },
{ type: 'stories', mode: 'file', pattern: ['src/stories/**/*', '.storybook/**'] },
],
// So it understands TS path aliases when resolving imports
'import/resolver': {
typescript: true,
},
},
rules: {
'boundaries/element-types': [
'warn',
{
default: 'disallow', // deny anything not explicitly allowed
rules: [
{
from: 'frontend',
allow: ['frontend', 'core'], // frontend can use itself + core, never backend
},
{
from: 'core',
allow: ['core'], // core can only use itself — never backend, never frontend
},
{
from: 'backend',
allow: ['backend', 'core', 'config', 'core-tests'], // backend can use itself + core + tests
},
{
from: 'stories',
allow: ['stories', 'core', 'frontend', 'core-tests'], // backend can use itself + core
},
{
from: 'core-tests',
allow: ['core', 'core-tests'], // backend can use itself + core
},
],
},
],
// Optional: flag any file under src/ that doesn't match one of the
// three element patterns above (catches stray/misplaced files)
'boundaries/no-unknown': 'warn'
},
}
]);
+1206 -635
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -5,13 +5,13 @@
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
"schema": "npm run -s schema:docs && npm run -s schema:app",
"schema:docs": "tsx src/backend/utils/SchemaDocsStaticUtils.ts",
"schema:app": "tsx src/backend/utils/SchemaAppStaticUtils.ts",
"schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts",
"schema:app": "node src/backend/utils/SchemaAppStaticUtils.ts",
"circular": "madge --circular --extensions ts src/index.ts",
"test": "npm run -s test:backend",
"test:backend": "mocha --reporter spec",
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
"start": "NODE_ENV=production tsx src/backend/index.ts",
"dev": "node src/backend/index.ts",
"start": "NODE_ENV=production node src/backend/index.ts",
"build:frontend": "vite build",
"build:backend": "tsc -p src/backend && npm run -s schema:app",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
@@ -25,7 +25,8 @@
"cliff": "git-cliff --unreleased",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"atproto:lex:tealfm": "npm exec -- lex-cli pull -c src/backend/common/vendor/teal/lex.config.ts && npm exec -- lex-cli generate -c src/backend/common/vendor/teal/lex.config.ts"
"atproto:lex:tealfm": "npm exec -- lex-cli pull -c src/backend/common/vendor/teal/lex.config.ts && npm exec -- lex-cli generate -c src/backend/common/vendor/teal/lex.config.ts",
"frontend:analyze": "npx esbuild src/client/index-next.tsx --bundle --platform=node --metafile=meta.json --outfile=/dev/null"
},
"exports": {
".": {
@@ -81,7 +82,7 @@
"avahi-browse": "^1.1.4",
"better-sse": "^0.8.0",
"body-parser": "^2.2.2",
"cacheable": "^1.10.4",
"cacheable": "^2.5.0",
"castv2": "^0.1.10",
"clone": "^2.1.2",
"common-tags": "^1.8.2",
@@ -109,7 +110,7 @@
"json-diff-ts": "^5.0.0-alpha.2",
"json5": "^2.2.3",
"jsondiffpatch": "^0.7.3",
"keyv": "^5.5.0",
"keyv": "^5.6.0",
"kodi-api": "^0.2.1",
"lastfm-ts-api": "^2.6.0",
"merge-error-cause": "^5.0.2",
@@ -118,6 +119,7 @@
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
"neotraverse": "^0.6.18",
"node-musickit-api": "^3.1.0-next.5",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.15.2",
@@ -137,8 +139,6 @@
"superagent": "^8.0.9",
"toad-scheduler": "^3.0.0",
"tough-cookie": "^5.1.2",
"ts-json-schema-generator": "2.3.0",
"tsx": "^4.21.0",
"typeson": "^9.0.4",
"typeson-registry": "^11.1.1",
"undici": "7.24.6",
@@ -210,10 +210,15 @@
"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-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"git-cliff": "^2.12.0",
"globals": "^17.7.0",
"ky": "^2.0.2",
@@ -239,8 +244,10 @@
"tailwindcss": "^4.2.2",
"tinyexec": "^1.1.1",
"ts-essentials": "^10.2.0",
"ts-json-schema-generator": "^2.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.12",
"with-local-tmp-dir": "^7.0.1"
},
+41 -41
View File
@@ -1,45 +1,45 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { childLogger, type Logger } from "@foxxmd/logging";
import {
cacheFunctions,
} from "@foxxmd/regex-buddy-core";
import EventEmitter from "events";
import { ComponentType, LifecycleInput, LifecycleStep, PlayData, PlayObject } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.js";
import type EventEmitter from "events";
import type {ComponentType, LifecycleInput, LifecycleStep, PlayData, PlayObject} from "../../core/Atomic.ts";
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 {
FLOW_CONTROL_TERM,
PlayTransformRules,
StageConfig,
type PlayTransformRules,
type StageConfig,
TRANSFORM_HOOK,
TransformHook
} from "./infrastructure/Transform.js";
import AbstractInitializable from "./AbstractInitializable.js";
import TransformerManager from "./transforms/TransformerManager.js";
import { getRoot } from "../ioc.js";
type TransformHook
} from "../../core/Transform.ts";
import AbstractInitializable from "./AbstractInitializable.ts";
import type TransformerManager from "./transforms/TransformerManager.ts";
import { getRoot } from "../ioc.ts";
import { nanoid } from "nanoid";
import { isDebugMode } from "../utils.js";
import { findCauseByFunc, findCauseByReference } from "../utils/ErrorUtils.js";
import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.js";
import { MSCache } from "./Cache.js";
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.js";
import { isDebugMode } from "../utils.ts";
import { findCauseByFunc, findCauseByReference } from "../utils/ErrorUtils.ts";
import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts";
import { playContentInvariantTransform } from "../utils/PlayComparisonUtils.ts";
import type { MSCache } from "./Cache.ts";
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.ts";
import clone from "clone";
import { loggerNoop } from "./MaybeLogger.js";
import { objectsEqual } from "../utils/DataUtils.js";
import { RetentionOptions } from "./infrastructure/config/database.js";
import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isCompactableProperty, parseRetentionOptions, parseRetentionOptionsDurations } from "./database/Database.js";
import { DbConcrete } from "./database/drizzle/drizzleUtils.js";
import { ComponentMinimalSelect, ComponentSelect } from "./database/drizzle/drizzleTypes.js";
import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.js";
import { ClientType } from "./infrastructure/config/client/clients.js";
import { SourceType } from "./infrastructure/config/source/sources.js";
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.js";
import { loggerNoop } from "./MaybeLogger.ts";
import { objectsEqual } from "../utils/DataUtils.ts";
import type {RetentionOptions} from "./infrastructure/config/database.ts";
import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isCompactableProperty, parseRetentionOptions, parseRetentionOptionsDurations } from "./database/Database.ts";
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} from "../../core/Atomic.ts";
import type {SourceType} from "../../core/Atomic.ts";
import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.ts";
import dayjs from "dayjs";
import { COMPONENT_STATE, ComponentCommonApi, ComponentCommonApiJson, ComponentState, PlayApiCommonDetailed } from "../../core/Api.js";
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
import { MarkRequired } from "ts-essentials";
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 { serializeError } from "serialize-error";
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
@@ -79,6 +79,10 @@ export default abstract class AbstractComponent extends AbstractInitializable {
};
}
public getUid() {
return this.config?.id ?? this.config?.name ?? this.name;
}
protected postCache(): Promise<void> {
try {
this.buildTransformRules();
@@ -96,18 +100,13 @@ export default abstract class AbstractComponent extends AbstractInitializable {
protected async doBuildDatabase(): Promise<true | string | undefined> {
await super.doBuildDatabase();
let name: string;
if('name' in this) {
name = this.name as string;
}
this.db = await getRoot().items.db();
this.componentRepo = new DrizzleComponentRepository(this.db, {logger: this.logger});
this.dbComponent = await this.componentRepo.findOrInsert({
mode: this.componentType,
type: this.type,
uid: this.config.id ?? this.config.name ?? name,
name: this.config.name ?? name
uid: this.getUid(),
name: this.config?.name ?? this.name
});
this.componentId = this.dbComponent.id;
return true;
@@ -473,6 +472,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
step.flowReason = skipMsg;
logger.debug(merged, skipMsg);
return [step, playTruth];
} else {
step.flowResult = onFailure;
let reason: string;
@@ -520,7 +520,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
delete newTransformedPlay.meta.lifecycleInputs;
}
return [step, newTransformedPlay];
return [step, newTransformedPlay ?? playTruth];
}
public abstract getRunningState(): ComponentState
+7 -7
View File
@@ -1,10 +1,10 @@
import { Logger } from "@foxxmd/logging";
import {truncateStringToLength } from "../../core/StringUtils.js";
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
import { hasUpstreamError } from "./errors/UpstreamError.js";
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
import { AuthCheckError, BuildDataError, ConnectionCheckError, ParseCacheError, PostInitError, StageError } from "./errors/MSErrors.js";
import { messageWithCausesTruncatedDefault } from "../../core/ErrorUtils.js";
import type { Logger } from "@foxxmd/logging";
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 { spawn } from 'abort-controller-x';
export default abstract class AbstractInitializable {
+13 -13
View File
@@ -1,7 +1,7 @@
import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions, KeyvCacheableMemory } from 'cacheable';
import { FlatCache, FlatCacheOptions } from 'flat-cache';
import KeyvValkey, { KeyvValkeyOptions } from '@keyv/valkey';
import dayjs, { Dayjs } from 'dayjs';
import { Cacheable, createKeyv, Keyv, type KeyvStoreAdapter, type KeyvOptions, type CacheableOptions, KeyvCacheableMemory } from 'cacheable';
import { FlatCache, type FlatCacheOptions } from 'flat-cache';
import KeyvValkey, { type KeyvValkeyOptions } from '@keyv/valkey';
import dayjs, { type Dayjs } from 'dayjs';
import duration from 'dayjs/plugin/duration.js';
import isBetween from 'dayjs/plugin/isBetween.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
@@ -9,19 +9,19 @@ import isToday from 'dayjs/plugin/isToday.js';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
import clone from 'clone';
import { childLogger, Logger } from '@foxxmd/logging';
import { projectDir } from './index.js';
import { childLogger, type Logger } from '@foxxmd/logging';
import { projectDir } from './index.ts';
import path from 'path';
import { cacheFunctions } from "@foxxmd/regex-buddy-core";
import { fileExists, fileOrDirectoryIsWriteable } from '../utils/FSUtils.js';
import { copyFile } from 'fs/promises';
import { asCacheConfig, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheConfigUser, CacheScrobbleProvider } from './infrastructure/Atomic.js';
import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.ts';
import { asCacheConfig, type CacheAuthProvider, type CacheConfig, type CacheConfigOptions, type CacheConfigUser, type CacheScrobbleProvider } from './infrastructure/Atomic.ts';
import { Typeson } from 'typeson';
import { builtin } from 'typeson-registry';
import { loggerNoop } from './MaybeLogger.js';
import { loggerNoop } from './MaybeLogger.ts';
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
import prom, { Gauge } from 'prom-client';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
import type { Gauge } from 'prom-client';
import prom from 'prom-client';
import { nonEmptyStringOrDefault } from '../../core/StringUtils.ts';
dayjs.extend(utc)
dayjs.extend(isBetween);
@@ -217,7 +217,7 @@ export class MSCache {
protected initCacheable = async (cacheFor: string, primaryConfig: CacheConfig, secondaryConfig?: CacheConfig) => {
let logger = childLogger(this.logger, cacheFor);
const logger = childLogger(this.logger, cacheFor);
const providerHints = [];
if(primaryConfig.provider === false) {
const cache = new Cacheable({primary: noopKeyv});
+1 -1
View File
@@ -1,4 +1,4 @@
import { Logger } from "@foxxmd/logging";
import type { Logger } from "@foxxmd/logging";
export class MaybeLogger {
+10 -12
View File
@@ -1,18 +1,16 @@
import { configDir } from '../index.js';
import { configDir } from '../index.ts';
import * as path from 'path';
import { promises as fs } from 'fs'
import { childLogger, Logger } from '@foxxmd/logging';
import { loggerNoop } from '../MaybeLogger.js';
import { fileExists, fileOrDirectoryIsWriteable } from '../../utils/FSUtils.js';
import { COMPACTABLE, compactableProperties, CompactableProperty, DEFAULT_RETENTION_COMPACT_AFTER, DEFAULT_RETENTION_DELETE_AFTER, RententionGranular, RetentionConfig, RetentionConfigValue, RetentionOption, RetentionValue, RetentionValueUnparsed } from '../infrastructure/config/database.js';
import { DurationValue } from '../infrastructure/Atomic.js';
import { Duration } from 'dayjs/plugin/duration.js';
import { childLogger, type Logger } from '@foxxmd/logging';
import { loggerNoop } from '../MaybeLogger.ts';
import { COMPACTABLE, type CompactableProperty, DEFAULT_RETENTION_COMPACT_AFTER, DEFAULT_RETENTION_DELETE_AFTER, type RetentionConfigValue, type RetentionOption, type RetentionValue, type RetentionValueUnparsed } from '../infrastructure/config/database.ts';
import type {DurationValue} from '../infrastructure/Atomic.ts';
import type {Duration} from 'dayjs/plugin/duration.js';
import dayjs from 'dayjs';
import { parseDurationFromDurationValue } from '../../utils/TimeUtils.js';
import assert, { AssertionError } from 'node:assert';
import { parseDurationFromDurationValue } from '../../utils/TimeUtils.ts';
import assert from 'node:assert';
import * as sqlite from 'node:sqlite';
import { parseBoolStrict } from '../../utils.js';
import { SimpleError } from '../errors/MSErrors.js';
import { parseBoolStrict } from '../../utils.ts';
import { SimpleError } from '../errors/MSErrors.ts';
export const MEMORY_DB_NAME = ':memory:';
export const isMemoryDb = (name: string): boolean => name === MEMORY_DB_NAME;
@@ -1,9 +1,9 @@
import type { SqliteDatabase, Migration } from 'sqlite-up';
import { MigrateBaseContext } from '../appMigrator.js';
import { plays as drizzlePlays } from '../drizzle/schema/schema.js';
import type {MigrateBaseContext} from '../appMigrator.ts';
import { plays as drizzlePlays } from '../drizzle/schema/schema.ts';
import clone from 'clone';
import { eq } from 'drizzle-orm';
import { PlayLifecycle, PlayObject } from '../../../../core/Atomic.js';
import type {PlayLifecycle, PlayObject} from '../../../../core/Atomic.ts';
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
+5 -5
View File
@@ -1,10 +1,10 @@
import { DbConcrete } from "./drizzle/drizzleUtils.js";
import { loggerNoop } from "../MaybeLogger.js";
import type {DbConcrete} from "./drizzle/drizzleUtils.ts";
import { loggerNoop } from "../MaybeLogger.ts";
import * as path from 'path';
import { childLogger, Logger } from "@foxxmd/logging";
import { projectDir } from "../index.js";
import { childLogger, type Logger } from "@foxxmd/logging";
import { projectDir } from "../index.ts";
import { Migrator } from 'sqlite-up';
import { MigrationStatus } from "../infrastructure/Atomic.js";
import type {MigrationStatus} from "../infrastructure/Atomic.ts";
export interface MigrateBaseContext {
db: DbConcrete
@@ -1,7 +1,6 @@
import { DBQueryConfig, DBQueryConfigWith, ExtractTablesFromSchema, KnownKeysOnly, RelationFieldsFilterInternals, Many, InferSelectModel, ExtractTablesWithRelations, type BuildQueryResult, RelationsFilter } from "drizzle-orm";
import { components, componentMigrations, playInputs, plays, queueStates, relations, playsHistorical } from "./schema/schema.js";
import {TSchema, TableName, Schema } from "./schema/schema.js";
import { MarkOptional, MarkRequired } from "ts-essentials";
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 {TSchema, TableName} from "./schema/schema.ts";
export type ComponentNew = typeof components.$inferInsert;
@@ -1,19 +1,18 @@
import { drizzle } from 'drizzle-orm/node-sqlite';
import { migrate } from 'drizzle-orm/node-sqlite/migrator';
import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
import { sql as dsl, LogWriter, Logger as DrizzleLogger } from 'drizzle-orm';
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
import { sql as dsl, type Logger as DrizzleLogger } from 'drizzle-orm';
import * as fs from 'fs/promises';
import * as path from 'path';
import { backupDb, getDbPath, getDbBackupPath, MEMORY_DB_NAME } from '../Database.js';
import { fileExists, fileOrDirectoryIsWriteable } from '../../../utils/FSUtils.js';
import { childLogger, Logger, LogLevel } from '@foxxmd/logging';
import { loggerNoop } from '../../MaybeLogger.js';
import { projectDir } from '../../index.js';
import { relations } from './schema/schema.js';
import { addToContext, executeQuery } from './logContext.js';
import { DatabaseSync } from 'node:sqlite';
import { migrateApp, getAppMigrationStatus } from '../appMigrator.js';
import { MigrationStatus } from '../../infrastructure/Atomic.js';
import { backupDb, getDbBackupPath, MEMORY_DB_NAME } from '../Database.ts';
import { fileExists, fileOrDirectoryIsWriteable } from '../../../utils/FSUtils.ts';
import { childLogger, type Logger, type LogLevel } from '@foxxmd/logging';
import { loggerNoop } from '../../MaybeLogger.ts';
import { projectDir } from '../../index.ts';
import { relations } from './schema/schema.ts';
import { addToContext, executeQuery } from './logContext.ts';
import { migrateApp, getAppMigrationStatus } from '../appMigrator.ts';
import type {MigrationStatus} from '../../infrastructure/Atomic.ts';
export async function getDbMigrationStatus(dbVal: string | DbConcrete, opts: {logger?: Logger, migrationsFolder?: string} = {}): Promise<MigrationStatus> {
const {
@@ -1,14 +1,13 @@
import assert from "node:assert";
import { PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates } from "./drizzleTypes.js";
import { PlayInputNew } from "./drizzleTypes.js";
import { QueueStateNew } from "./drizzleTypes.js";
import { ComponentNew } from "./drizzleTypes.js";
import { MarkOptional, MarkRequired } from "ts-essentials";
import { CLIENT_DEAD_QUEUE, DeadLetterScrobble, ErrorLike, PlayObject } from "../../../../core/Atomic.js";
import dayjs, { Dayjs } from "dayjs";
import { asPlay } from "../../../../core/PlayMarshalUtils.js";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.js";
import { hashObject } from "../../../utils/StringUtils.js";
import type {PlayHistoricalNew, PlayHistoricalSelect, PlayNew, PlaySelect, PlaySelectWithQueueStates} 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 dayjs from "dayjs";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.ts";
import { hashObject } from "../../../utils/StringUtils.ts";
import { serializeError } from "serialize-error";
export const generateComponentEntity = (data: MarkOptional<ComponentNew, 'uid'>): ComponentNew => {
@@ -57,7 +56,7 @@ export const hydratePlaySelect = <T extends PlaySelect | PlayHistoricalSelect>(s
return select.play;
}
let res = select.play;
const res = select.play;
// if(opts.includes('asPlay')) {
// res = asPlay(res);
// }
@@ -1,4 +1,4 @@
import { Logger } from '@foxxmd/logging'
import type { Logger } from '@foxxmd/logging'
import { AsyncLocalStorage } from 'async_hooks'
// based on https://numeric.substack.com/p/upgrading-drizzleorm-logging-with
@@ -1,14 +1,14 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { DbConcrete } from "../drizzleUtils.js";
import { CompareOpKey } from "../drizzleTypes.js";
import { Dayjs } from "dayjs";
import { RelationsFieldFilter, eq, inArray } from "drizzle-orm";
import { loggerNoop } from "../../../MaybeLogger.js";
import { capitalize } from "../../../../../core/StringUtils.js";
import { getConfigByTableName, relations, TableName } from "../schema/schema.js";
import { childLogger, type Logger } from "@foxxmd/logging";
import type { DbConcrete } from "../drizzleUtils.ts";
import type { Dayjs } from "dayjs";
import { type RelationsFieldFilter, eq, inArray } from "drizzle-orm";
import { loggerNoop } from "../../../MaybeLogger.ts";
import { capitalize } from "../../../../../core/StringUtils.ts";
import { getConfigByTableName, type TableName } from "../schema/schema.ts";
import assert from 'node:assert';
import { Cacheable } from "cacheable";
import { DateLike } from "../../../../../core/Atomic.js";
import type { Cacheable } from "cacheable";
import type { DateLike } from "../../../../../core/Atomic.ts";
import type { CompareDateBetween, CompareDateSingle } from "../../../../../core/Api.ts";
export interface DrizzleRepositoryOpts {
logger?: Logger
@@ -16,27 +16,7 @@ export interface DrizzleRepositoryOpts {
componentId?: number
}
export type CompareDateSingle<D extends DateLike = Dayjs> = {
type: CompareOpKey<D>
date: D
}
export type CompareDateBetween<D extends DateLike = Dayjs> = {
type: 'between',
range: [D, D],
inclusive?: boolean
}
export type CompareDateOp<D extends DateLike = Dayjs> = CompareDateSingle<D> | CompareDateBetween<D>;
export interface PaginatedQueryResponse {
limit: number,
offset: number
total?: number
}
export interface PaginatedResponse<T> {
data: T[]
meta: PaginatedQueryResponse
}
export interface ComponentConstrainedRepoOpts {
componentId?: number
}
@@ -66,7 +46,7 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
}
async updateById(id: number, data: Partial<typeof this.table.$inferInsert>): Promise<void> {
assert(id !== null && id !== undefined, `${typeof id === null ? 'null' : 'undefined'} given for entity id`);
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));
}
@@ -99,13 +79,12 @@ export class GenericRepository<T extends TableName> extends DrizzleBaseRepositor
}
export const buildDateCompare = (data: CompareDateOp): RelationsFieldFilter<Dayjs> => {
let q: RelationsFieldFilter<Dayjs> = {};
if (data.type !== 'between') {
q = {
return {
[data.type]: data.date
}
} else {
q = {
return {
AND: [
{
[data.inclusive ?? true ? 'gte' : 'gt']: data.range[0]
@@ -116,5 +95,4 @@ export const buildDateCompare = (data: CompareDateOp): RelationsFieldFilter<Dayj
]
}
}
return q;
}
@@ -1,10 +1,9 @@
import { Logger } from "drizzle-orm";
import { DrizzleBaseRepository, DrizzleRepositoryOpts } from "./BaseRepository.js";
import { DbConcrete } from "../drizzleUtils.js";
import { ComponentNew, ComponentSelect, FindWhere } from "../drizzleTypes.js";
import { components } from "../schema/schema.js";
import { generateComponentEntity } from "../entityUtils.js";
import { ComponentType } from "../../../../../core/Atomic.js";
import { DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
import type {DbConcrete} from "../drizzleUtils.ts";
import type {ComponentSelect, FindWhere} from "../drizzleTypes.ts";
import { components } from "../schema/schema.ts";
import { generateComponentEntity } from "../entityUtils.ts";
import type {ComponentType} from "../../../../../core/Atomic.ts";
export class DrizzleComponentRepository extends DrizzleBaseRepository<'components'> {
@@ -1,24 +1,19 @@
import { childLogger, Logger, LoggerAppExtras } from "@foxxmd/logging";
import { DbConcrete, runTransaction } from "../drizzleUtils.js";
import { loggerNoop } from "../../../MaybeLogger.js";
import { ErrorLike, PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../../../../core/Atomic.js";
import { generateInputEntity, generatePlayEntity, PlayEntityOpts, hydratePlaySelect, PlayHydateOptions, PlayHistoricalEntityOpts } from "../entityUtils.js";
import { playInputs, plays, playsHistorical, queueStates, relations } from "../schema/schema.js";
import { PlayNew, PlaySelect, PlayInputNew, FindWhere, FindMany, QueueStateSelect, FindWith, PlaySelectWithQueueStates, WhereClause, PlayWith, PlayHistoricalSelect, PlayHistoricalNew } from "../drizzleTypes.js";;
import { MarkOptional, MarkRequired, PathValue } from "ts-essentials";
import { genGroupIdStrFromPlay, removeEmptyArrays, removeUndefinedKeys } from "../../../../utils.js";
import dayjs, { Dayjs } from "dayjs";
import { RelationsFieldFilter, eq, inArray, ne, notInArray, desc, asc, and, sql, Placeholder } from "drizzle-orm";
import { CompactableProperty, RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.js";
import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.js";
import { buildDateCompare, CompareDateOp, ComponentConstrainedRepoOpts, DrizzleBaseRepository, DrizzleRepositoryOpts, PaginatedQueryResponse, PaginatedResponse } from "./BaseRepository.js";
import { asPlay } from "../../../../../core/PlayMarshalUtils.js";
import assert, { Assert } from "node:assert";
import { hashObject, parseArrayFromMaybeString } from "../../../../utils/StringUtils.js";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.js";
import { comparePlayTemporally, getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.js";
import { SourceType } from "../../../infrastructure/config/source/sources.js";
import { getTemporallyCloseDateCompareOp } from "./PlayRepository.js";
import { type DbConcrete, runTransaction } from "../drizzleUtils.ts";
import { type PlayObject, TA_DEFAULT_ACCURACY, type TemporalAccuracy } from "../../../../../core/Atomic.ts";
import { generatePlayEntity, hydratePlaySelect, type PlayHydateOptions, type PlayHistoricalEntityOpts } from "../entityUtils.ts";
import { plays, playsHistorical } from "../schema/schema.ts";
import type {FindWhere, FindMany, WhereClause, PlayHistoricalSelect, PlayHistoricalNew} from "../drizzleTypes.ts";;
import type { MarkOptional } from "ts-essentials";
import { removeUndefinedKeys } from '../../../../../core/DataUtils.ts';
import type {Dayjs} from "dayjs";
import { inArray, sql } from "drizzle-orm";
import { buildDateCompare, type CompareDateOp, type ComponentConstrainedRepoOpts, DrizzleBaseRepository, type DrizzleRepositoryOpts } from "./BaseRepository.ts";
import type {PaginatedResponse} from "../../../../../core/Api.ts";
import { hashObject } from "../../../../utils/StringUtils.ts";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.ts";
import { comparePlayTemporally, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.ts";
import type {SourceType} from "../../../../../core/Atomic.ts";
import { getTemporallyCloseDateCompareOp } from "./PlayRepository.ts";
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
@@ -162,6 +157,7 @@ export class DrizzlePlayHistoricalRepository extends DrizzleBaseRepository<'play
query = removeUndefinedKeys(query);
const results = await this.db.query.playsHistorical.findMany({
...query,
limit: args.limit,
offset: args.offset,
columns: {id: true},
@@ -230,7 +226,7 @@ export class DrizzlePlayHistoricalRepository extends DrizzleBaseRepository<'play
} else {
endRange = play.data.playDate.add(dateGranularity, 's');
}
let where: FindWhere<'playsHistorical'> = {
const where: FindWhere<'playsHistorical'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
};
@@ -271,9 +267,9 @@ export class DrizzlePlayHistoricalRepository extends DrizzleBaseRepository<'play
bufferTime
} = opts;
let query: FindMany<'playsHistorical'> = {};
const query: FindMany<'playsHistorical'> = {};
let where: FindWhere<'playsHistorical'> = {
const where: FindWhere<'playsHistorical'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {bufferTime})),
};
@@ -296,7 +292,7 @@ export const buildPlayHistoricalWhere = (args: PlayWhereOpts): WhereClause<'play
// old way
// let where: Parameters<(ReturnType<typeof getDb>)['query']['plays']['findMany']>[0]['where'] = {
// };
let where: FindWhere<'playsHistorical'> = {
const where: FindWhere<'playsHistorical'> = {
componentId: args.componentId
};
if (args.seenAt !== undefined) {
@@ -1,24 +1,24 @@
import { childLogger, Logger, LoggerAppExtras } from "@foxxmd/logging";
import { DbConcrete, runTransaction } from "../drizzleUtils.js";
import clone from 'clone';
import { Traverse, TraverseContext } from 'neotraverse/modern';
import { loggerNoop } from "../../../MaybeLogger.js";
import { DateLike, DeepReplaceValue, ErrorLike, PlayObject, PlayState, QueueName, REGEX_ISO8601_LOOSE, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../../../../core/Atomic.js";
import { generateInputEntity, generatePlayEntity, PlayEntityOpts, hydratePlaySelect, PlayHydateOptions } from "../entityUtils.js";
import { playInputs, plays, queueStates, relations, schema } from "../schema/schema.js";
import { PlayNew, PlaySelect, PlayInputNew, FindWhere, FindMany, QueueStateSelect, FindWith, PlaySelectWithQueueStates, WhereClause, PlayWith } from "../drizzleTypes.js";;
import { MarkOptional, MarkRequired, PathValue } from "ts-essentials";
import { genGroupIdStrFromPlay, removeEmptyArrays, removeUndefinedKeys } from "../../../../utils.js";
import dayjs, { Dayjs } from "dayjs";
import { RelationsFieldFilter, eq, inArray, ne, notInArray, desc, asc, and, sql, Placeholder, relationsFilterToSQL } from "drizzle-orm";
import { CompactableProperty, RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.js";
import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.js";
import { buildDateCompare, CompareDateOp, ComponentConstrainedRepoOpts, DrizzleBaseRepository, DrizzleRepositoryOpts, PaginatedQueryResponse, PaginatedResponse } from "./BaseRepository.js";
import assert, { Assert } from "node:assert";
import { hashObject, parseArrayFromMaybeString } from "../../../../utils/StringUtils.js";
import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.js";
import { comparePlayTemporally, getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.js";
import { SourceType } from "../../../infrastructure/config/source/sources.js";
import { childLogger } from "@foxxmd/logging";
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 { 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 { type CompactableProperty, type RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.ts";
import type {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 { 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";
;
// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
@@ -216,6 +216,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
query = removeUndefinedKeys(query);
const results = await this.db.query.plays.findMany({
...query,
limit: args.limit,
offset: args.offset,
columns: {id: true},
@@ -292,7 +293,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
dateComparer = 'updatedAt'
} = opts;
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
component: {
id: componentId
},
@@ -342,8 +343,8 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
const loggerDel = childLogger(this.logger, ['Retention', 'Delete']);
const loggerCom = childLogger(this.logger, ['Retention', 'Compact']);
let summaryDelStates: string[] = [];
let summaryCompactStates: string[] = [];
const summaryDelStates: string[] = [];
const summaryCompactStates: string[] = [];
loggerDel.debug('Starting cleanup...');
for(const retentionType of retentionPlayTypes) {
@@ -378,7 +379,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
}
const compactTypes = retentionOpts.compact;
let compactedFlags: CompactableProperty[] = [];
const compactedFlags: CompactableProperty[] = [];
if(compactTypes.includes('input')) {
compactedFlags.push('input');
}
@@ -584,7 +585,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
componentId = this.componentId,
hydrate
} = opts;
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
componentId
}
if(retries !== undefined) {
@@ -634,7 +635,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
} else {
endRange = play.data.playDate.add(dateGranularity, 's');
}
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
};
@@ -692,9 +693,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
with: qWith
} = opts;
let query: FindMany<'plays'> = {};
const query: FindMany<'plays'> = {};
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {bufferTime})),
};
@@ -790,7 +791,7 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
// old way
// let where: Parameters<(ReturnType<typeof getDb>)['query']['plays']['findMany']>[0]['where'] = {
// };
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
componentId: args.componentId
};
if (args.state !== undefined) {
@@ -826,7 +827,7 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
}
// so that we can use this type
// or else assigning an array to OR using only `typeof where.queueStates` causes a type error
let queueWhere: typeof where.queueStates.OR[0][] = [];
const queueWhere: typeof where.queueStates.OR[0][] = [];
for(const q of queues) {
queueWhere.push(
{
@@ -853,7 +854,7 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
OR: []
}
];
let textWhere: typeof where.RAW[] = [];
const textWhere: typeof where.RAW[] = [];
for(const t of args.text) {
textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.track')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`)
textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.artists')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`)
@@ -1,9 +1,9 @@
import { eq, and, lte, inArray } from "drizzle-orm";
import { DrizzleBaseRepository, DrizzleRepositoryOpts } from "./BaseRepository.js";
import { DbConcrete } from "../drizzleUtils.js";
import { QueueStateSelect } from "../drizzleTypes.js";
import { queueStates } from "../schema/schema.js";
import { CLIENT_DEAD_QUEUE } from "../../../../../core/Atomic.js";
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";
export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'> {
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
@@ -1,13 +1,12 @@
import { integer, sqliteTable, text, index, uniqueIndex, customType, AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { integer, sqliteTable, text, index, uniqueIndex, customType, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { defineRelations } from 'drizzle-orm';
import dayjs, { Dayjs } from "dayjs";
import { nanoid } from "nanoid";
import { ErrorLike, PlayObject } from "../../../../../core/Atomic.js";
import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.js";
import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js";
import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js";
import dayjs, { type Dayjs } from "dayjs";
import { COMPONENT_TYPE_CLIENT, COMPONENT_TYPE_SOURCE, type ErrorLike, type PlayObject } 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";
import { serializeError, deserializeError } from "serialize-error";
import { generatePlayUid } from "../../../../../core/StringUtils.js";
import { generatePlayUid } from "../../../../../core/StringUtils.ts";
const DayjsTimestamp = customType<
{
@@ -166,7 +165,7 @@ export const components = sqliteTable("components", {
id: integer({ mode: 'number' }).primaryKey(),
// user-provided id
uid: text({ length: 200 }).notNull(),
mode: text({enum: ['source','client']}).notNull(),
mode: text({enum: [COMPONENT_TYPE_SOURCE,COMPONENT_TYPE_CLIENT]}).notNull(),
// spotify, lastfm, etc...
type: text({length: 50}).notNull(),
// vanity display name
+1 -1
View File
@@ -1,4 +1,4 @@
import { ResponseError } from "superagent";
import type {ResponseError} from "superagent";
export const isSuperAgentResponseError = (e: any): e is ResponseError => {
return typeof e === 'object'
+4 -4
View File
@@ -1,9 +1,9 @@
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import mergeErrorCause from 'merge-error-cause';
import { findCauseByFunc, isAbortReasonErrorLike } from "../../utils/ErrorUtils.js";
import { UpstreamError, UpstreamErrorOptions } from "./UpstreamError.js";
import { findCauseByFunc, isAbortReasonErrorLike } from "../../utils/ErrorUtils.ts";
import { UpstreamError, type UpstreamErrorOptions } from "./UpstreamError.ts";
import {addKnownErrorConstructor, serializeError} from 'serialize-error';
import { LifecycleInput } from "../../../core/Atomic.js";
import type {LifecycleInput} from "../../../core/Atomic.ts";
export abstract class NamedError extends Error {
public abstract name: string;
@@ -186,7 +186,7 @@ export class InvalidRegexError extends SimpleError {
const msgParts = [
message ?? 'Regex(es) did not match the value given.',
];
let regArr = Array.isArray(regex) ? regex : [regex];
const regArr = Array.isArray(regex) ? regex : [regex];
for(const r of regArr) {
msgParts.push(`Regex: ${r}`)
}
+4 -5
View File
@@ -1,13 +1,12 @@
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
import { isArbitraryObject } from "../infrastructure/Atomic.js";
import ErrnoException = NodeJS.ErrnoException;
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
import { isArbitraryObject } from "../infrastructure/Atomic.ts";
export type NodeNetworkErrorCode = 'ENOTFOUND' | 'ETIMEDOUT' | 'EAI_AGAIN' | 'ECONNRESET' | 'ECONNREFUSED' | 'ERRADDRINUSE' | 'EADDRNOTAVAIL' | 'ECONNABORTED' | 'EHOSTUNREACH';
export const NETWORK_ERROR_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const NETWORK_ERROR_FAILURE_CODES: NodeNetworkErrorCode[] = ['ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ECONNREFUSED', 'ERRADDRINUSE', 'EADDRNOTAVAIL', 'ECONNABORTED', 'EHOSTUNREACH'];
export const isErrnoException = (error: unknown): error is ErrnoException => {
export const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => {
return isArbitraryObject(error) &&
error instanceof Error &&
(typeof error.errno === "number" || typeof error.errno === "undefined") &&
@@ -16,7 +15,7 @@ export const isErrnoException = (error: unknown): error is ErrnoException => {
(typeof error.syscall === "string" || typeof error.syscall === "undefined");
}
export interface NodeNetworkException extends ErrnoException {
export interface NodeNetworkException extends NodeJS.ErrnoException {
code: NodeNetworkErrorCode
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Response } from 'superagent';
import type { Response } from 'superagent';
import { findCauseByFunc } from "../../utils/ErrorUtils.js";
import { findCauseByFunc } from "../../utils/ErrorUtils.ts";
import { addKnownErrorConstructor } from 'serialize-error';
export type UpstreamErrorOptions = ErrorOptions & { showStopper?: boolean, response?: Response, responseBody?: object | string };
+19 -42
View File
@@ -1,21 +1,22 @@
import { Logger } from '@foxxmd/logging';
import { Dayjs, ManipulateType } from "dayjs";
import { Request, Response } from "express";
import { type NextFunction, type ParamsDictionary, type Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { ErrorLike, isPlayObject, PlayMeta, PlayObject, PlayObjectMinimal, UnixTimestamp } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
import { MusicBrainzApi } from 'musicbrainz-api';
import { SourceType } from './config/source/sources.js';
import { ClientType, clientTypes } from './config/client/clients.js';
import assert, { AssertionError } from 'assert';
import type { Logger, LogDataPretty } from '@foxxmd/logging';
import type { Dayjs, ManipulateType } from "dayjs";
import type { Request, Response } from "express";
import type { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import type { FixedSizeList } from 'fixed-size-list';
import { isPlayObject } from "../../../core/Atomic.ts";
import type {DeviceId, ErrorLike, PlayMeta, PlayObject, PlayObjectMinimal, PlayPlatformId, PlayUserId, UnixTimestamp} from "../../../core/Atomic.ts";
import type TupleMap from "../TupleMap.ts";
import type { MusicBrainzApi } from 'musicbrainz-api';
import type { ReportedPlayerStatus, SourceType } from "../../../core/Atomic.ts";
import type { ClientType } from "../../../core/Atomic.ts";
import assert from 'assert';
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
export interface LeveledLogData extends LogDataPretty {
levelLabel: string
}
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic', 'applemusic'];
export interface ComponentIdentifier {
type: SourceType | ClientType
name: string
@@ -51,21 +52,6 @@ export interface InternalConfig {
export type InternalConfigOptional = Omit<InternalConfig, 'logger'>
export type ReportedPlayerStatus = 'playing' | 'stopped' | 'paused' | 'unknown';
export const REPORTED_PLAYER_STATUSES = {
playing: 'playing' as ReportedPlayerStatus,
stopped: 'stopped' as ReportedPlayerStatus,
paused: 'paused' as ReportedPlayerStatus,
unknown: 'unknown' as ReportedPlayerStatus
}
export type CalculatedPlayerStatus = ReportedPlayerStatus | 'stale' | 'orphaned';
export const CALCULATED_PLAYER_STATUSES = {
...REPORTED_PLAYER_STATUSES,
stale: 'stale' as CalculatedPlayerStatus,
orphaned: 'orphaned' as CalculatedPlayerStatus,
}
export type ConfigureAsSource = 'source';
export type ConfigureAsClient = 'client';
export type ConfigureAs = ConfigureAsSource | ConfigureAsClient;
@@ -114,11 +100,6 @@ export interface ProgressAwarePlayObject extends PlayObjectMinimal {
initialTrackProgressPosition?: number
}
}
export type DeviceId = string;
export type PlayUserId = string;
export type PlayPlatformId = [DeviceId, PlayUserId];
export type PlayPlatformIdStr = string;
export type GroupedPlays = TupleMap<DeviceId,PlayUserId,ProgressAwarePlayObject[]>;
export type GroupedFixedPlays = TupleMap<DeviceId,PlayUserId,FixedSizeList<ProgressAwarePlayObject>>;
@@ -191,10 +172,6 @@ export type ExpressRequest = Request<ParamsDictionary, any, any, Query, Record<s
export type ExpressResponse = Response<any, Record<string, any>>;
export type ExpressHandler = (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>
export const DELIMITERS = [',','&','/','\\'];
export const DELIMITERS_NO_AMP = [',','/','\\'];
export const DELIMETERS_REGEX: RegExp = new RegExp(/[,&\/\\]/);
export const ARTIST_WEIGHT = 0.3;
export const TITLE_WEIGHT = 0.4;
export const TIME_WEIGHT = 0.5;
@@ -386,7 +363,7 @@ export interface PaginatedListens {
getPaginatedListens(params: PaginatedListensOptions<CursorType>): Promise<{data: PlayObject[], meta: PaginatedListensOptions<CursorType> & PaginatedResults<CursorType>}>
}
export const hasPaginagedListens = (obj: Object): obj is PaginatedListens => {
export const hasPaginagedListens = (obj: object): obj is PaginatedListens => {
return 'getPaginatedListens' in obj;
}
@@ -399,7 +376,7 @@ export interface PaginatedTimeRangeListens<T extends CursorType = CursorType> {
getPaginatedUnitOfTime(): ManipulateType;
}
export const hasPaginatedTimeRangeListens = (obj: Object): obj is PaginatedTimeRangeListens => {
export const hasPaginatedTimeRangeListens = (obj: object): obj is PaginatedTimeRangeListens => {
return 'getPaginatedTimeRangeListens' in obj;
}
@@ -412,7 +389,7 @@ export interface PagelessTimeRangeListens {
getPaginatedUnitOfTime(): ManipulateType;
}
export const hasPagelessTimeRangeListens = (obj: Object): obj is PagelessTimeRangeListens => {
export const hasPagelessTimeRangeListens = (obj: object): obj is PagelessTimeRangeListens => {
return 'getPagelessTimeRangeListens' in obj;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { UnixTimestamp } from "../../../core/Atomic.js"
import type {UnixTimestamp} from "../../../core/Atomic.ts";
export interface JobParameters {
/** maximum number of results to get for the entire job */
@@ -1,13 +1,13 @@
import { LogOptions } from "@foxxmd/logging";
import { ClientAIOConfig } from "./client/clients.js";
import { CommonClientOptions } from "./client/index.js";
import { RequestRetryOptions } from "./common.js";
import { WebhookConfig } from "./health/webhooks.js";
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
import { SourceAIOConfig } from "./source/sources.js";
import { CacheConfigOptions, CacheConfigUser, DurationValue } from "../Atomic.js";
import { TransformerCommonConfig } from "../../../../core/Atomic.js";
import { RetentionConfig } from "./database.js";
import type { LogOptions } from "@foxxmd/logging";
import type { ClientAIOConfig } from "./client/clients.ts";
import type { CommonClientOptions } from "./client/index.ts";
import type { RequestRetryOptions } from "./common.ts";
import type { WebhookConfig } from "./health/webhooks.ts";
import type { CommonSourceOptions, SourceRetryOptions } from "./source/index.ts";
import type { SourceAIOConfig } from "./source/sources.ts";
import type { CacheConfigUser, DurationValue } from "../Atomic.ts";
import type { TransformerCommonConfig } from "../../../../core/Atomic.ts";
import type { RetentionConfig } from "./database.ts";
export interface SourceDefaults extends CommonSourceOptions {
@@ -1,4 +1,4 @@
import { AtprotoDid } from "@atcute/lexicons/syntax";
import type {AtprotoDid} from "@atcute/lexicons/syntax";
export interface ATProtoUserIdentifierData {
/**
@@ -1,11 +1,11 @@
import { KoitoClientAIOConfig, KoitoClientConfig } from "./koito.js";
import { LastfmClientAIOConfig, LastfmClientConfig } from "./lastfm.js";
import { ListenBrainzClientAIOConfig, ListenBrainzClientConfig } from "./listenbrainz.js";
import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
import { TealClientAIOConfig, TealClientConfig } from "./tealfm.js";
import { RockSkyClientAIOConfig, RockSkyClientConfig } from "./rocksky.js";
import { LibrefmClientConfig, LibrefmClientAIOConfig } from "./librefm.js";
import { DiscordClientAIOConfig, DiscordClientConfig } from "./discord.js";
import type {KoitoClientAIOConfig, KoitoClientConfig} from "./koito.ts";
import type {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.ts";
import type {ListenBrainzClientAIOConfig, ListenBrainzClientConfig} from "./listenbrainz.ts";
import type {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.ts";
import type {TealClientAIOConfig, TealClientConfig} from "./tealfm.ts";
import type {RockSkyClientAIOConfig, RockSkyClientConfig} from "./rocksky.ts";
import type {LibrefmClientConfig, LibrefmClientAIOConfig} from "./librefm.ts";
import type {DiscordClientAIOConfig, DiscordClientConfig} from "./discord.ts";
export type ClientConfig =
MalojaClientConfig
@@ -57,23 +57,3 @@ export const clientInterfaces = [
...atomicClientInterfaces
];
export type ClientType =
'maloja'
| 'lastfm'
| 'librefm'
| 'listenbrainz'
| 'koito'
| 'tealfm'
| 'rocksky'
| 'discord';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
'librefm',
'listenbrainz',
'koito',
'tealfm',
'rocksky',
'discord'
];
@@ -1,6 +1,5 @@
import { ActivityType, StatusDisplayType } from "discord.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
import { ComponentType } from "../../../../../core/Atomic.js"
import type {CommonClientConfig, CommonClientData} from "./index.ts";
import type {ComponentType} from "../../../../../core/Atomic.ts";
export interface DiscordData {
token?: string
@@ -63,38 +62,14 @@ export interface ActivityTimestamps {
end?: number
}
export enum ActivityTypeOthers {
Hanging = 6
}
export type StatusDisplayType = 0 | 1 | 2;
export const STATUS_DISPLAY_TYPE = {
Name: 0,
State: 1,
Details: 2
} as const satisfies Record<string, StatusDisplayType>
export declare enum ActivityTypeReal {
/**
* Playing \{game\}
*/
Playing = 0,
/**
* Streaming \{details\}
*/
Streaming = 1,
/**
* Listening to \{name\}
*/
Listening = 2,
/**
* Watching \{details\}
*/
Watching = 3,
/**
* \{emoji\} \{state\}
*/
Custom = 4,
/**
* Competing in \{name\}
*/
Competing = 5,
Hanging = 6
}
export type ActivityType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export interface ActivityData {
name: string
@@ -120,7 +95,7 @@ export const ACTIVITY_TYPE = {
Custom: 4,
Competing: 5,
Hanging: 6
} as const satisfies Record<string, ActivityTypeReal>
} as const satisfies Record<string, ActivityType>
export const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png';
export const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png';
@@ -1,7 +1,7 @@
import { DurationValue } from "../../Atomic.js";
import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js";
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
import { RetentionConfig } from "../database.js";
import type {DurationValue} from "../../Atomic.ts";
import type {PlayTransformOptions} from "../../../../../core/Transform.ts";
import type {CommonConfig, RequestRetryOptions} from "../common.ts";
import type {RetentionConfig} from "../database.ts";
/**
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
@@ -1,6 +1,6 @@
import { ComponentType, UnixTimestamp } from "../../../../../core/Atomic.js"
import { RequestRetryOptions } from "../common.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
import type {ComponentType, UnixTimestamp} from "../../../../../core/Atomic.ts";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData} from "./index.ts";
export interface ListensResponse {
items: ListenObjectResponse[]
@@ -1,5 +1,5 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions} from "./index.ts";
export interface LastfmData extends CommonClientData, RequestRetryOptions {
/**
@@ -1,6 +1,6 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
import { LastfmClientOptions } from "./lastfm.js";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData} from "./index.ts";
import type {LastfmClientOptions} from "./lastfm.ts";
export interface LibrefmData extends CommonClientData, RequestRetryOptions {
/**
@@ -1,6 +1,6 @@
import { ComponentType } from "../../../../../core/Atomic.js";
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
import type {ComponentType} from "../../../../../core/Atomic.ts";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData} from "./index.ts";
export interface ListenBrainzData extends RequestRetryOptions{
/**
@@ -1,6 +1,6 @@
import { ComponentType } from "../../../../../core/Atomic.js";
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData } from "./index.js";
import type {ComponentType} from "../../../../../core/Atomic.ts";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData} from "./index.ts";
export interface MalojaData extends RequestRetryOptions {
/**
@@ -1,5 +1,5 @@
import { RequestRetryOptions } from "../common.js";
import { CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions } from "./index.js";
import type {RequestRetryOptions} from "../common.ts";
import type {CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions} from "./index.ts";
export interface RockSkyData extends RequestRetryOptions{
@@ -1,7 +1,7 @@
import { ComponentType } from "../../../../../core/Atomic.js"
import { RequestRetryOptions } from "../common.js"
import { ATProtoAppData, ATProtoUserIdentifierData } from "./atproto.js"
import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js"
import type {ComponentType} from "../../../../../core/Atomic.ts";
import type {RequestRetryOptions} from "../common.ts";
import type {ATProtoAppData, ATProtoUserIdentifierData} from "./atproto.ts";
import type {CommonClientConfig, CommonClientData, CommonClientOptions} from "./index.ts";
export type TealData = RequestRetryOptions & ATProtoUserIdentifierData & Partial<ATProtoAppData> & {
/**
@@ -1,5 +1,3 @@
import { keyOmit } from "../Atomic.js";
export interface CommonConfigPrimitives {
name?: string
id?: string
@@ -1,5 +1,5 @@
import { Duration } from "dayjs/plugin/duration.js";
import { DurationValue } from "../Atomic.js";
import type {Duration} from "dayjs/plugin/duration.js";
import type {DurationValue} from "../Atomic.ts";
export type RetentionPlayType = 'failed' | 'completed' | 'duped';
export const retentionPlayTypes: RetentionPlayType[] = ['failed','completed','duped'];
@@ -0,0 +1,58 @@
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface AppleMusicKey {
id: string
teamId: string
p8: string
}
export interface AppleMusicData extends CommonSourceData, PollingOptions {
key?: AppleMusicKey
token?: string
mediaUserToken?: string
/**
* Origin header to include in every Apple Music API request.
* Required when using a browser token (not a MusicKit key).
*
* @example "https://music.apple.com"
*/
origin?: string
}
export interface AppleMusicSourceConfig extends CommonSourceConfig {
data?: AppleMusicData
options?: CommonSourceOptions & {
logAuth?: boolean
logDiff?: boolean
/**
* Fixes a quirk where Apple Music's history API hides duplicate plays.
* If you listen to A → B → A, the API returns [A, B, X, Y ...] instead of [A, B, A, X, Y ...].
* This can cause MS to skip the second A play.
*
* When enabled (default), MS detects this pattern, keeps the interim tracks (B),
* and re-scrobbles A as a re-listen. Disable only if you notice false positives.
*
* @default true
* @examples [true, false]
*/
recoverUnchangedTopHistory?: boolean
/**
* Removes extraneous suffixes from album data
*
* Apple Music add ' - EP' and ' - Single' to album names for EP's and singles, respectively.
* These suffixes are not part of the official names for the album and can cause issues in scrobble services
* or when matching metadata (musicbrainz).
*
* When this option is true (default), Multi-scrobbler automatically removes these suffixes.
*
* @default true
* @examples [true, false]
*/
normalizeAlbum?: boolean
}
}
export interface AppleMusicSourceAIOConfig extends AppleMusicSourceConfig {
type: 'applemusic'
}
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions} from "./index.ts";
export interface AzuraStationInfoResponse {
id: string
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface ChromecastData extends CommonSourceData {
/**
@@ -1,6 +1,6 @@
import { Second } from "../../../../../core/Atomic.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {Second} from "../../../../../core/Atomic.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface DeezerData extends CommonSourceData, PollingOptions {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface LastFMEndpointData extends CommonSourceData {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface ListenbrainzEndpointData extends CommonSourceData {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions} from "./index.ts";
export interface IcecastMetadata {
@@ -1,9 +1,9 @@
import { FileLogOptions, LogLevel } from "@foxxmd/logging";
import type { FileLogOptions, LogLevel } from "@foxxmd/logging";
import { PlayTransformConfig, PlayTransformOptions } from "../../Transform.js";
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
import { RetentionConfig } from "../database.js";
import { DurationValue } from "../../Atomic.js";
import type { PlayTransformOptions } from "../../../../../core/Transform.ts";
import type { CommonConfig, RequestRetryOptions } from "../common.ts";
import type { RetentionConfig } from "../database.ts";
import type { DurationValue } from "../../Atomic.ts";
export interface SourceRetryOptions extends RequestRetryOptions {
/**
@@ -1,5 +1,5 @@
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import {
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
import type {
// @ts-expect-error weird typings?
CollectionType,
// @ts-expect-error weird typings?
@@ -1,5 +1,5 @@
import { PollingOptions, RequestRetryOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions, RequestRetryOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface JRiverData extends CommonSourceData, PollingOptions, RequestRetryOptions {
/**
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface KodiData extends CommonSourceData, PollingOptions {
@@ -1,7 +1,6 @@
import { KoitoData } from "../client/koito.js";
import { ListenBrainzData } from "../client/listenbrainz.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {KoitoData} from "../client/koito.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface KoitoSourceData extends KoitoData, CommonSourceData, PollingOptions {
}
@@ -1,6 +1,6 @@
import { LastfmData } from "../client/lastfm.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {LastfmData} from "../client/lastfm.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{}
@@ -1,6 +1,6 @@
import { LibrefmData } from "../client/librefm.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {LibrefmData} from "../client/librefm.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface librefmSourceData extends CommonSourceData, PollingOptions, LibrefmData{}
@@ -1,6 +1,6 @@
import { ListenBrainzData } from "../client/listenbrainz.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {ListenBrainzData} from "../client/listenbrainz.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions {
}
@@ -1,6 +1,6 @@
import { MalojaData } from "../client/maloja.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {MalojaData} from "../client/maloja.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions {
}
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface MopidyData extends CommonSourceData, PollingOptions {
/**
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface MPDData extends CommonSourceData, PollingOptions {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export const PLAYBACK_STATUS_PLAYING = 'Playing';
export const PLAYBACK_STATUS_PAUSED = 'Paused';
@@ -1,5 +1,6 @@
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { REPORTED_PLAYER_STATUSES } from '../../../../../core/Atomic.ts';
import type {ReportedPlayerStatus} from '../../../../../core/Atomic.ts';
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export const PLAYBACK_STATUS_PLAYING_MC = 'playing';
export const PLAYBACK_STATUS_PAUSED_MC = 'paused';
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface PlexApiData extends CommonSourceData, PollingOptions {
token?: string
@@ -1,6 +1,6 @@
import { RockSkyData, RockSkyOptions } from "../client/rocksky.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {RockSkyData, RockSkyOptions} from "../client/rocksky.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface RockskySourceData extends RockSkyData, CommonSourceData, PollingOptions {
}
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface SonosData extends CommonSourceData, PollingOptions {
/**
@@ -1,32 +1,33 @@
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
import { DeezerInternalSourceConfig, DeezerInternalAIOConfig, DeezerSourceAIOConfig, DeezerSourceConfig, DeezerCompatConfig, DeezerAIOCompatConfig } from "./deezer.js";
import { JellyApiSourceAIOConfig, JellyApiSourceConfig } from "./jellyfin.js";
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js";
import { PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js";
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
import { YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig } from "./ymbridge.js";
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js";
import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js";
import { TealSourceAIOConfig, TealSourceConfig } from "./tealfm.js";
import { RockskySourceAIOConfig, RockskySourceConfig } from "./rocksky.js";
import { LibrefmSouceAIOConfig, LibrefmSourceConfig } from "./librefm.js";
import { SonosSourceAIOConfig, SonosSourceConfig } from "./sonos.js";
import type {AzuracastSourceAIOConfig, AzuracastSourceConfig} from "./azuracast.ts";
import type {ChromecastSourceAIOConfig, ChromecastSourceConfig} from "./chromecast.ts";
import type {ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig} from "./endpointlz.ts";
import type {LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig} from "./endpointlfm.ts";
import type {DeezerInternalSourceConfig, DeezerSourceConfig, DeezerCompatConfig, DeezerAIOCompatConfig} from "./deezer.ts";
import type {JellyApiSourceAIOConfig, JellyApiSourceConfig} from "./jellyfin.ts";
import type {JRiverSourceAIOConfig, JRiverSourceConfig} from "./jriver.ts";
import type {KodiSourceAIOConfig, KodiSourceConfig} from "./kodi.ts";
import type {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.ts";
import type {ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig} from "./listenbrainz.ts";
import type {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.ts";
import type {MPDSourceAIOConfig, MPDSourceConfig} from "./mpd.ts";
import type {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.ts";
import type {MusikcubeSourceAIOConfig, MusikcubeSourceConfig} from "./musikcube.ts";
import type {MusicCastSourceConfig, MusicCastSourceAIOConfig} from "./musiccast.ts";
import type {PlexApiSourceConfig, PlexApiSourceAIOConfig} from "./plex.ts";
import type {SpotifySourceAIOConfig, SpotifySourceConfig} from "./spotify.ts";
import type {SubsonicSourceAIOConfig, SubSonicSourceConfig} from "./subsonic.ts";
import type {VLCSourceAIOConfig, VLCSourceConfig} from "./vlc.ts";
import type {WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig} from "./webscrobbler.ts";
import type {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.ts";
import type {YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig} from "./ymbridge.ts";
import type {IcecastSourceAIOConfig, IcecastSourceConfig} from "./icecast.ts";
import type {KoitoSourceAIOConfig, KoitoSourceConfig} from "./koito.ts";
import type {MalojaSourceAIOConfig, MalojaSourceConfig} from "./maloja.ts";
import type {TealSourceAIOConfig, TealSourceConfig} from "./tealfm.ts";
import type {RockskySourceAIOConfig, RockskySourceConfig} from "./rocksky.ts";
import type {LibrefmSouceAIOConfig, LibrefmSourceConfig} from "./librefm.ts";
import type {SonosSourceAIOConfig, SonosSourceConfig} from "./sonos.ts";
import type {AppleMusicSourceAIOConfig, AppleMusicSourceConfig} from "./applemusic.ts";
export type SourceConfig =
@@ -58,7 +59,8 @@ export type SourceConfig =
| KoitoSourceConfig
| TealSourceConfig
| RockskySourceConfig
| SonosSourceConfig;
| SonosSourceConfig
| AppleMusicSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -89,7 +91,8 @@ export type SourceAIOConfig =
| KoitoSourceAIOConfig
| TealSourceAIOConfig
| RockskySourceAIOConfig
| SonosSourceAIOConfig;
| SonosSourceAIOConfig
| AppleMusicSourceAIOConfig;
/** Used for docusaurus schemas
* We need to show "array of" for each type of config when looking at File Config
@@ -128,71 +131,9 @@ export type KoitoSourceConfigs = KoitoSourceConfig[];
export type TealSourceConfigs = TealSourceConfig[];
export type RockskySourceConfigs = RockskySourceConfig[];
export type SonosSourceConfigs = SonosSourceConfig[];
export type AppleMusicSourceConfigs = AppleMusicSourceConfig[];
export type SourceType =
'spotify'
| 'plex'
| 'subsonic'
| 'jellyfin'
| 'lastfm'
| 'librefm'
| 'deezer'
| 'endpointlz'
| 'endpointlfm'
| 'ytmusic'
| 'ymbridge'
| 'mpris'
| 'mopidy'
| 'musiccast'
| 'listenbrainz'
| 'jriver'
| 'kodi'
| 'webscrobbler'
| 'chromecast'
| 'maloja'
| 'musikcube'
| 'mpd'
| 'vlc'
| 'icecast'
| 'azuracast'
| 'koito'
| 'tealfm'
| 'rocksky'
| 'sonos';
export const sourceTypes: SourceType[] = [
'spotify',
'plex',
'subsonic',
'jellyfin',
'lastfm',
'librefm',
'deezer',
'endpointlz',
'endpointlfm',
'ytmusic',
'ymbridge',
'mpris',
'mopidy',
'musiccast',
'listenbrainz',
'jriver',
'kodi',
'webscrobbler',
'chromecast',
'maloja',
'musikcube',
'mpd',
'vlc',
'icecast',
'azuracast',
'koito',
'tealfm',
'rocksky',
'sonos'
];
export const atomicSourceInterfaces = [
'SpotifySourceConfig',
'PlexApiSourceConfig',
@@ -222,7 +163,8 @@ export const atomicSourceInterfaces = [
'KoitoSourceConfig',
'TealSourceConfig',
'RockskySourceConfig',
'SonosSourceConfig'
'SonosSourceConfig',
'AppleMusicSourceConfig'
];
export const sourceInterfaces = [
@@ -230,7 +172,4 @@ export const sourceInterfaces = [
...atomicSourceInterfaces
];
export const isSourceType = (data: string): data is SourceType => {
return sourceTypes.includes(data as SourceType);
};
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface SpotifySourceData extends CommonSourceData, PollingOptions {
/**
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface SubsonicData extends CommonSourceData, PollingOptions {
/**
@@ -1,6 +1,6 @@
import { TealData, TealOptions } from "../client/tealfm.js"
import { PollingOptions } from "../common.js"
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"
import type {TealData, TealOptions} from "../client/tealfm.ts";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface TealSourceData extends TealData, CommonSourceData, PollingOptions {
@@ -1,6 +1,6 @@
import { VlcMeta } from "vlc-client/dist/Types.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {VlcMeta} from "vlc-client/dist/Types.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface VLCData extends CommonSourceData, PollingOptions {
/**
@@ -1,4 +1,4 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface WebScrobblerData extends CommonSourceData {
/**
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData} from "./index.ts";
export interface YandexMusicBridgeData extends CommonSourceData, PollingOptions {
/** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */
@@ -1,5 +1,5 @@
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
import type {PollingOptions} from "../common.ts";
import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts";
export interface InnertubeOptions {
/**
+5 -4
View File
@@ -1,10 +1,11 @@
import { FileLogOptions, Logger, loggerAppRolling, LogLevel, LogLevelStreamEntry, LogOptions, parseLogOptions } from '@foxxmd/logging';
import { type FileLogOptions, type Logger, loggerAppRolling, type LogLevel, type LogLevelStreamEntry, type LogOptions, parseLogOptions } from '@foxxmd/logging';
import { buildDestinationJsonPrettyStream, buildDestinationRollingFile, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
import { PassThrough, Transform } from "node:stream";
import type { Transform } from "node:stream";
import { PassThrough } from "node:stream";
import path from "path";
import process from "process";
import { projectDir } from "./index.js";
import { isDebugMode } from '../utils.js';
import { projectDir } from "./index.ts";
import { isDebugMode } from '../utils.ts';
export let logPath = path.resolve(projectDir, `./logs`);
if (typeof process.env.CONFIG_DIR === 'string') {

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