Compare commits

...
294 Commits
Author SHA1 Message Date
FoxxMD 12f284dbd7 docs: Add preview UI callout to landing 2026-07-17 14:45:39 +00: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
FoxxMD 9c44ccb663 chore(release): bump version for release 2026-07-08 17:25:23 +00:00
FoxxMD f431f4d5af refactor(ui): Remove non-mobile sidebar and ad header links
* Remove non-mobile sidebar to free up some more screen real estate
* Add quick Links menu to header that hides on mobile breakpoint
2026-07-08 16:49:37 +00:00
FoxxMD be872d5ce1 fix(ui): Fix double mobile menu icon 2026-07-08 16:09:04 +00:00
FoxxMD 0246f7dfa6 fix(now playing): Fix realtime NP state handling
* Revert removing state on "should clear" statuses, causing false positive updates
* Insteat, filter out NP state to api based on "should clear" statuses
2026-07-08 15:43:42 +00:00
FoxxMD b57f325684 feat: Implement error persistence and live updated for components 2026-07-08 15:02:02 +00:00
FoxxMD 153f37e927 standardize props and components used in timeline 2026-07-08 14:01:11 +00:00
FoxxMD de4cb8a5c2 fix(ui): Conditionally render queue timeline items based on timestamp sameness and context 2026-07-08 13:38:18 +00:00
FoxxMD eb133e4f7e fix(ui): Fallback datetime for scrobble timeline item 2026-07-08 13:32:52 +00:00
FoxxMD a3e056e7c5 feat: Implement virtual states to make filtering play state easier
* Convert 'queued' and 'dead queued' play states to queue filters
* Fix relation filter sql generation for count query
2026-07-08 13:25:41 +00:00
FoxxMD 7d8b00ab22 fix(musicbrainz): Fix finding stage inputs in error causes 2026-07-07 20:33:15 +00:00
FoxxMD 8b633dd901 feat(musicbrainz): Store all queries to lifecycle inputs and persist even on errors
Should make MB debugging easier
2026-07-07 20:05:10 +00:00
FoxxMD f055cf683f feat: Add toJSON to custom errors so they serialize correctly automatically 2026-07-07 18:19:05 +00:00
FoxxMD baaa954f31 chore: Add eslint plugin for mocha tests 2026-07-07 17:54:19 +00:00
FoxxMD c727de9c40 refactor(ui): Refactor timeline items to be rendered independently and order by createdAt 2026-07-07 17:42:27 +00:00
FoxxMD e6f4a1dbd2 feat(client): Implement createdAt dates for scrobble actions 2026-07-07 16:37:45 +00:00
FoxxMD 3d6e86f258 feat(ui): Add queue states to timeline 2026-07-07 15:21:29 +00:00
FoxxMD e6130eb5eb feat(ui): Better error/warning indicators for timeline 2026-07-07 14:23:06 +00:00
FoxxMD 97b6042768 feat(ui): Implement basic error summary detection 2026-07-07 03:07:29 +00:00
FoxxMD b3c1ce6d8a feat(ui): Indicate failed scrobble is dead queued 2026-07-07 02:32:35 +00:00
FoxxMD 5daba47ac7 fix(ui): Fix conditional hook rendering 2026-07-06 20:08:18 +00:00
FoxxMD 8d7760df6d chore: Update eslint config to include react hooks rules 2026-07-06 20:07:51 +00:00
FoxxMD 75c95fdaa8 more error boundaries 2026-07-06 18:53:57 +00:00
FoxxMD e874ccc2d3 Add some error boundaries around components 2026-07-06 18:34:32 +00:00
FoxxMD 17e97b3a6a fix(ui): Fix now playing mode for player on client component details 2026-07-06 18:18:58 +00:00
FoxxMD c6389590fe fix api fixture typing for players 2026-07-06 18:05:52 +00:00
FoxxMD 7151d96b59 feat(ui): Implement log copy 2026-07-06 18:03:38 +00:00
FoxxMD b2b2018764 feat(ui): Use custom hook for player queries and fix now-playing forced re-render 2026-07-06 17:07:46 +00:00
FoxxMD 655b97ce1c feat(ui): Implement now playing ui element
* Reuse source player in always-nowplaying mode, reuse api structure
* Add expiration to player data to stop rendering player for non-realtime clients
2026-07-06 16:40:01 +00:00
FoxxMD 5c6edefc56 feat(ui): track virtual play key by uid rather than index
Should prevent pop-in and false update-indicator triggering
2026-07-02 21:32:48 +00:00
FoxxMD bf7fb4f558 feat(storybook): More accurate play list api mocking
* Reusable function for generic behavior
* Append to list and create unique plays on every page request
2026-07-02 21:32:02 +00:00
FoxxMD 32b64ff384 fix update icons not expiring 2026-07-02 20:00:04 +00:00
FoxxMD f6e0f4be4a feat(ui): Use play updatedAt field for determining updated icon 2026-07-02 19:44:19 +00:00
FoxxMD fb66f0ca84 feat(db): update updatedAt fields automatically 2026-07-02 19:43:48 +00:00
FoxxMD c4b1d72f3f feat(ui): Implement plays refresh 2026-07-02 15:55:37 +00:00
FoxxMD fb452eb40b feat(ui): try to constrain date picker size for mobile 2026-07-02 14:37:52 +00:00
FoxxMD d3fd03a78c feat(ui): Wrap all filters 2026-07-02 14:28:57 +00:00
FoxxMD 7b04e76c3b fix(ui): Accessing target/children when no ref, yet 2026-07-02 14:13:54 +00:00
FoxxMD 9f34c45192 fix(ui): Fix n+1 insertion bug on new play and add logging 2026-07-02 14:07:46 +00:00
FoxxMD 7ab0f710ff feat(ui): Better control over ephemeral updates to plays
* Use structuralSharing to add signal that play data was updated from invalidated query
* Make update badge more generic and fix timeout/re-render
* Play update story updates random play
2026-07-02 14:01:28 +00:00
FoxxMD ea1fb54de1 fix(ui): Revert datepicker formatting for now
Causing end date to jump back one day after every change?
2026-07-02 12:29:31 +00:00
FoxxMD 35fd84a1d0 feat(ui): Improve date picker useability
* reduce preset options to make smaller viewports easier to use
* Add icon to mobile users can avoid keyboard popup
* Disable openOnClick to avoid popup annoyance when user is trying to directly type dates
2026-07-02 01:07:20 +00:00
FoxxMD e498db8ad3 feat(ui): Implement flex wrapped hook 2026-07-01 22:44:17 +00:00
FoxxMD 145960ca04 feat(ui): Implement placeholder component state actions 2026-07-01 20:23:16 +00:00
FoxxMD 91ba24e165 feat(ui): Add placeholder primary actions from component state 2026-07-01 19:49:08 +00:00
FoxxMD 66fc7a2b09 feat(ui): Add back button on component details 2026-07-01 18:30:44 +00:00
FoxxMD 97a7c7044c feat(ui): Implement play actions in summary 2026-07-01 17:57:07 +00:00
FoxxMD 53d7de7e8d refactor(ui): Use custom hook for activity fetching 2026-07-01 17:12:55 +00:00
FoxxMD 32da14c08e make entire component header a link to details 2026-07-01 00:33:45 +00:00
FoxxMD c7f370f121 feat(ui): de-emphasize mode badge 2026-07-01 00:31:48 +00:00
FoxxMD 9d105173da refactor(storybook): More re-organizing 2026-06-30 22:47:47 +00:00
FoxxMD 3e7bbab5b0 refactor(storybook): organize storybook file and loglical 2026-06-30 21:03:14 +00:00
FoxxMD e04965eb35 fix(storybook): Fix router wiring for component list stories 2026-06-30 20:41:51 +00:00
FoxxMD f2c5e82665 keep names and sourcemap on build? 2026-06-30 20:28:07 +00:00
FoxxMD 4c41f0976d Add placeholder action buttons to play detail 2026-06-30 20:27:57 +00:00
FoxxMD db333184cc fix(ui): Fix text alignment in timeline titles 2026-06-30 20:27:38 +00:00
FoxxMD a5963a2333 (bad) ignore weird type error on cause unknown 2026-06-25 15:38:38 +00:00
FoxxMD 0fe81d6164 fix(ui): Fix content wrapping in detailed component 2026-06-25 15:13:52 +00:00
FoxxMD 3ad9f5dcac Add api fixture for generating fake errors 2026-06-25 15:13:27 +00:00
FoxxMD d39abe40c6 feat(ui): Hide error stacks at sm and below 2026-06-25 15:12:46 +00:00
FoxxMD 6f8ca72ccf fix(db): Fix accessing undefined lifecycle during retention cleanup 2026-06-24 19:29:35 +00:00
FoxxMD 2ae32ee19f feat(ui): Polish error alert 2026-06-24 19:21:15 +00:00
FoxxMD 2ac89c5db3 fix: Serialize errors at api boundary and fix recursive cause bug in error alert render 2026-06-24 18:27:34 +00:00
FoxxMD b0db1dd8bc feat(ui): Add error boundaries and better error detection for rendering 2026-06-24 17:18:26 +00:00
FoxxMD a603be6e22 fix(ui): Fix total stats count 2026-06-24 16:12:58 +00:00
FoxxMD 7ad23a76de fix(ui): Fix component summary not rendering live updates when no initial players 2026-06-24 15:51:48 +00:00
FoxxMD 4481646153 refactor(ui): Move query functions into factory 2026-06-24 15:51:26 +00:00
FoxxMD 5d2c9b557f refactor(ui): Move component player live add/delete into players container 2026-06-24 15:17:54 +00:00
FoxxMD eb69ae48f1 fix(ui): Ok to ignore empty error when rendering error alert 2026-06-24 14:19:54 +00:00
FoxxMD c7479b2f00 fix(ui): Fix player keys and use of floats 2026-06-24 14:18:46 +00:00
FoxxMD 8ae7a8d338 feat(ui): Implement live play insert 2026-06-24 14:14:17 +00:00
FoxxMD 7573c762c0 fix(db): Include input in created play data since some systems depend on this existing 2026-06-24 14:14:03 +00:00
FoxxMD 6d503a1a15 fix(storybook): Fix duplicate msw workers due to vite hot reload 2026-06-24 14:13:19 +00:00
FoxxMD 94d3ea5da8 fix: Fix typing for play update events 2026-06-24 01:10:05 +00:00
FoxxMD e998006e6c feat(ui): Implement live updates to activity summary and details 2026-06-24 00:52:01 +00:00
FoxxMD 234fc67ab2 refactor(client): Remove unnecessary notifier dependency 2026-06-23 20:05:33 +00:00
FoxxMD 37bb507359 feat(ui): Global sse status indicator 2026-06-23 19:51:14 +00:00
FoxxMD 1237056d59 feat(ui): Implement statuses for clients 2026-06-23 19:50:54 +00:00
FoxxMD 7174702f7b feat(ui): Implement status and state updates for sources 2026-06-23 18:44:19 +00:00
FoxxMD 3b721673c8 refactor(ui): Replace native links with react-router links 2026-06-23 16:10:52 +00:00
FoxxMD b64f0d2dd5 fix(ui): Fix color contrast for log lines 2026-06-23 16:03:20 +00:00
FoxxMD 7738dc004a feat(ui): Animate sleep indicator 2026-06-23 15:54:34 +00:00
FoxxMD 5c5563a917 Fix layout of state badge and status in component details 2026-06-23 15:48:32 +00:00
FoxxMD 7d08939a18 feat(ui): Implement sleeping indicator 2026-06-23 15:38:53 +00:00
FoxxMD fabc50aa62 switch back to directdomupdates for virtuallist 2026-06-23 13:40:42 +00:00
FoxxMD 4c0615d427 finally working infinite query loading 2026-06-23 13:31:16 +00:00
FoxxMD b4dd729150 refactor: Replace hardcoded queryopts
* Convert to json with deepreplacevalue
* hydrate dayjs at api boundary with generic object walker
2026-06-23 12:33:18 +00:00
FoxxMD f336089755 fix(ui): Fix header icon 2026-06-22 20:38:28 +00:00
FoxxMD 4fc6285081 fix: remove import that not longer exists 2026-06-22 20:26:04 +00:00
FoxxMD 0cf3e33fe6 fix artist alignment 2026-06-22 20:24:39 +00:00
FoxxMD ab424aee0d feat(ui): Implement track/artist/album text search 2026-06-22 20:21:54 +00:00
FoxxMD 9fb55d479d feat(ui): Add play count total and per day 2026-06-22 19:14:22 +00:00
FoxxMD c8fb01bbab Improvements to infinite querying 2026-06-22 18:00:00 +00:00
FoxxMD f008c4074d feat(ui): Implement infinite queries 2026-06-22 16:54:47 +00:00
FoxxMD d66922c99a Improve layout for activity summary 2026-06-22 16:16:05 +00:00
FoxxMD c7689199cc fix styling for filters 2026-06-22 15:21:49 +00:00
FoxxMD 9a8b6bfa09 feat(ui): wire up play filters to api 2026-06-22 14:47:44 +00:00
FoxxMD 45c8141137 with card 2026-06-20 02:34:52 +00:00
FoxxMD 4e7b3e9ceb better styling 2026-06-20 02:30:51 +00:00
FoxxMD dfcc9c32a8 partially working layout for filters 2026-06-20 02:17:00 +00:00
FoxxMD a3d891aa2f Add activity list heading 2026-06-19 20:52:46 +00:00
FoxxMD 088ae4a26a default to virtual list 2026-06-19 20:32:16 +00:00
FoxxMD f425b5a906 disable wdyr
causing issues with storybook
2026-06-19 20:11:15 +00:00
FoxxMD a4ddaeff61 install react-compiler 2026-06-19 20:10:55 +00:00
FoxxMD 8932224b57 add why-did-you-render for debugging 2026-06-19 20:00:48 +00:00
FoxxMD ffe2203ba5 implement virtual list with tanstack/virtual 2026-06-19 19:30:08 +00:00
FoxxMD 987819cc7c fix missing key warnings on react nodes 2026-06-19 15:50:39 +00:00
FoxxMD 974a017e5a feat(ui): Implement query-key-factory and activity summary fetchable 2026-06-19 15:39:58 +00:00
FoxxMD 594a59347e refactor activity summary into own component 2026-06-19 14:09:36 +00:00
FoxxMD b0970c6653 chore: standardize component type 2026-06-19 13:47:54 +00:00
FoxxMD d8017dda62 fix: fix typing on api fixture for queues 2026-06-18 20:33:20 +00:00
FoxxMD a8c3e88ec9 fix(ui): Fix progress background when in a container 2026-06-18 20:31:39 +00:00
FoxxMD 7bf31a7fc7 add chevron left button 2026-06-18 20:26:11 +00:00
FoxxMD deb3310e3f fix: Fix preserving play original data until it is persisted 2026-06-18 20:18:15 +00:00
FoxxMD 5450b48eb3 fix(ui): correct query args for showing plays by seen/desc 2026-06-18 20:17:50 +00:00
FoxxMD 70a94cff9b fix(ui): Fix showing error indicator when error is null 2026-06-18 20:17:32 +00:00
FoxxMD ff31259092 feat(ui): Implement fetchable play lists and detailed components 2026-06-18 19:41:02 +00:00
FoxxMD 8b4f1849f6 feat: Implement api endpoints for individual components, plays, and individual plays 2026-06-18 19:40:29 +00:00
FoxxMD 110ffad7d7 docs: Update atproto descriptions 2026-06-18 16:49:35 +00:00
FoxxMD 62a0670b4d Add players to detailed source component 2026-06-18 15:54:00 +00:00
FoxxMD 7a1142fdd5 actions placeholder 2026-06-18 15:20:58 +00:00
FoxxMD 5def3cde99 fix(ui): Fix accordion context 2026-06-18 14:54:27 +00:00
FoxxMD 075fa877ed Move player tooltip to bottom right position 2026-06-18 14:36:22 +00:00
FoxxMD 6ffb46a65a chore: Fix storybook theming, again 2026-06-18 14:12:21 +00:00
FoxxMD ac5f34ea5f fix(ui): Fix width of tooltip popover 2026-06-18 14:09:48 +00:00
FoxxMD 9975f377d0 fix(ui): Fix colors in player buffer tooltip to match system colors with contrast 2026-06-18 14:05:55 +00:00
FoxxMD 76b4606d17 chore: Use system theme for storybook 2026-06-18 14:05:36 +00:00
FoxxMD 1244e0ee3e fix(ui): Use more lenient css selectors for progress bar 2026-06-18 13:53:15 +00:00
FoxxMD c56b16e2e6 fix: Fix auth cache validation and fallback 2026-06-18 13:43:10 +00:00
FoxxMD 831fd420ff feat(ui): Add player buffer tooltip 2026-06-18 13:27:16 +00:00
FoxxMD 2125f92bd8 feat(ui): Implement player buffering real time positiono 2026-06-18 12:42:26 +00:00
FoxxMD 42c9383dda partial buffered player updates working 2026-06-18 03:06:53 +00:00
FoxxMD f5eab006f5 feat(ui): Implement buffering player progress with mui 2026-06-18 02:19:08 +00:00
FoxxMD 5a2c664fe1 fix(atproto): Fix rate limit expiration and display 2026-06-18 00:56:30 +00:00
FoxxMD d7cbb7c9bd fix: Fix missing implementation on test component 2026-06-17 21:22:20 +00:00
FoxxMD 8afe30bfd4 feat(ui): Implement stats, stat change indicator, and iniitial component detail 2026-06-17 21:19:18 +00:00
FoxxMD 24b67fc430 feat(ui): Implement better live stat indicators 2026-06-17 19:59:06 +00:00
FoxxMD d01a00ad13 feat(atproto): Implement rate limit checks 2026-06-17 17:02:19 +00:00
FoxxMD ac250eba39 feat(ui): Implement real state reporting for components 2026-06-17 14:56:47 +00:00
FoxxMD 778a2d6565 feat(ui): add activity indicator to logs panel 2026-06-17 13:57:19 +00:00
FoxxMD 0b9a7b78df feat(ui): Live player add/remove based on sse updates 2026-06-17 13:24:23 +00:00
FoxxMD 6b93e839fa fix(atproto): fix not throwing applogin exception 2026-06-17 01:56:55 +00:00
FoxxMD 7e6040ce7b fix(ui): Fix accessing components before db is ready 2026-06-17 01:46:45 +00:00
FoxxMD d4b2057c41 fix: fix nullish access of dbcomponent during testing 2026-06-17 01:44:26 +00:00
FoxxMD bc04603fb4 fix: closest match needs id to update updatedAt 2026-06-17 01:42:37 +00:00
FoxxMD ba2a23e039 fix: Fix app migrations table name inconsistency 2026-06-17 01:24:06 +00:00
FoxxMD 6bfe203a34 fix(atproto): Fix not throwing on applogin error 2026-06-17 01:08:40 +00:00
FoxxMD 8c6f34ebc7 fix: Guard against undefined synced reason 2026-06-17 01:05:48 +00:00
FoxxMD 18e20123c2 fix(ui): fix z-index on logs panel 2026-06-16 22:07:54 +00:00
FoxxMD dc44f6c921 feat(ui): Implement fetchable logs with filtering 2026-06-16 20:46:51 +00:00
FoxxMD a19fd42a5b feat(ui): Implement float logs 2026-06-16 20:15:17 +00:00
FoxxMD 206fd62f70 feat(ui): Splitter logs 2026-06-16 19:30:12 +00:00
FoxxMD 6123503ee8 feat(ui): Implement logs component 2026-06-16 18:12:54 +00:00
FoxxMD b751c6f5f9 feat(ui): Implement side nav 2026-06-16 15:33:55 +00:00
FoxxMD d1ef09ba0d feat(ui): Implement header 2026-06-15 21:08:29 +00:00
FoxxMD d073642203 feat(ui): Implement live updates with sse 2026-06-15 19:17:50 +00:00
FoxxMD 9bb48fbf13 POC player live update 2026-06-15 17:17:08 +00:00
FoxxMD bf8f1175ce feat(ui): Implement static component list in app entrypoint 2026-06-15 14:43:13 +00:00
FoxxMD 4bfcc40571 poc multi page 2026-06-12 17:57:54 +00:00
FoxxMD 892d035d7a feat(ui): Implement component summary list 2026-06-12 16:03:07 +00:00
FoxxMD d4019e6dea feat(ui): Implement Player and api fixtures 2026-06-11 20:48:10 +00:00
FoxxMD 3ce94f443a feat(ui): Implement component overview and api fixtures 2026-06-11 18:55:11 +00:00
FoxxMD b863a9773a better optimization of activity details 2026-06-11 00:18:46 +00:00
FoxxMD 489d7d12cb improvements to play list and story 2026-06-11 00:18:36 +00:00
FoxxMD 63c7ea7809 fea(ui): Implement tanstack-query for ActivityDetails and mock in storybook 2026-06-10 20:44:51 +00:00
FoxxMD a6a019e9fd activity ui improvements 2026-06-10 19:45:13 +00:00
FoxxMD dbd1c81c90 update timelline component
* make activity optional with fallback loading render
* fix storybook default args with constant
2026-06-10 19:06:13 +00:00
FoxxMD 2b42b40c0a update storybook 2026-06-10 19:05:01 +00:00
FoxxMD 4230674c2c some ui fixes 2026-06-10 17:36:53 +00:00
FoxxMD b0955270ba refactor: Remove indexed prop from PlayMeta
* Remove indexed any prop so meta is stricter (better catching bugs)
* Implement mergeable generic with meta where we do need extra prop
* Fix bad usage of meta now that indexed prop is gone
2026-06-10 01:54:35 +00:00
FoxxMD a194f752bb refactor: Remove lifecycleless types 2026-06-10 01:54:35 +00:00
FoxxMD 4180ae0c6b feat(migrations): Implement play lifecycle location migration 2026-06-10 01:54:35 +00:00
FoxxMD 3227541f34 feat(db): Add unix timestamp to database bakup file and fix test for detecting it 2026-06-10 01:54:35 +00:00
FoxxMD e949da3830 refactor: Move all lifecycle properties to top-level Play
And remove lifecycle from meta
2026-06-10 01:54:35 +00:00
FoxxMD e13d86b1d0 feat: Add more identying data to lifecycle step 2026-06-10 01:54:35 +00:00
FoxxMD f7577c43e0 refactor: Move scrobble to top level of play object 2026-06-10 01:54:35 +00:00
FoxxMD f3531f41ec feat: Add creation timestamps to lifecycle data 2026-06-10 01:54:35 +00:00
FoxxMD d9d37c68fc Implement api fixtures 2026-06-10 01:54:35 +00:00
FoxxMD 3bb887763f refactor: Move original play data to top-level property in play
* Simplifies lifecycle
* Simplifies play generation for testing
2026-06-10 01:54:35 +00:00
FoxxMD c65504ee9f feat(ui): Define initial API contract 2026-06-10 01:54:35 +00:00
FoxxMD 373fdb0dc2 fix(ui): Gaurd against undefined code prop 2026-06-10 01:54:35 +00:00
FoxxMD 825d0da532 fix(ui): Gaurd againast missing duration 2026-06-10 01:54:35 +00:00
FoxxMD 2efdada4c0 fix(musicbrainz): Fix default assignment of weights in stage config
Should not assign a default value so that stage defaults are not overriden in stage config when no explicit config is given.

Fixes #627
2026-06-10 01:41:41 +00:00
Matt Foxx 01afe9006a Merge pull request #626 from FoxxMD/lzImport
fix(listenbrainz endpoint): `import` handling and error responses
2026-06-09 11:48:24 -04:00
FoxxMD da9edb2116 feat(lzendpoint): Improved error handling
* Return error response when no sources found by request, or none configured
* try-catch and return well-formatted error response if source handling fails
2026-06-09 13:42:54 +00:00
FoxxMD d5c886d718 fix(listenbrainz): Handle 'import' listen_type with multiple scrobbles
#625
2026-06-09 13:28:44 +00:00
FoxxMD e99351407e fix(musicbrainz): Fix order of release priority list
Should be reversed since we use index as score, so "first" in the list should have the highest index/score

#624
2026-06-08 15:38:55 +00:00
Matt Foxx d9184fa350 Merge pull request #623 from FoxxMD/mpdArtistAssignment
fix(mpd): Assign empty array if artists not defined
2026-06-07 22:22:38 -04:00
Matt Foxx e4af1e3f18 Merge pull request #619 from FoxxMD/atprotoHistory
feat: Implement historical scrobble querying for atproto clients
2026-06-07 11:51:33 -04:00
FoxxMD 9ca4f3fa62 fix(mpd): Assign empty array if artists not defined
#621
2026-06-07 15:47:43 +00:00
FoxxMD 20e3f22f3f docs(azuracast): Add clearer guidance on station configuration and access 2026-06-05 19:55:14 +00:00
FoxxMD bca9e195c9 feat(scrobblers): Detect sync gap and resolve sync status if filled 2026-06-05 19:10:29 +00:00
FoxxMD 8b1765cb03 fix: Fix missing type in prometheus stats 2026-06-05 15:21:34 +00:00
FoxxMD 0605cf1fd5 feat: Implement compaction prometheus statistics 2026-06-05 15:05:53 +00:00
FoxxMD 07d6487875 feat: Implement database play prometheus stats
* Total plays by component/state
* Total historical plays by client
2026-06-05 14:54:29 +00:00
FoxxMD 6222aa5899 fix(rocksky): Do not persist scrobble payloads as historical plays 2026-06-05 13:33:04 +00:00
FoxxMD 47f61b491e fix: Fix tables used in historical play repository 2026-06-05 13:23:59 +00:00
FoxxMD 42818c51c1 feat(rocksky): Update configuration and document new token 2026-06-04 20:44:24 +00:00
FoxxMD a0fee4184e chore(rocksky): Bump version to get new exports 2026-06-04 20:23:26 +00:00
FoxxMD 470a70f951 feat(rocksky): Implement historical scrobbles 2026-06-04 20:07:08 +00:00
FoxxMD 2d2b3a6d48 feat(rocksky): Implement native sdk 2026-06-04 19:18:14 +00:00
FoxxMD 6f40dd387e chore(atproto): Remove unused @atproto packages 2026-06-04 17:58:12 +00:00
FoxxMD 2445336ec0 refactor(atproto): Remove unused oauth implementation
Oauth will need a larger, ground-up rewrite if/when its eventually done. Remove the old, unusued implementation for now to reduce complexity.
2026-06-04 17:54:41 +00:00
FoxxMD 41d181e02f refactor: Replace @atproto with @atcute for abstract/app api implementation 2026-06-04 17:50:40 +00:00
FoxxMD ff158c99cd refactor: Move some stateless functions out of atproto class 2026-06-04 16:05:58 +00:00
FoxxMD c1cdcd016a refactor: Move more atproto-generic function into abstract api base 2026-06-04 15:37:01 +00:00
FoxxMD fcb9092f8a refactor: Rename bluesky to atproto, classes 2026-06-04 15:14:45 +00:00
FoxxMD ba2bd0cc8a refactor(teal): Refactor atproto api implementation out of tight teal coupling 2026-06-04 15:12:37 +00:00
FoxxMD f224ce6b6a refactor: Rename bluesky to atproto 2026-06-04 14:51:49 +00:00
FoxxMD c74678e9ef feat(scrobbler): Improve historical scrobbler hydration
* Refactor post init behavior into promise so we can fallback to recent sync if full hydration fails
  * This way user can still compare real time scrobble activity without full hydration
* Simplify sync status usage
  * Log reason based on presence of sync error
  * Always try to do full hydration if not synced
* Fix missing logging for hydration error
* Add MS-scrobbled scrobbles to historical database
2026-06-04 13:53:08 +00:00
FoxxMD 1b1770ff29 chore(release): Bump version for release 2026-06-03 19:20:22 +00:00
Matt Foxx 41d3a63c42 Merge pull request #615 from FoxxMD/artistCreditCleanup
fix: Add missing artist credit transforms
2026-06-03 15:07:46 -04:00
FoxxMD 52240aa13b feat(teal): Implement historical scrobble searching for existing scrobbles and auto hydrate, or sync recent, on init 2026-06-03 19:06:26 +00:00
FoxxMD 95d7d276d7 refactor(teal): Generate and use real lexicon definitions for tealfm implementation
Removes hand-rolled interfaces and generates interfaces+validation for official lexicon definitions
2026-06-03 18:14:20 +00:00
FoxxMD 18c520ce2f feat(tealfm): Stream repo CAR to monitor download progress 2026-06-03 16:33:40 +00:00
FoxxMD fb65cb222a fix: fix rebase conflicts 2026-06-03 16:33:33 +00:00
FoxxMD c4f028fd2c feat(tealfm): Implement historical scrobble hydration 2026-06-03 16:33:24 +00:00
FoxxMD b85452f5c6 feat: Implement historical scrobble client abstract class 2026-06-03 16:32:16 +00:00
FoxxMD 6546ee3db2 Add package for atproto repo car handling 2026-06-03 16:29:57 +00:00
FoxxMD f0e798c052 fix: Add missing artist credit transforms
Fixes #613
2026-06-03 13:24:32 +00:00
FoxxMD 44424f2570 fix: more logging and arg fixes after re-generating lock file 2026-06-03 13:24:10 +00:00
FoxxMD 72df520300 chore: re-generate lock file
* Caused by npm/cli#6301
* will make packaging for nix easier NixOS/nixpkgs#519119
2026-06-03 12:54:31 +00:00
FoxxMD 25c783051d fix: Fix argument order for logger labels 2026-06-03 12:50:16 +00:00
FoxxMD 264bc31af6 Merge branch 'storybookRefresh' 2026-06-02 14:11:04 +00:00
FoxxMD e0bdb8fe3c docs: Fix typo in jriver docs 2026-06-02 12:52:09 +00:00
FoxxMD 4d7c93f7dc fix(maloja): Fix source maloja envs
#610
2026-06-01 22:32:15 +00:00
FoxxMD dd54075c52 docs: Fix bad config example for id on aio in 0.14.0 upgrade 2026-06-01 19:37:02 +00:00
FoxxMD ff08b6327d docs: Comment out required upgrade callout, for now
Unnessary information since there is nothing > 0.14.0. May confuse some users. Can uncomment after 0.14.1 or later.
2026-06-01 19:27:09 +00:00
FoxxMD 76392a9668 chore(release): Bump version for release 2026-06-01 18:45:20 +00:00
FoxxMD f47faabf7c docs: Remove example of caching for transform best practices
Example is already in the main cache configuration docs. Consolidate/remove the transform example so there is only one authorative example in the docs.
2026-06-01 17:30:56 +00:00
FoxxMD 120e280fc8 docs: More upgrade guidance for 0.14.0 2026-06-01 15:22:38 +00:00
FoxxMD 6ae80fd881 feat(docs): Update colors for regular important admonition 2026-06-01 14:51:11 +00:00
FoxxMD be70e7074a fix(docs): Fix css for important admonition details important type 2026-06-01 14:35:02 +00:00
Matt Foxx 65841a8c6b Merge pull request #609 from HiiJax/fixMbIdCaps
fix(tealfm): trackMbid -> trackMbId
2026-06-01 08:40:14 -04:00
Jax 82374fc806 fix(tealfm): trackMbid -> trackMbId 2026-05-31 20:02:09 -04:00
FoxxMD 004400d7ea refactor: Replace jsondiffpatch-react with @pierre/diffs
Good ridance.
2026-05-30 20:02:20 +00:00
FoxxMD d54e169a14 fix for transformstep date 2026-05-30 19:05:36 +00:00
FoxxMD 31c35647dd feat(ui): Update artist rendering to use credits 2026-05-30 19:05:36 +00:00
FoxxMD 204ee159d6 fix(ui): Update JSX namespace to fix missing ambient in react 19 2026-05-30 19:05:36 +00:00
FoxxMD 4fe1f8ced9 chore(release): Bump for beta release 2026-05-30 15:12:25 +00:00
Matt Foxx c26e6f77f5 Merge pull request #608 from FoxxMD/nowPlayingGranular
refactor: Now Playing rewrite to handle realtime client updates
2026-05-30 10:59:40 -04:00
FoxxMD e2b59900e5 fix(listenbrainz): Fix artist credit mapping for minimum track data 2026-05-30 14:59:18 +00:00
FoxxMD c9cde11871 fix(tealfm): Missing function call to statusExpiresSoon 2026-05-29 14:45:42 +00:00
FoxxMD ea0c75ccf3 chore: Simplify gitignore pattern for db files 2026-05-29 14:40:57 +00:00
FoxxMD 13a64bd63a refactor(now playing): Improve now playing to generically handle clients that have real-time updates
* Break out player status checks and thresholds into separate functions
* Consolidate all should update functionality into shouldUpdatePlayingNow for easier testing
* Introduce realtime flag for teal/discord and check if clearing is possible/desired
  * Remove equivalent functionality in individual clients
* (tealfm): Correctly set max threshold using track duration
* (tealfm): Skip clearing update if close to/after expiration time
* (tealfm): Increase min threshold to avoid spamming pds updates for pauses/skip track
2026-05-29 14:40:45 +00:00
FoxxMD c3b23271d4 fix(now playing): Fix acessing undefined play when source player state is reported but without any data
598#issuecomment-4566553387
2026-05-28 17:48:05 +00:00
FoxxMD d047c600c2 feat(tealfm): Update lexicon defs with new mbid uri format
teal-fm/teal#102
2026-05-28 16:13:06 +00:00
FoxxMD b6cae4a538 fix(jellyfin): Handle string or object data for album props when parsing to play
#598#issuecomment-4564522030
2026-05-28 15:08:14 +00:00
FoxxMD 1eed48456c feat: Make artist name to credit function more robust
* artistNameToCredit can now handle partial ArtistCredit object
* Re-use artistNameToCredit for mapping in artistNameToCredits
2026-05-28 15:07:19 +00:00
FoxxMD 20834e812e feat: case-insensitive proxy handles spread/rest 2026-05-28 14:44:02 +00:00
414 changed files with 23061 additions and 11448 deletions
+2 -1
View File
@@ -31,4 +31,5 @@ tmp-*
.env
*.env
.storybook
.stories
.stories
**/lexicons/**/*.json
+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"
+3 -5
View File
@@ -153,8 +153,6 @@ tmp-*
storybook-static
*.db
*.db.*
*.db-*lib
*.db
*.db.*
*.db-*lib
*.db*
**/lexicons/**/*.json
-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"
+2
View File
@@ -12,12 +12,14 @@ export default defineMain({
// "@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
'storybook-addon-remix-react-router'
// "@storybook/addon-onboarding"
],
features: {
// https://github.com/storybookjs/storybook/discussions/33279
sidebarOnboardingChecklist: false,
},
staticDirs: ['../public'],
"framework": "@storybook/react-vite",
viteFinal: async (config) => {
return mergeConfig(config, {
+1 -1
View File
@@ -2,5 +2,5 @@ import { addons } from 'storybook/manager-api';
import { themes } from 'storybook/theming';
addons.setConfig({
theme: themes.dark,
theme: themes.normal,
});
+10 -1
View File
@@ -3,8 +3,17 @@ import addonA11y from "@storybook/addon-a11y";
import { definePreview } from "@storybook/react-vite";
import { themes } from 'storybook/theming';
import { initialize, mswLoader, getWorker } from 'msw-storybook-addon';
// https://github.com/mswjs/msw-storybook-addon/issues/82#issuecomment-3894302575
try {
getWorker();
} catch (_) {
initialize();
}
export default definePreview({
loaders: [mswLoader],
parameters: {
controls: {
matchers: {
@@ -20,7 +29,7 @@ export default definePreview({
test: 'todo'
},
docs: {
theme: themes.dark,
theme: themes.normal,
},
},
+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
+2 -2
View File
@@ -4,7 +4,7 @@
"name": "FoxxMD Rocksky Client",
"configureAs": "client",
"data": {
"key": "7cdr86vis3mpq5b1pi01md0hkm7ykzzxjy81r",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQiOiJkaWQ6cGxjOng0NjVwZmtyeW5zaDZ...",
"handle": "alice.bsky.social"
}
},
@@ -13,7 +13,7 @@
"name": "FoxxMD Rocksky Source",
"configureAs": "source",
"data": {
"key": "e4xwktm7jxm607x8el1fcda30eu14dzb64h3j",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQiOiJkaWQ6cGxjOng0NjVwZmtyeW5zaDZ...",
"handle": "alice.bsky.social"
}
}
@@ -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
+26 -15
View File
@@ -13,7 +13,7 @@ To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)]
:::
[**Rocksky**](https://rocksky.app/) is music tracking and discovery platform built on the AT Protocol (Bluesky).
[**Rocksky**](https://rocksky.app/) is music tracking and discovery platform built on the AT Protocol and your your [**Atmosphere Account**](https://atmosphereaccount.com/).
<details>
@@ -23,13 +23,13 @@ To monitor and re-scrobble activity **from** Rocksky create a [Rocksky (Source)]
</details>
<DetailsAdmo type="warning" summary="Your Data Is Public">
<DetailsAdmo type="important" summary="Your Data Is Public">
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
</DetailsAdmo>
<DetailsAdmo type="warning" summary="No Scrobbling Guarantee">
<DetailsAdmo type="important" summary="No Scrobbling Guarantee">
Rocksky **does not guarantee** that every scrobble you send will be persisted. Rocksky's design goal is to store scrobbles with **metdata-complete, 3rd party verifiable track information** so that it can be a metadata-rich discovery platform, rather than an exhaustive scrobble data source-of-truth.
@@ -41,22 +41,16 @@ If you need your scrobbles to be 100% captured then you should scrobble to an *a
## Setup
You will need a [Bluesky](https://bsky.app/) account to use Rocksky.
You will need an [**Atmosphere Account**](https://atmosphereaccount.com/) to use Rocksky.
:::note
Usage is adapted from [Rocksky docs](https://docs.rocksky.app/migrating-to-rocksky-scrobble-api-957839m0) on [docs.rocksky.app](https://docs.rocksky.app)
:::
* Navigate to [rocksky.app](https://rocksky.app/) and sign up/logn using your Bluesky account
* Then, navigate to the [RockSky developer dashboard](https://rocksky.app/apikeys) and obtain an API Key
* Navigate to [rocksky.app](https://rocksky.app/) and sign up/logn using your Atmosphere Account
* Then, navigate to the [Access Tokens](https://rocksky.app/access-tokens) in the Rocksky settings dropdown, and create a new token
## Configuration
### Handle
The handle used with multi-scrobbler should be your **full** ATProto handle, including TLD.
The handle used with multi-scrobbler should be your **full** [**Atmosphere Account**](https://atmosphereaccount.com/) handle, including TLD.
* For regular Bluesky account this will be like: `alice.bsky.social`
* For Bluesky accounts that [use their website as their account](https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial) this is your domain: `mydomain.com`
@@ -64,11 +58,28 @@ The handle used with multi-scrobbler should be your **full** ATProto handle, inc
If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
<DetailsAdmo type="warning" summary="API Applications Deprecated">
In < 0.14.1, Multi-Scrobber used Rocksky's API Appication "API Key" for communication with a Listenbrainz-compatible API from Rocksky.
Going forward, this should be replaced with the official guidance in the [Setup](#setup) section above: use an **Access Token**. This uses the native Rocksky API which has more functionality.
If your configuration is using either
* (File/AIO) `'key': ...`
* (ENV) `ROCKSKY_KEY`
you should redo the [Setup](#setup) section and modify your configuration to use an Access Token instead.
Keys are now **deprecated** and functionality for them will be removed in a future version of Multi-Scrobbler.
</DetailsAdmo>
<Config config="RockSkyClientConfig" fileContent={JsonConfig} client name="rocksky">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
| `ROCKSKY_ID` | Yes | | A unique ID |
| `ROCKSKY_ID` | Yes | | A unique ID used in MS |
| `ROCKSKY_HANDLE` | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
| `ROCKSKY_KEY` | Yes | | API Key obtained from Rocksky developer dashboard |
| `ROCKSKY_TOKEN` | Yes | | Access Token obtained from Rocksky |
| `ROCKSKY_NAME` | No | | A vanity name different than ID |
</Config>
+10 -3
View File
@@ -16,7 +16,7 @@ To monitor and re-scrobble activity from a ATProto Profile create a [teal.fm (So
:::
[**teal.fm**](https://teal.fm/) is a social discovery and web viewing service for scrobbles stored/available on the ATProto network/your repository in a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds).
[**teal.fm**](https://teal.fm/) is a social discovery and browsing app for scrobbles stored in your [**Atmosphere Account**](https://atmosphereaccount.com/).
<details>
@@ -46,6 +46,13 @@ To view your teal.fm scrobble data you can:
## Setup
:::note
Multi-scrobbler will work with *any* Atmosphere Account, not just Bluesky. See [Configuration](#configuration) below for provider-agnostic config.
:::
#### Bluesky
* Create a [Bluesky](https://bsky.app) account, if you don't already have one
@@ -57,10 +64,10 @@ To view your teal.fm scrobble data you can:
### Identifier
The ATPRoto identifier used with multi-scrobbler should be either:
The ATProto identifier used with multi-scrobbler should be either:
* A valid [DID](https://atproto.com/specs/did#at-protocol-did-identifier-syntax), starting with `did:plc:...` or `did:web:...`
* Your **full** ATProto handle, including TLD
* Your **full** [Atmosphere Account](https://atmosphereaccount.com/) ATProto handle, including TLD
If using a handle:
@@ -16,9 +16,9 @@ Azuracast may not correctly report track length or position. If this is the case
:::
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
The Azuracast server should have **Use [High-Performance Now Playing Updates](https://www.azuracast.com/docs/developers/now-playing-data/#high-performance-updates)** enabled in _Administration -> System Settings_
#### URL
### URL
The URL used by MS to connect to Azuracast has the syntax:
@@ -47,6 +47,68 @@ If either is configured then MS will automatically scrobble based on when these
If neither is configured automatic scrobble defaults to **off** and you should either manually initiate it or explicitly set the default behavior.
### Station
#### Station Identifier
The station identifier multi-scrobbler uses (ENV `AZURA_STATION` or `station` in File/AIO config) needs to be the **URL Stub** or **shortcode** of the Azuracast station, **not** its display name. Use of the methods below to find an apprioriate station identifier.
<Tabs groupId="stationId" queryString>
<TabItem value="stub" label="URL Stub">
The **URL Stub** is the value used in the URL to access the station EX
```
https://azura.mydomain.com/public/my-cool-station
^^^^ url stub ^^
```
Use `my-cool-station` as the station identifier.
</TabItem>
<TabItem value="shortcode" label="Shortcode">
The **shortcode** can be found in the raw JSON response for your station. Navigate to this URL in your browser:
```
https://azura.mydomain.com/api/stations
```
Find your station in the returned data (by `name`) and then locate the value of the `shortcode` property in that object.
<details>
<summary>Example</summary>
```json
[
{
"id": 1,
"name": "My Cool Station",
"shortcode": "my-cool-station",
"description": "Re-broadcasts of my cool music",
"frontend": "icecast",
"backend": "liquidsoap",
...
```
Use `my-cool-station` as the station identifier.
</details>
</TabItem>
</Tabs>
#### Station Access
Multi-Scrobbler does not use authentication to communicate with Azuracast. Your station will likely need some form of **Public Pages** in order for you to monitor it.
In your station's **Edit Station Settings** -> **Public Pages** make sure that at least one of these is enabled:
* Enable Public Pages
* Enable Public APIs
:::note
For users who aren't administrators of Azuracast: if you can access the station using `https://azura.mydomain.com/public/xxxxxx` then it is public.
:::
## Configuration
<Config config="AzuracastSourceConfig" fileContent={AzuracastConfig} name="azuracast">
@@ -54,7 +116,7 @@ If neither is configured automatic scrobble defaults to **off** and you should e
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
| `AZURA_ID` | Yes | | A unique ID |
| `AZURA_URL` | Yes | | Azuracast URL *without station name* |
| `AZURA_STATION` | Yes | | The station name shown on the public page |
| `AZURA_STATION` | Yes | | The station URL Stub or shortcode |
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
| `AZURA_NAME` | No | | A vanity name different than ID |
@@ -9,7 +9,7 @@ import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import JriverConfig from '!!raw-loader!@site/../config/jriver.json.example';
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can be found in the JRiver GUI:
* Tools -> Options -> Media Network
* Check `Use Media Network to share this library...`
@@ -593,39 +593,6 @@ MS uses [caching](/configuration/#caching) to reduce the number of API calls nee
**If you are using any Transform stages you should configure [secondary caching](/configuration#secondary-caching)** to increase the cache size and lifetime of cached items. This will also reduce memory usage in MS.
<details>
<summary>Example</summary>
Add valkey service to your [multi-scrobbler docker compose stack](/installation?runType=docker-compose#docker) and configure MS to use it for metadata:
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
image: foxxmd/multi-scrobbler
# ...
environment:
# ...
// highlight-start
- CACHE_METADATA=valkey
- CACHE_METADATA_CONN=redis://valkey:6379
// highlight-end
# ...
// highlight-start
valkey:
image: valkey/valkey
volumes:
- valkeydata:/data
volumes:
valkeydata:
driver: local
// highlight-end
```
</details>
## Examples
See **Examples** sections in specific Stage docs (also in the sidebar):
+14
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)
@@ -64,6 +66,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?**
+64 -24
View File
@@ -9,7 +9,7 @@ import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import RequiredUpgrade from "@site/src/components/snippets/_upgrade-required.mdx"
<RequiredUpgrade before='< 0.14.0' current='0.14.0' old='0.13.2' />
{/* <RequiredUpgrade before='< 0.14.0' current='0.14.0' old='0.13.2' /> */}
## Source/Client IDs
@@ -75,32 +75,34 @@ Add an `id` to the top-level for each Source/Client configuration, next to `data
Add an `id` to the top-level for each Source/Client configuration, next to `data`:
```json title="koito.json"
[
{
"name": "koito-source",
"configureAs": "source",
"type": "koito",
// highlight-start
"id": "myKoitoID",
// highlight-end
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "admin",
"url": "http://192.168.0.100:4110"
```json title="config.json"
{
"sources": [
{
"name": "koito-source",
"configureAs": "source",
"type": "koito",
// highlight-start
"id": "myKoitoID",
// highlight-end
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "admin",
"url": "http://192.168.0.100:4110"
}
}
}
]
]
}
```
</TabItem>
</Tabs>
:::note[Default ID]
:::important[Default ID]
If you do not add an ID then Multi-Scrobbler will automatically use the **name** of the Source/Client as the ID. The name is shown in the Dashboard.
**If you do not add an ID then Multi-Scrobbler will automatically use the name of the Source/Client as the ID.** The name is shown in the Dashboard.
If you decide to add an ID later you must use the name as the ID in order to keep Plays/Scobbles associated with the same config.
If you decide to add an ID later, you must use the Source/Client name as the ID in order to keep Plays/Scobbles associated with the same config.
:::
@@ -108,13 +110,19 @@ If you decide to add an ID later you must use the name as the ID in order to kee
:::tip
Before upgrading, if your MS dashboard shows 0 queued/failed for all [Scrobble Clients](/configuration/clients) then you can **ignore this step.**
Before upgrading, if your MS dashboard shows 0 queued/failed for all [Scrobble Clients](/configuration/clients) then you can **ignore this section.**
:::
:::important
The cached scrobbles for a Client/Source were based on its **name**. If you plan on changing the name of a Client/Source with this upgrade you should do it **after** this migration has run or else MS will not be able to find any associated cached scrobbles.
:::
MS `0.14.0` introduces a database for persisting Plays/Scrobbles, queues, and other data. Prior to `0.14.0`, queued/failed scrobbles were stored in a cache **file** inside your [`CONFIG_DIR`](/installation/?dockerSetting=storage#recommended-settings) named `ms-scrobble.cache`.
When a [Client](/configuration/clients) is created and **enabled** in `0.14.0` it will attempt to automatically migrate scrobbles from this cache into the database.
When a [Client](/configuration/clients) is created and **enabled** in `0.14.0` it will attempt to **automatically migrate scrobbles** from this cache into the database.
* You can see progress/results for this process by filtering logs for `[Cached Scrobble Migration]`
* If the process fails MS will attempt to migrate failed scrobbles, again, the next time it is started
@@ -140,7 +148,7 @@ In `0.14.0` [Cache](/configuration#caching) has been simplified with much of the
This only applies to users who have `cache` in their [AIO Config](/configuration/?configType=aio#configuration-types) `config.json` or are using `CACHE_*` [ENV Config](/configuration/?configType=env#configuration-types)
If you do not have any of the above [Cache](/configuration#caching) configuration defined then you can **ignore this step.**
If you do not have any of the above [Cache](/configuration#caching) configuration defined then you can **ignore this section.**
:::
@@ -210,9 +218,41 @@ Remove the `connection` property from the `auth` object. If using `file` the ent
</TabItem>
</Tabs>
### Renamed ENV Keys
## Renamed ENV Keys
In an effort to standard ENV prefixes some ENV keys have been renamed:
* `LZENDPOINT_ENABLE` => `LZE_ENABLE`
* `LFMENDPOINT_ENABLE` => `LFM_ENABLE`
* `LFMENDPOINT_ENABLE` => `LFM_ENABLE`
## (Optional) ENV Client/Source Names
In the MS Dashboard, prior to 0.14.0, UI Client/Sources configured with [ENV Config](/configuration/?configType=env#configuration-types) always had generated names like `unnamed-koito`.
ENV Config Clients/Sources can now be configured with a name by using the `_NAME` suffix, example:
```yaml title="compose.yaml"
services:
multi-scrobbler:
# ...
environment:
- KOTIO_TOKEN=...
# ...
- KOITO_ID=myKoitoID
// highlight-start
- KOITO_NAME=Koito A
// highlight-end
- JELLYFIN_URL=...
# ..
- JELLYFIN_ID=myJellyfinID
// highlight-start
- JELLYFIN_NAME=Foxx Jelly
// highlight-end
```
:::important
If you have **cached scrobbles** make sure to upgrade and run [Cached Scrobble Migration](#cached-scrobble-migration) **before** adding names to Client/Sources.
:::
+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>
)
}
+6 -6
View File
@@ -1,14 +1,14 @@
import CodeInline from '@theme/CodeInline';
The [AT Protocol](https://docs.bsky.app/docs/advanced-guides/atproto) is a standard to allow different applications to share and interpret your social data.
Your [**Atmosphere Account**](https://atmosphereaccount.com) consists of social data (it's just JSON) stored in a personal [repository](https://atproto.com/guides/data-repos) on a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds), all of which is standardized under the [**AT Protocol**](https://atproto.com).
Your data is stored in a personal [repository](https://atproto.com/guides/data-repos) in a [PDS (Personal Data Server)](https://github.com/Bluesky-social/pds), which is data-type agnostic (it's just JSON). Each application defines their own datatypes and pulls data from your repository to render to you, your network, etc...
The AT Protocol defines how your data is stored, how you authenticate to write to it, and how indpendent apps share and consume it. Critically, each layer (data storage, app, communication) of the AT Protocol can be operated by *indpendent* entities so your data and experience not locked behind a walled garden.
Even though the application and the PDS/repository are independent, all of this is done for you automatically when you create a [Bluesky](https://bsky.social/about) account.
Although each layer can be implemented separately (you can self-host everything!), it is normally a *simple* process for an end-user to create and start using an Atmosphere Account as many apps can do everything for you. The most common example being a [Bluesky](https://bsky.social/about), but there are other [public account providers](https://atmosphereaccount.com/explore).
So...
When Multi-Scrobbler uses the AT Protocol...
* multi-scrobbler "scrobbles" your plays to your repository
* multi-scrobbler "scrobbles" (writes) your plays to your repository
* in a JSON data structure which includes [{props.name}'s data type and structure](https://atproto.com/specs/lexicon)
* at a [known location for {props.name}'s data](https://atproto.com/guides/glossary#collection) (<CodeInline>{props.lexicon}</CodeInline>), in a list that is appended to as you continue to scrobble
* when accessing {props.name}, or another application that knows how to read it, your data is pulled from the repository to render the app
* when accessing {props.name}, or another application that knows how to read it, your data is pulled from the repository to render their app
+10 -2
View File
@@ -13,7 +13,8 @@
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
--ifm-color-important-contrast-background: #bb91ff;
--ifm-color-important-contrast-background: #e7d7ff;
--ifm-color-important-highlight: #b94be8;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
@@ -28,6 +29,7 @@
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--ifm-color-important-contrast-background: #5a3795;
--ifm-color-important-highlight: #e09cfd;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
@@ -40,7 +42,7 @@ details[class^='details_'].alert--info {
margin-bottom: var(--ifm-paragraph-margin-bottom);
&:not(.alert--warning,.alert--success,.alert--secondary,.alert--danger) {
&:not(.alert--warning,.alert--success,.alert--secondary,.alert--danger,.alert--important) {
--ifm-alert-background-color: transparent;
> summary {
@@ -98,6 +100,12 @@ details[class^='details_'].alert--info {
}
.alert--info.theme-admonition-important {
--ifm-alert-background-color: var(--ifm-color-important-contrast-background);
--ifm-alert-foreground-color: var(--ifm-color-secondary-contrast-foreground);
--ifm-alert-border-color: var(--ifm-color-important-highlight);
}
/** https://stackoverflow.com/a/79808430/1469797 */
img.image-center {
display: block;
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

+212 -27
View File
@@ -1,39 +1,224 @@
// 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 { defineConfig, globalIgnores } from "eslint/config";
// @ts-check
import eslint from '@eslint/js';
import js from '@eslint/js';
import globals from 'globals';
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';
export default tsEslint.config({
files: ['src/backend/**/*.ts'],
plugins: {
"prefer-arrow-functions": arrow
},
ignores: [
'eslint.config.js',
'src/backend/tests/**/*.ts'
const defaultRules = {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'no-unused-vars': [
'warn',
{
args: 'none',
caughtErrors: 'none',
destructuredArrayIgnorePattern: "^_",
"argsIgnorePattern": "^_",
ignoreRestSiblings: true,
ignoreUsingDeclarations: true
}
],
extends: [
eslint.configs.recommended,
...tsEslint.configs.recommended,
"@typescript-eslint/no-unused-vars": [
"warn",
{
args: 'none',
caughtErrors: 'none',
destructuredArrayIgnorePattern: "^_",
"argsIgnorePattern": "^_",
ignoreRestSiblings: true,
ignoreUsingDeclarations: true
}
],
rules: {
'no-useless-catch': 'off',
'@typescript-eslint/no-unused-vars': 'off',
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
"allowNamedFunctions": false,
"classPropertiesAllowed": false,
"disallowPrototype": false,
"returnStyle": "unchanged",
"singleReturnOnly": false
}
"prefer-arrow-functions/prefer-arrow-functions": [
"warn",
{
"allowNamedFunctions": false,
"classPropertiesAllowed": false,
"disallowPrototype": false,
"returnStyle": "unchanged",
"singleReturnOnly": false
}
],
"arrow-body-style": ["warn", "as-needed"],
"@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',
'dist'
]),
{
plugins: {
"prefer-arrow-functions": arrow,
js,
'unused-imports': unusedImports
},
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';
// "import/consistent-type-specifier-style": [
// "error", "prefer-top-level-if-only-type-imports"
// ]
},
extends: [
tsEslint.configs.recommended,
"js/recommended"
],
"arrow-body-style": ["warn", "as-needed"],
"@typescript-eslint/no-explicit-any": "warn"
languageOptions: {
globals: {
...globals.node,
}
},
files: ['src/**/*.ts','src/**/*.tsx']
},
{
extends: [
storybook.configs["flat/recommended"],
],
files: ['src/client/stories/**/*.tsx'],
},
{
extends: [
hooks.configs.flat.recommended,
],
languageOptions: {
globals: {
...globals.browser,
}
},
files: ['src/client/**/*.tsx'],
},
{
extends: [
mochaPlugin.configs.recommended,
],
languageOptions: {
globals: {
...globals.node,
}
},
files: ['src/backend/tests/**/*.ts'],
rules: {
...defaultRules,
"prefer-arrow-functions/prefer-arrow-functions": ["off"],
"@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': [
'error',
{
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'], // backend can use itself + core
},
{
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': 'error'
},
}
}, storybook.configs["flat/recommended"]);
]);
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="description"
content="Dashboard for Multi Scrobbler"
/>
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi Scrobbler</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app. aaaa</noscript>
<div id="root"></div>
<script type="module" src="/src/client/index-next.tsx"></script>
</body>
</html>
+6884 -4428
View File
File diff suppressed because it is too large Load Diff
+61 -28
View File
@@ -1,17 +1,17 @@
{
"name": "multi-scrobbler",
"version": "0.14.0-beta.2",
"version": "0.14.2",
"type": "module",
"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",
@@ -24,7 +24,9 @@
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\"",
"cliff": "git-cliff --unreleased",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"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",
"frontend:analyze": "npx esbuild src/client/index-next.tsx --bundle --platform=node --metafile=meta.json --outfile=/dev/null"
},
"exports": {
".": {
@@ -49,9 +51,13 @@
"homepage": ".",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
"@atcute/identity-resolver-node": "^1.0.3",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@atcute/bluesky": "^4.0.5",
"@atcute/client": "^5.1.0",
"@atcute/identity": "^2.0.0",
"@atcute/identity-resolver": "^2.0.0",
"@atcute/lexicons": "^2.0.0",
"@atcute/password-session": "^1.0.0",
"@atcute/repo": "^1.0.0",
"@donedeal0/superdiff": "^1.1.1",
"@ewanc26/tid": "^1.0.2",
"@foxxmd/chromecast-client": "^1.0.4",
@@ -64,9 +70,11 @@
"@kenyip/backoff-strategies": "^1.0.4",
"@keyv/valkey": "^1.0.8",
"@lukehagar/plexjs": "^0.39.0",
"@pierre/diffs": "^1.2.4",
"@rocksky/sdk": "^0.2.2",
"@supercharge/promise-pool": "^3.0.0",
"@svrooij/sonos": "^2.5.0",
"@xhayper/discord-rpc": "^1.3.0",
"@xhayper/discord-rpc": "1.3.0",
"abort-controller-x": "^0.5.0",
"address": "^1.2.2",
"ajv": "^8.18.0",
@@ -74,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",
@@ -102,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",
@@ -121,6 +129,8 @@
"passport-deezer": "^0.2.0",
"patch-package": "^8.0.1",
"prom-client": "^15.1.3",
"qs": "^6.15.2",
"react-use-timeout": "^1.0.0",
"round-robin-js": "^3.0.10",
"serialize-error": "^13.0.1",
"spotify-web-api-node": "^5.0.2",
@@ -128,8 +138,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",
@@ -139,22 +147,33 @@
"youtubei.js": "^15.0.1"
},
"devDependencies": {
"@chakra-ui/react": "^3.34.0",
"@atcute/lex-cli": "^3.1.0",
"@chakra-ui/react": "^3.36.0",
"@chromatic-com/storybook": "^5.1.2",
"@dbus-types/notifications": "^0.0.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@eslint/js": "^10.0.1",
"@faker-js/faker": "^9.0.1",
"@flamefrontend/sse-runtime-react": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@gr2m/net-interceptor": "^1.0.0",
"@lukemorales/query-key-factory": "^1.3.4",
"@mui/material": "^9.1.1",
"@react-hook/resize-observer": "^2.0.2",
"@react-hook/window-size": "^3.1.1",
"@react-nano/use-event-source": "^0.16.0",
"@reduxjs/toolkit": "^2.11.2",
"@storybook/addon-a11y": "^10.3.6",
"@storybook/addon-docs": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@rolldown/plugin-babel": "^0.2.3",
"@storybook/addon-a11y": "^10.4.3",
"@storybook/addon-docs": "^10.4.3",
"@storybook/react-vite": "^10.4.3",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-virtual": "^3.14.3",
"@tsconfig/node18": "^1.0.1",
"@types/chai": "^4.3.0",
"@types/chai-as-promised": "^7.1.5",
@@ -172,6 +191,7 @@
"@types/node": "^24.12.2",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/qs": "^6.15.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.5",
@@ -179,21 +199,30 @@
"@types/spotify-web-api-node": "^5.0.7",
"@types/superagent": "^8.1.9",
"@types/xml2js": "^0.4.11",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^6.0.2",
"@welldone-software/why-did-you-render": "^10.0.1",
"ansi-to-react": "^6.2.6",
"autoprefixer": "^10.4.27",
"babel-plugin-react-compiler": "^1.0.0",
"chai": "^4.3.6",
"chai-as-promised": "^8.0.2",
"clsx": "^2.1.1",
"drizzle-kit": "^1.0.0-rc.2-c5a84d1",
"eslint": "^10.3.0",
"eslint": "^10.6.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-boundaries": "^7.0.2",
"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-unused-imports": "^4.4.1",
"git-cliff": "^2.12.0",
"jsondiffpatch-react": "^1.0.8",
"globals": "^17.7.0",
"ky": "^2.0.2",
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.12.10",
"msw": "^2.14.6",
"msw-storybook-addon": "^2.0.7",
"next-themes": "^0.4.6",
"nodemon": "^3.0.3",
"playwright": "^1.58.2",
@@ -203,16 +232,19 @@
"react-icons": "^5.6.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.4",
"react-virtuoso": "^4.18.7",
"sass": "^1.99.0",
"shiki": "^4.0.2",
"sinon": "^21.0.2",
"storybook": "^10.3.6",
"storybook": "^10.4.3",
"storybook-addon-remix-react-router": "^6.1.0",
"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"
},
@@ -234,10 +266,11 @@
},
"musicbrainz-api": {
"rate-limit-threshold": "0.2.1"
},
"jsondiffpatch-react": {
"react": "$react",
"react-dom": "$react-dom"
}
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+149 -59
View File
@@ -1,41 +1,46 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { childLogger, type Logger } from "@foxxmd/logging";
import {
cacheFunctions,
} from "@foxxmd/regex-buddy-core";
import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core/Atomic.js";
import { buildPlayHumanDiffable, 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 { findCauseByReference } from "../utils/ErrorUtils.js";
import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { metaInvariantTransform, 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 { 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, 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 };
@@ -50,10 +55,14 @@ export default abstract class AbstractComponent extends AbstractInitializable {
protected db: DbConcrete;
protected componentRepo!: DrizzleComponentRepository;
protected dbComponent!: ComponentSelect;
componentId!: number;
protected retentionOpts: RetentionOptions;
status: string = 'Waiting to initialize...';
emitter: EventEmitter;
protected componentType: 'source' | 'client';
protected componentType: ComponentType;
type: ClientType | SourceType;
name: string;
protected constructor(config: AbstractComponentConfig) {
super(config);
@@ -80,12 +89,12 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
protected async postInitialize(): Promise<void> {
super.postInitialize();
await super.postInitialize();
this.componentRepo.updateById(this.dbComponent.id, {lastReadyAt: dayjs()});
}
protected async doBuildDatabase(): Promise<true | string | undefined> {
super.doBuildDatabase();
await super.doBuildDatabase();
let name: string;
if('name' in this) {
@@ -100,6 +109,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
uid: this.config.id ?? this.config.name ?? name,
name: this.config.name ?? name
});
this.componentId = this.dbComponent.id;
return true;
}
@@ -208,11 +218,16 @@ export default abstract class AbstractComponent extends AbstractInitializable {
this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
return;
}
this.setStatus('Running retention cleanup');
try {
const repo = new DrizzlePlayRepository(this.db, {logger: this.logger});
await repo.retentionCleanup(this.componentType, this.retentionOpts);
this.setStatus('Retention cleanup finished');
} catch (e) {
this.logger.warn(new Error('Failed to do retention cleanup', {cause: e}));
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
this.warning = retentionErr;
this.logger.warn(retentionErr);
this.setStatus('Retention cleanup failed');
}
}
@@ -288,7 +303,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
for(const s of cachedSteps) {
steps.push({...s, cached: true});
steps.push({...s, cached: true, createdAt: dayjs().toISOString()});
if(shouldTransform && s.patch !== undefined) {
transformedPlay.data = patchObject(transformedPlay.data, s.patch); // jdiff.patch(clone(transformedPlay.data),s.patch);
}
@@ -307,7 +322,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
if(step.error !== undefined && step.flowKnownState !== 'skip' && !step.returnPartial) {
// revert to original play but keep steps for paper trail
transformedPlay = play;
transformedPlay.meta.lifecycle.steps = steps;
transformedPlay.lifecycle = steps;
} else {
transformedPlay = stepPlay;
}
@@ -321,34 +336,30 @@ export default abstract class AbstractComponent extends AbstractInitializable {
const historyToDiff: {name: string, data?: PlayData}[] = [
{name: 'Pre Transform', data: play.data}
];
if(steps.length > 0 && transformedPlay.meta?.lifecycle?.steps === undefined) {
const {
meta: {
lifecycle: {
steps = [],
...lifecycleRest
},
} = {}
} = transformedPlay;
transformedPlay.meta.lifecycle = {
...lifecycleRest,
steps
}
let isNew = false;
if(steps.length > 0 && transformedPlay.lifecycle === undefined) {
transformedPlay.lifecycle = steps;
isNew = true;
}
const {
lifecycle = []
} = transformedPlay;
steps.forEach((s, index) => {
const existingStepIndex = transformedPlay.meta.lifecycle.steps.findIndex(x => x.name === 'hookType' && x.source === this.getIdentifier());
if(existingStepIndex !== -1) {
transformedPlay.meta.lifecycle.steps[existingStepIndex] = s;
} else {
transformedPlay.meta.lifecycle.steps.push(s);
if(!isNew) {
const existingStepIndex = lifecycle.findIndex(x => x.stageName === s.stageName && x.stageType === s.stageType && x.hook === s.hook && x.source === this.getIdentifier());
if(existingStepIndex !== -1) {
transformedPlay.lifecycle[existingStepIndex] = s;
} else {
transformedPlay.lifecycle.push(s);
}
}
if(shouldLog === 'all') {
if(s.patch === undefined) {
historyToDiff.push({name: `${s.source}-${s.name}`});
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName}`});
} else {
const patched = patchObject(historyToDiff[historyToDiff.length - 1].data, s.patch);
historyToDiff.push({name: `${s.source}-${s.name}${s.cached ? ' (Cached)' : ''}`, data: patched});
historyToDiff.push({name: `${s.source}-${s.hook}-${s.stageType}-${s.stageName} ${s.cached ? ' (Cached)' : ''}`, data: patched});
}
}
});
@@ -410,11 +421,18 @@ export default abstract class AbstractComponent extends AbstractInitializable {
asyncId = nanoid(6)
} = opts;
const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}`
const existingStepIndex = playTruth.meta.lifecycle.steps.findIndex(x => x.name === stepName && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 && playTruth.meta.lifecycle.steps[existingStepIndex] !== undefined ? playTruth.meta.lifecycle.steps[existingStepIndex] : {
name: stepName,
const {
lifecycle = []
} = playTruth;
//const stepName = `${hookType} - ${hookItem.type} - ${hookItem.name}`
const existingStepIndex = lifecycle.findIndex(x => x.hook === hookType && hookItem.name === x.stageName && x.stageType === hookItem.type && x.source === this.getIdentifier());
const step: LifecycleStep = existingStepIndex !== -1 && lifecycle[existingStepIndex] !== undefined ? lifecycle[existingStepIndex] : {
stageName: hookItem.name,
hook: hookType,
stageType: hookItem.type,
source: this.getIdentifier(),
createdAt: dayjs().toString()
}
let newTransformedPlay: PlayObject,
@@ -430,7 +448,16 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
step.stageName = stageName;
if (err !== undefined) {
const lifecycleErrorContext = findCauseByFunc(err, (e) => 'lifecycleInputs' in e && e.lifecycleInputs !== undefined);
if (lifecycleErrorContext !== undefined) {
// @ts-expect-error it does exist
step.inputs = clone(lifecycleErrorContext.lifecycleInputs) as LifecycleInput[];
// @ts-expect-error it does exist
delete lifecycleErrorContext.lifecycleInputs;
}
const merged = mergeSimpleError(err);
step.error = merged;
@@ -495,4 +522,67 @@ export default abstract class AbstractComponent extends AbstractInitializable {
return [step, newTransformedPlay];
}
public abstract getRunningState(): ComponentState
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
let state: ComponentState;
if(!this.initializedOnce || this.initializing) {
state = COMPONENT_STATE.INITIALIZING;
} else if(!this.isReady()) {
state = COMPONENT_STATE.NOT_READY;
} else {
state = this.getRunningState()
}
return {
id: this.dbComponent.id,
uid: this.dbComponent.uid,
name: this.dbComponent.name,
state,
mode: this.dbComponent.mode,
countNonLive: this.dbComponent.countNonLive,
createdAt: this.dbComponent.createdAt?.toISOString(),
lastReadyAt: this.dbComponent.lastReadyAt?.toISOString(),
lastActiveAt: this.dbComponent.lastActiveAt?.toISOString(),
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
...this.additionalApiData()
}
}
public emitEvent = (eventName: string, payload: Record<string, any> = {}) => {
this.emitter.emit(eventName, {
type: this.type,
name: this.name,
componentId: this.dbComponent?.id,
from: this.componentType,
data: payload,
});
}
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
if('error' in payload && payload.error instanceof Error) {
payload.error = serializeError(payload.error);
}
if('warning' in payload && payload.warning instanceof Error) {
payload.warning = serializeError(payload.warning);
}
this.emitEvent('componentUpdate', payload);
}
protected emitPlayUpdate = (payload: MarkRequired<Partial<PlayApiCommonDetailed>, 'uid'>) => {
this.emitEvent('playUpdate', payload);
}
protected emitPlayInsert = (payload: PlayApiCommonDetailed) => {
this.emitEvent('playInsert', payload);
}
async notify(payload: Omit<WebhookPayload, 'identifier'>) {
this.emitEvent('notify', {...payload, identifier: this.getIdentifier()});
this.setStatus(payload.title);
}
protected setStatus = (status: string) => {
this.status = status;
this.emitComponentUpdate({status});
}
}
+22 -10
View File
@@ -1,10 +1,10 @@
import { childLogger, 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, TransformRulesError } 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 {
@@ -19,7 +19,10 @@ export default abstract class AbstractInitializable {
databaseOK?: boolean | null;
connectionOK?: boolean | null;
cacheOK?: boolean | null;
error?: Error;
warning?: Error;
protected initializedOnce: boolean = false;
initializing: boolean = false;
config: Record<string, any>;
@@ -32,17 +35,21 @@ export default abstract class AbstractInitializable {
}
public abstract notify(payload: WebhookPayload): Promise<void>;
protected setStatus(status: string): void {
return;
}
protected abstract getIdentifier(): string;
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string, internalOnly?: boolean} = {}) => {
this.initializedOnce = true;
if(this.initController !== undefined) {
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`);
}
this.initController = new AbortController();
return spawn(this.initController.signal, async (signal, {defer, fork}) => {
this.setStatus('Initializing...');
defer(async () => {
this.initializing = false;
@@ -75,17 +82,22 @@ export default abstract class AbstractInitializable {
await this.checkConnection(force);
await this.testAuth(force);
this.logger.info('Fully Initialized!');
this.setStatus('Initialized!');
try {
await this.postInitialize();
} catch (e) {
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
}
this.error = undefined;
this.warning = undefined;
return true;
} catch(e) {
if(notify) {
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
}
throw new Error('Initialization failed', {cause: e});
const initError = new Error('Initialization failed', {cause: e});
this.error = initError;
throw initError;
} finally {
this.initializing = false;
}
+15 -15
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});
@@ -329,12 +329,12 @@ export class MSCache {
if (!this.hasInit) {
let authConfig: CacheConfig | undefined;
try {
if(asCacheConfig(this.config.scrobble)) {
if(asCacheConfig(this.config.auth)) {
authConfig = this.config.auth;
}
} catch (e) {
this.logger.warn(new Error('Could not validate auth config! will fallback to memory cache only', {cause: e}));
this.cacheScrobble = await this.initCacheable('Scrobble', {provider: false});
this.cacheAuth = await this.initCacheable('Auth', {provider: false});
}
this.cacheAuth = await this.initCacheable('Auth', {provider: 'memory', ttl: '3m'}, authConfig);
+1 -1
View File
@@ -1,4 +1,4 @@
import { Logger } from "@foxxmd/logging";
import type { Logger } from "@foxxmd/logging";
export class MaybeLogger {
+11 -13
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;
@@ -34,7 +32,7 @@ export const backupDb = async (db: sqlite.DatabaseSync, dbPath: string, opts: {
const {
logger: parentLogger = loggerNoop,
suffix,
suffix = dayjs().unix().toString(),
} = opts;
const logger = childLogger(parentLogger, 'Migrations');
@@ -0,0 +1,66 @@
import type { SqliteDatabase, Migration } from 'sqlite-up';
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 type {PlayLifecycle, PlayObject} from '../../../../core/Atomic.ts';
export const up: Migration<MigrateBaseContext>['up'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
ctx.logger.info('Migrating Play lifecycle data to top-level location. This may take some time...');
let more = true;
let offset = 0,
processed = 0,
updated = 0;
while (more) {
const playRows = await ctx.db.select().from(drizzlePlays).limit(100).offset(offset);
for (const row of playRows) {
const play = row.play as PlayObject<{lifecycle?: PlayLifecycle}>;
try {
const {
meta: {
lifecycle,
lifecycle: {
steps = [],
scrobble
} = {}
} = {}
} = play;
if (lifecycle === undefined) {
// already migrated or unneeded
processed++;
continue;
}
if (steps.length > 0) {
row.play.lifecycle = clone(steps);
}
if (scrobble !== undefined) {
row.play.scrobble = clone(scrobble);
}
// @ts-expect-error
delete row.play.meta.lifecycle;
await ctx.db.update(drizzlePlays).set({ play: row.play }).where(eq(drizzlePlays.id, row.id));
updated++;
processed++;
} catch (e) {
ctx.logger.warn(new Error(`Failed to migrate Play ${row.id} (${row.uid})`, { cause: e }));
}
}
offset += 100;
ctx.logger.verbose(`Migration Progress: Processed ${processed} | Updated ${updated}`);
if(playRows.length < 100) {
more = false;
}
}
// Migration code here
// context is passed as ctx
};
export const down: Migration<MigrateBaseContext>['down'] = async (db: SqliteDatabase, ctx: MigrateBaseContext): Promise<void> => {
// Rollback code here
// context is passed as ctx
};
+10 -8
View File
@@ -1,16 +1,18 @@
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
logger: Logger
}
const migrationsTable = '__app_migrations';
export const getAppMigrationStatus = async (db: DbConcrete, opts: {logger?: Logger, migrationsAppFolder?: string} = {}): Promise<MigrationStatus> => {
const {
logger: parentLogger = loggerNoop,
@@ -21,7 +23,7 @@ export const getAppMigrationStatus = async (db: DbConcrete, opts: {logger?: Logg
const migrator = new Migrator({
db: db.$client,
migrationsDir: migrationsAppFolder,
migrationsTable: '__app_migrations'
migrationsTable: migrationsTable
});
const status = await migrator.status();
@@ -40,12 +42,12 @@ export const migrateApp = async (db: DbConcrete, opts: {logger?: Logger, migrati
logger: parentLogger = loggerNoop,
migrationsAppFolder = path.resolve(projectDir, 'src/backend/common/database/appMigrations')
} = opts;
const logger = childLogger(parentLogger, 'App');
const logger = childLogger(parentLogger, ['App', 'Migrations']);
const migrator = new Migrator<MigrateBaseContext>({
db: db.$client,
migrationsDir: migrationsAppFolder,
migrationsTable: 'appMigrations'
migrationsTable: migrationsTable
});
migrator.on('migration:applied', function (name: string, batch: number): void {
@@ -1,11 +1,11 @@
import { DBQueryConfig, DBQueryConfigWith, ExtractTablesFromSchema, KnownKeysOnly, RelationFieldsFilterInternals, Many, InferSelectModel, ExtractTablesWithRelations, type BuildQueryResult, RelationsFilter } from "drizzle-orm";
import { components, componentMigrations, playInputs, plays, queueStates, relations } 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;
export type ComponentSelect = GenericRelationResult<'components', 'migrations'>;
export type ComponentMinimalSelect = typeof components.$inferSelect;
export type ComponentMigrationNew = typeof componentMigrations.$inferInsert;
export type ComponentMigrationSelect = typeof componentMigrations.$inferSelect;
@@ -21,6 +21,8 @@ export type PlaySelectWithQueueStates = GenericRelationResult<'plays', 'queueSta
export type PlayWith<K extends keyof TSchema['plays']["relations"]> = GenericRelationResult<'plays', K>;
export type PlayNew = typeof plays.$inferInsert;
export type PlayHistoricalSelect = typeof playsHistorical.$inferSelect;
export type PlayHistoricalNew = typeof playsHistorical.$inferInsert;
// useful references for building types
// https://github.com/drizzle-team/drizzle-orm/discussions/2596
@@ -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 { 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 => {
@@ -20,6 +19,7 @@ export const generateComponentEntity = (data: MarkOptional<ComponentNew, 'uid'>)
}
export type PlayEntityOpts = Partial<Pick<PlayNew, 'seenAt' | 'playedAt' | 'uid' | 'state' | 'parentId' | 'componentId'>> & { error?: ErrorLike };
export type PlayHistoricalEntityOpts = Partial<Pick<PlayHistoricalNew, 'seenAt' | 'playedAt' | 'uid' | 'componentId'>>;
export const generatePlayEntity = (play: PlayObject, opts: PlayEntityOpts = {}): PlayNew => {
const {
@@ -51,12 +51,12 @@ export const generatePlayEntity = (play: PlayObject, opts: PlayEntityOpts = {}):
export type PlayHydateOptions = 'asPlay' | 'id' | 'uid';
export const hydratePlaySelect = (select: PlaySelect, opts: PlayHydateOptions[] = ['id','uid']): PlayObject => {
export const hydratePlaySelect = <T extends PlaySelect | PlayHistoricalSelect>(select: T, opts: PlayHydateOptions[] = ['id','uid']): PlayObject => {
if(opts.length === 0) {
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,36 +1,22 @@
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 type { Cacheable } from "cacheable";
import type { DateLike } from "../../../../../core/Atomic.ts";
import type { CompareDateBetween, CompareDateSingle } from "../../../../../core/Api.ts";
export interface DrizzleRepositoryOpts {
logger?: Logger
cache?: Cacheable
componentId?: number
}
export type CompareDateOp = {
type: CompareOpKey<Dayjs>
date: Dayjs
} | {
type: 'between',
range: [Dayjs, Dayjs],
inclusive?: boolean
}
export interface PaginatedQueryResponse {
limit: number,
offset: number
}
export interface PaginatedResponse<T> {
data: T[]
meta: PaginatedQueryResponse
}
export type CompareDateOp<D extends DateLike = Dayjs> = CompareDateSingle<D> | CompareDateBetween<D>;
export interface ComponentConstrainedRepoOpts {
componentId?: number
}
@@ -43,6 +29,7 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
table: ReturnType<typeof getConfigByTableName<T>>
db: DbConcrete;
componentId?: number
cache?: Cacheable
constructor(db: DbConcrete, tableName: TableName, displayName: string, opts: DrizzleRepositoryOpts = {}) {
this.db = db;
@@ -51,6 +38,7 @@ export abstract class DrizzleBaseRepository<T extends TableName> {
this.table = getConfigByTableName(this.tableName);
this.logger = childLogger(opts.logger ?? loggerNoop, ['Database', capitalize(displayName)]);
this.componentId = opts.componentId;
this.cache = opts.cache;
}
async deleteByIds(ids: number[]): Promise<void> {
@@ -58,6 +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, `${id === null ? 'null' : 'undefined'} given for entity id`);
await this.db.update(this.table).set(data).where(eq(this.table.id, id));
}
@@ -90,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]
@@ -107,5 +95,4 @@ export const buildDateCompare = (data: CompareDateOp): RelationsFieldFilter<Dayj
]
}
}
return q;
}
@@ -1,9 +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 { 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'> {
@@ -11,7 +11,7 @@ export class DrizzleComponentRepository extends DrizzleBaseRepository<'component
super(db, 'components', 'Component', opts);
}
findOrInsert = async (data: { mode: 'source' | 'client', type: string, uid?: string, name?: string }): Promise<ComponentSelect> => {
findOrInsert = async (data: { mode: ComponentType, type: string, uid?: string, name?: string }): Promise<ComponentSelect> => {
const where: FindWhere<'components'> = {
mode: data.mode,
type: data.type,
@@ -0,0 +1,318 @@
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?
export interface PlayWhereOpts {
componentId?: number
seenAt?: CompareDateOp
playedAt?: CompareDateOp
uid?: string[]
}
export interface QueryPlaysOpts extends PlayWhereOpts {
sort?: 'seenAt' | 'playedAt'
order?: 'asc' | 'desc'
limit?: number
offset?: number
}
export interface HydrateOpts {
hydrate?: PlayHydateOptions[]
}
export type RepositoryCreatePlayHistoricalOpts = PlayHistoricalEntityOpts
& Pick<PlayHistoricalNew, 'play' | 'componentId'>;
type PlayIdentifierPrimitiveMap = {
uid: string;
id: number;
};
const identifierExtractor: { [K in keyof PlayIdentifierPrimitiveMap]: (play: {id: number, uid: string}) => PlayIdentifierPrimitiveMap[K] } = {
id: (play) => play.id,
uid: (play) => play.uid,
};
export class DrizzlePlayHistoricalRepository extends DrizzleBaseRepository<'playsHistorical'> {
constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
super(db, 'playsHistorical', 'Plays Historical', opts);
}
findByUid = async (uid: string, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<PlayHistoricalSelect | undefined> => {
const res = await this.db.query.playsHistorical.findFirst({
where: {
uid,
componentId: opts.componentId ?? this.componentId
}
});
res.play = hydratePlaySelect(res, opts.hydrate);
return res;
}
hasByUid = async (uid: string, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<boolean> => {
const res = await this.db.query.playsHistorical.findFirst({
columns: {id: true},
where: {
uid,
componentId: opts.componentId ?? this.componentId
}
});
return res !== undefined;
}
createPlays = async (entitiesOpts: RepositoryCreatePlayHistoricalOpts[], opts: HydrateOpts = {}) => {
const {
hydrate
} = opts;
let playRows: PlayHistoricalSelect[];
await runTransaction(this.db, async () => {
const entitiesData = entitiesOpts.map((data) => {
const {
play,
...rest
} = data;
return generatePlayEntity(play, { componentId: this.componentId, ...rest });
});
playRows = await this.db.insert(playsHistorical).values(entitiesData).returning();
});
return playRows.map(x => ({...x, play: hydratePlaySelect(x, hydrate)}));
}
findPlays = async (args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<PlayHistoricalSelect[]> => {
const {
hydrate,
componentId = this.componentId
} = opts;
// this does not work as type for query variable
// it erases the result type for some reason
//
// Parameters<typeof this.db.query.plays.findMany>[0]
// this does work but it is also integrated into FindWith
//let withQuery: Parameters<typeof this.db.query.plays.findMany>[0]['with'] = undefined;
let query: FindMany<'playsHistorical'> = {
limit: args.limit,
offset: args.offset
};
query.where = buildPlayHistoricalWhere({componentId: componentId, ...args});
if (args.sort !== undefined) {
query.orderBy = {
[args.sort]: args.order ?? 'desc'
}
} else {
query.orderBy = {
id: 'asc'
}
}
query = removeUndefinedKeys(query);
const results = await this.db.query.playsHistorical.findMany(query);
return results.map((x) => ({...x, play: hydratePlaySelect(x, hydrate)}));
}
findPlayIds = async (args: QueryPlaysOpts, opts: ComponentConstrainedRepoOpts = {}): Promise<number[]> => {
const {
componentId = this.componentId
} = opts;
let query: FindMany<'playsHistorical'> = {
limit: args.limit,
offset: args.offset,
};
query.where = buildPlayHistoricalWhere({componentId: componentId, ...args});
if (args.sort !== undefined) {
query.orderBy = {
[args.sort]: args.order ?? 'desc'
}
} else {
query.orderBy = {
id: 'asc'
}
}
query = removeUndefinedKeys(query);
const results = await this.db.query.playsHistorical.findMany({
...query,
limit: args.limit,
offset: args.offset,
columns: {id: true},
orderBy: args.sort !== undefined ? {[args.sort]: args.order ?? 'desc'} : {id: 'asc'},
});
return results.map((x) => x.id);
}
findPlayIdentifiers = async <T extends keyof PlayIdentifierPrimitiveMap>(args: QueryPlaysOpts, identifier: T, opts: ComponentConstrainedRepoOpts = {}): Promise<PlayIdentifierPrimitiveMap[T][]> => {
const {
componentId = this.componentId,
} = opts;
const results = await this.db.query.playsHistorical.findMany({
limit: args.limit,
offset: args.offset,
columns: {id: true, uid: true},
orderBy: args.sort !== undefined ? {[args.sort]: args.order ?? 'desc'} : {id: 'asc'},
where: buildPlayHistoricalWhere({componentId: componentId, ...args})
});
// we getting fancy now
return results.map(identifierExtractor[identifier]);
}
findPlaysPaginated = async (args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<PaginatedResponse<PlayHistoricalSelect>> => {
const {
limit = 100,
offset = 0,
...rest
} = args;
const clampedLimit = Math.min(limit, 100);
const res = await this.findPlays({limit: clampedLimit, offset, ...rest}, opts);
return {data: res, meta: {limit: clampedLimit, offset}};
}
// async updateById(id: number, data: Partial<PlayNew>): Promise<void> {
// if(data.play !== undefined) {
// data.play = withoutDbAwareness(data.play);
// }
// super.updateById(id, data);
// }
deletePlays = async (playsData: (Pick<PlayHistoricalSelect, 'id'> | number)[]) => {
const ids = playsData.map(x => typeof x === 'number' ? x : x.id);
await this.db.delete(playsHistorical).where(inArray(plays.id, ids));
}
public checkExisting = async (play: PlayObject, opts: {taAccuracy?: TemporalAccuracy[]} & ComponentConstrainedRepoOpts = {}): Promise<PlayHistoricalSelect | undefined> => {
const {
componentId = this.componentId,
taAccuracy = TA_DEFAULT_ACCURACY,
} = opts;
const hash = hashObject(playContentBasicInvariantTransform(play).data);
// we get all plays with a play date between playdate - (source accuracy) AND (playDateCompleted or playDate) + (source accuracy)
// which we can then use with temporal comparison to make sure we are comparing the correct dates
//
// this isn't as fast as just comparing playDate directly but its still much faster/cheaper than paginating plays and doing everything in-memory
const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
let endRange: Dayjs;
if(play.data.playDateCompleted !== undefined) {
// this will be present if source reports it
// or we tracked it live with MemorySource
endRange = play.data.playDateCompleted.add(dateGranularity, 's');
} else {
endRange = play.data.playDate.add(dateGranularity, 's');
}
const where: FindWhere<'playsHistorical'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
};
const mbidId = playMbidIdentifier(play);
if(mbidId !== undefined) {
where.AND = [
{
OR: [
{
playHash: hash
},
{
mbidIdentifier: mbidId
}
]
}
]
} else {
where.playHash = hash;
}
const res = await this.db.query.playsHistorical.findMany({
where
});
if(res.length === 0) {
return undefined;
}
return res.map(x => ({...x, play: hydratePlaySelect(x)})).find(x => {
const temporalComparison = comparePlayTemporally(x.play, play);
return hasAcceptableTemporalAccuracy(temporalComparison.match, taAccuracy)
})
}
public getTemporallyClosePlays = async (play: PlayObject, opts: {bufferTime?: number} & ComponentConstrainedRepoOpts = {}): Promise<PlayHistoricalSelect[]> => {
const {
componentId = this.componentId,
bufferTime
} = opts;
const query: FindMany<'playsHistorical'> = {};
const where: FindWhere<'playsHistorical'> = {
componentId,
playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {bufferTime})),
};
query.where = where;
return ((await this.db.query.playsHistorical.findMany({
where,
})) as PlayHistoricalSelect[]).map(x => ({...x, play: hydratePlaySelect(x)}));
}
public getPlayCountByComponent = async () => {
const res = await this.db.all(sql`select componentId, count(*) from plays_historical p
group by componentId;`);
return res;
}
}
export const buildPlayHistoricalWhere = (args: PlayWhereOpts): WhereClause<'playsHistorical'> => {
// old way
// let where: Parameters<(ReturnType<typeof getDb>)['query']['plays']['findMany']>[0]['where'] = {
// };
const where: FindWhere<'playsHistorical'> = {
componentId: args.componentId
};
if (args.seenAt !== undefined) {
where.seenAt = buildDateCompare(args.seenAt);
}
if (args.playedAt !== undefined) {
where.playedAt = buildDateCompare(args.playedAt);
}
if(args.uid !== undefined) {
where.uid = {
in: args.uid
}
}
return where;
}
export const playToRepositoryCreatePlayHistoricalOpts = (data: MarkOptional<RepositoryCreatePlayHistoricalOpts, 'componentId'>): RepositoryCreatePlayHistoricalOpts => {
return {
play: data.play,
uid: data.play.meta?.playId,
...data
}
}
@@ -1,43 +1,45 @@
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 } from "../entityUtils.js";
import { playInputs, plays, queueStates, relations } 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 } 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 { 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?
export interface QueueCriteria {
queueName: string
queueName: QueueName
queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
}
export interface PlayWhereOpts {
export interface PlayWhereOpts<D extends DateLike = Dayjs> {
state?: PlaySelect['state'][]
stateNot?: PlaySelect['state'][]
componentId?: number
seenAt?: CompareDateOp
playedAt?: CompareDateOp
seenAt?: CompareDateOp<D>
playedAt?: CompareDateOp<D>
queues?: QueueCriteria[]
uid?: string[]
text?: string[]
}
export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues';
export interface QueryPlaysOpts extends PlayWhereOpts {
export interface QueryPlaysOpts<D extends DateLike = Dayjs> extends PlayWhereOpts<D> {
sort?: 'seenAt' | 'playedAt'
order?: 'asc' | 'desc'
with?: WithPlayRelation[]
@@ -45,6 +47,8 @@ export interface QueryPlaysOpts extends PlayWhereOpts {
offset?: number
}
export type QueryPlaysOptsJson = DeepReplaceValue<QueryPlaysOpts<Dayjs>, [[Dayjs, string]]>;
export interface HydrateOpts {
hydrate?: PlayHydateOptions[]
}
@@ -73,26 +77,27 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
super(db, 'plays', 'Plays', opts);
}
findByUid = async (uid: string, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
findByUid = async (uid: string, opts: HydrateOpts & Pick<QueryPlaysOpts, 'with'> & ComponentConstrainedRepoOpts = {}): Promise<PlaySelectWithQueueStates | undefined> => {
const res = await this.db.query.plays.findFirst({
where: {
uid,
componentId: opts.componentId ?? this.componentId
},
with: {
queueStates: true
}
with: buildPlayWith([...(opts.with ?? []), 'queues'])
});
if(res === undefined) {
return undefined;
}
res.play = hydratePlaySelect(res, opts.hydrate);
return res;
return res as PlaySelectWithQueueStates;
}
createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}) => {
createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}): Promise<PlayWith<'input'>[]> => {
const {
hydrate
} = opts;
let playRows: PlaySelect[];
let playRows: PlayWith<'input'>[];
await runTransaction(this.db, async () => {
@@ -105,9 +110,9 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
return generatePlayEntity(play, { componentId: this.componentId, ...rest });
});
playRows = await this.db.insert(plays).values(entitiesData).returning();
const nakedPlays = await this.db.insert(plays).values(entitiesData).returning();
const inputDatas = playRows.map((x, index) => {
const inputDatas = nakedPlays.map((x, index) => {
const {
play,
input,
@@ -121,10 +126,10 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
});
const inputRow = await this.db.insert(playInputs).values(inputDatas);
playRows = nakedPlays.map((x, index) => ({...x, play: hydratePlaySelect(x, hydrate), input: inputRow[index]}));
});
return playRows.map(x => ({...x, play: hydratePlaySelect(x, hydrate)}));
return playRows;
}
findPlays = async (args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise<PlaySelect[]> => {
@@ -211,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},
@@ -244,7 +250,20 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
} = args;
const clampedLimit = Math.min(limit, 100);
const res = await this.findPlays({limit: clampedLimit, offset, ...rest}, opts) as T[];
return {data: res, meta: {limit: clampedLimit, offset}};
const where = buildPlayWhere({componentId: args.componentId ?? this.componentId, ...rest});
// https://github.com/drizzle-team/drizzle-orm/issues/5218#issuecomment-3854900241
const filter = relationsFilterToSQL(plays,
// @ts-expect-error don't have a good way to type this yet
where,
relations.plays.relations,
relations);
// https://github.com/drizzle-team/drizzle-orm/discussions/3119#discussioncomment-16379557
const count = await this.db.$count(plays, filter);
return {data: res, meta: {limit: clampedLimit, offset, total: count}};
}
// async updateById(id: number, data: Partial<PlayNew>): Promise<void> {
@@ -274,7 +293,7 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
dateComparer = 'updatedAt'
} = opts;
let where: FindWhere<'plays'> = {
const where: FindWhere<'plays'> = {
component: {
id: componentId
},
@@ -324,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) {
@@ -360,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');
}
@@ -404,12 +423,14 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
}
compactedPlay = playRow.play;
compactedPlay.meta.lifecycle.steps = compactedPlay.meta.lifecycle.steps.map(x => {
if(x.inputs == undefined) {
return x;
}
return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
});
if(compactedPlay.lifecycle !== undefined) {
compactedPlay.lifecycle = compactedPlay.lifecycle.map(x => {
if(x.inputs == undefined) {
return x;
}
return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
});
}
}
const updater = this.db.update(plays);
@@ -564,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) {
@@ -614,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)),
};
@@ -672,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})),
};
@@ -690,6 +711,29 @@ export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
with: buildPlayWith(qWith)
})) as PlayWith<'queueStates'>[]).map(x => ({...x, play: hydratePlaySelect(x)}));
}
public getComponentPlayCountByState = async (componentId?: number): Promise<{state: PlayState, 'count(*)': number}[]> => {
const res = await this.db.all(sql`select state, count(*) from plays p
where componentId = ${componentId ?? this.componentId}
group by state;`);
return res as {state: PlayState, 'count(*)': number}[];
}
public getPlayCountByState = async () => {
const res = await this.db.all(sql`select state,componentId, count(*) from plays p
group by state,componentId;`);
return res;
}
public getCompactedPlayCountByComponent = async () => {
const res = await this.db.all(sql`select componentId,compacted,count(*) from plays p
where compacted IS NOT NULL
group by componentId,compacted;`);
return res;
}
}
export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
@@ -747,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) {
@@ -783,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(
{
@@ -802,20 +846,37 @@ export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
}
}
}
if(args.text !== undefined && args.text.length > 0) {
where.AND = [
{
OR: []
}
];
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()} || '%'`)
textWhere.push((p) => sql`lower(json_extract(${p.play}, '$.data.album')) LIKE '%' || ${t.toLocaleLowerCase()} || '%'`)
}
where.AND = [
{
OR: textWhere.map(x => ({RAW: x}))
}
]
}
return where;
}
export const playToRepositoryCreatePlayOpts = (data: MarkOptional<RepositoryCreatePlayOpts, 'input' | 'componentId'>): RepositoryCreatePlayOpts => {
const {
play: {
meta: {
lifecycle: {
input,
original,
...lifecycleRest
} = {},
...metaRest
},
original: {
play: playOriginal,
data: playData
} = {},
...playRest
},
...rest
@@ -824,58 +885,13 @@ export const playToRepositoryCreatePlayOpts = (data: MarkOptional<RepositoryCrea
return {
play: {
...playRest,
meta: {
...metaRest,
// @ts-expect-error
lifecycle: {
...lifecycleRest
}
}
},
...rest,
input: {
play: original,
data: input
play: playOriginal,
data: playData
}
}
}
export type RequestPlayQuery = Partial< Record<keyof Exclude<QueryPlaysOpts, 'componentId'>, string>>;
export const queryArgsFromRequest = (rec: RequestPlayQuery): QueryPlaysOpts => {
const {
state,
stateNot,
uid,
with: withQuery,
seenAt,
playedAt,
limit,
sort,
order,
offset,
componentId,
queues,
...rest
} = rec;
let queryArgs: QueryPlaysOpts = removeEmptyArrays<QueryPlaysOpts>({
state: parseArrayFromMaybeString(state) as PlaySelect['state'][],
stateNot: parseArrayFromMaybeString(stateNot) as PlaySelect['state'][],
uid: parseArrayFromMaybeString(uid),
with: parseArrayFromMaybeString(withQuery) as WithPlayRelation[],
sort: sort as 'playedAt' | 'seenAt',
order: order as 'asc' | 'desc',
...rest
});
if(limit !== undefined) {
queryArgs.limit = Number.parseInt(limit);
}
if(offset !== undefined) {
queryArgs.offset = Number.parseInt(offset);
}
return queryArgs;
}
export type RequestPlayQuery = Partial< Record<keyof Exclude<QueryPlaysOpts, 'componentId'>, string>>;
@@ -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,12 +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.ts";
const DayjsTimestamp = customType<
{
@@ -67,12 +67,12 @@ const ErrorLikeJson = customType<
export const plays = sqliteTable("plays", {
id: integer().primaryKey(),
uid: text({ length: 30 }).notNull().unique().$defaultFn(() => nanoid(20)),
uid: text({ length: 30 }).notNull().unique().$defaultFn(() => generatePlayUid()),
componentId: integer().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
error: ErrorLikeJson('error'),
playedAt: DayjsTimestamp('playedAt'),
seenAt: DayjsTimestamp('seenAt'),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs()),
play: PlayJson('play').notNull(), // text({ mode: 'json' }).notNull().$type<PlayObject>(),
state: text({enum: ['queued','discovered','discarded','scrobbled','failed','duped']}).notNull(),
// https://orm.drizzle.team/docs/indexes-constraints#foreign-key
@@ -144,7 +144,7 @@ export const queueStates = sqliteTable("play_queue_states", {
retries: integer().notNull().default(0),
error: ErrorLikeJson('error'),
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs())
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs())
}, (table) => [
index('play_queue_state_id_idx').on(table.playId)
]);
@@ -165,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
@@ -207,7 +207,7 @@ export const jobs = sqliteTable("jobs", {
imported: integer().notNull().default(0),
scrobbled: integer().notNull().default(0),
createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()),
updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()).$onUpdate(() => dayjs()),
completedAt: DayjsTimestamp('completedAt')
});
@@ -289,7 +289,7 @@ export const getConfigByTableName = <T extends TableName>(name: T) => {
}
}
const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
export const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs};
export type TSchema = typeof relations;
export type Schema = typeof schema;
+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'
+48 -13
View File
@@ -1,12 +1,15 @@
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 { isAbortError } from "abort-controller-x";
import {addKnownErrorConstructor} from 'serialize-error';
import { findCauseByFunc, isAbortReasonErrorLike } from "../../utils/ErrorUtils.ts";
import { UpstreamError, type UpstreamErrorOptions } from "./UpstreamError.ts";
import {addKnownErrorConstructor, serializeError} from 'serialize-error';
import type {LifecycleInput} from "../../../core/Atomic.ts";
export abstract class NamedError extends Error {
public abstract name: string;
public toJSON() {
return serializeError(this);
}
}
export abstract class StageError extends NamedError {}
@@ -44,7 +47,10 @@ const STACK_AT_REGEX = new RegExp(/[\n\r]\s*at/);
export class SimpleError extends Error implements HasSimpleError {
simple: boolean;
name = 'SimpleError';
override name = 'SimpleError';
public toJSON() {
return serializeError(this);
}
stackShortened: boolean = false;
@@ -77,22 +83,47 @@ export class SimpleError extends Error implements HasSimpleError {
addKnownErrorConstructor(SimpleError, () => new SimpleError(''))
export class StageTransformError extends NamedError {
name = 'Stage Transform';
override name = 'Stage Transform';
lifecycleInputs?: LifecycleInput[]
stageName: string;
constructor(name: string, message: string, options?: ErrorOptions) {
super(message, options);
constructor(name: string, message: string, options: ErrorOptions & {inputs?: LifecycleInput[]} = {}) {
const {
inputs,
...restOpts
} = options;
super(message, restOpts);
this.lifecycleInputs = inputs;
this.stageName = name;
}
}
addKnownErrorConstructor(StageTransformError, () => new StageTransformError('',''))
export class SkipTransformStageError extends SimpleError {
name = 'Skip Transform Stage';
lifecycleInputs?: LifecycleInput[]
override name = 'Skip Transform Stage';
public constructor(msg: string, options: ErrorOptions & { simple?: boolean, shortStack?: boolean, inputs?: LifecycleInput[]} = {}) {
const {
inputs,
...restOpts
} = options;
super(msg, restOpts);
this.lifecycleInputs = inputs;
}
}
addKnownErrorConstructor(SkipTransformStageError, () => new SkipTransformStageError(''))
export class StagePrerequisiteError extends SimpleError {
name = 'Stage Prerequisite';
lifecycleInputs?: LifecycleInput[]
override name = 'Stage Prerequisite';
public constructor(msg: string, options: ErrorOptions & { simple?: boolean, shortStack?: boolean, inputs?: LifecycleInput[]} = {}) {
const {
inputs,
...restOpts
} = options;
super(msg, restOpts);
this.lifecycleInputs = inputs;
}
}
addKnownErrorConstructor(StagePrerequisiteError, () => new StagePrerequisiteError(''))
@@ -113,13 +144,17 @@ export const mergeSimpleError = (err: Error): Error => {
// mergeErrorCause mutates the argument
// and we want to be able to do more cause/error parsing after merging for logging
// so give it a copy instead of the original
return mergeErrorCause(structuredClone(err));
const mergedErr = mergeErrorCause(structuredClone(err));
if(!('toJson' in mergedErr)) {
// @ts-expect-error this is used by serialize error with out custom errors
mergedErr.toJSON = () => serializeError(mergedErr);
}
}
return err;
}
export class ScrobbleSubmitError<T extends (object | string) = object> extends UpstreamError {
name = 'Scrobble Submit Error';
override name = 'Scrobble Submit Error';
payload?: T;
constructor(message: string, options?: UpstreamErrorOptions & {payload?: T}) {
super(message, options);
@@ -151,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
}
+3 -3
View File
@@ -1,13 +1,13 @@
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 };
export class UpstreamError extends Error {
name = 'UpstreamError';
override name = 'UpstreamError';
showStopper: boolean = false;
response?: Response
responseBody?: object | string
+21 -45
View File
@@ -1,21 +1,21 @@
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, PlayMetaLifecycleless, PlayObject, PlayObjectLifecycleless, 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 { type DeviceId, type ErrorLike, isPlayObject, type PlayMeta, type PlayObject, type PlayObjectMinimal, type PlayPlatformId, type PlayUserId, type 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 interface LeveledLogData extends LogDataPretty {
levelLabel: string
}
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
export const isClientType = (data: string): data is ClientType => {
return clientTypes.includes(data as ClientType);
}
export interface ComponentIdentifier {
type: SourceType | ClientType
name: string
@@ -51,21 +51,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;
@@ -109,16 +94,11 @@ export interface FormatPlayObjectOptions {
[key: string]: any
}
export interface ProgressAwarePlayObject extends PlayObjectLifecycleless {
meta: PlayMetaLifecycleless & {
export interface ProgressAwarePlayObject extends PlayObjectMinimal {
meta: PlayMeta & {
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>>;
@@ -131,7 +111,7 @@ export const SINGLE_USER_PLATFORM_ID_STR = `${NO_DEVICE}-${NO_USER}`;
export interface ScrobbledPlayObject {
play: PlayObject
scrobble: PlayObjectLifecycleless
scrobble: PlayObjectMinimal
}
@@ -191,10 +171,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 +362,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 +375,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 +388,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 {
@@ -0,0 +1,29 @@
import type {AtprotoDid} from "@atcute/lexicons/syntax";
export interface ATProtoUserIdentifierData {
/**
* Identify the account to login as
*
* * For **App Password** Auth - your email
* * For **Oauth** - your handle minus the @
*/
identifier: string
did?: string
}
export interface ATProtoAppData {
/**
* The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account
*
* This is created under https://bsky.app/settings/app-passwords
*
* **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT**
*/
appPassword: string
}
export interface HandleData {
did: AtprotoDid
pds: string
handle: string
}
@@ -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,5 +1,5 @@
import { ActivityType, StatusDisplayType } from "discord.js"
import { CommonClientConfig, CommonClientData } from "./index.js"
import type {CommonClientConfig, CommonClientData} from "./index.ts";
import type {ComponentType} from "../../../../../core/Atomic.ts";
export interface DiscordData {
token?: string
@@ -20,7 +20,7 @@ export interface DiscordClientConfig extends CommonClientConfig {
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
configureAs?: ComponentType
data: DiscordClientData
}
@@ -62,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
@@ -119,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 { 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[]
@@ -74,7 +74,7 @@ export interface KoitoClientConfig extends CommonClientConfig {
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
configureAs?: ComponentType
data: KoitoClientData
}
@@ -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,5 +1,6 @@
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{
/**
@@ -31,7 +32,7 @@ export interface ListenBrainzClientConfig extends CommonClientConfig {
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
configureAs?: ComponentType
data: ListenBrainzClientData
}
@@ -1,5 +1,6 @@
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 {
/**
@@ -27,7 +28,7 @@ export interface MalojaClientConfig extends CommonClientConfig {
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
configureAs?: ComponentType
data: MalojaClientData
}
@@ -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{
@@ -8,7 +8,14 @@ export interface RockSkyData extends RequestRetryOptions{
*
* @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"]
* */
key: string
key?: string
/**
* Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account
*
* @examples ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ....."]
* */
token?: string
/**
* The **fully-qualified** handle for your ATPRoto/Bluesky account, like:
@@ -1,28 +1,15 @@
import { RequestRetryOptions } from "../common.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 interface TealData extends RequestRetryOptions {
export type TealData = RequestRetryOptions & ATProtoUserIdentifierData & Partial<ATProtoAppData> & {
/**
* The base URI of the Multi-Scrobbler to use for ATProto OAuth
*
* Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol.
*/
baseUri?: string
/**
* Identify the account to login as
*
* * For **App Password** Auth - your email
* * For **Oauth** - your handle minus the @
*/
identifier: string
/**
* The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account
*
* This is created under https://bsky.app/settings/app-passwords
*
* **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT**
*/
appPassword?: string
}
export interface TealClientData extends TealData, CommonClientData {
@@ -35,7 +22,7 @@ export interface TealClientConfig extends CommonClientConfig {
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
configureAs?: ComponentType
data: TealClientData
options?: TealClientOptions
}
@@ -51,42 +38,6 @@ export interface TealClientOptions extends TealOptions,CommonClientOptions {
export interface TealClientAIOConfig extends TealClientConfig {
type: 'tealfm'
}
export interface TealArtistCredit {
artistName?: string,
artistMbId?: string
}
/**
* https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/play.json
* https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/defs.json
* */
export interface ScrobbleRecord {
$type: "fm.teal.alpha.feed.play",
trackName: string,
playedTime: string,
duration?: number
artists?: TealArtistCredit[]
/** Album name */
releaseName?: string
/** A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)` */
submissionClientAgent: string,
musicServiceBaseDomain?: string
// musicbrainz
recordingMbId?: string
releaseMbId?: string
isrc?: string,
/** The URL associated with this track */
originUrl?: string
[x: string]: unknown
}
export interface StatusRecord {
$type: "fm.teal.alpha.actor.status",
/** item is just ScrobbleRecord, but without $type */
item: Omit<ScrobbleRecord, '$type'>,
time: string,
expiry: string,
[x: string]: unknown
}
export interface ListRecord<T> {
uri: string;
@@ -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'];
@@ -1,5 +1,6 @@
export interface WebhookPayload {
title?: string
identifier: string
message: string
priority: Priority
}
@@ -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,32 @@
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";
export type SourceConfig =
@@ -130,69 +130,6 @@ export type RockskySourceConfigs = RockskySourceConfig[];
export type SonosSourceConfigs = SonosSourceConfig[];
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',
@@ -230,7 +167,4 @@ export const sourceInterfaces = [
...atomicSourceInterfaces
];
export const isSourceType = (data: string): data is SourceType => {
return sourceTypes.includes(data as SourceType);
};

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