Compare commits

...

433 Commits

Author SHA1 Message Date
Kendall Garner 9e3e038d42 [Remote] Actually fix auth (#260)
* fix favicon, basic auth

* actual fix......
2023-09-24 17:31:33 -07:00
jeffvli b375238baf Bump to v0.4.0 2023-09-24 17:23:39 -07:00
Kendall Garner 02b06a07be fix favicon, basic auth (#259) 2023-09-24 17:02:25 -07:00
Kendall Garner d7f21b3c6b special socket for dev; defer to default otherwise (#258)
* special socket for dev; defer to default otherwise

* Add write-all permissions to docker push

* special socket for dev; defer to default otherwise

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-24 16:56:45 -07:00
jeffvli 1113ef972f Remove docker push on pr 2023-09-24 16:54:43 -07:00
jeffvli 46a2c29b22 Add write-all permissions to docker push 2023-09-23 22:36:56 -07:00
jeffvli ebcb7bc4d1 Add path param for ND song list api 2023-09-23 15:37:16 -07:00
jeffvli 0b62bee3a6 Add grid view for tracks (#128) 2023-09-23 15:36:57 -07:00
jeffvli d3503af12c Add song count column to albums list 2023-09-23 04:05:15 -07:00
jeffvli 571ea3c653 Add rating hotkeys (#208) 2023-09-23 03:20:04 -07:00
jeffvli f0e518d3c8 Add quit button to menu (#184) 2023-09-22 18:04:15 -07:00
jeffvli 47dc83f360 Make collapsed sidebar navigation configurable 2023-09-22 17:55:03 -07:00
jeffvli 8cbc25a932 Add browser forward/back hotkeys (#155) 2023-09-22 17:52:00 -07:00
jeffvli 0cba405b45 Add navigation buttons to the collapsed sidebar (#203) 2023-09-22 15:33:28 -07:00
jeffvli 45b80ac395 Add discsubtitle for navidrome (#217) 2023-09-22 15:12:23 -07:00
jeffvli 8b0fe69e1c Fix alignment of button leftIcon 2023-09-22 15:11:27 -07:00
jeffvli 25e621372c Parse URLs from note field (#154) 2023-09-22 04:25:16 -07:00
jeffvli 14f4649b93 Move drag container to scrollarea component 2023-09-22 02:40:27 -07:00
jeffvli 1a87adb728 Fix transient props for styled-components v6 2023-09-22 02:34:57 -07:00
jeffvli bb9bf7ba6a Add docker run instructions 2023-09-22 00:31:56 -07:00
jeffvli fb7e7bfa3e Add latest tag 2023-09-21 21:44:03 -07:00
jeffvli a8a14a62c0 Separate auto and manual docker pushes 2023-09-21 21:13:49 -07:00
jeffvli cd836d54db Add docker publish workflow 2023-09-21 20:46:48 -07:00
jeffvli c90c43944d Fix logo path 2023-09-21 20:31:39 -07:00
jeffvli fd7468a4fe Add drag container for web library headers (#206) 2023-09-21 18:46:47 -07:00
jeffvli c4f9868a6b Revert library header line clamp to 2 lines (#215) 2023-09-21 17:52:14 -07:00
jeffvli fbb0907a70 Fix lyrics mask 2023-09-21 17:41:27 -07:00
jeffvli 201ee895f9 Allow css vendor-prefix 2023-09-21 17:41:19 -07:00
jeffvli 51be0153d3 Adjust fullscreen player styles
- Remove opacity on metadata section
- Add text shadow to metadata text
- Add padding under title
- Uppercase artists and album name
2023-09-21 17:35:22 -07:00
jeffvli 29a9a11085 Fix subsonic song duration 2023-09-21 17:35:22 -07:00
Kendall Garner 65f28bb9dc Replaygain support for Web Player (#243)
* replaygain!

* resume context

* don't fire both players

* replaygain for jellyfin

* actually remove console.log

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-21 17:06:13 -07:00
jeffvli fd264daffc Add new app icon (#232) 2023-09-21 11:24:20 -07:00
Alberto Rodríguez 18e35f2ba9 Added docker image build script (#245)
* Added docker image build script

* Changed to alpine docker and expose port 9180

* Use multi-stage build

---------

Co-authored-by: = <=>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-09-20 18:01:47 -07:00
Kendall Garner 487e9be8ec Invalidate playlist song list on update (#248) 2023-09-20 16:28:59 -07:00
Benjamin d9049ed066 Prevent MPV from loading user config/scripts (#247) 2023-09-20 16:27:36 -07:00
Kendall Garner 6e62448b88 fix other places of duration display (and other minor fixes) (#249)
* fix other places of duration display

* add back comma

* add max-width for image
2023-09-20 16:07:40 -07:00
jeffvli ec457d5125 Lint files based on updated rules 2023-09-15 20:42:38 -07:00
jeffvli d45b01625b Re-add linting for styled-components
- Update styled-components to v6
- Update stylelint to v15
- Add styled-components css plugin
2023-09-15 20:42:03 -07:00
jeffvli 2defa5cc13 Fix seek slider from duration normalizations 2023-09-15 19:31:34 -07:00
jeffvli 9cc9c3a87f Bump electron to v25.8.1 2023-09-15 16:54:17 -07:00
jeffvli 153d8ce6ce Fix nd/jf duration normalizations 2023-09-15 16:52:14 -07:00
jeffvli 5e33212112 Add dedicated refresh button to list views (#235) 2023-09-15 13:47:39 -07:00
jeffvli 7d6990eb90 Add notice regarding broken MPV version 2023-09-15 12:52:03 -07:00
jeffvli d75ea94161 Fix first launch mpv playback (#210) 2023-09-15 03:08:17 -07:00
Kendall Garner 1badecc20a always call autoNext, even if not used (#241) 2023-09-10 15:08:48 -07:00
Kendall Garner c90a56811d [bugfix]: support final lyric with no newline (#240) 2023-09-10 15:07:21 -07:00
nate contino 4e5e3bc9a1 Adjust quarantine bit warning wording to include all Macs running 12+ (#236) 2023-09-10 15:04:24 -07:00
Kendall Garner c8397bb5ef Add transparency/opacity for queue sidebar (#231)
* add opacity

* add background for song metadata

* Add padding and border radius to opacity elements

* Remove font-weight transition on active lyrics (#233)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-10 15:03:46 -07:00
Alberto Rodríguez 0ae53b023c improved client detection (#229)
Co-authored-by: = <=>
2023-09-10 13:01:32 -07:00
Kendall Garner 1acfa93f1a Improve MPV initialization and restore (#222)
- set mpv settings only after it has successfully started (at least on linux, settings were not taken)
- change timing of restore queue to behave properly
2023-08-24 18:28:50 -07:00
jeffvli b60ba27892 Allow reuathentication for jellyfin (#214) 2023-08-24 18:17:20 -07:00
jeffvli 7ddba8ede7 Fix JF song filter import (#223) 2023-08-24 18:10:58 -07:00
jeffvli a8bd53b757 Adjust jellyfin playlist fetch 2023-08-24 18:04:01 -07:00
jeffvli 877b2e9f3b Fix normalized album duration values (#205) 2023-08-11 21:08:13 -07:00
jeffvli 663893dccb Fix missing related artist images 2023-08-09 21:30:27 -07:00
jeffvli 96ace40fc3 Upgrade electron-builder to latest 2023-08-08 10:01:08 -07:00
jeffvli e9de9f5b65 Fix electron build target 2023-08-08 09:52:58 -07:00
jeffvli c92c94cf1a Bump to v0.3.0 2023-08-08 09:44:14 -07:00
jeffvli 1d664bbbd7 Home screen improvements
- Only show spinner on load rather than fetch
- Add refresh button to explore
- Adjust stale times
2023-08-08 09:26:53 -07:00
jeffvli 7c59722f0a Improve genres for jellyfin
- Support music folder
- Add images
- Fix genre filters on album/track filters
2023-08-08 09:26:53 -07:00
jeffvli 3f813b1a26 Make album detail columns customizable 2023-08-08 09:26:53 -07:00
jeffvli 13d6758500 Center playerbar time values 2023-08-08 09:26:53 -07:00
jeffvli 2b6323c396 Fix min-size header image 2023-08-08 09:26:53 -07:00
jeffvli 8338aaf18d Add related genre albums to album detail 2023-08-08 09:26:48 -07:00
jeffvli 5f5c3bbb11 Fix filter on id pages 2023-08-08 01:30:05 -07:00
jeffvli 2a9d30e43d Fix genre grid route 2023-08-08 01:29:12 -07:00
jeffvli d1e5571163 Add initial fetch for all grid views 2023-08-08 01:26:02 -07:00
jeffvli e542fcb8aa Memoize context values 2023-08-08 00:38:32 -07:00
jeffvli 1111fd00a1 Remove web build from electron build 2023-08-08 00:35:29 -07:00
jeffvli 6ae8046781 Fix search params being overriden on table view 2023-08-08 00:33:47 -07:00
jeffvli 689b40eb91 Add web prod build 2023-08-08 00:23:46 -07:00
jeffvli b3bdff446d Add vercel rewrite 2023-08-08 00:23:38 -07:00
jeffvli 8686a7c592 Various lyrics improvements
- Update wording on provider fetcher
- Invalidate query on reset instead of resetQuery
2023-08-07 21:59:55 -07:00
jeffvli 59a851f8c8 Add new version dialog 2023-08-07 21:44:39 -07:00
jeffvli fedef48411 Add dialog component 2023-08-07 21:42:52 -07:00
jeffvli e3fc99cf82 Additional adjustments on mpv play 2023-08-07 14:48:02 -07:00
jeffvli f09ad1da89 Add dynamic grid sizing 2023-08-07 14:42:47 -07:00
Gelaechter 1ab75f7187 Fix sidebar layout to show full album cover (#193) 2023-08-06 12:03:45 -07:00
jeffvli 3d1f36e85a Replace header with regular image 2023-08-06 12:02:13 -07:00
jeffvli 23e791c829 Attempt additional cleanup on mpv start 2023-08-06 11:41:40 -07:00
jeffvli aaaaee7043 Format themes 2023-08-06 10:48:51 -07:00
jeffvli fca135ce2b Add additional lyrics customizability options (#146) 2023-08-04 19:32:41 -07:00
jeffvli 72b4a60c7b Adjust folder filter on song list 2023-08-04 13:45:10 -07:00
jeffvli ff68de8c09 Fix jellyfin album song order (#192) 2023-08-04 13:26:33 -07:00
jeffvli c8663db4ba Clean up 2023-08-04 13:08:40 -07:00
jeffvli 95780f1969 Add optional chain to jf genres 2023-08-04 13:07:39 -07:00
jeffvli 1327d58b23 Fix clear, clean up 2023-08-04 12:29:55 -07:00
jeffvli f6d239d87c Improve lyrics persistence
- Search overrides existing lyrics query
- Separate reset / clear commands
2023-08-04 11:51:01 -07:00
jeffvli 80fb844d3c Adjust subsonic genre normalization 2023-08-04 10:32:35 -07:00
jeffvli b35d3c3256 Set scrollbar autohide to leave 2023-08-04 03:31:36 -07:00
jeffvli 14453a8524 Change dropdown transitions back to fade 2023-08-04 03:28:42 -07:00
jeffvli d0aba6e16e Support genres in context menu 2023-08-04 02:27:04 -07:00
jeffvli 0b207c78e7 Add genre link to table cells 2023-08-04 01:53:23 -07:00
jeffvli ee83fdba71 Persist lyrics queries in indexeddb 2023-08-04 01:41:45 -07:00
jeffvli adfa748bfb Adjust genre links on detail pages 2023-08-03 19:04:12 -07:00
jeffvli 505974320f Fix custom filter implementation 2023-08-03 19:03:23 -07:00
jeffvli 5896d886d7 Add genre list route and implementation 2023-08-03 16:58:35 -07:00
jeffvli f6d74ce9c3 Add params to genre list query key, adjust cache/staletime 2023-07-31 17:53:21 -07:00
jeffvli f443c466b0 Fix song list filter genres not loading 2023-07-31 17:40:55 -07:00
jeffvli 8029712b55 Add initial genre list support 2023-07-31 17:17:26 -07:00
jeffvli 4d5085f230 Fix linter rules 2023-07-31 17:17:25 -07:00
jeffvli 9f60769b65 Fix missing autosizer types 2023-07-31 17:17:25 -07:00
jeffvli e618ac7590 Re-add limits to list item queries 2023-07-31 17:17:25 -07:00
jeffvli 9f55238b74 Add simpleimg to title cell 2023-07-31 17:17:25 -07:00
Gelaechter 93e00e7afb Fix Jellyfin album duration normalization (#191) 2023-07-29 10:35:30 -07:00
jeffvli 8e83beffcc Adjust title font sizes, fix placeholder sizing 2023-07-24 15:07:56 -07:00
jeffvli 230fa33525 Adjust various elements on album artist page 2023-07-24 14:51:37 -07:00
jeffvli ed070850a4 Remove current album from "more from this artist" 2023-07-24 14:51:37 -07:00
jeffvli 2072f9554e Memoize context menu provider 2023-07-24 14:51:37 -07:00
jeffvli 2aaf3c34c8 Refactor library header 2023-07-24 14:51:33 -07:00
jeffvli b57f601e1b Add css modules extensions 2023-07-24 14:37:42 -07:00
jeffvli 51f8415025 Update lockfile 2023-07-23 05:36:42 -07:00
jeffvli e6bcb4e237 Fix ag-grid styles 2023-07-23 05:31:10 -07:00
Kendall Garner c9dbf9b5be Add remote control (#164)
* draft add remotes

* add favorite, rating

* add basic auth
2023-07-23 05:23:18 -07:00
jeffvli 0a13d047bb Begin support for container queries with css modules 2023-07-23 05:18:08 -07:00
jeffvli 84bec824f2 Add css modules, remove styled components linters and utils 2023-07-23 05:16:29 -07:00
jeffvli 03a4a1da55 Fix color by id if no image found 2023-07-23 03:50:55 -07:00
jeffvli 2c9509b58d Add recommended css module extension 2023-07-23 03:49:45 -07:00
jeffvli 42ea5af2eb Improve css module support
- Add readable identifiers
- Export as camel case
2023-07-23 03:49:32 -07:00
jeffvli ebf0d3b47f Add new rules 2023-07-23 01:51:08 -07:00
jeffvli e44b8592e5 Bump packages 2023-07-23 01:50:20 -07:00
jeffvli f9338aafcd Fix original search params from being replaced 2023-07-22 05:26:15 -07:00
jeffvli 3aec139f58 Use search params for artist-specific lists 2023-07-22 05:05:33 -07:00
jeffvli 8a367b00a3 Remove paginated table display type 2023-07-22 04:42:46 -07:00
jeffvli 46374ef2b5 Fix delete on full playlist view 2023-07-22 01:52:55 -07:00
jeffvli febe1a703c Revert library header height change 2023-07-21 19:11:15 -07:00
jeffvli 853770ea8e Prevent wrong initial color on navigation on the same route 2023-07-21 18:51:37 -07:00
jeffvli 48eaddbeda Reduce z-index on page headers
- Potentially causes tooltips to be hidden
2023-07-21 18:40:52 -07:00
jeffvli 0a26c489b6 Change page animation easing transition 2023-07-21 18:32:47 -07:00
jeffvli bbee3fc655 Update scrollArea for relevant pages 2023-07-21 18:04:05 -07:00
jeffvli a8dfc7bcd6 Use memoized carousel component for pages 2023-07-21 18:03:19 -07:00
jeffvli 74384639de Add memoized version of grid carousel 2023-07-21 18:00:51 -07:00
jeffvli 20524452ae Fix noHeader implementation for scrollArea 2023-07-21 18:00:13 -07:00
jeffvli f274801be6 Remove table header x margins 2023-07-21 17:58:04 -07:00
jeffvli 9d18384b2d Add stickyHeader prop to table component 2023-07-21 17:53:54 -07:00
jeffvli 92d7560362 Fix custom header text positioning to match originals 2023-07-21 17:30:37 -07:00
jeffvli 47d84fae2d Add missing peer package 2023-07-21 05:38:16 -07:00
jeffvli c3d8791455 Refactor scrollarea component for overlayscrollbars 2023-07-21 05:20:40 -07:00
jeffvli 3d6f5a2748 Add overlayscrollbars package 2023-07-21 04:02:27 -07:00
jeffvli 61403510d4 Increase height of detail header 2023-07-21 03:35:59 -07:00
jeffvli e796b031ea Clean up various queries 2023-07-21 00:18:53 -07:00
jeffvli 2d62b9d72d Set full height on disc number cell 2023-07-21 00:18:53 -07:00
jeffvli f5cbcace64 Remove autofit on gridReady
- Potentially causes horizontal scrollbar to flash on render
2023-07-21 00:18:53 -07:00
jeffvli e7c15ef5f1 Tweak average color algorithm 2023-07-21 00:18:53 -07:00
jeffvli 31eb22f968 Improve header color styles on detail pages 2023-07-21 00:18:44 -07:00
jeffvli 713260bfc9 Add rgb to rgba util 2023-07-20 17:09:10 -07:00
jeffvli ba00538cc3 Brighten sidebar items 2023-07-20 17:08:58 -07:00
jeffvli dd2dd797a1 Add check for undefined list query 2023-07-20 17:00:01 -07:00
jeffvli eec556d34a Additional fix to query key list values 2023-07-20 03:38:50 -07:00
jeffvli 7378fd1f20 Fix broken pagination split logic 2023-07-20 02:08:23 -07:00
jeffvli 6821735f65 Split key pagination in central handler 2023-07-20 01:55:49 -07:00
jeffvli 1cb0a1d72a Add initial data fetch function to grid 2023-07-20 00:41:18 -07:00
jeffvli 287f1dc0e1 Update search list implementation 2023-07-20 00:41:18 -07:00
jeffvli 6dd9333dbb Update album list implementation 2023-07-20 00:41:13 -07:00
jeffvli 55937e71db Allow play button click handler to have args 2023-07-20 00:41:13 -07:00
jeffvli c0e3174d09 Fix artist image placeholders for Navidrome 2023-07-20 00:41:13 -07:00
jeffvli 440cc04fbc Update album artist list implementation 2023-07-20 00:41:13 -07:00
jeffvli 6cd27c3e88 Update song list implementation 2023-07-20 00:41:09 -07:00
jeffvli 85964bfded Update playlist list implementation 2023-07-20 00:41:05 -07:00
jeffvli 8b4a2d1ac0 Simplify list store and table implementation 2023-07-20 00:41:04 -07:00
jeffvli 9bcefb3105 Add generic list context 2023-07-20 00:41:04 -07:00
jeffvli 4029127018 Add fallback to line clamp in case of artist overflow 2023-07-20 00:41:04 -07:00
jeffvli f9ddd3140a Add search to playlist list 2023-07-20 00:41:04 -07:00
jeffvli 651af8539a Add reusable list filter refresh handler 2023-07-20 00:41:00 -07:00
jeffvli 4e4eca14ec Add change action to search clear button (#176) 2023-07-19 01:32:55 -07:00
jeffvli 1ec70bfa78 Add search to playlist api 2023-07-19 01:32:09 -07:00
jeffvli c3f97dfa4c Split pagination from filter in query keys 2023-07-19 01:29:42 -07:00
jeffvli bba27c5ddb Migrate playlist list to use list store 2023-07-19 00:28:46 -07:00
jeffvli 78860db537 Bump react-icons version 2023-07-18 18:40:07 -07:00
jeffvli ece7fecc76 Highlight currently playing song on all song tables (#178) 2023-07-18 18:39:39 -07:00
jeffvli 919016ca5a Add table context 2023-07-18 18:34:51 -07:00
jeffvli b8dfbf9d49 Add Inter font style 2023-07-18 17:38:41 -07:00
jeffvli 179129b7cb Add actions table column 2023-07-18 17:37:32 -07:00
jeffvli 817675ee0e Update playlist headers 2023-07-18 09:54:51 -07:00
jeffvli 57cdb0eb69 Reduce size of sidebar items 2023-07-18 09:51:47 -07:00
jeffvli 8233a56def Fix smart playlist sort order on creation (#163) 2023-07-16 23:59:51 -07:00
jeffvli 0c54b79c09 Clean up 2023-07-16 23:57:31 -07:00
jeffvli 3fb9853eb6 Fix select label positioning 2023-07-16 23:57:01 -07:00
jeffvli 1de89071e8 Fix hidden scrollbar for electron 25
- Newer chromium versions do not support scrollbar overlay css
2023-07-16 23:48:44 -07:00
jeffvli be37dada13 Bump to electron v25 2023-07-16 23:33:38 -07:00
jeffvli c27a9a8ffb Remove base animation of smart playlist query builder display 2023-07-16 23:25:00 -07:00
jeffvli be0792a5c7 Refresh playlist list on delete (#134) 2023-07-16 23:23:07 -07:00
jeffvli 37e4940c2e Set full playlist view as default 2023-07-16 13:40:50 -07:00
jeffvli e965bd2663 Update album artist list views 2023-07-16 13:35:40 -07:00
jeffvli b9caa73405 Fix list response types 2023-07-16 13:35:40 -07:00
jeffvli 0ba8d5bf70 Add extra item types to card row 2023-07-16 13:35:32 -07:00
jeffvli 1fc5e9a0e8 Update song list table view 2023-07-16 11:44:33 -07:00
jeffvli f09227d963 Update album table view 2023-07-16 11:15:47 -07:00
jeffvli 47ecbf0601 Fix column width declarations 2023-07-16 11:06:09 -07:00
jeffvli 481258484c Add reusable virtual table hook 2023-07-15 20:27:35 -07:00
jeffvli 3dcb0dc4ed Allow navigating directly to playlist song view 2023-07-15 15:57:40 -07:00
jeffvli d64040f3f0 Enable delete button in playlist song list 2023-07-15 15:53:36 -07:00
jeffvli 63a77ae68c Add playlist grid views 2023-07-15 11:22:09 -07:00
jeffvli e980e31bd2 Handle playlist card types 2023-07-15 11:22:09 -07:00
jeffvli 3b5dff795f Change default popup transition 2023-07-15 11:22:09 -07:00
jeffvli 8129a3994b Add order toggle button component 2023-07-15 11:22:09 -07:00
jeffvli 734b632c6c Fix item size slider for grid on album list 2023-07-15 11:22:09 -07:00
jeffvli 34f05fa2a5 Add undefined check to error message
- In some cases you can get an irrecoverable UI due to this being undefined
2023-07-15 11:22:09 -07:00
Kyan f74e02eb09 Add fix to hide the "More from this artist" carousel when it contains no albums (#173)
* Add fix to hide carousel with other albums
2023-07-15 11:13:56 -07:00
jeffvli 287fbab29a Set initial count on playlist list (#180)
- Allows for easier infinite scrolling to end of list
2023-07-14 19:13:03 -07:00
jeffvli e9d1e4a597 Set stylelint indentation to 4 2023-07-04 17:19:29 -07:00
jeffvli 70f893e5e9 Create vercel rewrite (#168) 2023-07-01 19:24:06 -07:00
jeffvli 30e52ebb54 Lint all files 2023-07-01 19:14:12 -07:00
jeffvli 22af76b4d6 Update prettier for 4 space tab 2023-07-01 19:06:57 -07:00
jeffvli cb7bf438e9 Add new app logo 2023-07-01 14:11:15 -07:00
zackslash a1b5c21a84 use platform window bar style as default (#150) 2023-07-01 13:50:11 -07:00
zackslash 4c5fa0750b fix label-font-family typo (#157) 2023-07-01 12:45:55 -07:00
Gelaechter 22160ba59f Show first instance when starting a second one (#149) 2023-07-01 12:45:08 -07:00
jeffvli ba8e23e8d4 Add missing sidebar configuration 2023-06-14 00:45:10 -07:00
jeffvli 7fa4641dfe Bump to v0.2.0 2023-06-14 00:13:29 -07:00
jeffvli 4167af098f Various cleanup 2023-06-14 00:12:10 -07:00
jeffvli c5f551e963 Use flex gap instead of line height for lyrics 2023-06-13 18:47:47 -07:00
jeffvli fbd0e5b27b Fix image position when scaling 2023-06-13 18:47:43 -07:00
jeffvli 5877b8cc6f Fix feature carousel dynamic styles 2023-06-13 18:38:55 -07:00
jeffvli 23f4bfde99 Add additional outline button styles 2023-06-13 18:38:07 -07:00
jeffvli 4898fa7dcf Add responsive styling for min-width sidebar 2023-06-13 18:08:42 -07:00
jeffvli a6990fd732 Fix various queue behavior
- Fix add next behavior when shuffle is enabled
- Fix shuffled queue when songs are removed from queue
- Fix queue indices when currently playing song is removed
- Re-shuffle queue after queue is finished when shuffle is enabled
2023-06-13 17:47:40 -07:00
Kendall Garner 2fac9efc1b initial implementation for password saving (#132)
* initial implementation for password saving

* support restoring password in interceptor

* Fix modal overflow and position styles

* warn about 429, better error handling

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-06-13 10:52:51 -07:00
jeffvli a3a84766e4 Fix artist/album artist cells (#117) 2023-06-11 23:27:50 -07:00
jeffvli 0e9a77ffe0 Remove unneeded link condition 2023-06-11 20:40:27 -07:00
jeffvli 8f7e6a5222 Disable link from invalid artist on table cells (#117) 2023-06-11 20:38:32 -07:00
jeffvli 736945d6ef Set explicit window bar width 2023-06-11 20:18:28 -07:00
jeffvli f97e855f51 Support local navidrome album artist image (#116) 2023-06-11 20:02:14 -07:00
Kendall Garner d6e628099c Add LrcLib Fetcher (#136)
* lrclib, do not show search/clear buttons if no fetchers configured
2023-06-11 12:45:50 -07:00
Gelaechter d7ca25525c Add zooming functionality (#140) 2023-06-11 12:45:13 -07:00
Jeff 72099cb1fe Add configuration for player image aspect ratio (#138) 2023-06-10 19:14:49 -07:00
jeffvli eeefe9d9dc Add updated scroll container to unsync lyrics 2023-06-09 17:24:34 -07:00
jeffvli 86c3e54119 Add tooltip to offset input 2023-06-09 17:24:34 -07:00
jeffvli ea0737cf1f Fix overlay scrollbar 2023-06-09 17:24:34 -07:00
jeffvli f4eaacc64a Adjust vertical padding on lyrics display 2023-06-09 17:24:34 -07:00
jeffvli 7f6efbe6dc Hide search / clear actions on web 2023-06-09 17:24:34 -07:00
jeffvli 72811dbedb Fix broken layout on lyrics scroll
- App layout would break when transitioning into the full-screen due to scrollIntoView
- Replace scroll action with scrollTop implementation
2023-06-09 17:24:34 -07:00
jeffvli 493e13ebc0 Fix id return type on netease fetcher 2023-06-09 17:24:34 -07:00
jeffvli 14aeee888f Adjust header, input, and modal styles 2023-06-09 17:24:34 -07:00
jeffvli cbc08d6f03 Improve lyrics match with scored searches 2023-06-09 17:24:34 -07:00
jeffvli 77703b904f Allow override on songs without default found lyrics 2023-06-09 17:24:34 -07:00
jeffvli 762644d23d Fix provider text 2023-06-09 17:24:34 -07:00
jeffvli 75403078d2 Disable action buttons when no song selected 2023-06-09 17:24:34 -07:00
jeffvli 255a131f3b Require only single query for search 2023-06-09 17:24:34 -07:00
jeffvli e56350c1c2 Add remaining lyric actions functionality 2023-06-09 17:24:34 -07:00
jeffvli aaa1b5f63a Fix override on song change 2023-06-09 17:24:34 -07:00
jeffvli 3d409bb6f1 Fix queue container layout 2023-06-09 17:24:34 -07:00
jeffvli 7ab532be07 Improve accessibility of actions bar 2023-06-09 17:24:34 -07:00
jeffvli 946b73d215 Miscellanous fixes 2023-06-09 17:24:34 -07:00
jeffvli 2f0634dc03 Add lyric search selection and override 2023-06-09 17:24:34 -07:00
jeffvli f8ecb3fc53 Update fetchers to support search 2023-06-09 17:24:34 -07:00
jeffvli 01608fa875 Fix ref issue 2023-06-09 17:24:34 -07:00
jeffvli 14a6766072 Add initial lyrics search UI 2023-06-09 17:24:34 -07:00
jeffvli 0fa5b6496f Add lyric search functions and query 2023-06-09 17:24:34 -07:00
jeffvli 43c11ab6e3 Add alternative lyrics format parser
- Many synced lyrics on NetEase are broken due to not being the standard lrc format
2023-06-09 17:24:34 -07:00
jeffvli 41a901f3c4 Catch error on jellyfin query fail 2023-06-09 17:24:34 -07:00
jeffvli 2bdc664619 Fix animation transition on fullscreen player open 2023-06-09 17:24:34 -07:00
Kendall Garner 8835fc640a Include lyric simplification, restore clear button
- merges lyric simplification
- restores metadata/clear
2023-06-09 17:24:34 -07:00
jeffvli f92cd89c46 Add fade in animation for lyrics container 2023-06-09 17:24:34 -07:00
jeffvli a1a113d3c6 Move scroll container to wrap lyrics only 2023-06-09 17:24:34 -07:00
jeffvli 3f78c3f420 Move all lyrics fetching logic into query 2023-06-09 17:24:34 -07:00
jeffvli f10912d930 Set lyrics scrollarea to max height by default 2023-06-09 17:24:34 -07:00
jeffvli 98fa47348c Replace main lyrics listener with promise handler 2023-06-09 17:24:34 -07:00
jeffvli d38c846e80 Update lyric fetcher return types 2023-06-09 17:24:34 -07:00
Kendall Garner 007a099951 Lyrics Improvements
- Make the settings text actually consistent with behavior
- Add metadata (artist/track name) for fetched tracks
- Add ability to remove incorrectly fetched lyric
- Add lyric fetch cache; save the last 10 fetches
- Add ability to change offset in full screen, add more comments
2023-06-09 17:24:34 -07:00
jeffvli 9622cd346c Force play on queue row double click 2023-06-06 19:45:47 -07:00
jeffvli c3c1f4cc5f Refactor mpv initialization/cleanup
- Don't re-initialize the player on re-render
- Fixes the player potentially crashing on hot reload
2023-06-06 10:48:47 -07:00
jeffvli d97fe4c621 Replace node-mpv with fork version 2023-06-06 00:57:05 -07:00
jeffvli 7e5733db34 Add missing key on sidebar 2023-06-06 00:55:49 -07:00
jeffvli d1dde2428f Minor fixes on main component 2023-06-04 16:46:05 -07:00
jeffvli 190dd71b3c Fix styling on unsync lyrics 2023-06-04 16:46:05 -07:00
jeffvli feb61c28d7 Change padding on vertical player 2023-06-04 16:46:05 -07:00
jeffvli f380eccc68 Improve responsive styles for playerbar 2023-06-04 16:46:05 -07:00
jeffvli cf43bf360e Use scale instead of font-size for active lyric 2023-06-04 16:46:05 -07:00
jeffvli 48dfd469ed Additional styling changes 2023-06-04 16:46:05 -07:00
jeffvli 5dd860735d Adjust lyrics styling / animations 2023-06-04 16:46:05 -07:00
jeffvli 7cd2077dcd Refactor layout to grid 2023-06-04 16:46:05 -07:00
jeffvli 7430bba853 Update minimum app dimensions 2023-06-04 16:46:05 -07:00
jeffvli 782c351ca6 Disable query if no song present 2023-06-04 16:46:05 -07:00
Kendall Garner 3aef2a80a7 rename listener function 2023-06-04 16:46:05 -07:00
Kendall Garner 85a10c799a address comments 2023-06-04 16:46:05 -07:00
Kendall Garner 9eef570740 support .txt 2023-06-04 16:46:05 -07:00
Kendall Garner 58f38b2655 add jellyfin, improvements 2023-06-04 16:46:05 -07:00
Kendall Garner 85d2576bdc Improved lyric syncing, fetch
- uses a somewhat more sane way to parse lyrics and teardown timeouts
- adds 'seeked' to setCurrentTime to make detecting seeks in lyric much easier
- adds ability to fetch lyrics from genius/netease (desktop only)
2023-06-04 16:46:05 -07:00
Kendall Garner 23f9bd4e9f initial implementation for lyrics 2023-06-04 16:46:05 -07:00
jeffvli 8eb0029bb8 Add undefined check 2023-06-03 05:46:20 -07:00
jeffvli c8a0df4759 Add configurable sidebar 2023-06-03 05:46:20 -07:00
jeffvli e7bc29a8f1 Remove unneeded hook 2023-06-03 05:46:20 -07:00
jeffvli 5295c69f46 Bump ts-rest 2023-06-03 05:46:15 -07:00
jeffvli f58552be84 Remove unneeded console logs 2023-06-03 00:40:57 -07:00
jeffvli cd57142caf Fix duplicate import 2023-06-03 00:40:13 -07:00
jeffvli 86ad2d0383 Fix invalid spinner props 2023-06-03 00:39:52 -07:00
jeffvli 7d5aa6fd13 Add sidebar customization settings 2023-06-03 00:39:33 -07:00
jeffvli f2ef630921 Fix global button styling on settings page 2023-06-02 22:38:49 -07:00
jeffvli 9250b30249 Downgrade framer-motion from v10 -> v9
- Issues are present in v10 that prevent drag/drop reorder from working properly
2023-06-02 18:35:36 -07:00
jeffvli 2b16cce0aa Move global search to default result 2023-06-02 17:25:15 -07:00
jeffvli 34870556b4 Add auto-focus to search input 2023-06-02 13:13:33 -07:00
jeffvli 7e2d9bd585 Split album list views 2023-06-02 13:09:28 -07:00
jeffvli 691bc8f1ef Add full container spinner/loader 2023-06-02 13:07:30 -07:00
jeffvli 5dbc0c61c5 Clean up from mantine upgrade 2023-06-02 11:48:43 -07:00
jeffvli 0bc1ee3492 Downgrade auto-sizer package
- issue with types with ts v4
2023-06-02 11:48:22 -07:00
jeffvli 7403a46f91 Remove initial animation on page header 2023-06-02 11:38:33 -07:00
jeffvli 8ffb81093d Improve feature carousel component
- Add play button
- Clamp title to 1 line
- Restrict to 1 genre and 1 artist
- Infinite loop pagination
2023-06-02 01:30:08 -07:00
jeffvli d312c3c70a Handle initial render item count for carousel 2023-06-02 01:21:52 -07:00
jeffvli cd66a9dccb Clean up 2023-06-02 01:21:02 -07:00
jeffvli f2690b262f Remove container query requirement for carousel sizing 2023-06-02 01:01:50 -07:00
jeffvli 63c5a83911 Bump packages 2023-06-01 20:19:07 -07:00
jeffvli 17b1acad9d Optimize various pages 2023-06-01 20:08:43 -07:00
jeffvli e7c7eb3ec0 Split up main content route component 2023-05-31 01:13:54 -07:00
jeffvli fa0a21a021 Optimize app outlet 2023-05-31 00:27:16 -07:00
jeffvli 791088deb6 Persist scroll offset on table-view album list 2023-05-30 20:05:52 -07:00
jeffvli 9c1a2a4a8d Fix playlist form
- Invalid initial state and definition for public playlist
2023-05-30 19:35:22 -07:00
jeffvli 6d092d9ebc Add native frame styles per OS 2023-05-27 14:02:10 -07:00
jeffvli 73997cf6c7 Add clarity to conditional restart 2023-05-27 14:02:10 -07:00
jeffvli 1d074dae2e Adjust conditionals in a few places 2023-05-27 14:02:10 -07:00
Gelaechter a878875f83 Add native titlebar & fix app restart for AppImages 2023-05-27 14:02:10 -07:00
Kendall Garner d055ae89e0 media session for windows/mac 2023-05-26 18:20:27 -07:00
Kendall Garner f83639d5f8 round volume, update all clients 2023-05-26 18:20:27 -07:00
Kendall Garner 97ccf3bc6d add media session/mpris 2023-05-26 18:20:27 -07:00
jeffvli 76805a0b19 Fix ND potentially setting undefined undefined credential (#60) 2023-05-24 00:33:35 -07:00
jeffvli 0103a84358 Add clear buttons to search input 2023-05-21 21:01:23 -07:00
jeffvli 611cbc6dd9 Fix search results being capped to window height 2023-05-21 20:51:52 -07:00
jeffvli 011f260e94 Bump to v0.1.1 2023-05-21 20:17:18 -07:00
jeffvli e937425f4f Fix shuffled queue set by double click 2023-05-21 20:14:22 -07:00
jeffvli bc2624bffd Add fullscreen player toggle tooltip to sidebar (#114) 2023-05-21 19:47:46 -07:00
jeffvli 4f21c26e5d Fix double play trigger (maybe) 2023-05-21 19:44:32 -07:00
jeffvli e6a4ce2e64 Set global media hotkeys enabled by default 2023-05-21 19:43:42 -07:00
jeffvli 5b98238b3a Prevent clicking on disabled sidebar items 2023-05-21 19:39:40 -07:00
jeffvli d96c0d547a Hide search results when not on home page 2023-05-21 19:37:45 -07:00
jeffvli 3c62de8347 Fix all playlist actions 2023-05-21 18:20:46 -07:00
jeffvli 07d4dc37b5 Hide fetch notification if error 2023-05-21 18:19:43 -07:00
jeffvli 64c5f25d18 Fix JF playlist controller 2023-05-21 18:19:02 -07:00
jeffvli 098e86b1f4 Fix ND playlist controller 2023-05-21 18:15:47 -07:00
jeffvli adc3e421f6 Increase size of create playlist modal 2023-05-21 17:53:43 -07:00
jeffvli d289797d65 Add margin under image 2023-05-21 16:48:50 -07:00
jeffvli 6218b27117 Fix no-repeat on mpv (#55) 2023-05-21 16:43:47 -07:00
jeffvli 549db7b1bf Fix tooltip parent component 2023-05-21 16:03:25 -07:00
Kendall Garner 8ee99adb2d Fix full screen overflow (#113)
* fix text overflow making image take up too much space in full screen

* Fix missing key

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-05-21 16:01:37 -07:00
jeffvli da519c2250 Bump to version 0.1.0 2023-05-21 15:48:07 -07:00
jeffvli 7cd33ad388 Update github templates 2023-05-21 15:44:30 -07:00
jeffvli 8ae368ea4f Change artist display component 2023-05-21 15:36:15 -07:00
jeffvli 22e31b92a4 Update outline button styling 2023-05-21 15:35:23 -07:00
jeffvli a308efaf06 Fix jellyfin discography views (#81) 2023-05-21 15:34:52 -07:00
jeffvli 977cb89481 Add fullscreen player button tooltip 2023-05-21 15:09:32 -07:00
jeffvli 0c3b030b13 Add create playlist button on playlist list page 2023-05-21 15:02:57 -07:00
jeffvli 86080c7875 Revert paper bg 2023-05-21 08:14:30 -07:00
jeffvli b71c3c7c53 Handle song detail add 2023-05-21 08:13:48 -07:00
jeffvli debdb92dcf Add shuffle all feature 2023-05-21 07:33:22 -07:00
jeffvli ba6f2a1637 Fix left icon placement 2023-05-21 07:31:58 -07:00
jeffvli 7c6f62023a Fix song null check on queue add 2023-05-21 07:31:18 -07:00
jeffvli de50002ea7 Add random song list query 2023-05-21 07:30:28 -07:00
jeffvli 41a251c2ac Decrease toast durations 2023-05-21 07:18:41 -07:00
jeffvli 10d7664733 Add stop button to playerbar 2023-05-21 03:17:45 -07:00
jeffvli fed96d1fce Additional player adjustments
- Set volume on play
- Explicitly pause/play on set queue
2023-05-21 03:08:25 -07:00
Kendall Garner 106fc90c4a Add ability to save/restore queue (#111)
* add ability to save/restore play queue

* Add restoreQueue action

* Add optional pause param on setQueue

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-05-21 02:29:58 -07:00
jeffvli c1c6ce33e4 Fix type 2023-05-20 23:22:43 -07:00
jeffvli 26bc7d23ae Adjust default themes 2023-05-20 23:22:02 -07:00
jeffvli 30dc833b79 Add additional padding under home carousels 2023-05-20 23:13:57 -07:00
jeffvli 292737d53c Add query cancellation for play queue requests
- Opens a notification after 2s to allow for manual cancellation of in-progress query
2023-05-20 23:13:20 -07:00
jeffvli 652c4a1f81 Use play handler from context 2023-05-20 23:09:26 -07:00
jeffvli fb158bc069 Add types to query keys object 2023-05-20 22:40:22 -07:00
jeffvli 51c2731b07 Handle queue all songs by double click (#67) 2023-05-20 21:31:00 -07:00
jeffvli 93530008a9 Add custom query prop to play queue add 2023-05-20 20:58:11 -07:00
jeffvli 6747fbb701 Add initialSongId prop as alternative to initialIndex 2023-05-20 20:47:07 -07:00
jeffvli 06d253228a Fix normalized types 2023-05-20 20:22:10 -07:00
jeffvli c8b1b4d394 Update electron web preferences 2023-05-20 20:21:14 -07:00
jeffvli 0320fe6dcc Add mpv load error notification
- Add retry limit on error
2023-05-20 20:19:41 -07:00
jeffvli 1f36978bb9 Fix deprecated import 2023-05-20 20:00:09 -07:00
jeffvli 6a01d44600 Clean up mpv startup 2023-05-20 19:56:17 -07:00
jeffvli 35f9798bed Update full-width cell renderer 2023-05-20 19:21:23 -07:00
jeffvli 897af4661b Add extraProps param on column defs 2023-05-20 18:41:24 -07:00
jeffvli 3df2915f5f Allow initialIndex on queue add (#67)
- Clean up play queue handler
- Split out functions to utils
2023-05-20 18:40:45 -07:00
jeffvli 02caf896ff Update playqueueadd props 2023-05-20 14:55:08 -07:00
jeffvli 7dd56bfb9b Add --idle to default mpv parameters (#76) 2023-05-20 14:40:55 -07:00
jeffvli fe59011882 Fix conditionals on album artist detail 2023-05-20 02:26:24 -07:00
jeffvli c854fd0a5b Re-add artistInfo api call for navidrome 2023-05-20 02:24:40 -07:00
jeffvli 645b4fe332 Prevent error on attempt to context menu during render 2023-05-20 02:08:37 -07:00
jeffvli e5f24b3160 Set context menu based on item type 2023-05-20 02:08:37 -07:00
jeffvli fff1315fa5 Add search route 2023-05-20 02:08:37 -07:00
jeffvli ba0543f861 Fix type for addToQueue reducer 2023-05-20 02:08:37 -07:00
jeffvli 30c4d5baf1 Add param to search route to denote tab 2023-05-20 02:08:37 -07:00
jeffvli e8f7ae637f Add state to search route navigation 2023-05-20 02:08:37 -07:00
jeffvli b5fa6f0baa Handle song detail on playqueue add 2023-05-20 02:08:37 -07:00
jeffvli c4fb9a2e72 Add song detail controller 2023-05-20 02:08:37 -07:00
jeffvli 2cefc092ce Close window after selecting search item 2023-05-20 02:08:37 -07:00
jeffvli a7ea54cf4b Add jellyfin search api 2023-05-20 02:08:37 -07:00
jeffvli deb4e34895 Adjust styles 2023-05-20 02:08:37 -07:00
jeffvli 33ecf9faa6 Add command item breadcrumbs 2023-05-20 02:08:37 -07:00
jeffvli cf6325d0ba Decrease item padding 2023-05-20 02:08:37 -07:00
jeffvli c12c1bad73 Add library search 2023-05-20 02:08:37 -07:00
jeffvli cf9ed31dfd Updates to general commands 2023-05-20 02:08:37 -07:00
jeffvli c296927bbb Prevent render error on missing endpoint 2023-05-20 02:08:37 -07:00
jeffvli 32ebe6b739 Add subsonic/nd search api 2023-05-20 02:08:37 -07:00
jeffvli c85a7079eb Add handlers to open command palette 2023-05-20 02:08:37 -07:00
jeffvli 822060b82c Add base command palette component 2023-05-20 02:08:37 -07:00
jeffvli 547fe7be38 Add global search state 2023-05-20 02:08:37 -07:00
jeffvli ccf5588435 Add cmdk package 2023-05-20 02:08:37 -07:00
jeffvli 4cb54bc9da Fix misc types 2023-05-17 21:07:04 -07:00
jeffvli ce72ff5e8d Remove mpv quit effect
- Causing errors in dev build
2023-05-17 17:51:14 -07:00
jeffvli 71b9cace53 Add callback for swiper zoom change 2023-05-17 17:47:05 -07:00
jeffvli 5637327e8a Fix conditionals on jellyfin normalization 2023-05-17 17:38:49 -07:00
jeffvli a1072b461f Add inferred api types to controller 2023-05-17 17:38:13 -07:00
jeffvli 3fb24d5f64 Re-add infinite album list query 2023-05-17 17:26:23 -07:00
jeffvli e45252d16c Fix mpv sample rate setting
- Fix default input value
- Disable property on 0 value
2023-05-17 17:25:20 -07:00
jeffvli 48ef7a987f Add new swiper carousels to pages 2023-05-17 17:12:23 -07:00
jeffvli 58d912065b Add swiper card / update virt cards 2023-05-17 17:11:33 -07:00
jeffvli d8130f48e2 Add swiper carousel component 2023-05-17 17:10:30 -07:00
jeffvli 89afa9b836 Fix subsonic error result 2023-05-14 18:34:08 -07:00
jeffvli 684ba13175 Re-order app menu
- Move version number to menu
- Add link to github
2023-05-14 02:01:37 -07:00
jeffvli 2399105f6c Change dropdown item selection style 2023-05-14 02:00:23 -07:00
jeffvli d42f4dbe4f Add swiper package 2023-05-14 01:58:05 -07:00
jeffvli cf32a7ff21 Debounce hotkey set to improve performance 2023-05-14 01:57:42 -07:00
jeffvli 5eea3d7e01 Fix duplicate keys on grid skeletons 2023-05-13 23:09:20 -07:00
jeffvli e2e3a50f1f Add grid card indicator for favorite items 2023-05-13 23:06:02 -07:00
jeffvli 4c98afb613 Add hotkey controls to relevant pages 2023-05-13 22:55:58 -07:00
jeffvli d7f24262fd Add hotkeys manager
- Add configuration to settings store
- Initialize global hotkeys on startup from renderer
2023-05-13 22:55:58 -07:00
jeffvli 6056504f00 Add ipcRenderer send function to preload 2023-05-13 22:55:58 -07:00
jeffvli cef92243f5 Fix favorite mutation 2023-05-13 22:54:24 -07:00
jeffvli 8d5c82b0c6 Fix query array parser for navidrome api 2023-05-13 22:53:14 -07:00
jeffvli 003fb26c60 Add checkbox component 2023-05-11 02:51:00 -07:00
jeffvli 4eb90d20a2 Handle list auto size when vertical scroll appears 2023-05-11 01:58:04 -07:00
jeffvli cf489d3934 Fix types for updated packages 2023-05-10 20:00:39 -07:00
jeffvli 416476cc66 Set card image max height
- Fixes oversizing due to virtual grid
2023-05-10 19:54:45 -07:00
jeffvli bdc3daf6da Switch ND song list parameter to album_artist_id 2023-05-10 18:46:03 -07:00
jeffvli 129515d57a Fix deprecated import 2023-05-10 18:45:22 -07:00
jeffvli 76ca03d8e3 Remove shadow on playerbar 2023-05-10 18:21:34 -07:00
jeffvli e49fe6c452 Add collapsible sidebar (#68)
- Sidebar can collapse by menu option or dragging
2023-05-10 18:20:04 -07:00
jeffvli ec7a053a74 Remove text color transition 2023-05-10 18:13:24 -07:00
jeffvli 9e4e6172c3 Bump packages 2023-05-10 18:12:29 -07:00
jeffvli eca26e912f Set card images to cover
- Better UX since it makes the grid look more consistent
2023-05-10 14:34:06 -07:00
jeffvli f9e410a1f5 Set fullscreen player over right sidebar 2023-05-10 03:08:55 -07:00
jeffvli 87abd0c6f5 Fix subsonic params parser 2023-05-09 19:33:46 -07:00
jeffvli e3665e6407 Adjust jellyfin types to include additional properties 2023-05-09 18:58:24 -07:00
jeffvli c87905f6c2 Set auto_restart prop to true on mpv instance 2023-05-09 18:55:54 -07:00
jeffvli 2100c1495d Improve grid card components
- Dynamic placeholder depending on item type
- Fix skeleton for default card
2023-05-09 18:55:26 -07:00
jeffvli b5da8aeb55 Remove skeleton animation
- Performance concerns due to large number of animated skeletons
2023-05-09 18:51:26 -07:00
jeffvli 5eeded6c72 Fix fallback to album image for Jellyfin (#97) 2023-05-09 12:01:51 -07:00
jeffvli 346b8be122 Fix JF discography view (#81) 2023-05-09 11:06:01 -07:00
434 changed files with 84037 additions and 73682 deletions
+5
View File
@@ -7,6 +7,10 @@ import { dependencies as externals } from '../../release/app/package.json';
import webpackPaths from './webpack.paths'; import webpackPaths from './webpack.paths';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
const styledComponentsTransformer = createStyledComponentsTransformer();
const configuration: webpack.Configuration = { const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})], externals: [...Object.keys(externals || {})],
@@ -20,6 +24,7 @@ const configuration: webpack.Configuration = {
options: { options: {
// Remove this line to enable type checking in webpack builds // Remove this line to enable type checking in webpack builds
transpileOnly: true, transpileOnly: true,
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
}, },
}, },
}, },
+120
View File
@@ -0,0 +1,120 @@
import 'webpack-dev-server';
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const port = process.env.PORT || 4343;
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: 'remote.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);
+132
View File
@@ -0,0 +1,132 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: 'remote.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'remote.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);
+13 -1
View File
@@ -67,7 +67,10 @@ const configuration: webpack.Configuration = {
{ {
loader: 'css-loader', loader: 'css-loader',
options: { options: {
modules: true, modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true, sourceMap: true,
importLoaders: 1, importLoaders: 1,
}, },
@@ -168,6 +171,14 @@ const configuration: webpack.Configuration = {
.on('close', (code: number) => process.exit(code!)) .on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError)); .on('error', (spawnError) => console.error(spawnError));
console.log('Starting remote.js builder...');
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...'); console.log('Starting Main Process...');
spawn('npm', ['run', 'start:main'], { spawn('npm', ['run', 'start:main'], {
shell: true, shell: true,
@@ -175,6 +186,7 @@ const configuration: webpack.Configuration = {
}) })
.on('close', (code: number) => { .on('close', (code: number) => {
preloadProcess.kill(); preloadProcess.kill();
remoteProcess.kill();
process.exit(code!); process.exit(code!);
}) })
.on('error', (spawnError) => console.error(spawnError)); .on('error', (spawnError) => console.error(spawnError));
+4 -1
View File
@@ -54,7 +54,10 @@ const configuration: webpack.Configuration = {
{ {
loader: 'css-loader', loader: 'css-loader',
options: { options: {
modules: true, modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true, sourceMap: true,
importLoaders: 1, importLoaders: 1,
}, },
+5 -1
View File
@@ -49,7 +49,10 @@ const configuration: webpack.Configuration = {
{ {
loader: 'css-loader', loader: 'css-loader',
options: { options: {
modules: true, modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true, sourceMap: true,
importLoaders: 1, importLoaders: 1,
}, },
@@ -103,6 +106,7 @@ const configuration: webpack.Configuration = {
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
filename: path.join('index.html'), filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: { minify: {
collapseWhitespace: true, collapseWhitespace: true,
removeAttributeQuotes: true, removeAttributeQuotes: true,
+135
View File
@@ -0,0 +1,135 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distWebPath,
publicPath: '/',
filename: 'renderer.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);
+8
View File
@@ -5,7 +5,9 @@ const rootPath = path.join(__dirname, '../..');
const dllPath = path.join(__dirname, '../dll'); const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src'); const srcPath = path.join(rootPath, 'src');
const assetsPath = path.join(rootPath, 'assets');
const srcMainPath = path.join(srcPath, 'main'); const srcMainPath = path.join(srcPath, 'main');
const srcRemotePath = path.join(srcPath, 'remote');
const srcRendererPath = path.join(srcPath, 'renderer'); const srcRendererPath = path.join(srcPath, 'renderer');
const releasePath = path.join(rootPath, 'release'); const releasePath = path.join(rootPath, 'release');
@@ -16,15 +18,19 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules');
const distPath = path.join(appPath, 'dist'); const distPath = path.join(appPath, 'dist');
const distMainPath = path.join(distPath, 'main'); const distMainPath = path.join(distPath, 'main');
const distRemotePath = path.join(distPath, 'remote');
const distRendererPath = path.join(distPath, 'renderer'); const distRendererPath = path.join(distPath, 'renderer');
const distWebPath = path.join(distPath, 'web');
const buildPath = path.join(releasePath, 'build'); const buildPath = path.join(releasePath, 'build');
export default { export default {
assetsPath,
rootPath, rootPath,
dllPath, dllPath,
srcPath, srcPath,
srcMainPath, srcMainPath,
srcRemotePath,
srcRendererPath, srcRendererPath,
releasePath, releasePath,
appPath, appPath,
@@ -33,6 +39,8 @@ export default {
srcNodeModulesPath, srcNodeModulesPath,
distPath, distPath,
distMainPath, distMainPath,
distRemotePath,
distRendererPath, distRendererPath,
distWebPath,
buildPath, buildPath,
}; };
+13 -4
View File
@@ -5,20 +5,29 @@ import fs from 'fs';
import webpackPaths from '../configs/webpack.paths'; import webpackPaths from '../configs/webpack.paths';
const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
const remotePath = path.join(webpackPaths.distMainPath, 'remote.js');
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
if (!fs.existsSync(mainPath)) { if (!fs.existsSync(mainPath)) {
throw new Error( throw new Error(
chalk.whiteBright.bgRed.bold( chalk.whiteBright.bgRed.bold(
'The main process is not built yet. Build it by running "npm run build:main"' 'The main process is not built yet. Build it by running "npm run build:main"',
) ),
);
}
if (!fs.existsSync(remotePath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The remote process is not built yet. Build it by running "npm run build:remote"',
),
); );
} }
if (!fs.existsSync(rendererPath)) { if (!fs.existsSync(rendererPath)) {
throw new Error( throw new Error(
chalk.whiteBright.bgRed.bold( chalk.whiteBright.bgRed.bold(
'The renderer process is not built yet. Build it by running "npm run build:renderer"' 'The renderer process is not built yet. Build it by running "npm run build:renderer"',
) ),
); );
} }
+1
View File
@@ -4,5 +4,6 @@ import webpackPaths from '../configs/webpack.paths';
export default function deleteSourceMaps() { export default function deleteSourceMaps() {
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map')); rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map')); rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
} }
+8 -1
View File
@@ -16,12 +16,13 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'], '@typescript-eslint/no-shadow': ['off'],
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/no-use-before-define': ['error'],
'default-case': 'off', 'default-case': 'off',
'import/extensions': 'off', 'import/extensions': 'off',
'import/no-absolute-path': 'off', 'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json // A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error', 'import/no-unresolved': 'error',
'import/order': [ 'import/order': [
'error', 'error',
@@ -50,8 +51,14 @@ module.exports = {
'no-console': 'off', 'no-console': 'off',
'no-nested-ternary': 'off', 'no-nested-ternary': 'off',
'no-restricted-syntax': 'off', 'no-restricted-syntax': 'off',
'no-shadow': 'off',
'no-underscore-dangle': 'off', 'no-underscore-dangle': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
'prefer-destructuring': 'off', 'prefer-destructuring': 'off',
'react/function-component-definition': 'off',
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': 'off', 'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [ 'react/jsx-sort-props': [
'error', 'error',
+2
View File
@@ -1 +1,3 @@
# These are supported funding model platforms # These are supported funding model platforms
ko_fi: jeffvli
+3 -2
View File
@@ -39,6 +39,7 @@ labels: 'bug'
<!--- Include as many relevant details about the environment you experienced the bug in --> <!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version : - Application version (e.g. v0.1.0) :
- Operating System and version : - Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) : - Node version (if developing locally) :
@@ -7,3 +7,5 @@ labels: 'enhancement'
## What do you want to be added? ## What do you want to be added?
## Additional context ## Additional context
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
+3 -3
View File
@@ -10,8 +10,8 @@ exemptLabels:
staleLabel: wontfix staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable # Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false closeComment: false
+46
View File
@@ -0,0 +1,46 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR
permissions: write-all
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+38
View File
@@ -0,0 +1,38 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR (Manual)
on: workflow_dispatch
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npm run package
npm run lint npm run lint
npm run package
npm exec tsc npm exec tsc
npm test npm test
Regular → Executable
View File
+3 -1
View File
@@ -2,11 +2,13 @@
"printWidth": 100, "printWidth": 100,
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"overrides": [ "overrides": [
{ {
"files": ["**/*.css", "**/*.scss", "**/*.html"], "files": ["**/*.css", "**/*.scss", "**/*.html"],
"options": { "options": {
"singleQuote": false "singleQuote": true
} }
} }
], ],
+6 -20
View File
@@ -1,31 +1,17 @@
{ {
"processors": ["stylelint-processor-styled-components"], "customSyntax": "postcss-styled-syntax",
"customSyntax": "postcss-scss",
"extends": [ "extends": [
"stylelint-config-standard-scss", "stylelint-config-standard",
"stylelint-config-styled-components", "stylelint-config-styled-components",
"stylelint-config-rational-order" "stylelint-config-recess-order"
], ],
"rules": { "rules": {
"color-function-notation": ["legacy"],
"declaration-empty-line-before": null, "declaration-empty-line-before": null,
"order/properties-order": [],
"plugin/rational-order": [
true,
{
"border-in-box-model": false,
"empty-line-between-groups": false
}
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null, "declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null, "selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }], "selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [ "selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
true, "declaration-colon-newline-after": null,
{ "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] } "property-no-vendor-prefix": null
],
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
"declaration-colon-newline-after": null
} }
} }
+3 -1
View File
@@ -3,6 +3,8 @@
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig", "EditorConfig.EditorConfig",
"stylelint.vscode-stylelint", "stylelint.vscode-stylelint",
"esbenp.prettier-vscode" "esbenp.prettier-vscode",
"clinyong.vscode-css-modules",
"Huuums.vscode-fast-folder-structure"
] ]
} }
+1 -3
View File
@@ -7,9 +7,7 @@
"request": "launch", "request": "launch",
"protocol": "inspector", "protocol": "inspector",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": [ "runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
"run start:main --inspect=5858 --remote-debugging-port=9223"
],
"preLaunchTask": "Start Webpack Dev" "preLaunchTask": "Start Webpack Dev"
}, },
{ {
+35 -6
View File
@@ -10,14 +10,17 @@
{ "directory": "./server", "changeProcessCWD": true } { "directory": "./server", "changeProcessCWD": true }
], ],
"typescript.tsserver.experimental.enableProjectDiagnostics": true, "typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true, "source.fixAll.eslint": true,
"source.fixAll.stylelint": false "source.fixAll.stylelint": true,
"source.organizeImports": false,
"source.formatDocument": true
}, },
"css.validate": false, "css.validate": true,
"less.validate": false, "less.validate": false,
"scss.validate": false, "scss.validate": true,
"scss.lint.unknownAtRules": "warning",
"scss.lint.unknownProperties": "warning",
"javascript.validate.enable": false, "javascript.validate.enable": false,
"javascript.format.enable": false, "javascript.format.enable": false,
"typescript.format.enable": false, "typescript.format.enable": false,
@@ -35,9 +38,35 @@
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"], "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"], "stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
"typescript.updateImportsOnFileMove.enabled": "always", "typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
"folderTemplates.structures": [
{
"name": "TypeScript Feature Component With CSS Modules",
"omitParentDirectory": true,
"structure": [
{
"fileName": "<FTName | kebabcase>.tsx",
"template": "Functional Component with CSS Modules"
},
{
"fileName": "<FTName | kebabcase>.module.scss"
}
]
}
],
"folderTemplates.fileTemplates": {
"Functional Component with CSS Modules": [
"import styles from './<FTName | kebabcase>.module.scss';",
"",
"interface <FTName | pascalcase>Props {}",
"",
"export const <FTName | pascalcase> = ({}: <FTName | pascalcase>Props) => {",
" return <div></div>;",
"};"
]
}
} }
+17
View File
@@ -0,0 +1,17 @@
# --- Builder stage
FROM node:18-alpine as builder
WORKDIR /app
COPY . /app
# Scripts include electron-specific dependencies, which we don't need
RUN npm install --legacy-peer-deps --ignore-scripts
RUN npm run build:web
# --- Production stage
FROM nginx:alpine-slim
COPY --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 9180
CMD ["nginx", "-g", "daemon off;"]
+38 -5
View File
@@ -1,3 +1,5 @@
<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" />
# Feishin # Feishin
<p align="center"> <p align="center">
@@ -34,27 +36,58 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
- [x] Modern UI - [x] Modern UI
- [x] Scrobble playback to your server - [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome) - [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1) - [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
## Screenshots ## Screenshots
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
## Getting Started ## Getting Started
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). ### Desktop (recommended)
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
### Web and Docker
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
```bash
# Run the latest version
docker run --name feishin --port 9180:9180 ghcr.io/jeffvli/feishin:latest
# Build the image locally
docker build -t feishin .
docker run --name feishin --port 9180:9180 feishin
```
### Configuration
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
## FAQ ## FAQ
### MPV is either not working or is rapidly switching between pause/play states
First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.
### What music servers does Feishin support? ### What music servers does Feishin support?
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233). Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
- [Navidrome](https://github.com/navidrome/navidrome) - [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin) - [Jellyfin](https://github.com/jellyfin/jellyfin)
- ~~[Gonic](https://github.com/sentriz/gonic)~~ - [Funkwhale](https://funkwhale.audio/) - TBD
- ~~[Astiga](https://asti.ga/)~~ - Subsonic-compatible servers - TBD
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
## Development ## Development
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

+20
View File
@@ -0,0 +1,20 @@
server {
listen 9180;
sendfile on;
default_type application/octet-stream;
gzip on;
gzip_http_version 1.1;
gzip_disable "MSIE [1-6]\.";
gzip_min_length 256;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_comp_level 9;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html =404;
}
}
+6040 -9896
View File
File diff suppressed because it is too large Load Diff
+55 -40
View File
@@ -2,14 +2,18 @@
"name": "feishin", "name": "feishin",
"productName": "Feishin", "productName": "Feishin",
"description": "Feishin music server", "description": "Feishin music server",
"version": "0.0.1-alpha6", "version": "0.4.0",
"scripts": { "scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", "build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", "lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:styles": "npx stylelint **/*.tsx", "lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux", "package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir", "package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
@@ -17,6 +21,7 @@
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts", "start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts", "start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest", "test": "jest",
@@ -51,7 +56,7 @@
"package.json" "package.json"
], ],
"afterSign": ".erb/scripts/notarize.js", "afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1", "electronVersion": "25.8.1",
"mac": { "mac": {
"target": { "target": {
"target": "default", "target": "default",
@@ -60,6 +65,7 @@
"x64" "x64"
] ]
}, },
"icon": "assets/icons/icon.icns",
"type": "distribution", "type": "distribution",
"hardenedRuntime": true, "hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist", "entitlements": "assets/entitlements.mac.plist",
@@ -84,14 +90,15 @@
"target": [ "target": [
"nsis", "nsis",
"zip" "zip"
] ],
"icon": "assets/icons/icon.ico"
}, },
"linux": { "linux": {
"target": [ "target": [
"AppImage", "AppImage",
"tar.xz" "tar.xz"
], ],
"icon": "assets/icons/placeholder.png", "icon": "assets/icons/icon.png",
"category": "Development" "category": "Development"
}, },
"directories": { "directories": {
@@ -190,8 +197,8 @@
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1", "css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0", "detect-port": "^1.3.0",
"electron": "^22.3.1", "electron": "^25.8.1",
"electron-builder": "^24.0.0-alpha.13", "electron-builder": "^24.6.3",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1", "electron-notarize": "^1.2.1",
"electronmon": "^2.0.2", "electronmon": "^2.0.2",
@@ -218,6 +225,7 @@
"lint-staged": "^12.3.7", "lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0", "mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2", "postcss-syntax": "^0.36.2",
"prettier": "^2.6.2", "prettier": "^2.6.2",
"react-refresh": "^0.12.0", "react-refresh": "^0.12.0",
@@ -227,19 +235,19 @@
"sass": "^1.49.11", "sass": "^1.49.11",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"stylelint": "^14.9.1", "stylelint": "^15.10.3",
"stylelint-config-rational-order": "^0.1.2", "stylelint-config-css-modules": "^4.3.0",
"stylelint-config-recess-order": "^4.3.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-config-standard-scss": "^4.0.0", "stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1", "stylelint-config-styled-components": "^0.1.1",
"stylelint-order": "^5.0.0",
"stylelint-processor-styled-components": "^1.10.0",
"terser-webpack-plugin": "^5.3.1", "terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4", "ts-jest": "^27.1.4",
"ts-loader": "^9.2.8", "ts-loader": "^9.2.8",
"ts-node": "^10.7.0", "ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0", "tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4", "typescript": "^5.2.2",
"typescript-plugin-styled-components": "^2.0.0", "typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.71.0", "webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0", "webpack-bundle-analyzer": "^4.5.0",
@@ -254,55 +262,62 @@
"@ag-grid-community/react": "^28.2.1", "@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1", "@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.8", "@mantine/core": "^6.0.17",
"@mantine/dates": "^6.0.8", "@mantine/dates": "^6.0.17",
"@mantine/dropzone": "^6.0.8", "@mantine/form": "^6.0.17",
"@mantine/form": "^6.0.8", "@mantine/hooks": "^6.0.17",
"@mantine/hooks": "^6.0.8", "@mantine/modals": "^6.0.17",
"@mantine/modals": "^6.0.8", "@mantine/notifications": "^6.0.17",
"@mantine/notifications": "^6.0.8", "@mantine/utils": "^6.0.17",
"@mantine/utils": "^6.0.8", "@tanstack/react-query": "^4.32.1",
"@tanstack/react-query": "^4.24.4", "@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.19.2", "@ts-rest/core": "^3.23.0",
"axios": "^1.3.6", "axios": "^1.4.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"electron-debug": "^3.2.0", "electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1", "electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6", "electron-log": "^4.4.6",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"electron-updater": "^4.6.5", "electron-updater": "^4.6.5",
"fast-average-color": "^9.2.0", "fast-average-color": "^9.3.0",
"format-duration": "^2.0.0", "format-duration": "^2.0.0",
"framer-motion": "^8.1.3", "framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0", "history": "^5.3.0",
"i18next": "^21.6.16", "i18next": "^21.6.16",
"immer": "^9.0.15", "idb-keyval": "^6.2.1",
"is-electron": "^2.2.1", "immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"nanoid": "^3.3.3", "nanoid": "^3.3.3",
"net": "^1.0.2", "net": "^1.0.2",
"node-mpv": "^2.0.0-beta.2", "node-mpv": "github:jeffvli/Node-MPV",
"overlayscrollbars": "^2.2.1",
"overlayscrollbars-react": "^0.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7", "react-i18next": "^11.16.7",
"react-icons": "^4.7.1", "react-icons": "^4.10.1",
"react-player": "^2.11.0", "react-player": "^2.11.0",
"react-router": "^6.5.0", "react-router": "^6.5.0",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0", "react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.8", "react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.8", "react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.6", "styled-components": "^6.0.8",
"zod": "^3.19.1", "swiper": "^9.3.1",
"zustand": "^4.1.4" "zod": "^3.21.4",
"zustand": "^4.3.9"
}, },
"resolutions": { "resolutions": {
"styled-components": "^5" "styled-components": "^6"
}, },
"devEngines": { "devEngines": {
"node": ">=14.x", "node": ">=14.x",
+350 -18
View File
@@ -1,19 +1,21 @@
{ {
"name": "feishin", "name": "feishin",
"version": "0.0.1-alpha6", "version": "0.4.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "feishin", "name": "feishin",
"version": "0.0.1-alpha6", "version": "0.4.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"mpris-service": "^2.1.2" "cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "22.3.1" "electron": "25.3.0"
} }
}, },
"node_modules/@electron/get": { "node_modules/@electron/get": {
@@ -97,9 +99,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "16.18.16", "version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==", "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true "dev": true
}, },
"node_modules/@types/responselike": { "node_modules/@types/responselike": {
@@ -147,6 +149,11 @@
"file-uri-to-path": "1.0.0" "file-uri-to-path": "1.0.0"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/boolean": { "node_modules/boolean": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -207,6 +214,42 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/cheerio": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"htmlparser2": "^8.0.1",
"parse5": "^7.0.0",
"parse5-htmlparser2-tree-adapter": "^7.0.0"
},
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/clone-response": { "node_modules/clone-response": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -219,6 +262,32 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dbus-next": { "node_modules/dbus-next": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz", "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
@@ -327,20 +396,71 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/duplexer": { "node_modules/duplexer": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"node_modules/electron": { "node_modules/electron": {
"version": "22.3.1", "version": "25.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==", "integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron/get": "^2.0.0", "@electron/get": "^2.0.0",
"@types/node": "^16.11.26", "@types/node": "^18.11.18",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1"
}, },
"bin": { "bin": {
@@ -359,6 +479,17 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -633,6 +764,24 @@
"hexy": "bin/hexy_cmd.js" "hexy": "bin/hexy_cmd.js"
} }
}, },
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -820,6 +969,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-is": { "node_modules/object-is": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
@@ -861,6 +1021,29 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dependencies": {
"entities": "^4.4.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
"dependencies": {
"domhandler": "^5.0.2",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pause-stream": { "node_modules/pause-stream": {
"version": "0.0.11", "version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
@@ -1102,6 +1285,26 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"node_modules/ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml2js": { "node_modules/xml2js": {
"version": "0.4.23", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
@@ -1205,9 +1408,9 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "16.18.16", "version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==", "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
"dev": true "dev": true
}, },
"@types/responselike": { "@types/responselike": {
@@ -1248,6 +1451,11 @@
"file-uri-to-path": "1.0.0" "file-uri-to-path": "1.0.0"
} }
}, },
"boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"boolean": { "boolean": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -1296,6 +1504,33 @@
"get-intrinsic": "^1.0.2" "get-intrinsic": "^1.0.2"
} }
}, },
"cheerio": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
"requires": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"htmlparser2": "^8.0.1",
"parse5": "^7.0.0",
"parse5-htmlparser2-tree-adapter": "^7.0.0"
}
},
"cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"requires": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
}
},
"clone-response": { "clone-response": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -1305,6 +1540,23 @@
"mimic-response": "^1.0.0" "mimic-response": "^1.0.0"
} }
}, },
"css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
"requires": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
}
},
"css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
},
"dbus-next": { "dbus-next": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz", "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
@@ -1381,19 +1633,52 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
}
},
"domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
},
"domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"requires": {
"domelementtype": "^2.3.0"
}
},
"domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"requires": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
}
},
"duplexer": { "duplexer": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"electron": { "electron": {
"version": "22.3.1", "version": "25.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==", "integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@electron/get": "^2.0.0", "@electron/get": "^2.0.0",
"@types/node": "^16.11.26", "@types/node": "^18.11.18",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1"
} }
}, },
@@ -1406,6 +1691,11 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
},
"env-paths": { "env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -1608,6 +1898,17 @@
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==" "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
}, },
"htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"http-cache-semantics": { "http-cache-semantics": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -1756,6 +2057,14 @@
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"dev": true "dev": true
}, },
"nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"requires": {
"boolbase": "^1.0.0"
}
},
"object-is": { "object-is": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
@@ -1785,6 +2094,23 @@
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"dev": true "dev": true
}, },
"parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"requires": {
"entities": "^4.4.0"
}
},
"parse5-htmlparser2-tree-adapter": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
"requires": {
"domhandler": "^5.0.2",
"parse5": "^7.0.0"
}
},
"pause-stream": { "pause-stream": {
"version": "0.0.11", "version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
@@ -1964,6 +2290,12 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"requires": {}
},
"xml2js": { "xml2js": {
"version": "0.4.23", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
+5 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "feishin", "name": "feishin",
"version": "0.0.1-alpha6", "version": "0.4.0",
"description": "", "description": "",
"main": "./dist/main/main.js", "main": "./dist/main/main.js",
"author": { "author": {
@@ -13,10 +13,12 @@
"postinstall": "npm run electron-rebuild && npm run link-modules" "postinstall": "npm run electron-rebuild && npm run link-modules"
}, },
"dependencies": { "dependencies": {
"mpris-service": "^2.1.2" "cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "22.3.1" "electron": "25.3.0"
}, },
"license": "GPL-3.0" "license": "GPL-3.0"
} }
+2
View File
@@ -1,2 +1,4 @@
import './lyrics';
import './player'; import './player';
import './remote';
import './settings'; import './settings';
+209
View File
@@ -0,0 +1,209 @@
import axios, { AxiosResponse } from 'axios';
import { load } from 'cheerio';
import { orderSearchResults } from './shared';
import {
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
const SEARCH_URL = 'https://genius.com/api/search/song';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts
export interface GeniusResponse {
meta: Meta;
response: Response;
}
export interface Meta {
status: number;
}
export interface Response {
next_page: number;
sections: Section[];
}
export interface Section {
hits: Hit[];
type: string;
}
export interface Hit {
highlights: any[];
index: string;
result: Result;
type: string;
}
export interface Result {
_type: string;
annotation_count: number;
api_path: string;
artist_names: string;
featured_artists: any[];
full_title: string;
header_image_thumbnail_url: string;
header_image_url: string;
id: number;
instrumental: boolean;
language: string;
lyrics_owner_id: number;
lyrics_state: string;
lyrics_updated_at: number;
path: string;
primary_artist: PrimaryArtist;
pyongs_count: null;
relationships_index_url: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display: string;
song_art_image_thumbnail_url: string;
song_art_image_url: string;
stats: Stats;
title: string;
title_with_featured: string;
updated_by_human_at: number;
url: string;
}
export interface PrimaryArtist {
_type: string;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: string;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
}
export interface ReleaseDateComponents {
day: number;
month: number;
year: number;
}
export interface Stats {
hot: boolean;
unreviewed_annotations: number;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<GeniusResponse>;
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '5',
q: searchQuery,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
return {
artist: song.artist_names,
id: song.url,
name: song.full_title,
source: LyricSource.GENIUS,
};
});
return orderSearchResults({ params, results: songResults });
}
async function getSongId(
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
let result: AxiosResponse<GeniusResponse>;
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '1',
q: `${params.artist} ${params.name}`,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
if (!hit) {
return null;
}
return {
artist: hit.artist_names,
id: hit.url,
name: hit.full_title,
source: LyricSource.GENIUS,
};
}
export async function getLyricsBySongId(url: string): Promise<string | null> {
let result: AxiosResponse<string, any>;
try {
result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) {
console.error('Genius lyrics request got an error!', e);
return null;
}
const $ = load(result.data.split('<br/>').join('\n'));
const lyricsDiv = $('div.lyrics');
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
const lyricSections = $('div[class^=Lyrics__Container]')
.map((_, e) => $(e).text())
.toArray()
.join('\n');
return lyricSections;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const response = await getSongId(params);
if (!response) {
console.error('Could not find the song on Genius!');
return null;
}
const lyrics = await getLyricsBySongId(response.id);
if (!lyrics) {
console.error('Could not get lyrics on Genius!');
return null;
}
return {
artist: response.artist,
id: response.id,
lyrics,
name: response.name,
source: LyricSource.GENIUS,
};
}
+149
View File
@@ -0,0 +1,149 @@
import { ipcMain } from 'electron';
import {
query as queryGenius,
getSearchResults as searchGenius,
getLyricsBySongId as getGenius,
} from './genius';
import {
query as queryLrclib,
getSearchResults as searchLrcLib,
getLyricsBySongId as getLrcLib,
} from './lrclib';
import {
query as queryNetease,
getSearchResults as searchNetease,
getLyricsBySongId as getNetease,
} from './netease';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
QueueSong,
LyricGetQuery,
LyricSource,
} from '../../../../renderer/api/types';
import { store } from '../settings/index';
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
type SearchFetcher = (
params: LyricSearchQuery,
) => Promise<InternetProviderLyricSearchResponse[] | null>;
type GetFetcher = (id: string) => Promise<string | null>;
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
const FETCHERS: Record<LyricSource, SongFetcher> = {
[LyricSource.GENIUS]: queryGenius,
[LyricSource.LRCLIB]: queryLrclib,
[LyricSource.NETEASE]: queryNetease,
};
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
[LyricSource.NETEASE]: searchNetease,
};
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
[LyricSource.GENIUS]: getGenius,
[LyricSource.LRCLIB]: getLrcLib,
[LyricSource.NETEASE]: getNetease,
};
const MAX_CACHED_ITEMS = 10;
const lyricCache = new Map<string, CachedLyrics>();
const getRemoteLyrics = async (song: QueueSong) => {
const sources = store.get('lyrics', []) as LyricSource[];
const cached = lyricCache.get(song.id);
if (cached) {
for (const source of sources) {
const data = cached[source];
if (data) return data;
}
}
let lyricsFromSource = null;
for (const source of sources) {
const params = {
album: song.album || song.name,
artist: song.artistName,
duration: song.duration,
name: song.name,
};
const response = await FETCHERS[source](params);
if (response) {
const newResult = cached
? {
...cached,
[source]: response,
}
: ({ [source]: response } as CachedLyrics);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
lyricCache.delete(toRemove);
}
lyricCache.set(song.id, newResult);
lyricsFromSource = response;
break;
}
}
return lyricsFromSource;
};
const searchRemoteLyrics = async (params: LyricSearchQuery) => {
const sources = store.get('lyrics', []) as LyricSource[];
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
[LyricSource.GENIUS]: [],
[LyricSource.LRCLIB]: [],
[LyricSource.NETEASE]: [],
};
for (const source of sources) {
const response = await SEARCH_FETCHERS[source](params);
if (response) {
response.forEach((result) => {
results[source].push(result);
});
}
}
return results;
};
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
const { remoteSongId, remoteSource } = params;
const response = await GET_FETCHERS[remoteSource](remoteSongId);
if (!response) {
return null;
}
return response;
};
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
const lyric = await getRemoteLyrics(song);
return lyric;
});
ipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {
const lyricResults = await searchRemoteLyrics(params);
return lyricResults;
});
ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
const lyricResults = await getRemoteLyricsById(params);
return lyricResults;
});
+119
View File
@@ -0,0 +1,119 @@
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
import axios, { AxiosResponse } from 'axios';
import { orderSearchResults } from './shared';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '../../../../renderer/api/types';
const FETCH_URL = 'https://lrclib.net/api/get';
const SEEARCH_URL = 'https://lrclib.net/api/search';
const TIMEOUT_MS = 5000;
export interface LrcLibSearchResponse {
albumName: string;
artistName: string;
id: number;
name: string;
}
export interface LrcLibTrackResponse {
albumName: string;
artistName: string;
duration: number;
id: number;
instrumental: boolean;
isrc: string;
lang: string;
name: string;
plainLyrics: string | null;
releaseDate: string;
spotifyId: string;
syncedLyrics: string | null;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<LrcLibSearchResponse[]>;
if (!params.name) {
return null;
}
try {
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
params: {
q: params.name,
},
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
if (!result.data) return null;
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
return {
artist: song.artistName,
id: String(song.id),
name: song.name,
source: LyricSource.LRCLIB,
};
});
return orderSearchResults({ params, results: songResults });
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) {
console.error('LrcLib lyrics request got an error!', e);
return null;
}
return result.data.syncedLyrics || result.data.plainLyrics || null;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
params: {
album_name: params.album,
artist_name: params.artist,
duration: params.duration,
track_name: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
if (!lyrics) {
console.error(`Could not get lyrics on LrcLib!`);
return null;
}
return {
artist: result.data.artistName,
id: String(result.data.id),
lyrics,
name: result.data.name,
source: LyricSource.LRCLIB,
};
}
+167
View File
@@ -0,0 +1,167 @@
import axios, { AxiosResponse } from 'axios';
import { LyricSource } from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
import type {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '/@/renderer/api/types';
const SEARCH_URL = 'https://music.163.com/api/search/get';
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
export interface NetEaseResponse {
code: number;
result: Result;
}
export interface Result {
hasMore: boolean;
songCount: number;
songs: Song[];
}
export interface Song {
album: Album;
alias: string[];
artists: Artist[];
copyrightId: number;
duration: number;
fee: number;
ftype: number;
id: number;
mark: number;
mvid: number;
name: string;
rUrl: null;
rtype: number;
status: number;
transNames?: string[];
}
export interface Album {
artist: Artist;
copyrightId: number;
id: number;
mark: number;
name: string;
picId: number;
publishTime: number;
size: number;
status: number;
transNames?: string[];
}
export interface Artist {
albumSize: number;
alias: any[];
fansGroup: null;
id: number;
img1v1: number;
img1v1Url: string;
name: string;
picId: number;
picUrl: null;
trans: null;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<NetEaseResponse>;
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
limit: 5,
offset: 0,
s: searchQuery,
type: '1',
},
});
} catch (e) {
console.error('NetEase search request got an error!', e);
return null;
}
const rawSongsResult = result?.data.result?.songs;
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
return {
artist,
id: String(song.id),
name: song.name,
source: LyricSource.NETEASE,
};
});
return orderSearchResults({ params, results: songResults });
}
async function getMatchedLyrics(
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
const results = await getSearchResults(params);
const firstMatch = results?.[0];
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
return null;
}
return firstMatch;
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<any, any>;
try {
result = await axios.get(LYRICS_URL, {
params: {
id: songId,
kv: '-1',
lv: '-1',
},
});
} catch (e) {
console.error('NetEase lyrics request got an error!', e);
return null;
}
return result.data.klyric?.lyric || result.data.lrc?.lyric;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const lyricsMatch = await getMatchedLyrics(params);
if (!lyricsMatch) {
console.error('Could not find the song on NetEase!');
return null;
}
const lyrics = await getLyricsBySongId(lyricsMatch.id);
if (!lyrics) {
console.error('Could not get lyrics on NetEase!');
return null;
}
return {
artist: lyricsMatch.artist,
id: lyricsMatch.id,
lyrics,
name: lyricsMatch.name,
source: LyricSource.NETEASE,
};
}
+34
View File
@@ -0,0 +1,34 @@
import Fuse from 'fuse.js';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
export const orderSearchResults = (args: {
params: LyricSearchQuery;
results: InternetProviderLyricSearchResponse[];
}) => {
const { params, results } = args;
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
fieldNormWeight: 1,
includeScore: true,
keys: [
{ getFn: (song) => song.name, name: 'name', weight: 3 },
{ getFn: (song) => song.artist, name: 'artist' },
],
threshold: 1.0,
};
const fuse = new Fuse(results, options);
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
return searchResults.map((result) => ({
...result.item,
score: result.score,
}));
};
+121 -37
View File
@@ -1,98 +1,165 @@
import console from 'console';
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { getMpvInstance } from '../../../main'; import { getMpvInstance } from '../../../main';
import { PlayerData } from '/@/renderer/store'; import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv'; declare module 'node-mpv';
function wait(timeout: number) { // function wait(timeout: number) {
return new Promise((resolve) => { // return new Promise((resolve) => {
setTimeout(() => { // setTimeout(() => {
resolve('resolved'); // resolve('resolved');
}, timeout); // }, timeout);
// });
// }
ipcMain.handle('player-is-running', async () => {
return getMpvInstance()?.isRunning();
});
ipcMain.handle('player-clean-up', async () => {
getMpvInstance()?.stop();
getMpvInstance()?.clearPlaylist();
}); });
}
ipcMain.on('player-start', async () => { ipcMain.on('player-start', async () => {
await getMpvInstance()?.play(); await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
}); });
// Starts the player // Starts the player
ipcMain.on('player-play', async () => { ipcMain.on('player-play', async () => {
await getMpvInstance()?.play(); await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
}); });
// Pauses the player // Pauses the player
ipcMain.on('player-pause', async () => { ipcMain.on('player-pause', async () => {
await getMpvInstance()?.pause(); await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
}); });
// Stops the player // Stops the player
ipcMain.on('player-stop', async () => { ipcMain.on('player-stop', async () => {
await getMpvInstance()?.stop(); await getMpvInstance()
?.stop()
.catch((err) => {
console.log('MPV failed to stop', err);
});
}); });
// Goes to the next track in the playlist // Goes to the next track in the playlist
ipcMain.on('player-next', async () => { ipcMain.on('player-next', async () => {
await getMpvInstance()?.next(); await getMpvInstance()
?.next()
.catch((err) => {
console.log('MPV failed to go to next', err);
});
}); });
// Goes to the previous track in the playlist // Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => { ipcMain.on('player-previous', async () => {
await getMpvInstance()?.prev(); await getMpvInstance()
?.prev()
.catch((err) => {
console.log('MPV failed to go to previous', err);
});
}); });
// Seeks forward or backward by the given amount of seconds // Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => { ipcMain.on('player-seek', async (_event, time: number) => {
await getMpvInstance()?.seek(time); await getMpvInstance()
?.seek(time)
.catch((err) => {
console.log('MPV failed to seek', err);
});
}); });
// Seeks to the given time in seconds // Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => { ipcMain.on('player-seek-to', async (_event, time: number) => {
await getMpvInstance()?.goToPosition(time); await getMpvInstance()
?.goToPosition(time)
.catch((err) => {
console.log(`MPV failed to seek to ${time}`, err);
});
}); });
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons // Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => { ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current && !data.queue.next) { if (!data.queue.current && !data.queue.next) {
await getMpvInstance()?.clearPlaylist(); await getMpvInstance()
await getMpvInstance()?.pause(); ?.clearPlaylist()
.catch((err) => {
console.log('MPV failed to clear playlist', err);
});
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
return; return;
} }
let complete = false;
while (!complete) {
try { try {
if (data.queue.current) { if (data.queue.current) {
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace'); getMpvInstance()
} ?.load(data.queue.current.streamUrl, 'replace')
.then(() => {
// eslint-disable-next-line promise/always-return
if (data.queue.next) { if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append'); getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
})
.catch((err) => {
console.log('MPV failed to load song', err);
getMpvInstance()?.play();
});
} }
complete = true;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
await wait(500);
} }
if (pause) {
getMpvInstance()?.pause();
} }
}); });
// Replaces the queue in position 1 to the given data // Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => { ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await getMpvInstance()?.getPlaylistSize(); const size = await getMpvInstance()
?.getPlaylistSize()
.catch((err) => {
console.log('MPV failed to get playlist size', err);
});
if (!size) { if (!size) {
return; return;
} }
if (size > 1) { if (size > 1) {
await getMpvInstance()?.playlistRemove(1); await getMpvInstance()
?.playlistRemove(1)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
} }
if (data.queue.next) { if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append'); await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
} }
}); });
@@ -101,23 +168,40 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue // Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without // This allows us to easily set update the next song in the queue without
// disturbing the currently playing song // disturbing the currently playing song
await getMpvInstance()?.playlistRemove(0); await getMpvInstance()
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
getMpvInstance()?.pause();
});
if (data.queue.next) { if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append'); await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
} }
}); });
// Sets the volume to the given value (0-100) // Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => { ipcMain.on('player-volume', async (_event, value: number) => {
await getMpvInstance()?.volume(value); await getMpvInstance()
?.volume(value)
.catch((err) => {
console.log('MPV failed to set volume', err);
});
}); });
// Toggles the mute status // Toggles the mute status
ipcMain.on('player-mute', async () => { ipcMain.on('player-mute', async (_event, mute: boolean) => {
await getMpvInstance()?.mute(); await getMpvInstance()
?.mute(mute)
.catch((err) => {
console.log('MPV failed to toggle mute', err);
});
}); });
ipcMain.on('player-quit', async () => { ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
await getMpvInstance()?.stop(); return getMpvInstance()?.getTimePosition();
}); });
+651
View File
@@ -0,0 +1,651 @@
import { Stats, promises } from 'fs';
import { readFile } from 'fs/promises';
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
import { join } from 'path';
import { deflate, gzip } from 'zlib';
import axios from 'axios';
import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { isLinux } from '../../../utils';
let mprisPlayer: any | undefined;
if (isLinux()) {
// eslint-disable-next-line global-require
mprisPlayer = require('../../linux/mpris').mprisPlayer;
}
interface RemoteConfig {
enabled: boolean;
password: string;
port: number;
username: string;
}
interface MimeType {
css: string;
html: string;
ico: string;
js: string;
}
interface StatefulWebSocket extends WebSocket {
alive: boolean;
auth: boolean;
}
let server: Server | undefined;
let wsServer: WsServer<StatefulWebSocket> | undefined;
const settings: RemoteConfig = {
enabled: false,
password: '',
port: 4333,
username: '',
};
type SendData = ServerEvent & {
client: StatefulWebSocket;
};
function send({ client, event, data }: SendData): void {
if (client.readyState === WebSocket.OPEN) {
if (client.alive && client.auth) {
client.send(JSON.stringify({ data, event }));
}
}
}
function broadcast(message: ServerEvent): void {
if (wsServer) {
for (const client of wsServer.clients) {
send({ client, ...message });
}
}
}
const shutdownServer = () => {
if (wsServer) {
wsServer.clients.forEach((client) => client.close(4000));
wsServer.close();
wsServer = undefined;
}
if (server) {
server.close();
server = undefined;
}
};
const MIME_TYPES: MimeType = {
css: 'text/css',
html: 'text/html; charset=UTF-8',
ico: 'image/x-icon',
js: 'application/javascript',
};
const PING_TIMEOUT_MS = 10000;
const UP_TIMEOUT_MS = 5000;
enum Encoding {
GZIP = 'gzip',
NONE = 'none',
ZLIB = 'deflate',
}
const GZIP_REGEX = /\bgzip\b/;
const ZLIB_REGEX = /bdeflate\b/;
let currentSong: SongUpdate = {
currentTime: 0,
};
const getEncoding = (encoding: string | string[]): Encoding => {
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
for (const code of encodingArray) {
if (code.match(GZIP_REGEX)) {
return Encoding.GZIP;
}
if (code.match(ZLIB_REGEX)) {
return Encoding.ZLIB;
}
}
return Encoding.NONE;
};
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
function setOk(
res: ServerResponse,
mtimeMs: number,
extension: keyof MimeType,
encoding: Encoding,
data?: Buffer,
) {
res.statusCode = data ? 200 : 304;
res.setHeader('Content-Type', MIME_TYPES[extension]);
res.setHeader('ETag', `"${mtimeMs}"`);
res.setHeader('Cache-Control', 'public');
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
res.end(data);
}
async function serveFile(
req: IncomingMessage,
file: string,
extension: keyof MimeType,
res: ServerResponse,
): Promise<void> {
const fileName = `${file}.${extension}`;
const path = app.isPackaged
? join(__dirname, '../remote', fileName)
: join(__dirname, '../../../../../.erb/dll', fileName);
let stats: Stats;
try {
stats = await promises.stat(path);
} catch (error) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
// This is a resolve, even though it is an error, because we want specific (non 500) status
return Promise.resolve();
}
const encodings = req.headers['accept-encoding'] ?? '';
const selectedEncoding = getEncoding(encodings);
const ifMatch = req.headers['if-none-match'];
const fileInfo = cache.get(fileName);
let cached = fileInfo?.get(selectedEncoding);
if (cached && cached[0] !== stats.mtimeMs) {
cache.get(fileName)!.delete(selectedEncoding);
cached = undefined;
}
if (ifMatch && cached) {
const options = ifMatch.split(',');
for (const option of options) {
const mTime = Number(option.replaceAll('"', '').trim());
if (cached[0] === mTime) {
setOk(res, cached[0], extension, selectedEncoding);
return Promise.resolve();
}
}
}
if (!cached || cached[0] !== stats.mtimeMs) {
const content = await readFile(path);
switch (selectedEncoding) {
case Encoding.GZIP:
return new Promise((resolve, reject) => {
gzip(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
case Encoding.ZLIB:
return new Promise((resolve, reject) => {
deflate(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
default: {
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
return Promise.resolve();
}
}
}
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
return Promise.resolve();
}
function authorize(req: IncomingMessage): boolean {
if (settings.username || settings.password) {
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
const authorization = req.headers.authorization?.split(' ')[1] || '';
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
return login === settings.username && password === settings.password;
}
return true;
}
const enableServer = (config: RemoteConfig): Promise<void> => {
return new Promise<void>((resolve, reject) => {
try {
if (server) {
server.close();
}
server = createServer({}, async (req, res) => {
if (!authorize(req)) {
res.statusCode = 401;
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
res.end('Authorization required');
return;
}
try {
switch (req.url) {
case '/': {
await serveFile(req, 'index', 'html', res);
break;
}
case '/favicon.ico': {
await serveFile(req, 'favicon', 'ico', res);
break;
}
case '/remote.css': {
await serveFile(req, 'remote', 'css', res);
break;
}
case '/remote.js': {
await serveFile(req, 'remote', 'js', res);
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(req.headers.authorization);
break;
}
default: {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not FOund');
}
}
} catch (error) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
}
});
server.listen(config.port, resolve);
wsServer = new WebSocketServer({ server });
wsServer.on('connection', (ws) => {
let authFail: number | undefined;
ws.alive = true;
if (!settings.username && !settings.password) {
ws.auth = true;
} else {
authFail = setTimeout(() => {
if (!ws.auth) {
ws.close();
}
}, 10000) as unknown as number;
}
ws.on('error', console.error);
ws.on('message', (data) => {
try {
const json = JSON.parse(data.toString()) as ClientEvent;
const event = json.event;
if (!ws.auth) {
if (event === 'authenticate') {
const auth = json.header.split(' ')[1];
const [login, password] = Buffer.from(auth, 'base64')
.toString()
.split(':');
if (login === settings.username && password === settings.password) {
ws.auth = true;
} else {
ws.close();
}
clearTimeout(authFail);
} else {
return;
}
}
switch (event) {
case 'pause': {
getMainWindow()?.webContents.send('renderer-player-pause');
break;
}
case 'play': {
getMainWindow()?.webContents.send('renderer-player-play');
break;
}
case 'next': {
getMainWindow()?.webContents.send('renderer-player-next');
break;
}
case 'previous': {
getMainWindow()?.webContents.send('renderer-player-previous');
break;
}
case 'proxy': {
const toFetch = currentSong.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g,
'',
);
if (!toFetch) return;
axios
.get(toFetch, { responseType: 'arraybuffer' })
.then((resp) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: Buffer.from(resp.data, 'binary').toString(
'base64',
),
event: 'proxy',
});
}
return null;
})
.catch((error) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: error.message,
event: 'error',
});
}
});
break;
}
case 'repeat': {
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
break;
}
case 'shuffle': {
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
break;
}
case 'volume': {
let volume = Number(json.volume);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
if (mprisPlayer) {
mprisPlayer.volume = volume / 100;
}
break;
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentSong.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentSong.song.serverId,
});
}
break;
}
}
} catch (error) {
console.error(error);
}
});
ws.on('pong', () => {
ws.alive = true;
});
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
});
const heartBeat = setInterval(() => {
wsServer?.clients.forEach((ws) => {
if (!ws.alive) {
ws.terminate();
return;
}
ws.alive = false;
ws.ping();
});
}, PING_TIMEOUT_MS);
wsServer.on('close', () => {
clearInterval(heartBeat);
});
setTimeout(() => {
reject(new Error('Server did not come up'));
}, UP_TIMEOUT_MS);
} catch (error) {
reject(error);
shutdownServer();
}
});
};
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
settings.enabled = enabled;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
});
ipcMain.handle('remote-port', async (_event, port: number) => {
settings.port = port;
});
ipcMain.on('remote-password', (_event, password: string) => {
settings.password = password;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.handle(
'remote-settings',
async (_event, enabled: boolean, port: number, username: string, password: string) => {
settings.enabled = enabled;
settings.password = password;
settings.port = port;
settings.username = username;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
},
);
ipcMain.on('remote-username', (_event, username: string) => {
settings.username = username;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userFavorite = favorite;
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
return;
}
}
});
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userRating = rating;
broadcast({ data: { id: songId, rating }, event: 'rating' });
return;
}
}
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
ipcMain.on('update-song', (_event, data: SongUpdate) => {
const { song, ...rest } = data;
const songChanged = song?.id !== currentSong.song?.id;
if (!song?.id) {
currentSong = {
...currentSong,
...data,
song: undefined,
};
} else {
currentSong = {
...currentSong,
...data,
};
}
if (songChanged) {
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
} else {
broadcast({ data: rest, event: 'song' });
}
});
ipcMain.on('update-volume', (_event, volume: number) => {
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
if (mprisPlayer) {
mprisPlayer.on('loopStatus', (event: string) => {
const repeat =
event === 'Playlist'
? PlayerRepeat.ALL
: event === 'Track'
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
mprisPlayer.on('shuffle', (shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
mprisPlayer.on('volume', (vol: number) => {
let volume = Math.round(vol * 100);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
}
+39 -1
View File
@@ -1,4 +1,4 @@
import { ipcMain } from 'electron'; import { ipcMain, safeStorage } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
export const store = new Store(); export const store = new Store();
@@ -10,3 +10,41 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => {
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => { ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
store.set(`${data.property}`, data.value); store.set(`${data.property}`, data.value);
}); });
ipcMain.handle('password-get', (_event, server: string): string | null => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
if (!servers) {
return null;
}
const encrypted = servers[server];
if (!encrypted) return null;
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
return decrypted;
}
return null;
});
ipcMain.on('password-remove', (_event, server: string) => {
const passwords = store.get('server', {}) as Record<string, string>;
if (server in passwords) {
delete passwords[server];
}
store.set({ server: passwords });
});
ipcMain.handle('password-set', (_event, password: string, server: string) => {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(password);
const passwords = store.get('server', {}) as Record<string, string>;
passwords[server] = encrypted.toString('hex');
store.set({ server: passwords });
return true;
}
return false;
});
+37 -36
View File
@@ -1,8 +1,7 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import Player from 'mpris-service'; import Player from 'mpris-service';
import { QueueSong, RelatedArtist } from '../../../renderer/api/types'; import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
import { getMainWindow } from '../../main'; import { getMainWindow } from '../../main';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
const mprisPlayer = Player({ const mprisPlayer = Player({
identity: 'Feishin', identity: 'Feishin',
@@ -59,9 +58,17 @@ mprisPlayer.on('previous', () => {
} }
}); });
mprisPlayer.on('volume', (event: any) => { mprisPlayer.on('volume', (vol: number) => {
getMainWindow()?.webContents.send('mpris-request-volume', { let volume = Math.round(vol * 100);
volume: event,
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
getMainWindow()?.webContents.send('request-volume', {
volume,
}); });
}); });
@@ -76,13 +83,13 @@ mprisPlayer.on('loopStatus', (event: string) => {
}); });
mprisPlayer.on('position', (event: any) => { mprisPlayer.on('position', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-position', { getMainWindow()?.webContents.send('request-position', {
position: event.position / 1e6, position: event.position / 1e6,
}); });
}); });
mprisPlayer.on('seek', (event: number) => { mprisPlayer.on('seek', (event: number) => {
getMainWindow()?.webContents.send('mpris-request-seek', { getMainWindow()?.webContents.send('request-seek', {
offset: event / 1e6, offset: event / 1e6,
}); });
}); });
@@ -95,38 +102,32 @@ ipcMain.on('mpris-update-seek', (_event, arg) => {
mprisPlayer.seeked(arg * 1e6); mprisPlayer.seeked(arg * 1e6);
}); });
ipcMain.on('mpris-update-volume', (_event, arg) => { ipcMain.on('update-volume', (_event, volume) => {
mprisPlayer.volume = Number(arg); mprisPlayer.volume = Number(volume) / 100;
}); });
ipcMain.on('mpris-update-repeat', (_event, arg) => { const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
mprisPlayer.loopStatus = arg; [PlayerRepeat.ALL]: 'Playlist',
[PlayerRepeat.ONE]: 'Track',
[PlayerRepeat.NONE]: 'None',
};
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[arg];
}); });
ipcMain.on('mpris-update-shuffle', (_event, arg) => { ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = arg; mprisPlayer.shuffle = shuffle;
}); });
ipcMain.on( ipcMain.on('update-song', (_event, args: SongUpdate) => {
'mpris-update-song',
(
_event,
args: {
currentTime: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
song: QueueSong;
status: PlayerStatus;
},
) => {
const { song, status, repeat, shuffle } = args || {}; const { song, status, repeat, shuffle } = args || {};
try { try {
mprisPlayer.playbackStatus = status; mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
if (repeat) { if (repeat) {
mprisPlayer.loopStatus = mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
} }
if (shuffle) { if (shuffle) {
@@ -144,16 +145,15 @@ ipcMain.on(
mprisPlayer.metadata = { mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl, 'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null, 'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song?.id 'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`) ? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '', : '',
'xesam:album': song.album || null, 'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null, 'xesam:albumArtist': song.albumArtists?.length
'xesam:artist': ? song.albumArtists.map((artist) => artist.name)
song.artists?.length !== 0
? song.artists?.map((artist: RelatedArtist) => artist.name)
: null, : null,
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
'xesam:discNumber': song.discNumber ? song.discNumber : null, 'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null, 'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:title': song.name || null, 'xesam:title': song.name || null,
@@ -164,5 +164,6 @@ ipcMain.on(
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
}, });
);
export { mprisPlayer };
+258 -29
View File
@@ -8,7 +8,9 @@
* When running `npm run build` or `npm run build:main`, this file is compiled to * When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins. * `./src/main.js` using webpack. This gives us some performance wins.
*/ */
import path from 'path'; import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import { import {
app, app,
BrowserWindow, BrowserWindow,
@@ -18,6 +20,7 @@ import {
Tray, Tray,
Menu, Menu,
nativeImage, nativeImage,
BrowserWindowConstructorOptions,
} from 'electron'; } from 'electron';
import electronLocalShortcut from 'electron-localshortcut'; import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log'; import log from 'electron-log';
@@ -27,7 +30,7 @@ import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index'; import { store } from './features/core/settings/index';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils'; import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import './features'; import './features';
declare module 'node-mpv'; declare module 'node-mpv';
@@ -81,6 +84,10 @@ const singleInstance = app.requestSingleInstanceLock();
if (!singleInstance) { if (!singleInstance) {
app.quit(); app.quit();
} else {
app.on('second-instance', () => {
mainWindow?.show();
});
} }
const RESOURCES_PATH = app.isPackaged const RESOURCES_PATH = app.isPackaged
@@ -97,7 +104,6 @@ export const getMainWindow = () => {
const createWinThumbarButtons = () => { const createWinThumbarButtons = () => {
if (isWindows()) { if (isWindows()) {
console.log('setting buttons');
getMainWindow()?.setThumbarButtons([ getMainWindow()?.setThumbarButtons([
{ {
click: () => getMainWindow()?.webContents.send('renderer-player-previous'), click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
@@ -123,7 +129,9 @@ const createTray = () => {
return; return;
} }
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico')); tray = isLinux()
? new Tray(getAssetPath('icons/icon.png'))
: new Tray(getAssetPath('icons/icon.ico'));
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ {
click: () => { click: () => {
@@ -182,14 +190,36 @@ const createWindow = async () => {
await installExtensions(); await installExtensions();
} }
const nativeFrame = store.get('window_window_bar_style') === 'linux';
store.set('window_has_frame', nativeFrame);
const nativeFrameConfig: Record<string, BrowserWindowConstructorOptions> = {
linux: {
autoHideMenuBar: true,
frame: true,
},
macOS: {
autoHideMenuBar: true,
frame: false,
titleBarStyle: 'hidden',
trafficLightPosition: { x: 10, y: 10 },
},
windows: {
autoHideMenuBar: true,
frame: true,
},
};
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
autoHideMenuBar: true,
frame: false, frame: false,
height: 900, height: 900,
icon: getAssetPath('icon.png'), icon: getAssetPath('icons/icon.png'),
minHeight: 600, minHeight: 640,
minWidth: 640, minWidth: 480,
show: false, show: false,
webPreferences: { webPreferences: {
allowRunningInsecureContent: !!store.get('ignore_ssl'),
backgroundThrottling: false, backgroundThrottling: false,
contextIsolation: true, contextIsolation: true,
devTools: true, devTools: true,
@@ -197,9 +227,12 @@ const createWindow = async () => {
preload: app.isPackaged preload: app.isPackaged
? path.join(__dirname, 'preload.js') ? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'), : path.join(__dirname, '../../.erb/dll/preload.js'),
webSecurity: store.get('ignore_cors') ? false : undefined, webSecurity: !store.get('ignore_cors'),
}, },
width: 1440, width: 1440,
...(nativeFrame && isLinux() && nativeFrameConfig.linux),
...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
}); });
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => { electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
@@ -227,8 +260,18 @@ const createWindow = async () => {
}); });
ipcMain.on('app-restart', () => { ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
app.exit();
app.relaunch({
args: process.argv.slice(1).concat(['--appimage-extract-and-run']),
execPath: process.env.APPIMAGE,
});
app.exit(0);
} else {
app.relaunch(); app.relaunch();
app.exit(0); app.exit(0);
}
}); });
ipcMain.on('global-media-keys-enable', () => { ipcMain.on('global-media-keys-enable', () => {
@@ -239,9 +282,39 @@ const createWindow = async () => {
disableMediaKeys(); disableMediaKeys();
}); });
ipcMain.on('player-restore-queue', () => {
if (store.get('resume')) {
const queueLocation = join(app.getPath('userData'), 'queue');
access(queueLocation, constants.F_OK, (accessError) => {
if (accessError) {
console.error('unable to access saved queue: ', accessError);
return;
}
readFile(queueLocation, (readError, buffer) => {
if (readError) {
console.error('failed to read saved queue: ', readError);
return;
}
inflate(buffer, (decompressError, data) => {
if (decompressError) {
console.error('failed to decompress queue: ', decompressError);
return;
}
const queue = JSON.parse(data.toString());
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
});
});
});
}
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean; const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) { if (globalMediaKeysEnabled !== false) {
enableMediaKeys(mainWindow); enableMediaKeys(mainWindow);
} }
@@ -263,6 +336,8 @@ const createWindow = async () => {
mainWindow = null; mainWindow = null;
}); });
let saved = false;
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
if (!exitFromTray && store.get('window_exit_to_tray')) { if (!exitFromTray && store.get('window_exit_to_tray')) {
if (isMacOS() && !forceQuit) { if (isMacOS() && !forceQuit) {
@@ -271,6 +346,43 @@ const createWindow = async () => {
event.preventDefault(); event.preventDefault();
mainWindow?.hide(); mainWindow?.hide();
} }
if (!saved && store.get('resume')) {
event.preventDefault();
saved = true;
getMainWindow()?.webContents.send('renderer-player-save-queue');
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
const queueLocation = join(app.getPath('userData'), 'queue');
const serialized = JSON.stringify(data);
try {
await new Promise<void>((resolve, reject) => {
deflate(serialized, { level: 1 }, (error, deflated) => {
if (error) {
reject(error);
} else {
writeFile(queueLocation, deflated, (writeError) => {
if (writeError) {
reject(writeError);
} else {
resolve();
}
});
}
});
});
} catch (error) {
console.error('error saving queue state: ', error);
} finally {
mainWindow?.close();
if (forceQuit) {
app.exit();
}
}
});
}
}); });
mainWindow.on('minimize', (event: any) => { mainWindow.on('minimize', (event: any) => {
@@ -308,7 +420,6 @@ const createWindow = async () => {
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService'); app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined; const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const prefetchPlaylistParams = [ const prefetchPlaylistParams = [
'--prefetch-playlist=no', '--prefetch-playlist=no',
@@ -316,10 +427,10 @@ const prefetchPlaylistParams = [
'--prefetch-playlist', '--prefetch-playlist',
]; ];
const DEFAULT_MPV_PARAMETERS = () => { const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = []; const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) { if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes'); parameters.push('--prefetch-playlist=yes');
} }
@@ -331,26 +442,39 @@ let mpvInstance: MpvAPI | null = null;
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => { const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
const { extraParameters, properties } = data; const { extraParameters, properties } = data;
mpvInstance = new MpvAPI( const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
console.log('Setting mpv params: ', params);
const extra = isDevelopment ? '-dev' : '';
const mpv = new MpvAPI(
{ {
audio_only: true, audio_only: true,
auto_restart: false, auto_restart: false,
binary: MPV_BINARY_PATH || '', binary: MPV_BINARY_PATH || '',
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
time_update: 1, time_update: 1,
}, },
MPV_PARAMETERS || extraParameters params,
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
: DEFAULT_MPV_PARAMETERS(),
); );
mpvInstance.setMultipleProperties(properties || {}); // eslint-disable-next-line promise/catch-or-return
mpv.start()
mpvInstance.start().catch((error) => { .catch((error) => {
console.log('error starting mpv', error); console.log('MPV failed to start', error);
})
.finally(() => {
console.log('Setting MPV properties: ', properties);
mpv.setMultipleProperties(properties || {});
}); });
mpvInstance.on('status', (status) => { mpv.on('status', (status, ...rest) => {
console.log('MPV Event: status', status.property, status.value, rest);
if (status.property === 'playlist-pos') { if (status.property === 'playlist-pos') {
if (status.value === -1) {
mpv?.stop();
}
if (status.value !== 0) { if (status.value !== 0) {
getMainWindow()?.webContents.send('renderer-player-auto-next'); getMainWindow()?.webContents.send('renderer-player-auto-next');
} }
@@ -358,24 +482,33 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
}); });
// Automatically updates the play button when the player is playing // Automatically updates the play button when the player is playing
mpvInstance.on('resumed', () => { mpv.on('resumed', () => {
console.log('MPV Event: resumed');
getMainWindow()?.webContents.send('renderer-player-play'); getMainWindow()?.webContents.send('renderer-player-play');
}); });
// Automatically updates the play button when the player is stopped // Automatically updates the play button when the player is stopped
mpvInstance.on('stopped', () => { mpv.on('stopped', () => {
console.log('MPV Event: stopped');
getMainWindow()?.webContents.send('renderer-player-stop'); getMainWindow()?.webContents.send('renderer-player-stop');
}); });
// Automatically updates the play button when the player is paused // Automatically updates the play button when the player is paused
mpvInstance.on('paused', () => { mpv.on('paused', () => {
console.log('MPV Event: paused');
getMainWindow()?.webContents.send('renderer-player-pause'); getMainWindow()?.webContents.send('renderer-player-pause');
}); });
// Event output every interval set by time_update, used to update the current time // Event output every interval set by time_update, used to update the current time
mpvInstance.on('timeposition', (time: number) => { mpv.on('timeposition', (time: number) => {
getMainWindow()?.webContents.send('renderer-player-current-time', time); getMainWindow()?.webContents.send('renderer-player-current-time', time);
}); });
mpv.on('quit', () => {
console.log('MPV Event: quit');
});
return mpv;
}; };
export const getMpvInstance = () => { export const getMpvInstance = () => {
@@ -398,12 +531,109 @@ ipcMain.on(
'player-restart', 'player-restart',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => { async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
mpvInstance?.quit(); mpvInstance?.quit();
createMpv(data); mpvInstance = createMpv(data);
},
);
ipcMain.on(
'player-initialize',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
console.log('Initializing MPV with data: ', data);
mpvInstance = createMpv(data);
},
);
ipcMain.on('player-quit', async () => {
mpvInstance?.stop();
mpvInstance?.quit();
mpvInstance = null;
});
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NEXT = 'next',
PAUSE = 'pause',
PLAY = 'play',
PLAY_PAUSE = 'playPause',
PREVIOUS = 'previous',
SHUFFLE = 'toggleShuffle',
SKIP_BACKWARD = 'skipBackward',
SKIP_FORWARD = 'skipForward',
STOP = 'stop',
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
TOGGLE_QUEUE = 'toggleQueue',
TOGGLE_REPEAT = 'toggleRepeat',
VOLUME_DOWN = 'volumeDown',
VOLUME_UP = 'volumeUp',
}
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
[BindingActions.PLAY_PAUSE]: () =>
getMainWindow()?.webContents.send('renderer-player-play-pause'),
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
[BindingActions.SHUFFLE]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
[BindingActions.SKIP_BACKWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
[BindingActions.SKIP_FORWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
[BindingActions.TOGGLE_REPEAT]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
[BindingActions.VOLUME_UP]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-up'),
[BindingActions.VOLUME_DOWN]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-down'),
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
[BindingActions.TOGGLE_QUEUE]: () => {},
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
};
ipcMain.on(
'set-global-shortcuts',
(
_event,
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
) => {
// Since we're not tracking the previous shortcuts, we need to unregister all of them
globalShortcut.unregisterAll();
for (const shortcut of Object.keys(data)) {
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
const isValidHotkey =
data[shortcut as BindingActions].hotkey &&
data[shortcut as BindingActions].hotkey !== '';
if (isGlobalHotkey && isValidHotkey) {
const accelerator = hotkeyToElectronAccelerator(
data[shortcut as BindingActions].hotkey,
);
globalShortcut.register(accelerator, () => {
HOTKEY_ACTIONS[shortcut as BindingActions]();
});
}
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
}, },
); );
app.on('before-quit', () => { app.on('before-quit', () => {
getMpvInstance()?.stop(); getMpvInstance()?.stop();
getMpvInstance()?.quit();
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
@@ -418,8 +648,7 @@ app.on('window-all-closed', () => {
} }
}); });
app app.whenReady()
.whenReady()
.then(() => { .then(() => {
createWindow(); createWindow();
createTray(); createTray();
+15 -5
View File
@@ -18,7 +18,9 @@ export default class MenuBuilder {
} }
const template = const template =
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate(); process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
@@ -151,7 +153,9 @@ export default class MenuBuilder {
}, },
{ {
click() { click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme'); shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
}, },
label: 'Documentation', label: 'Documentation',
}, },
@@ -211,7 +215,9 @@ export default class MenuBuilder {
{ {
accelerator: 'F11', accelerator: 'F11',
click: () => { click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
}, },
label: 'Toggle &Full Screen', label: 'Toggle &Full Screen',
}, },
@@ -227,7 +233,9 @@ export default class MenuBuilder {
{ {
accelerator: 'F11', accelerator: 'F11',
click: () => { click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
}, },
label: 'Toggle &Full Screen', label: 'Toggle &Full Screen',
}, },
@@ -244,7 +252,9 @@ export default class MenuBuilder {
}, },
{ {
click() { click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme'); shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
}, },
label: 'Documentation', label: 'Documentation',
}, },
+4
View File
@@ -2,16 +2,20 @@ import { contextBridge } from 'electron';
import { browser } from './preload/browser'; import { browser } from './preload/browser';
import { ipc } from './preload/ipc'; import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings'; import { localSettings } from './preload/local-settings';
import { lyrics } from './preload/lyrics';
import { mpris } from './preload/mpris'; import { mpris } from './preload/mpris';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player'; import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { remote } from './preload/remote';
import { utils } from './preload/utils'; import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
browser, browser,
ipc, ipc,
localSettings, localSettings,
lyrics,
mpris, mpris,
mpvPlayer, mpvPlayer,
mpvPlayerListener, mpvPlayerListener,
remote,
utils, utils,
}); });
+7
View File
@@ -4,6 +4,13 @@ const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel); ipcRenderer.removeAllListeners(channel);
}; };
const send = (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
};
export const ipc = { export const ipc = {
removeAllListeners, removeAllListeners,
send,
}; };
export type Ipc = typeof ipc;
+23 -1
View File
@@ -1,4 +1,4 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
const store = new Store(); const store = new Store();
@@ -23,10 +23,32 @@ const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable'); ipcRenderer.send('global-media-keys-disable');
}; };
const passwordGet = async (server: string): Promise<string | null> => {
return ipcRenderer.invoke('password-get', server);
};
const passwordRemove = (server: string) => {
ipcRenderer.send('password-remove', server);
};
const passwordSet = async (password: string, server: string): Promise<boolean> => {
return ipcRenderer.invoke('password-set', password, server);
};
const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
};
export const localSettings = { export const localSettings = {
disableMediaKeys, disableMediaKeys,
enableMediaKeys, enableMediaKeys,
get, get,
passwordGet,
passwordRemove,
passwordSet,
restart, restart,
set, set,
setZoomFactor,
}; };
export type LocalSettings = typeof localSettings;
+33
View File
@@ -0,0 +1,33 @@
import { ipcRenderer } from 'electron';
import {
InternetProviderLyricSearchResponse,
LyricGetQuery,
LyricSearchQuery,
LyricSource,
QueueSong,
} from '/@/renderer/api/types';
const getRemoteLyricsBySong = (song: QueueSong) => {
const result = ipcRenderer.invoke('lyric-by-song', song);
return result;
};
const searchRemoteLyrics = (
params: LyricSearchQuery,
): Promise<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {
const result = ipcRenderer.invoke('lyric-search', params);
return result;
};
const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
return result;
};
export const lyrics = {
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
};
export type Lyrics = typeof lyrics;
+9 -38
View File
@@ -1,9 +1,5 @@
import { IpcRendererEvent, ipcRenderer } from 'electron'; import { IpcRendererEvent, ipcRenderer } from 'electron';
import { QueueSong } from '/@/renderer/api/types'; import type { PlayerRepeat } from '/@/renderer/types';
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
ipcRenderer.send('mpris-update-song', args);
};
const updatePosition = (timeSec: number) => { const updatePosition = (timeSec: number) => {
ipcRenderer.send('mpris-update-position', timeSec); ipcRenderer.send('mpris-update-position', timeSec);
@@ -13,18 +9,6 @@ const updateSeek = (timeSec: number) => {
ipcRenderer.send('mpris-update-seek', timeSec); ipcRenderer.send('mpris-update-seek', timeSec);
}; };
const updateVolume = (volume: number) => {
ipcRenderer.send('mpris-update-volume', volume);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('mpris-update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('mpris-update-shuffle', shuffle);
};
const toggleRepeat = () => { const toggleRepeat = () => {
ipcRenderer.send('mpris-toggle-repeat'); ipcRenderer.send('mpris-toggle-repeat');
}; };
@@ -33,38 +17,25 @@ const toggleShuffle = () => {
ipcRenderer.send('mpris-toggle-shuffle'); ipcRenderer.send('mpris-toggle-shuffle');
}; };
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => { const requestToggleRepeat = (
ipcRenderer.on('mpris-request-position', cb); cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
}; ) => {
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('mpris-request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('mpris-request-volume', cb);
};
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb); ipcRenderer.on('mpris-request-toggle-repeat', cb);
}; };
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => { const requestToggleShuffle = (
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb); ipcRenderer.on('mpris-request-toggle-shuffle', cb);
}; };
export const mpris = { export const mpris = {
requestPosition,
requestSeek,
requestToggleRepeat, requestToggleRepeat,
requestToggleShuffle, requestToggleShuffle,
requestVolume,
toggleRepeat, toggleRepeat,
toggleShuffle, toggleShuffle,
updatePosition, updatePosition,
updateRepeat,
updateSeek, updateSeek,
updateShuffle,
updateSong,
updateVolume,
}; };
export type Mpris = typeof mpris;
+90 -5
View File
@@ -1,10 +1,22 @@
import { ipcRenderer, IpcRendererEvent } from 'electron'; import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store'; import { PlayerData, PlayerState } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => { const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data); ipcRenderer.send('player-restart', data);
}; };
const isRunning = () => {
return ipcRenderer.invoke('player-is-running');
};
const cleanup = () => {
return ipcRenderer.invoke('player-clean-up');
};
const setProperties = (data: Record<string, any>) => { const setProperties = (data: Record<string, any>) => {
console.log('Setting property :>>', data); console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data); ipcRenderer.send('player-set-properties', data);
@@ -18,8 +30,8 @@ const currentTime = () => {
ipcRenderer.send('player-current-time'); ipcRenderer.send('player-current-time');
}; };
const mute = () => { const mute = (mute: boolean) => {
ipcRenderer.send('player-mute'); ipcRenderer.send('player-mute', mute);
}; };
const next = () => { const next = () => {
@@ -38,6 +50,14 @@ const previous = () => {
ipcRenderer.send('player-previous'); ipcRenderer.send('player-previous');
}; };
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => { const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds); ipcRenderer.send('player-seek', seconds);
}; };
@@ -46,8 +66,8 @@ const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds); ipcRenderer.send('player-seek-to', seconds);
}; };
const setQueue = (data: PlayerData) => { const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data); ipcRenderer.send('player-set-queue', data, pause);
}; };
const setQueueNext = (data: PlayerData) => { const setQueueNext = (data: PlayerData) => {
@@ -66,6 +86,10 @@ const quit = () => {
ipcRenderer.send('player-quit'); ipcRenderer.send('player-quit');
}; };
const getCurrentTime = async () => {
return ipcRenderer.invoke('player-get-time');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => { const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb); ipcRenderer.on('renderer-player-auto-next', cb);
}; };
@@ -98,13 +122,59 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) =
ipcRenderer.on('renderer-player-stop', cb); ipcRenderer.on('renderer-player-stop', cb);
}; };
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => { const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb); ipcRenderer.on('renderer-player-quit', cb);
}; };
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
};
export const mpvPlayer = { export const mpvPlayer = {
autoNext, autoNext,
cleanup,
currentTime, currentTime,
getCurrentTime,
initialize,
isRunning,
mute, mute,
next, next,
pause, pause,
@@ -112,6 +182,8 @@ export const mpvPlayer = {
previous, previous,
quit, quit,
restart, restart,
restoreQueue,
saveQueue,
seek, seek,
seekTo, seekTo,
setProperties, setProperties,
@@ -124,11 +196,24 @@ export const mpvPlayer = {
export const mpvPlayerListener = { export const mpvPlayerListener = {
rendererAutoNext, rendererAutoNext,
rendererCurrentTime, rendererCurrentTime,
rendererError,
rendererNext, rendererNext,
rendererPause, rendererPause,
rendererPlay, rendererPlay,
rendererPlayPause, rendererPlayPause,
rendererPrevious, rendererPrevious,
rendererQuit, rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop, rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
}; };
export type MpvPLayer = typeof mpvPlayer;
export type MpvPlayerListener = typeof mpvPlayerListener;
+101
View File
@@ -0,0 +1,101 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { SongUpdate } from '/@/renderer/types';
const requestFavorite = (
cb: (
event: IpcRendererEvent,
data: { favorite: boolean; id: string; serverId: string },
) => void,
) => {
ipcRenderer.on('request-favorite', cb);
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('request-position', cb);
};
const requestRating = (
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
) => {
ipcRenderer.on('request-rating', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
};
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-enable', enabled);
return result;
};
const setRemotePort = (port: number): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-port', port);
return result;
};
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
ipcRenderer.send('update-favorite', favorite, serverId, ids);
};
const updatePassword = (password: string) => {
ipcRenderer.send('remote-password', password);
};
const updateSetting = (
enabled: boolean,
port: number,
username: string,
password: string,
): Promise<string | null> => {
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
};
const updateRating = (rating: number, serverId: string, ids: string[]) => {
ipcRenderer.send('update-rating', rating, serverId, ids);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (args: SongUpdate) => {
ipcRenderer.send('update-song', args);
};
const updateUsername = (username: string) => {
ipcRenderer.send('remote-username', username);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('update-volume', volume);
};
export const remote = {
requestFavorite,
requestPosition,
requestRating,
requestSeek,
requestVolume,
setRemoteEnabled,
setRemotePort,
updateFavorite,
updatePassword,
updateRating,
updateRepeat,
updateSetting,
updateShuffle,
updateSong,
updateUsername,
updateVolume,
};
export type Remote = typeof remote;
+2
View File
@@ -5,3 +5,5 @@ export const utils = {
isMacOS, isMacOS,
isWindows, isWindows,
}; };
export type Utils = typeof utils;
+21
View File
@@ -29,3 +29,24 @@ export const isWindows = () => {
export const isLinux = () => { export const isLinux = () => {
return process.platform === 'linux'; return process.platform === 'linux';
}; };
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
return accelerator;
};
+84
View File
@@ -0,0 +1,84 @@
import { useEffect } from 'react';
import { MantineProvider } from '@mantine/core';
import './styles/global.scss';
import { useIsDark, useReconnect } from '/@/remote/store';
import { Shell } from '/@/remote/components/shell';
export const App = () => {
const isDark = useIsDark();
const reconnect = useReconnect();
useEffect(() => {
reconnect();
}, [reconnect]);
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: isDark ? 'dark' : 'light',
components: {
AppShell: {
styles: {
body: {
height: '100vh',
overflow: 'scroll',
},
},
},
Modal: {
styles: {
body: {
background: 'var(--modal-bg)',
height: '100vh',
},
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
},
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
},
other: {},
spacing: {
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<Shell />
</MantineProvider>
);
};
@@ -0,0 +1,20 @@
import { CiImageOff, CiImageOn } from 'react-icons/ci';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useShowImage, useToggleShowImage } from '/@/remote/store';
export const ImageButton = () => {
const showImage = useShowImage();
const toggleImage = useToggleShowImage();
return (
<RemoteButton
mr={5}
size="xl"
tooltip={showImage ? 'Hide Image' : 'Show Image'}
variant="default"
onClick={() => toggleImage()}
>
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
</RemoteButton>
);
};
@@ -0,0 +1,21 @@
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useConnected, useReconnect } from '/@/remote/store';
import { RiRestartLine } from 'react-icons/ri';
export const ReconnectButton = () => {
const connected = useConnected();
const reconnect = useReconnect();
return (
<RemoteButton
$active={!connected}
mr={5}
size="xl"
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
variant="default"
onClick={() => reconnect()}
>
<RiRestartLine size={30} />
</RemoteButton>
);
};
@@ -0,0 +1,60 @@
import { MouseEvent, ReactNode, Ref, forwardRef } from 'react';
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
import { Tooltip } from '/@/renderer/components/tooltip';
import styled from 'styled-components';
interface StyledButtonProps extends MantineButtonProps {
$active?: boolean;
children: ReactNode;
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
ref: Ref<HTMLButtonElement>;
}
export interface ButtonProps extends StyledButtonProps {
tooltip: string;
}
const StyledButton = styled(Button)<StyledButtonProps>`
svg {
display: flex;
fill: ${({ $active: active }) =>
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
stroke: var(--playerbar-btn-fg);
}
&:hover {
background: var(--playerbar-btn-bg-hover);
svg {
fill: ${({ $active: active }) =>
active
? 'var(--primary-color) !important'
: 'var(--playerbar-btn-fg-hover) !important'};
}
}
`;
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, tooltip, ...props }: ButtonProps, ref) => {
return (
<Tooltip
withinPortal
label={tooltip}
>
<StyledButton
{...props}
ref={ref}
>
{children}
</StyledButton>
</Tooltip>
);
},
);
RemoteButton.defaultProps = {
$active: false,
onClick: undefined,
onMouseDown: undefined,
};
@@ -0,0 +1,27 @@
import { useIsDark, useToggleDark } from '/@/remote/store';
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { AppTheme } from '/@/renderer/themes/types';
import { useEffect } from 'react';
export const ThemeButton = () => {
const isDark = useIsDark();
const toggleDark = useToggleDark();
useEffect(() => {
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
document.body.setAttribute('data-theme', targetTheme);
}, [isDark]);
return (
<RemoteButton
mr={5}
size="xl"
tooltip="Toggle Theme"
variant="default"
onClick={() => toggleDark()}
>
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
</RemoteButton>
);
};
+175
View File
@@ -0,0 +1,175 @@
import { useCallback } from 'react';
import { Group, Image, Rating, Text, Title } from '@mantine/core';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import formatDuration from 'format-duration';
import debounce from 'lodash/debounce';
import {
RiHeartLine,
RiPauseFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiVolumeUpFill,
} from 'react-icons/ri';
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
const send = useSend();
const showImage = useShowImage();
const id = song?.id;
const setRating = useCallback(
(rating: number) => {
send({ event: 'rating', id: id!, rating });
},
[send, id],
);
const debouncedSetRating = debounce(setRating, 400);
return (
<>
{song && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
<Title order={2}>Album: {song.album}</Title>
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Group position="apart">
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
{song.releaseDate && (
<Title order={3}>
Released: {new Date(song.releaseDate).toLocaleDateString()}
</Title>
)}
<Title order={3}>Plays: {song.playCount}</Title>
</Group>
</>
)}
<Group
grow
spacing={0}
>
<RemoteButton
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
>
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
send({ event: 'play' });
}
}}
>
{status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
>
<RiSkipForwardFill size={25} />
</RemoteButton>
</Group>
<Group
grow
spacing={0}
>
<RemoteButton
$active={shuffle || false}
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
variant="default"
onClick={() => send({ event: 'shuffle' })}
>
<RiShuffleFill size={25} />
</RemoteButton>
<RemoteButton
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
tooltip={`Repeat ${
repeat === PlayerRepeat.ONE
? 'One'
: repeat === PlayerRepeat.ALL
? 'all'
: 'none'
}`}
variant="default"
onClick={() => send({ event: 'repeat' })}
>
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={25} />
) : (
<RiRepeat2Line size={25} />
)}
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!song}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
variant="default"
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
>
<RiHeartLine size={25} />
</RemoteButton>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Rating
sx={{ margin: 'auto' }}
value={song.userRating ?? 0}
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
/>
</Tooltip>
</div>
)}
</Group>
<WrapperSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
rightLabel={
<Text
size="xs"
weight={600}
>
{volume ?? 0}
</Text>
}
value={volume ?? 0}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
/>
{showImage && (
<Image
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
onError={() => send({ event: 'proxy' })}
/>
)}
</>
);
};
+75
View File
@@ -0,0 +1,75 @@
import {
AppShell,
Container,
Flex,
Grid,
Header,
Image,
MediaQuery,
Skeleton,
Title,
} from '@mantine/core';
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
import { ImageButton } from '/@/remote/components/buttons/image-button';
import { RemoteContainer } from '/@/remote/components/remote-container';
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
import { useConnected } from '/@/remote/store';
export const Shell = () => {
const connected = useConnected();
return (
<AppShell
header={
<Header height={60}>
<Grid>
<Grid.Col span="auto">
<div>
<Image
fit="contain"
height={60}
src="/favicon.ico"
width={60}
/>
</div>
</Grid.Col>
<MediaQuery
smallerThan="sm"
styles={{ display: 'none' }}
>
<Grid.Col
sm={6}
xs={0}
>
<Title ta="center">Feishin Remote</Title>
</Grid.Col>
</MediaQuery>
<Grid.Col span="auto">
<Flex
direction="row"
justify="right"
>
<ReconnectButton />
<ImageButton />
<ThemeButton />
</Flex>
</Grid.Col>
</Grid>
</Header>
}
padding="md"
>
<Container>
{connected ? (
<RemoteContainer />
) : (
<Skeleton
height={300}
width="100%"
/>
)}
</Container>
</AppShell>
);
};
+62
View File
@@ -0,0 +1,62 @@
import { useState, ReactNode } from 'react';
import { SliderProps } from '@mantine/core';
import styled from 'styled-components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
margin: 10px 0;
`;
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
leftLabel?: ReactNode;
onChangeEnd: (value: number) => void;
rightLabel?: ReactNode;
value: number;
}
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const [seek, setSeek] = useState(0);
return (
<SliderContainer>
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
<SliderWrapper>
<PlayerbarSlider
{...props}
min={0}
size={6}
value={!isSeeking ? value ?? 0 : seek}
w="100%"
onChange={(e) => {
setIsSeeking(true);
setSeek(e);
}}
onChangeEnd={(e) => {
props.onChangeEnd(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
</SliderContainer>
);
};
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin Remote</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import { Notifications } from '@mantine/notifications';
import { createRoot } from 'react-dom/client';
import { App } from '/@/remote/app';
const container = document.getElementById('root')! as HTMLElement;
const root = createRoot(container);
root.render(
<>
<Notifications
containerWidth="300px"
position="bottom-center"
/>
<App />
</>,
);
+239
View File
@@ -0,0 +1,239 @@
import { hideNotification, showNotification } from '@mantine/notifications';
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import merge from 'lodash/merge';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
interface StatefulWebSocket extends WebSocket {
natural: boolean;
}
interface SettingsState {
connected: boolean;
info: Omit<SongUpdateSocket, 'currentTime'>;
isDark: boolean;
showImage: boolean;
socket?: StatefulWebSocket;
}
export interface SettingsSlice extends SettingsState {
actions: {
reconnect: () => void;
send: (data: ClientEvent) => void;
toggleIsDark: () => void;
toggleShowImage: () => void;
};
}
const initialState: SettingsState = {
connected: false,
info: {},
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
showImage: true,
};
interface NotificationProps extends MantineNotificationProps {
type?: 'error' | 'warning';
}
const showToast = ({ type, ...props }: NotificationProps) => {
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({
autoClose: defaultDuration,
styles: () => ({
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '1rem',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
...props,
});
};
const toast = {
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
};
export const useRemoteStore = create<SettingsSlice>()(
persist(
devtools(
immer((set, get) => ({
actions: {
reconnect: async () => {
const existing = get().socket;
if (existing) {
if (
existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING
) {
existing.natural = true;
existing.close(4001);
}
}
let authHeader: string | undefined;
try {
const credentials = await fetch('/credentials');
authHeader = await credentials.text();
} catch (error) {
console.error('Failed to get credentials');
}
set((state) => {
const socket = new WebSocket(
// eslint-disable-next-line no-restricted-globals
location.href.replace('http', 'ws'),
) as StatefulWebSocket;
socket.natural = false;
socket.addEventListener('message', (message) => {
const { event, data } = JSON.parse(message.data) as ServerEvent;
switch (event) {
case 'error': {
toast.error({ message: data, title: 'Socket error' });
break;
}
case 'favorite': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userFavorite = data.favorite;
}
});
break;
}
case 'proxy': {
set((state) => {
if (state.info.song) {
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
}
});
break;
}
case 'rating': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userRating = data.rating;
}
});
break;
}
case 'song': {
set((nested) => {
nested.info = { ...nested.info, ...data };
});
}
}
});
socket.addEventListener('open', () => {
if (authHeader) {
socket.send(
JSON.stringify({
event: 'authenticate',
header: authHeader,
}),
);
}
set({ connected: true });
});
socket.addEventListener('close', (reason) => {
if (reason.code === 4002 || reason.code === 4003) {
// eslint-disable-next-line no-restricted-globals
location.reload();
} else if (reason.code === 4000) {
toast.warn({
message: 'Feishin remote server is down',
title: 'Connection closed',
});
} else if (reason.code !== 4001 && !socket.natural) {
toast.error({
message: 'Socket closed for unexpected reason',
title: 'Connection closed',
});
}
if (!socket.natural) {
set({ connected: false, info: {} });
}
});
state.socket = socket;
});
},
send: (data: ClientEvent) => {
console.log(data, get().socket);
get().socket?.send(JSON.stringify(data));
},
toggleIsDark: () => {
set((state) => {
state.isDark = !state.isDark;
});
},
toggleShowImage: () => {
set((state) => {
state.showImage = !state.showImage;
});
},
},
...initialState,
})),
{ name: 'store_settings' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
name: 'store_settings',
version: 6,
},
),
);
export const useConnected = () => useRemoteStore((state) => state.connected);
export const useInfo = () => useRemoteStore((state) => state.info);
export const useIsDark = () => useRemoteStore((state) => state.isDark);
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
export const useShowImage = () => useRemoteStore((state) => state.showImage);
export const useSend = () => useRemoteStore((state) => state.actions.send);
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);
+127
View File
@@ -0,0 +1,127 @@
@use '../../renderer/themes/default.scss';
@use '../../renderer/themes/dark.scss';
@use '../../renderer/themes/light.scss';
@use '../../renderer/styles/ag-grid.scss';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body,
html {
position: absolute;
display: block;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
color: var(--content-text-color);
background: var(--content-bg);
font-family: var(--content-font-family);
font-size: var(--root-font-size);
user-select: none;
}
@media only screen and (max-width: 639px) {
body,
html {
overflow-x: auto;
}
}
#app {
height: inherit;
}
*,
*:before,
*:after {
box-sizing: border-box;
text-rendering: optimizeLegibility;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-text-size-adjust: none;
outline: none;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-corner {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
a {
text-decoration: none;
}
button {
-webkit-app-region: no-drag;
}
.overlay-scrollbar {
overflow-y: overlay !important;
overflow-x: overlay !important;
}
.hide-scrollbar {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.mantine-ScrollArea-thumb[data-state='visible'] {
animation: fadeIn 0.3s forwards;
}
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
animation: fadeOut 0.2s forwards;
}
+66
View File
@@ -0,0 +1,66 @@
import type { QueueSong } from '/@/renderer/api/types';
import type { SongUpdate } from '/@/renderer/types';
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
song?: QueueSong | null;
}
export interface ServerError {
data: string;
event: 'error';
}
export interface ServerFavorite {
data: { favorite: boolean; id: string };
event: 'favorite';
}
export interface ServerProxy {
data: string;
event: 'proxy';
}
export interface ServerRating {
data: { id: string; rating: number };
event: 'rating';
}
export interface ServerSong {
data: SongUpdateSocket;
event: 'song';
}
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
export interface ClientSimpleEvent {
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
}
export interface ClientFavorite {
event: 'favorite';
favorite: boolean;
id: string;
}
export interface ClientRating {
event: 'rating';
id: string;
rating: number;
}
export interface ClientVolume {
event: 'volume';
volume: number;
}
export interface ClientAuth {
event: 'authenticate';
header: string;
}
export type ClientEvent =
| ClientAuth
| ClientSimpleEvent
| ClientFavorite
| ClientRating
| ClientVolume;
+65 -6
View File
@@ -44,9 +44,13 @@ import type {
UpdatePlaylistResponse, UpdatePlaylistResponse,
UserListResponse, UserListResponse,
AuthenticationResponse, AuthenticationResponse,
SearchArgs,
SearchResponse,
LyricsArgs,
LyricsResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types'; import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse } from './types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller'; import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller'; import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller'; import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
@@ -74,16 +78,19 @@ export type ControllerEndpoint = Partial<{
getFolderList: () => void; getFolderList: () => void;
getFolderSongs: () => void; getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>; getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>; getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>; getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getUserList: (args: UserListArgs) => Promise<UserListResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>; scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>; setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>; updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>; }>;
@@ -115,16 +122,19 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: jfController.getGenreList, getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList, getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail, getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList, getPlaylistList: jfController.getPlaylistList,
getPlaylistSongList: jfController.getPlaylistSongList, getPlaylistSongList: jfController.getPlaylistSongList,
getRandomSongList: jfController.getRandomSongList,
getSongDetail: undefined, getSongDetail: undefined,
getSongList: jfController.getSongList, getSongList: jfController.getSongList,
getTopSongs: jfController.getTopSongList, getTopSongs: jfController.getTopSongList,
getUserList: undefined, getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist, removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble, scrobble: jfController.scrobble,
search: jfController.search,
setRating: undefined, setRating: undefined,
updatePlaylist: jfController.updatePlaylist, updatePlaylist: jfController.updatePlaylist,
}, },
@@ -148,16 +158,19 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: ndController.getGenreList, getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail, getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList, getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList, getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getSongDetail: ndController.getSongDetail, getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList, getSongList: ndController.getSongList,
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: ndController.getUserList, getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist, removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating, setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist, updatePlaylist: ndController.updatePlaylist,
}, },
@@ -180,6 +193,7 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: undefined, getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined, getPlaylistDetail: undefined,
getPlaylistList: undefined, getPlaylistList: undefined,
@@ -188,6 +202,7 @@ const endpoints: ApiController = {
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: undefined, getUserList: undefined,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined, setRating: undefined,
updatePlaylist: undefined, updatePlaylist: undefined,
}, },
@@ -198,17 +213,18 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
if (!serverType) { if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' }); toast.error({ message: 'No server selected', title: 'Unable to route request' });
return () => undefined; throw new Error(`No server selected`);
} }
const controllerFn = endpoints[serverType][endpoint]; const controllerFn = endpoints?.[serverType]?.[endpoint];
if (typeof controllerFn !== 'function') { if (typeof controllerFn !== 'function') {
toast.error({ toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`, message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request', title: 'Unable to route request',
}); });
return () => undefined;
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
} }
return endpoints[serverType][endpoint]; return endpoints[serverType][endpoint];
@@ -249,6 +265,15 @@ const getSongList = async (args: SongListArgs) => {
)?.(args); )?.(args);
}; };
const getSongDetail = async (args: SongDetailArgs) => {
return (
apiController(
'getSongDetail',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getSongDetail']
)?.(args);
};
const getMusicFolderList = async (args: MusicFolderListArgs) => { const getMusicFolderList = async (args: MusicFolderListArgs) => {
return ( return (
apiController( apiController(
@@ -395,7 +420,10 @@ const deleteFavorite = async (args: FavoriteArgs) => {
const updateRating = async (args: SetRatingArgs) => { const updateRating = async (args: SetRatingArgs) => {
return ( return (
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating'] apiController(
'setRating',
args.apiClientProps.server?.type,
) as ControllerEndpoint['setRating']
)?.(args); )?.(args);
}; };
@@ -410,7 +438,34 @@ const getTopSongList = async (args: TopSongListArgs) => {
const scrobble = async (args: ScrobbleArgs) => { const scrobble = async (args: ScrobbleArgs) => {
return ( return (
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble'] apiController(
'scrobble',
args.apiClientProps.server?.type,
) as ControllerEndpoint['scrobble']
)?.(args);
};
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
const getRandomSongList = async (args: RandomSongListArgs) => {
return (
apiController(
'getRandomSongList',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getRandomSongList']
)?.(args);
};
const getLyrics = async (args: LyricsArgs) => {
return (
apiController(
'getLyrics',
args.apiClientProps.server?.type,
) as ControllerEndpoint['getLyrics']
)?.(args); )?.(args);
}; };
@@ -427,15 +482,19 @@ export const controller = {
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList,
getSongDetail,
getSongList, getSongList,
getTopSongList, getTopSongList,
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search,
updatePlaylist, updatePlaylist,
updateRating, updateRating,
}; };
+4
View File
@@ -15,6 +15,10 @@ export interface JFGenreListResponse extends JFBasePaginatedResponse {
export type JFGenreList = JFGenreListResponse; export type JFGenreList = JFGenreListResponse;
export enum JFGenreListSort {
NAME = 'SortName',
}
export type JFAlbumArtistDetailResponse = JFAlbumArtist; export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse; export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
+35 -14
View File
@@ -3,24 +3,29 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core'; import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios'; import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs'; import qs from 'qs';
import { toast } from '/@/renderer/components';
import { ServerListItem } from '/@/renderer/types'; import { ServerListItem } from '/@/renderer/types';
import omitBy from 'lodash/omitBy'; import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';
const c = initContract(); const c = initContract();
export const contract = c.router({ export const contract = c.router({
addToPlaylist: { addToPlaylist: {
body: jfType._parameters.addToPlaylist, body: z.null(),
method: 'POST', method: 'POST',
path: 'playlists/:id/items', path: 'playlists/:id/items',
query: jfType._parameters.addToPlaylist,
responses: { responses: {
200: jfType._response.addToPlaylist, 204: jfType._response.addToPlaylist,
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
authenticate: { authenticate: {
body: jfType._parameters.authenticate, body: jfType._parameters.authenticate,
headers: z.object({
'X-Emby-Authorization': z.string(),
}),
method: 'POST', method: 'POST',
path: 'users/authenticatebyname', path: 'users/authenticatebyname',
responses: { responses: {
@@ -103,6 +108,7 @@ export const contract = c.router({
getGenreList: { getGenreList: {
method: 'GET', method: 'GET',
path: 'genres', path: 'genres',
query: jfType._parameters.genreList,
responses: { responses: {
200: jfType._response.genreList, 200: jfType._response.genreList,
400: jfType._response.error, 400: jfType._response.error,
@@ -169,6 +175,14 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
getSongLyrics: {
method: 'GET',
path: 'users/:userId/Items/:id/Lyrics',
responses: {
200: jfType._response.lyrics,
404: jfType._response.error,
},
},
getTopSongsList: { getTopSongsList: {
method: 'GET', method: 'GET',
path: 'users/:userId/items', path: 'users/:userId/items',
@@ -190,7 +204,7 @@ export const contract = c.router({
removeFromPlaylist: { removeFromPlaylist: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',
path: 'items/:id', path: 'playlists/:id/items',
query: jfType._parameters.removeFromPlaylist, query: jfType._parameters.removeFromPlaylist,
responses: { responses: {
200: jfType._response.removeFromPlaylist, 200: jfType._response.removeFromPlaylist,
@@ -224,6 +238,15 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
search: {
method: 'GET',
path: 'users/:userId/items',
query: jfType._parameters.search,
responses: {
200: jfType._response.search,
400: jfType._response.error,
},
},
updatePlaylist: { updatePlaylist: {
body: jfType._parameters.updatePlaylist, body: jfType._parameters.updatePlaylist,
method: 'PUT', method: 'PUT',
@@ -247,19 +270,15 @@ axiosClient.interceptors.response.use(
}, },
(error) => { (error) => {
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer; const currentServer = useAuthStore.getState().currentServer;
if (currentServer) { if (currentServer) {
const serverId = currentServer.id; useAuthStore
const token = currentServer.credential; .getState()
console.log(`token is expired: ${token}`); .actions.updateServer(currentServer.id, { credential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
} }
authenticationFailure(currentServer);
} }
return Promise.reject(error); return Promise.reject(error);
@@ -313,6 +332,7 @@ export const jfApiClient = (args: {
}); });
return { return {
body: result.data, body: result.data,
headers: result.headers as any,
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
@@ -320,7 +340,8 @@ export const jfApiClient = (args: {
const error = e as AxiosError; const error = e as AxiosError;
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
return { return {
body: response.data, body: response?.data,
headers: response?.headers as any,
status: response.status, status: response.status,
}; };
} }
+257 -17
View File
@@ -40,16 +40,51 @@ import {
RemoveFromPlaylistResponse, RemoveFromPlaylistResponse,
PlaylistDetailResponse, PlaylistDetailResponse,
PlaylistListResponse, PlaylistListResponse,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
LyricsArgs,
LyricsResponse,
genreListSortMap,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize'; import { jfNormalize } from './jellyfin-normalize';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types'; import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import packageJson from '../../../../package.json'; import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
const formatCommaDelimitedString = (value: string[]) => { const formatCommaDelimitedString = (value: string[]) => {
return value.join(','); return value.join(',');
}; };
function getHostname(): string {
if (isElectron()) {
return 'Desktop Client';
}
const agent = navigator.userAgent;
switch (true) {
case agent.toLowerCase().indexOf('edge') > -1:
return 'Microsoft Edge';
case agent.toLowerCase().indexOf('edg/') > -1:
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
case agent.toLowerCase().indexOf('opr') > -1:
return 'Opera';
case agent.toLowerCase().indexOf('chrome') > -1:
return 'Chrome';
case agent.toLowerCase().indexOf('trident') > -1:
return 'Internet Explorer';
case agent.toLowerCase().indexOf('firefox') > -1:
return 'Firefox';
case agent.toLowerCase().indexOf('safari') > -1:
return 'Safari';
default:
return 'PC';
}
}
const authenticate = async ( const authenticate = async (
url: string, url: string,
body: { body: {
@@ -65,7 +100,9 @@ const authenticate = async (
Username: body.username, Username: body.username,
}, },
headers: { headers: {
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`, 'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
packageJson.version
}"`,
}, },
}); });
@@ -108,18 +145,33 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
}; };
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => { const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args; const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getGenreList(); if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getGenreList({
query: {
Fields: 'ItemCounts',
ParentId: query?.musicFolderId,
Recursive: true,
SearchTerm: query?.searchTerm,
SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get genre list'); throw new Error('Failed to get genre list');
} }
return { return {
items: res.body.Items.map(jfNormalize.genre), items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),
startIndex: 0, startIndex: query.startIndex || 0,
totalRecordCount: res.body?.Items?.length || 0, totalRecordCount: res.body?.TotalRecordCount || 0,
}; };
}; };
@@ -240,7 +292,7 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailRespons
Fields: 'Genres, DateCreated, MediaSources, ParentId', Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
ParentId: query.id, ParentId: query.id,
SortBy: 'Album,SortName', SortBy: 'ParentIndexNumber,IndexNumber,SortName',
}, },
}); });
@@ -276,6 +328,9 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
userId: apiClientProps.server?.userId, userId: apiClientProps.server?.userId,
}, },
query: { query: {
AlbumArtistIds: query.artistIds
? formatCommaDelimitedString(query.artistIds)
: undefined,
IncludeItemTypes: 'MusicAlbum', IncludeItemTypes: 'MusicAlbum',
Limit: query.limit, Limit: query.limit,
ParentId: query.musicFolderId, ParentId: query.musicFolderId,
@@ -354,7 +409,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined; const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined; const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined; const artistIdsFilter = query.artistIds
? formatCommaDelimitedString(query.artistIds)
: undefined;
const res = await jfApiClient(apiClientProps).getSongList({ const res = await jfApiClient(apiClientProps).getSongList({
params: { params: {
@@ -382,7 +439,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
} }
return { return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')), items: res.body.Items.map((item) =>
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
),
startIndex: query.startIndex, startIndex: query.startIndex,
totalRecordCount: res.body.TotalRecordCount, totalRecordCount: res.body.TotalRecordCount,
}; };
@@ -396,16 +455,17 @@ const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResp
} }
const res = await jfApiClient(apiClientProps).addToPlaylist({ const res = await jfApiClient(apiClientProps).addToPlaylist({
body: { body: null,
Ids: body.songId,
UserId: apiClientProps?.server?.userId,
},
params: { params: {
id: query.id, id: query.id,
}, },
query: {
Ids: body.songId,
UserId: apiClientProps.server?.userId,
},
}); });
if (res.status !== 200) { if (res.status !== 204) {
throw new Error('Failed to add to playlist'); throw new Error('Failed to add to playlist');
} }
@@ -427,7 +487,7 @@ const removeFromPlaylist = async (
}, },
}); });
if (res.status !== 200) { if (res.status !== 204) {
throw new Error('Failed to remove from playlist'); throw new Error('Failed to remove from playlist');
} }
@@ -499,6 +559,20 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
throw new Error('No userId found'); throw new Error('No userId found');
} }
const musicFoldersRes = await jfApiClient(apiClientProps).getMusicFolderList({
params: {
userId: apiClientProps.server?.userId,
},
});
if (musicFoldersRes.status !== 200) {
throw new Error('Failed playlist folder');
}
const playlistFolder = musicFoldersRes.body.Items.filter(
(folder) => folder.CollectionType === jfType._enum.collection.PLAYLISTS,
)?.[0];
const res = await jfApiClient(apiClientProps).getPlaylistList({ const res = await jfApiClient(apiClientProps).getPlaylistList({
params: { params: {
userId: apiClientProps.server?.userId, userId: apiClientProps.server?.userId,
@@ -507,8 +581,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview', Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
IncludeItemTypes: 'Playlist', IncludeItemTypes: 'Playlist',
Limit: query.limit, Limit: query.limit,
MediaTypes: 'Audio', ParentId: playlistFolder?.Id,
Recursive: true, SearchTerm: query.searchTerm,
SortBy: playlistListSortMap.jellyfin[query.sortBy], SortBy: playlistListSortMap.jellyfin[query.sortBy],
SortOrder: sortOrderMap.jellyfin[query.sortOrder], SortOrder: sortOrderMap.jellyfin[query.sortOrder],
StartIndex: query.startIndex, StartIndex: query.startIndex,
@@ -703,6 +777,169 @@ const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
return null; return null;
}; };
const search = async (args: SearchArgs): Promise<SearchResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
let songs: z.infer<typeof jfType._response.songList>['Items'] = [];
if (query.albumLimit) {
const res = await jfApiClient(apiClientProps).getAlbumList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
EnableTotalRecordCount: true,
ImageTypeLimit: 1,
IncludeItemTypes: 'MusicAlbum',
Limit: query.albumLimit,
Recursive: true,
SearchTerm: query.query,
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: query.albumStartIndex || 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album list');
}
albums = res.body.Items;
}
if (query.albumArtistLimit) {
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
ImageTypeLimit: 1,
IncludeArtists: true,
Limit: query.albumArtistLimit,
Recursive: true,
SearchTerm: query.query,
StartIndex: query.albumArtistStartIndex || 0,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get album artist list');
}
albumArtists = res.body.Items;
}
if (query.songLimit) {
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
EnableTotalRecordCount: true,
Fields: 'Genres, DateCreated, MediaSources, ParentId',
IncludeItemTypes: 'Audio',
Limit: query.songLimit,
Recursive: true,
SearchTerm: query.query,
SortBy: 'Album,SortName',
SortOrder: 'Ascending',
StartIndex: query.songStartIndex || 0,
UserId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get song list');
}
songs = res.body.Items;
}
return {
albumArtists: albumArtists.map((item) =>
jfNormalize.albumArtist(item, apiClientProps.server),
),
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
};
};
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const yearsGroup = [];
if (query.minYear && query.maxYear) {
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
yearsGroup.push(String(i));
}
}
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
const res = await jfApiClient(apiClientProps).getSongList({
params: {
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ParentId',
GenreIds: query.genre ? query.genre : undefined,
IncludeItemTypes: 'Audio',
Limit: query.limit,
ParentId: query.musicFolderId,
Recursive: true,
SortBy: JFSongListSort.RANDOM,
SortOrder: JFSortOrder.ASC,
StartIndex: 0,
Years: yearsFilter,
},
});
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
return {
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
startIndex: 0,
totalRecordCount: res.body.Items.length || 0,
};
};
const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getSongLyrics({
params: {
id: query.songId,
userId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get lyrics');
}
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
return res.body.Lyrics[0].Text;
}
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
};
export const jfController = { export const jfController = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@@ -716,13 +953,16 @@ export const jfController = {
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,
getPlaylistSongList, getPlaylistSongList,
getRandomSongList,
getSongList, getSongList,
getTopSongList, getTopSongList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search,
updatePlaylist, updatePlaylist,
}; };
+77 -21
View File
@@ -80,10 +80,6 @@ const getSongCoverArtUrl = (args: {
}) => { }) => {
const size = args.size ? args.size : 100; const size = args.size ? args.size : 100;
if (!args.item.ImageTags?.Primary) {
return null;
}
if (args.item.ImageTags.Primary) { if (args.item.ImageTags.Primary) {
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
@@ -94,10 +90,7 @@ const getSongCoverArtUrl = (args: {
); );
} }
if (!args.item?.AlbumPrimaryImageTag) { if (args.item?.AlbumPrimaryImageTag) {
return null;
}
// Fall back to album art if no image embedded // Fall back to album art if no image embedded
return ( return (
`${args.baseUrl}/Items` + `${args.baseUrl}/Items` +
@@ -106,6 +99,9 @@ const getSongCoverArtUrl = (args: {
`?width=${size}&height=${size}` + `?width=${size}&height=${size}` +
'&quality=96' '&quality=96'
); );
}
return null;
}; };
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => { const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
@@ -138,9 +134,15 @@ const normalizeSong = (
name: entry.Name, name: entry.Name,
})), })),
albumId: item.AlbumId, albumId: item.AlbumId,
artistName: item.ArtistItems[0]?.Name, artistName: item?.ArtistItems?.[0]?.Name,
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })), artists: item?.ArtistItems?.map((entry) => ({
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
bitRate:
item.MediaSources?.[0].Bitrate &&
Number(Math.trunc(item.MediaSources[0].Bitrate / 1000)),
bpm: null, bpm: null,
channels: null, channels: null,
comment: null, comment: null,
@@ -148,15 +150,28 @@ const normalizeSong = (
container: (item.MediaSources && item.MediaSources[0]?.Container) || null, container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
createdAt: item.DateCreated, createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
duration: item.RunTimeTicks / 10000000, discSubtitle: null,
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })), duration: item.RunTimeTicks / 10000,
gain: item.LUFS
? {
track: -18 - item.LUFS,
}
: null,
genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null,
name: item.Name, name: item.Name,
path: (item.MediaSources && item.MediaSources[0]?.Path) || null, path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0, playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId, playlistItemId: item.PlaylistItemId,
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null, // releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
@@ -166,11 +181,11 @@ const normalizeSong = (
serverType: ServerType.JELLYFIN, serverType: ServerType.JELLYFIN,
size: item.MediaSources && item.MediaSources[0]?.Size, size: item.MediaSources && item.MediaSources[0]?.Size,
streamUrl: getStreamUrl({ streamUrl: getStreamUrl({
container: item.MediaSources[0]?.Container, container: item.MediaSources?.[0]?.Container,
deviceId, deviceId,
eTag: item.MediaSources[0]?.ETag, eTag: item.MediaSources?.[0]?.ETag,
id: item.Id, id: item.Id,
mediaSourceId: item.MediaSources[0]?.Id, mediaSourceId: item.MediaSources?.[0]?.Id,
server, server,
}), }),
trackNumber: item.IndexNumber, trackNumber: item.IndexNumber,
@@ -193,11 +208,20 @@ const normalizeAlbum = (
imageUrl: null, imageUrl: null,
name: entry.Name, name: entry.Name,
})) || [], })) || [],
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })), artists: item.ArtistItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
name: entry.Name,
})),
backdropImageUrl: null, backdropImageUrl: null,
createdAt: item.DateCreated, createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id, id: item.Id,
imagePlaceholderUrl: null, imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({ imageUrl: getAlbumCoverArtUrl({
@@ -249,7 +273,12 @@ const normalizeAlbumArtist = (
backgroundImageUrl: null, backgroundImageUrl: null,
biography: item.Overview || null, biography: item.Overview || null,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id, id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({ imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '', baseUrl: server?.url || '',
@@ -285,7 +314,12 @@ const normalizePlaylist = (
return { return {
description: item.Overview || null, description: item.Overview || null,
duration: item.RunTimeTicks / 10000, duration: item.RunTimeTicks / 10000,
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), genres: item.GenreItems?.map((entry) => ({
id: entry.Id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: entry.Name,
})),
id: item.Id, id: item.Id,
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
@@ -330,10 +364,32 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
// }; // };
// }; // };
const normalizeGenre = (item: JFGenre): Genre => { const getGenreCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.genre>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}&height=${size}` +
'&quality=96'
);
};
const normalizeGenre = (item: JFGenre, server: ServerListItem | null): Genre => {
return { return {
albumCount: undefined, albumCount: undefined,
id: item.Id, id: item.Id,
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
itemType: LibraryItem.GENRE,
name: item.Name, name: item.Name,
songCount: undefined, songCount: undefined,
}; };
+63 -15
View File
@@ -39,28 +39,39 @@ const error = z.object({
const baseParameters = z.object({ const baseParameters = z.object({
AlbumArtistIds: z.string().optional(), AlbumArtistIds: z.string().optional(),
ArtistIds: z.string().optional(), ArtistIds: z.string().optional(),
ContributingArtistIds: z.string().optional(),
EnableImageTypes: z.string().optional(), EnableImageTypes: z.string().optional(),
EnableTotalRecordCount: z.boolean().optional(), EnableTotalRecordCount: z.boolean().optional(),
EnableUserData: z.boolean().optional(), EnableUserData: z.boolean().optional(),
EnableUserDataTypes: z.boolean().optional(),
ExcludeArtistIds: z.string().optional(),
ExcludeItemIds: z.string().optional(),
ExcludeItemTypes: z.string().optional(), ExcludeItemTypes: z.string().optional(),
Fields: z.string().optional(), Fields: z.string().optional(),
ImageTypeLimit: z.number().optional(), ImageTypeLimit: z.number().optional(),
IncludeArtists: z.boolean().optional(),
IncludeGenres: z.boolean().optional(),
IncludeItemTypes: z.string().optional(), IncludeItemTypes: z.string().optional(),
IncludeMedia: z.boolean().optional(),
IncludePeople: z.boolean().optional(),
IncludeStudios: z.boolean().optional(),
IsFavorite: z.boolean().optional(), IsFavorite: z.boolean().optional(),
Limit: z.number().optional(), Limit: z.number().optional(),
MediaTypes: z.string().optional(), MediaTypes: z.string().optional(),
NameStartsWith: z.string().optional(),
ParentId: z.string().optional(), ParentId: z.string().optional(),
Recursive: z.boolean().optional(), Recursive: z.boolean().optional(),
SearchTerm: z.string().optional(), SearchTerm: z.string().optional(),
SortBy: z.string().optional(), SortBy: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(), SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(), StartIndex: z.number().optional(),
Tags: z.string().optional(),
UserId: z.string().optional(), UserId: z.string().optional(),
Years: z.string().optional(),
}); });
const paginationParameters = z.object({ const paginationParameters = z.object({
Limit: z.number().optional(), Limit: z.number().optional(),
NameStartsWith: z.string().optional(),
SortOrder: z.enum(sortOrderValues).optional(), SortOrder: z.enum(sortOrderValues).optional(),
StartIndex: z.number().optional(), StartIndex: z.number().optional(),
}); });
@@ -76,9 +87,9 @@ const imageTags = z.object({
}); });
const imageBlurHashes = z.object({ const imageBlurHashes = z.object({
Backdrop: z.string().optional(), Backdrop: z.record(z.string(), z.string()).optional(),
Logo: z.string().optional(), Logo: z.record(z.string(), z.string()).optional(),
Primary: z.string().optional(), Primary: z.record(z.string(), z.string()).optional(),
}); });
const userData = z.object({ const userData = z.object({
@@ -293,10 +304,21 @@ const genre = z.object({
Type: z.string(), Type: z.string(),
}); });
const genreList = z.object({ const genreList = pagination.extend({
Items: z.array(genre), Items: z.array(genre),
}); });
const genreListSort = {
NAME: 'SortName',
} as const;
const genreListParameters = paginationParameters.merge(
baseParameters.extend({
SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(genreListSort).optional(),
}),
);
const musicFolder = z.object({ const musicFolder = z.object({
BackdropImageTags: z.array(z.string()), BackdropImageTags: z.array(z.string()),
ChannelId: z.null(), ChannelId: z.null(),
@@ -341,7 +363,7 @@ const playlist = z.object({
UserData: userData, UserData: userData,
}); });
const jfPlaylistListSort = { const playlistListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName', ALBUM_ARTIST: 'AlbumArtist,SortName',
DURATION: 'Runtime', DURATION: 'Runtime',
NAME: 'SortName', NAME: 'SortName',
@@ -352,7 +374,7 @@ const jfPlaylistListSort = {
const playlistListParameters = paginationParameters.merge( const playlistListParameters = paginationParameters.merge(
baseParameters.extend({ baseParameters.extend({
IncludeItemTypes: z.literal('Playlist'), IncludeItemTypes: z.literal('Playlist'),
SortBy: z.nativeEnum(jfPlaylistListSort).optional(), SortBy: z.nativeEnum(playlistListSort).optional(),
}), }),
); );
@@ -384,6 +406,7 @@ const song = z.object({
ImageTags: imageTags, ImageTags: imageTags,
IndexNumber: z.number(), IndexNumber: z.number(),
IsFolder: z.boolean(), IsFolder: z.boolean(),
LUFS: z.number().optional(),
LocationType: z.string(), LocationType: z.string(),
MediaSources: z.array(mediaSources), MediaSources: z.array(mediaSources),
MediaType: z.string(), MediaType: z.string(),
@@ -450,7 +473,7 @@ const album = z.object({
UserData: userData.optional(), UserData: userData.optional(),
}); });
const jfAlbumListSort = { const albumListSort = {
ALBUM_ARTIST: 'AlbumArtist,SortName', ALBUM_ARTIST: 'AlbumArtist,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName', COMMUNITY_RATING: 'CommunityRating,SortName',
CRITIC_RATING: 'CriticRating,SortName', CRITIC_RATING: 'CriticRating,SortName',
@@ -468,7 +491,7 @@ const albumListParameters = paginationParameters.merge(
IncludeItemTypes: z.literal('MusicAlbum'), IncludeItemTypes: z.literal('MusicAlbum'),
IsFavorite: z.boolean().optional(), IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(), SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumListSort).optional(), SortBy: z.nativeEnum(albumListSort).optional(),
Tags: z.string().optional(), Tags: z.string().optional(),
Years: z.string().optional(), Years: z.string().optional(),
}), }),
@@ -478,7 +501,7 @@ const albumList = pagination.extend({
Items: z.array(album), Items: z.array(album),
}); });
const jfAlbumArtistListSort = { const albumArtistListSort = {
ALBUM: 'Album,SortName', ALBUM: 'Album,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName', DURATION: 'Runtime,AlbumArtist,Album,SortName',
NAME: 'Name,SortName', NAME: 'Name,SortName',
@@ -491,7 +514,7 @@ const albumArtistListParameters = paginationParameters.merge(
baseParameters.extend({ baseParameters.extend({
Filters: z.string().optional(), Filters: z.string().optional(),
Genres: z.string().optional(), Genres: z.string().optional(),
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(), SortBy: z.nativeEnum(albumArtistListSort).optional(),
Years: z.string().optional(), Years: z.string().optional(),
}), }),
); );
@@ -504,9 +527,10 @@ const similarArtistListParameters = baseParameters.extend({
Limit: z.number().optional(), Limit: z.number().optional(),
}); });
const jfSongListSort = { const songListSort = {
ALBUM: 'Album,SortName', ALBUM: 'Album,SortName',
ALBUM_ARTIST: 'AlbumArtist,Album,SortName', ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
ALBUM_DETAIL: 'ParentIndexNumber,IndexNumber,SortName',
ARTIST: 'Artist,Album,SortName', ARTIST: 'Artist,Album,SortName',
COMMUNITY_RATING: 'CommunityRating,SortName', COMMUNITY_RATING: 'CommunityRating,SortName',
DURATION: 'Runtime,AlbumArtist,Album,SortName', DURATION: 'Runtime,AlbumArtist,Album,SortName',
@@ -518,7 +542,8 @@ const jfSongListSort = {
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName', RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
} as const; } as const;
const songListParameters = baseParameters.extend({ const songListParameters = paginationParameters.merge(
baseParameters.extend({
AlbumArtistIds: z.string().optional(), AlbumArtistIds: z.string().optional(),
AlbumIds: z.string().optional(), AlbumIds: z.string().optional(),
ArtistIds: z.string().optional(), ArtistIds: z.string().optional(),
@@ -527,10 +552,11 @@ const songListParameters = baseParameters.extend({
Genres: z.string().optional(), Genres: z.string().optional(),
IsFavorite: z.boolean().optional(), IsFavorite: z.boolean().optional(),
SearchTerm: z.string().optional(), SearchTerm: z.string().optional(),
SortBy: z.nativeEnum(jfSongListSort).optional(), SortBy: z.nativeEnum(songListSort).optional(),
Tags: z.string().optional(), Tags: z.string().optional(),
Years: z.string().optional(), Years: z.string().optional(),
}); }),
);
const songList = pagination.extend({ const songList = pagination.extend({
Items: z.array(song), Items: z.array(song),
@@ -614,11 +640,29 @@ const favorite = z.object({
const favoriteParameters = z.object({}); const favoriteParameters = z.object({});
const searchParameters = paginationParameters.merge(baseParameters);
const search = z.any();
const lyricText = z.object({
Start: z.number().optional(),
Text: z.string(),
});
const lyrics = z.object({
Lyrics: z.array(lyricText),
});
export const jfType = { export const jfType = {
_enum: { _enum: {
albumArtistList: albumArtistListSort,
albumList: albumListSort,
collection: jfCollection, collection: jfCollection,
external: jfExternal, external: jfExternal,
genreList: genreListSort,
image: jfImage, image: jfImage,
playlistList: playlistListSort,
songList: songListSort,
}, },
_parameters: { _parameters: {
addToPlaylist: addToPlaylistParameters, addToPlaylist: addToPlaylistParameters,
@@ -630,11 +674,13 @@ export const jfType = {
createPlaylist: createPlaylistParameters, createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters, deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters, favorite: favoriteParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters, musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters, playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters, playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters, similarArtistList: similarArtistListParameters,
songList: songListParameters, songList: songListParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
@@ -652,12 +698,14 @@ export const jfType = {
favorite, favorite,
genre, genre,
genreList, genreList,
lyrics,
musicFolderList, musicFolderList,
playlist, playlist,
playlistList, playlistList,
playlistSongList, playlistSongList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search,
song, song,
songList, songList,
topSongsList, topSongsList,
+157 -36
View File
@@ -1,12 +1,16 @@
import { initClient, initContract } from '@ts-rest/core'; import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios'; import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import omitBy from 'lodash/omitBy'; import omitBy from 'lodash/omitBy';
import qs from 'qs'; import qs from 'qs';
import { ndType } from './navidrome-types'; import { ndType } from './navidrome-types';
import { resultWithHeaders } from '/@/renderer/api/utils'; import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { toast } from '/@/renderer/components/toast/index';
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types'; import { ServerListItem } from '/@/renderer/types';
import { toast } from '/@/renderer/components';
const localSettings = isElectron() ? window.electron.localSettings : null;
const c = initContract(); const c = initContract();
@@ -84,6 +88,7 @@ export const contract = c.router({
getGenreList: { getGenreList: {
method: 'GET', method: 'GET',
path: 'genre', path: 'genre',
query: ndType._parameters.genreList,
responses: { responses: {
200: resultWithHeaders(ndType._response.genreList), 200: resultWithHeaders(ndType._response.genreList),
500: resultWithHeaders(ndType._response.error), 500: resultWithHeaders(ndType._response.error),
@@ -168,44 +173,26 @@ axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' }); return qs.stringify(params, { arrayFormat: 'repeat' });
}; };
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: response.headers['x-nd-authorization'] as string,
});
}
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
}
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => { const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?'); const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params); const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
// Convert indexed object to array
const newParams: Record<string, any> = {};
Object.keys(parsedParams).forEach((key) => {
const isIndexedArrayObject =
typeof parsedParams[key] === 'object' &&
Object.keys(parsedParams[key] || {}).includes('0');
if (!isIndexedArrayObject) {
newParams[key] = parsedParams[key];
} else {
newParams[key] = Object.values(parsedParams[key] || {});
}
});
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
return { return {
params: notNilParams, params: notNilParams,
@@ -213,6 +200,136 @@ const parsePath = (fullPath: string) => {
}; };
}; };
let authSuccess = true;
let shouldDelay = false;
const RETRY_DELAY_MS = 1000;
const MAX_RETRIES = 5;
const waitForResult = async (count = 0): Promise<void> => {
return new Promise((resolve) => {
if (count === MAX_RETRIES || !shouldDelay) resolve();
setTimeout(() => {
waitForResult(count + 1)
.then(resolve)
.catch(resolve);
}, RETRY_DELAY_MS);
});
};
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
const TIMEOUT_ERROR = Error();
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential,
});
}
}
authSuccess = true;
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
if (localSettings && currentServer?.savePassword) {
// eslint-disable-next-line promise/no-promise-in-callback
return localSettings
.passwordGet(currentServer.id)
.then(async (password: string | null) => {
authSuccess = false;
if (password === null) {
throw error;
}
if (shouldDelay) {
await waitForResult();
// Hopefully the delay was sufficient for authentication.
// Otherwise, it will require manual intervention
if (authSuccess) {
return axiosClient.request(error.config);
}
throw error;
}
shouldDelay = true;
// Do not use axiosClient. Instead, manually make a post
const res = await axios.post(`${currentServer.url}/auth/login`, {
password,
username: currentServer.username,
});
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
});
const serverId = currentServer.id;
useAuthStore
.getState()
.actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
// special error to prevent sending a second message, and stop other messages that could be enqueued
limitedFail.cancel();
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
const newCredential = res.data.token;
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
useAuthStore.getState().actions.updateServer(currentServer.id, {
credential: subsonicCredential,
ndCredential: newCredential,
});
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
authSuccess = true;
return axiosClient.request(error.config);
})
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
}
// make sure to pass the error so axios will error later on
throw newError;
})
.finally(() => {
shouldDelay = false;
});
}
limitedFail(currentServer);
}
return Promise.reject(error);
},
);
export const ndApiClient = (args: { export const ndApiClient = (args: {
server: ServerListItem | null; server: ServerListItem | null;
signal?: AbortSignal; signal?: AbortSignal;
@@ -235,6 +352,8 @@ export const ndApiClient = (args: {
} }
try { try {
if (shouldDelay) await waitForResult();
const result = await axiosClient.request({ const result = await axiosClient.request({
data: body, data: body,
headers: { headers: {
@@ -248,6 +367,7 @@ export const ndApiClient = (args: {
}); });
return { return {
body: { data: result.data, headers: result.headers }, body: { data: result.data, headers: result.headers },
headers: result.headers as any,
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
@@ -256,6 +376,7 @@ export const ndApiClient = (args: {
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
return { return {
body: { data: response.data, headers: response.headers }, body: { data: response.data, headers: response.headers },
headers: response.headers as any,
status: response.status, status: response.status,
}; };
} }
@@ -38,10 +38,12 @@ import {
PlaylistSongListResponse, PlaylistSongListResponse,
RemoveFromPlaylistResponse, RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs, RemoveFromPlaylistArgs,
genreListSortMap,
} from '../types'; } from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize'; import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types'; import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
const authenticate = async ( const authenticate = async (
url: string, url: string,
@@ -93,17 +95,25 @@ const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
}; };
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => { const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
const { apiClientProps } = args; const { query, apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getGenreList({}); const res = await ndApiClient(apiClientProps).getGenreList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: genreListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
name: query.searchTerm,
},
});
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get genre list'); throw new Error('Failed to get genre list');
} }
return { return {
items: res.body.data, items: res.body.data.map((genre) => ndNormalize.genre(genre)),
startIndex: 0, startIndex: query.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
}; };
@@ -119,6 +129,13 @@ const getAlbumArtistDetail = async (
}, },
}); });
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
});
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get album artist detail'); throw new Error('Failed to get album artist detail');
} }
@@ -127,7 +144,24 @@ const getAlbumArtistDetail = async (
throw new Error('Server is required'); throw new Error('Server is required');
} }
return ndNormalize.albumArtist(res.body.data, apiClientProps.server); return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
...(!res.body.data.largeImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
}),
...(!res.body.data.mediumImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
}),
...(!res.body.data.smallImageUrl && {
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
}),
}),
},
apiClientProps.server,
);
}; };
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => { const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
@@ -221,8 +255,8 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
_order: sortOrderMap.navidrome[query.sortOrder], _order: sortOrderMap.navidrome[query.sortOrder],
_sort: songListSortMap.navidrome[query.sortBy], _sort: songListSortMap.navidrome[query.sortBy],
_start: query.startIndex, _start: query.startIndex,
album_artist_id: query.artistIds,
album_id: query.albumIds, album_id: query.albumIds,
artist_id: query.artistIds,
title: query.searchTerm, title: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
}, },
@@ -233,7 +267,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
} }
return { return {
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')), items: res.body.data.map((song) =>
ndNormalize.song(song, apiClientProps.server, '', query.imageSize),
),
startIndex: query?.startIndex || 0, startIndex: query?.startIndex || 0,
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
}; };
@@ -326,6 +362,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
_order: sortOrderMap.navidrome[query.sortOrder], _order: sortOrderMap.navidrome[query.sortOrder],
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined, _sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex, _start: query.startIndex,
q: query.searchTerm,
...query._custom?.navidrome, ...query._custom?.navidrome,
}, },
}); });
@@ -369,7 +406,9 @@ const getPlaylistSongList = async (
query: { query: {
_end: query.startIndex + (query.limit || 0), _end: query.startIndex + (query.limit || 0),
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC', _order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID, _sort: query.sortBy
? songListSortMap.navidrome[query.sortBy]
: ndType._enum.songList.ID,
_start: query.startIndex, _start: query.startIndex,
}, },
}); });
@@ -415,7 +454,7 @@ const removeFromPlaylist = async (
id: query.id, id: query.id,
}, },
query: { query: {
ids: query.songId, id: query.songId,
}, },
}); });
@@ -1,8 +1,31 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types'; import {
Song,
LibraryItem,
Album,
Playlist,
User,
AlbumArtist,
Genre,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod'; import z from 'zod';
import { ndType } from './navidrome-types'; import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { NDGenre } from '/@/renderer/api/navidrome.types';
const getImageUrl = (args: { url: string | null }) => {
const { url } = args;
if (url === '/app/artist-placeholder.webp') {
return null;
}
if (url?.match('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')) {
return null;
}
return url;
};
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
baseUrl: string | undefined; baseUrl: string | undefined;
@@ -51,7 +74,6 @@ const normalizeSong = (
}); });
const imagePlaceholderUrl = null; const imagePlaceholderUrl = null;
return { return {
album: item.album, album: item.album,
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }], albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
@@ -66,15 +88,30 @@ const normalizeSong = (
container: item.suffix, container: item.suffix,
createdAt: item.createdAt.split('T')[0], createdAt: item.createdAt.split('T')[0],
discNumber: item.discNumber, discNumber: item.discNumber,
duration: item.duration, discSubtitle: item.discSubtitle ? item.discSubtitle : null,
genres: item.genres, duration: item.duration * 1000,
gain:
item.rgAlbumGain || item.rgTrackGain
? { album: item.rgAlbumGain, track: item.rgTrackGain }
: null,
genres: item.genres?.map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id, id,
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl, imageUrl,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
lyrics: item.lyrics ? item.lyrics : null,
name: item.title, name: item.title,
path: item.path, path: item.path,
peak:
item.rgAlbumPeak || item.rgTrackPeak
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
: null,
playCount: item.playCount, playCount: item.playCount,
playlistItemId, playlistItemId,
releaseDate: new Date(item.year, 0, 1).toISOString(), releaseDate: new Date(item.year, 0, 1).toISOString(),
@@ -115,7 +152,12 @@ const normalizeAlbum = (
backdropImageUrl: imageBackdropUrl, backdropImageUrl: imageBackdropUrl,
createdAt: item.createdAt.split('T')[0], createdAt: item.createdAt.split('T')[0],
duration: item.duration * 1000 || null, duration: item.duration * 1000 || null,
genres: item.genres, genres: item.genres?.map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id: item.id, id: item.id,
imagePlaceholderUrl, imagePlaceholderUrl,
imageUrl, imageUrl,
@@ -139,18 +181,24 @@ const normalizeAlbum = (
}; };
const normalizeAlbumArtist = ( const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist>, item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: ServerListItem | null, server: ServerListItem | null,
): AlbumArtist => { ): AlbumArtist => {
const imageUrl = const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
return { return {
albumCount: item.albumCount, albumCount: item.albumCount,
backgroundImageUrl: null, backgroundImageUrl: null,
biography: item.biography || null, biography: item.biography || null,
duration: null, duration: null,
genres: item.genres, genres: item.genres?.map((genre) => ({
id: genre.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: genre.name,
})),
id: item.id, id: item.id,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
@@ -159,13 +207,12 @@ const normalizeAlbumArtist = (
playCount: item.playCount, playCount: item.playCount,
serverId: server?.id || 'unknown', serverId: server?.id || 'unknown',
serverType: ServerType.NAVIDROME, serverType: ServerType.NAVIDROME,
similarArtists: null, similarArtists:
// similarArtists: item.similarArtists?.map((artist) => ({
// item.similarArtists?.map((artist) => ({ id: artist.id,
// id: artist.id, imageUrl: artist?.artistImageUrl || null,
// imageUrl: artist?.artistImageUrl || null, name: artist.name,
// name: artist.name, })) || null,
// })) || null,
songCount: item.songCount, songCount: item.songCount,
userFavorite: item.starred, userFavorite: item.starred,
userRating: item.rating, userRating: item.rating,
@@ -207,6 +254,17 @@ const normalizePlaylist = (
}; };
}; };
const normalizeGenre = (item: NDGenre): Genre => {
return {
albumCount: undefined,
id: item.id,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.name,
songCount: undefined,
};
};
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => { const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
return { return {
createdAt: item.createdAt, createdAt: item.createdAt,
@@ -222,6 +280,7 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
export const ndNormalize = { export const ndNormalize = {
album: normalizeAlbum, album: normalizeAlbum,
albumArtist: normalizeAlbumArtist, albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
playlist: normalizePlaylist, playlist: normalizePlaylist,
song: normalizeSong, song: normalizeSong,
user: normalizeUser, user: normalizeUser,
+33 -1
View File
@@ -52,6 +52,16 @@ const genre = z.object({
name: z.string(), name: z.string(),
}); });
const genreListSort = {
NAME: 'name',
SONG_COUNT: 'songCount',
} as const;
const genreListParameters = paginationParameters.extend({
_sort: z.nativeEnum(genreListSort).optional(),
name: z.string().optional(),
});
const genreList = z.array(genre); const genreList = z.array(genre);
const albumArtist = z.object({ const albumArtist = z.object({
@@ -156,6 +166,7 @@ const albumListParameters = paginationParameters.extend({
id: z.string().optional(), id: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),
recently_added: z.boolean().optional(), recently_added: z.boolean().optional(),
recently_played: z.boolean().optional(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
year: z.number().optional(), year: z.number().optional(),
}); });
@@ -170,22 +181,30 @@ const song = z.object({
bitRate: z.number(), bitRate: z.number(),
bookmarkPosition: z.number(), bookmarkPosition: z.number(),
bpm: z.number().optional(), bpm: z.number().optional(),
catalogNum: z.string().optional(),
channels: z.number().optional(), channels: z.number().optional(),
comment: z.string().optional(), comment: z.string().optional(),
compilation: z.boolean(), compilation: z.boolean(),
createdAt: z.string(), createdAt: z.string(),
discNumber: z.number(), discNumber: z.number(),
discSubtitle: z.string().optional(),
duration: z.number(), duration: z.number(),
embedArtPath: z.string().optional(),
externalInfoUpdatedAt: z.string().optional(),
externalUrl: z.string().optional(),
fullText: z.string(), fullText: z.string(),
genre: z.string(), genre: z.string(),
genres: z.array(genre), genres: z.array(genre),
hasCoverArt: z.boolean(), hasCoverArt: z.boolean(),
id: z.string(), id: z.string(),
imageFiles: z.string().optional(),
largeImageUrl: z.string().optional(),
lyrics: z.string().optional(), lyrics: z.string().optional(),
mbzAlbumArtistId: z.string().optional(), mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(), mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(), mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(), mbzTrackId: z.string().optional(),
mediumImageUrl: z.string().optional(),
orderAlbumArtistName: z.string(), orderAlbumArtistName: z.string(),
orderAlbumName: z.string(), orderAlbumName: z.string(),
orderArtistName: z.string(), orderArtistName: z.string(),
@@ -194,7 +213,12 @@ const song = z.object({
playCount: z.number(), playCount: z.number(),
playDate: z.string(), playDate: z.string(),
rating: z.number().optional(), rating: z.number().optional(),
rgAlbumGain: z.number().optional(),
rgAlbumPeak: z.number().optional(),
rgTrackGain: z.number().optional(),
rgTrackPeak: z.number().optional(),
size: z.number(), size: z.number(),
smallImageUrl: z.string().optional(),
sortAlbumArtistName: z.string(), sortAlbumArtistName: z.string(),
sortArtistName: z.string(), sortArtistName: z.string(),
starred: z.boolean(), starred: z.boolean(),
@@ -231,10 +255,14 @@ const ndSongListSort = {
const songListParameters = paginationParameters.extend({ const songListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndSongListSort).optional(), _sort: z.nativeEnum(ndSongListSort).optional(),
album_artist_id: z.array(z.string()).optional(),
album_id: z.array(z.string()).optional(), album_id: z.array(z.string()).optional(),
artist_id: z.array(z.string()).optional(), artist_id: z.array(z.string()).optional(),
genre_id: z.string().optional(), genre_id: z.string().optional(),
path: z.string().optional(),
starred: z.boolean().optional(), starred: z.boolean().optional(),
title: z.string().optional(),
year: z.number().optional(),
}); });
const playlist = z.object({ const playlist = z.object({
@@ -269,6 +297,8 @@ const ndPlaylistListSort = {
const playlistListParameters = paginationParameters.extend({ const playlistListParameters = paginationParameters.extend({
_sort: z.nativeEnum(ndPlaylistListSort).optional(), _sort: z.nativeEnum(ndPlaylistListSort).optional(),
owner_id: z.string().optional(), owner_id: z.string().optional(),
q: z.string().optional(),
smart: z.boolean().optional(),
}); });
const playlistSong = song.extend({ const playlistSong = song.extend({
@@ -309,13 +339,14 @@ const removeFromPlaylist = z.object({
}); });
const removeFromPlaylistParameters = z.object({ const removeFromPlaylistParameters = z.object({
ids: z.array(z.string()), id: z.array(z.string()),
}); });
export const ndType = { export const ndType = {
_enum: { _enum: {
albumArtistList: ndAlbumArtistListSort, albumArtistList: ndAlbumArtistListSort,
albumList: ndAlbumListSort, albumList: ndAlbumListSort,
genreList: genreListSort,
playlistList: ndPlaylistListSort, playlistList: ndPlaylistListSort,
songList: ndSongListSort, songList: ndSongListSort,
userList: ndUserListSort, userList: ndUserListSort,
@@ -326,6 +357,7 @@ export const ndType = {
albumList: albumListParameters, albumList: albumListParameters,
authenticate: authenticateParameters, authenticate: authenticateParameters,
createPlaylist: createPlaylistParameters, createPlaylist: createPlaylistParameters,
genreList: genreListParameters,
playlistList: playlistListParameters, playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters, removeFromPlaylist: removeFromPlaylistParameters,
songList: songListParameters, songList: songListParameters,
+166 -12
View File
@@ -1,3 +1,5 @@
import { QueryFunctionContext } from '@tanstack/react-query';
import { LyricSource } from './types';
import type { import type {
AlbumListQuery, AlbumListQuery,
SongListQuery, SongListQuery,
@@ -10,16 +12,57 @@ import type {
UserListQuery, UserListQuery,
AlbumArtistDetailQuery, AlbumArtistDetailQuery,
TopSongListQuery, TopSongListQuery,
SearchQuery,
SongDetailQuery,
RandomSongListQuery,
LyricsQuery,
LyricSearchQuery,
GenreListQuery,
} from './types'; } from './types';
export const queryKeys = { export const splitPaginatedQuery = (key: any) => {
const { startIndex, limit, ...filter } = key || {};
if (startIndex !== undefined || limit !== undefined) {
return {
filter,
pagination: {
limit,
startIndex,
},
};
}
return {
filter,
pagination: undefined,
};
};
export type QueryPagination = {
limit?: number;
startIndex?: number;
};
export const queryKeys: Record<
string,
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
> = {
albumArtists: { albumArtists: {
detail: (serverId: string, query?: AlbumArtistDetailQuery) => { detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
if (query) return [serverId, 'albumArtists', 'detail', query] as const; if (query) return [serverId, 'albumArtists', 'detail', query] as const;
return [serverId, 'albumArtists', 'detail'] as const; return [serverId, 'albumArtists', 'detail'] as const;
}, },
list: (serverId: string, query?: AlbumArtistListQuery) => { list: (serverId: string, query?: AlbumArtistListQuery) => {
if (query) return [serverId, 'albumArtists', 'list', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'albumArtists', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'albumArtists', 'list', filter] as const;
}
return [serverId, 'albumArtists', 'list'] as const; return [serverId, 'albumArtists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'albumArtists'] as const, root: (serverId: string) => [serverId, 'albumArtists'] as const,
@@ -31,10 +74,34 @@ export const queryKeys = {
albums: { albums: {
detail: (serverId: string, query?: AlbumDetailQuery) => detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const, [serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery) => { list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
if (query) return [serverId, 'albums', 'list', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination && artistId) {
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
}
if (query && pagination) {
return [serverId, 'albums', 'list', filter, pagination] as const;
}
if (query && artistId) {
return [serverId, 'albums', 'list', artistId, filter] as const;
}
if (query) {
return [serverId, 'albums', 'list', filter] as const;
}
return [serverId, 'albums', 'list'] as const; return [serverId, 'albums', 'list'] as const;
}, },
related: (serverId: string, id: string, query?: AlbumDetailQuery) => {
if (query) {
return [serverId, 'albums', id, 'related', query] as const;
}
return [serverId, 'albums', id, 'related'] as const;
},
root: (serverId: string) => [serverId, 'albums'], root: (serverId: string) => [serverId, 'albums'],
serverRoot: (serverId: string) => [serverId, 'albums'], serverRoot: (serverId: string) => [serverId, 'albums'],
songs: (serverId: string, query: SongListQuery) => songs: (serverId: string, query: SongListQuery) =>
@@ -42,13 +109,32 @@ export const queryKeys = {
}, },
artists: { artists: {
list: (serverId: string, query?: ArtistListQuery) => { list: (serverId: string, query?: ArtistListQuery) => {
if (query) return [serverId, 'artists', 'list', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'artists', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'artists', 'list', filter] as const;
}
return [serverId, 'artists', 'list'] as const; return [serverId, 'artists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'artists'] as const, root: (serverId: string) => [serverId, 'artists'] as const,
}, },
genres: { genres: {
list: (serverId: string) => [serverId, 'genres', 'list'] as const, list: (serverId: string, query?: GenreListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'genres', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'genres', 'list', filter] as const;
}
return [serverId, 'genres', 'list'] as const;
},
root: (serverId: string) => [serverId, 'genres'] as const, root: (serverId: string) => [serverId, 'genres'] as const,
}, },
musicFolders: { musicFolders: {
@@ -56,34 +142,102 @@ export const queryKeys = {
}, },
playlists: { playlists: {
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => { detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
if (query) return [serverId, 'playlists', id, 'detail', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'playlists', id, 'detail', filter, pagination] as const;
}
if (query) {
return [serverId, 'playlists', id, 'detail', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'detail'] as const; if (id) return [serverId, 'playlists', id, 'detail'] as const;
return [serverId, 'playlists', 'detail'] as const; return [serverId, 'playlists', 'detail'] as const;
}, },
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => { detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'detailSongList', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const; if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
return [serverId, 'playlists', 'detailSongList'] as const; return [serverId, 'playlists', 'detailSongList'] as const;
}, },
list: (serverId: string, query?: PlaylistListQuery) => { list: (serverId: string, query?: PlaylistListQuery) => {
if (query) return [serverId, 'playlists', 'list', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'playlists', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'playlists', 'list', filter] as const;
}
return [serverId, 'playlists', 'list'] as const; return [serverId, 'playlists', 'list'] as const;
}, },
root: (serverId: string) => [serverId, 'playlists'] as const, root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => { songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
if (query) return [serverId, 'playlists', id, 'songList', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
}
if (query && id) {
return [serverId, 'playlists', id, 'songList', filter] as const;
}
if (id) return [serverId, 'playlists', id, 'songList'] as const; if (id) return [serverId, 'playlists', id, 'songList'] as const;
return [serverId, 'playlists', 'songList'] as const; return [serverId, 'playlists', 'songList'] as const;
}, },
}, },
search: {
list: (serverId: string, query?: SearchQuery) => {
if (query) return [serverId, 'search', 'list', query] as const;
return [serverId, 'search', 'list'] as const;
},
root: (serverId: string) => [serverId, 'search'] as const,
},
server: { server: {
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },
songs: { songs: {
detail: (serverId: string, query?: SongDetailQuery) => {
if (query) return [serverId, 'songs', 'detail', query] as const;
return [serverId, 'songs', 'detail'] as const;
},
list: (serverId: string, query?: SongListQuery) => { list: (serverId: string, query?: SongListQuery) => {
if (query) return [serverId, 'songs', 'list', query] as const; const { pagination, filter } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'list', filter, pagination] as const;
}
if (query) {
return [serverId, 'songs', 'list', filter] as const;
}
return [serverId, 'songs', 'list'] as const; return [serverId, 'songs', 'list'] as const;
}, },
lyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'select', query] as const;
return [serverId, 'song', 'lyrics'] as const;
},
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
return ['song', 'lyrics', 'remote', searchQuery] as const;
},
lyricsSearch: (query?: LyricSearchQuery) => {
if (query) return ['lyrics', 'search', query] as const;
return ['lyrics', 'search'] as const;
},
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const;
},
root: (serverId: string) => [serverId, 'songs'] as const, root: (serverId: string) => [serverId, 'songs'] as const,
}, },
users: { users: {
+43 -4
View File
@@ -1,5 +1,6 @@
import { initClient, initContract } from '@ts-rest/core'; import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios'; import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs'; import qs from 'qs';
import { z } from 'zod'; import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
@@ -40,6 +41,14 @@ export const contract = c.router({
200: ssType._response.musicFolderList, 200: ssType._response.musicFolderList,
}, },
}, },
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
query: ssType._parameters.randomSongList,
responses: {
200: ssType._response.randomSongList,
},
},
getTopSongsList: { getTopSongsList: {
method: 'GET', method: 'GET',
path: 'getTopSongs.view', path: 'getTopSongs.view',
@@ -64,6 +73,14 @@ export const contract = c.router({
200: ssType._response.scrobble, 200: ssType._response.scrobble,
}, },
}, },
search3: {
method: 'GET',
path: 'search3.view',
query: ssType._parameters.search3,
responses: {
200: ssType._response.search3,
},
},
setRating: { setRating: {
method: 'GET', method: 'GET',
path: 'setRating.view', path: 'setRating.view',
@@ -101,6 +118,18 @@ axiosClient.interceptors.response.use(
}, },
); );
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
const parsedParams = qs.parse(params);
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
return {
params: notNilParams,
path,
};
};
export const ssApiClient = (args: { export const ssApiClient = (args: {
server: ServerListItem | null; server: ServerListItem | null;
signal?: AbortSignal; signal?: AbortSignal;
@@ -113,6 +142,8 @@ export const ssApiClient = (args: {
let baseUrl: string | undefined; let baseUrl: string | undefined;
const authParams: Record<string, any> = {}; const authParams: Record<string, any> = {};
const { params, path: api } = parsePath(path);
if (server) { if (server) {
baseUrl = `${server.url}/rest`; baseUrl = `${server.url}/rest`;
const token = server.credential; const token = server.credential;
@@ -130,7 +161,9 @@ export const ssApiClient = (args: {
} }
try { try {
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({ const result = await axiosClient.request<
z.infer<typeof ssType._response.baseResponse>
>({
data: body, data: body,
headers, headers,
method: method as Method, method: method as Method,
@@ -139,22 +172,28 @@ export const ssApiClient = (args: {
f: 'json', f: 'json',
v: '1.13.0', v: '1.13.0',
...authParams, ...authParams,
...params,
}, },
signal, signal,
url: `${baseUrl}/${path}`, url: `${baseUrl}/${api}`,
}); });
return { return {
body: result.data['subsonic-response'], body: result.data['subsonic-response'],
headers: result.headers as any,
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
console.log('CATCH ERR');
if (isAxiosError(e)) { if (isAxiosError(e)) {
const error = e as AxiosError; const error = e as AxiosError;
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
return { return {
body: response.data, body: response?.data,
status: response.status, headers: response.headers as any,
status: response?.status,
}; };
} }
throw e; throw e;
@@ -17,6 +17,10 @@ import {
ScrobbleResponse, ScrobbleResponse,
SongListResponse, SongListResponse,
TopSongListArgs, TopSongListArgs,
SearchArgs,
SearchResponse,
RandomSongListResponse,
RandomSongListArgs,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
@@ -262,8 +266,9 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
return { return {
items: items:
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) || res.body.topSongs?.song?.map((song) =>
[], ssNormalize.song(song, apiClientProps.server, ''),
) || [],
startIndex: 0, startIndex: 0,
totalRecordCount: res.body.topSongs?.song?.length || 0, totalRecordCount: res.body.topSongs?.song?.length || 0,
}; };
@@ -305,13 +310,73 @@ const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
return null; return null;
}; };
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).search3({
query: {
albumCount: query.albumLimit,
albumOffset: query.albumStartIndex,
artistCount: query.albumArtistLimit,
artistOffset: query.albumArtistStartIndex,
query: query.query,
songCount: query.songLimit,
songOffset: query.songStartIndex,
},
});
if (res.status !== 200) {
throw new Error('Failed to search');
}
return {
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
ssNormalize.albumArtist(artist, apiClientProps.server),
),
albums: res.body.searchResult3?.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server),
),
songs: res.body.searchResult3?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
};
};
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
const { query, apiClientProps } = args;
const res = await ssApiClient(apiClientProps).getRandomSongList({
query: {
fromYear: query.minYear,
genre: query.genre,
musicFolderId: query.musicFolderId,
size: query.limit,
toYear: query.maxYear,
},
});
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
return {
items: res.body.randomSongs?.song?.map((song) =>
ssNormalize.song(song, apiClientProps.server, ''),
),
startIndex: 0,
totalRecordCount: res.body.randomSongs?.song?.length || 0,
};
};
export const ssController = { export const ssController = {
authenticate, authenticate,
createFavorite, createFavorite,
getArtistInfo, getArtistInfo,
getMusicFolderList, getMusicFolderList,
getRandomSongList,
getTopSongList, getTopSongList,
removeFavorite, removeFavorite,
scrobble, scrobble,
search3,
setRating, setRating,
}; };
@@ -1,7 +1,7 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem } from '/@/renderer/api/types'; import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types'; import { ServerListItem, ServerType } from '/@/renderer/types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@@ -10,7 +10,7 @@ const getCoverArtUrl = (args: {
credential: string | undefined; credential: string | undefined;
size: number; size: number;
}) => { }) => {
const size = args.size ? args.size : 150; const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) { if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null; return null;
@@ -67,11 +67,15 @@ const normalizeSong = (
container: item.contentType, container: item.contentType,
createdAt: item.created, createdAt: item.created,
discNumber: item.discNumber || 1, discNumber: item.discNumber || 1,
duration: item.duration || 0, discSubtitle: null,
duration: item.duration ? item.duration * 1000 : 0,
gain: null,
genres: item.genre genres: item.genre
? [ ? [
{ {
id: item.genre, id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre, name: item.genre,
}, },
] ]
@@ -81,8 +85,10 @@ const normalizeSong = (
imageUrl, imageUrl,
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null,
name: item.title, name: item.title,
path: item.path, path: item.path,
peak: null,
playCount: item?.playCount || 0, playCount: item?.playCount || 0,
releaseDate: null, releaseDate: null,
releaseYear: item.year ? String(item.year) : null, releaseYear: item.year ? String(item.year) : null,
@@ -98,6 +104,93 @@ const normalizeSong = (
}; };
}; };
const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>,
server: ServerListItem | null,
): AlbumArtist => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 100,
}) || null;
return {
albumCount: item.albumCount ? Number(item.albumCount) : 0,
backgroundImageUrl: null,
biography: null,
duration: null,
genres: [],
id: item.id,
imageUrl,
itemType: LibraryItem.ALBUM_ARTIST,
lastPlayedAt: null,
name: item.name,
playCount: null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
similarArtists: [],
songCount: null,
userFavorite: false,
userRating: null,
};
};
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album>,
server: ServerListItem | null,
): Album => {
const imageUrl =
getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArt,
credential: server?.credential,
size: 300,
}) || null;
return {
albumArtists: item.artistId
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
: [],
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
backdropImageUrl: null,
createdAt: item.created,
duration: item.duration,
genres: item.genre
? [
{
id: item.genre,
imageUrl: null,
itemType: LibraryItem.GENRE,
name: item.genre,
},
]
: [],
id: item.id,
imagePlaceholderUrl: null,
imageUrl,
isCompilation: null,
itemType: LibraryItem.ALBUM,
lastPlayedAt: null,
name: item.name,
playCount: null,
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
releaseYear: item.year ? Number(item.year) : null,
serverId: server?.id || 'unknown',
serverType: ServerType.SUBSONIC,
size: null,
songCount: item.songCount,
songs: [],
uniqueId: nanoid(),
updatedAt: item.created,
userFavorite: item.starred || false,
userRating: item.userRating || null,
};
};
export const ssNormalize = { export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong, song: normalizeSong,
}; };
+40 -1
View File
@@ -135,7 +135,7 @@ const artistInfoParameters = z.object({
}); });
const artistInfo = z.object({ const artistInfo = z.object({
artistInfo2: z.object({ artistInfo: z.object({
biography: z.string().optional(), biography: z.string().optional(),
largeImageUrl: z.string().optional(), largeImageUrl: z.string().optional(),
lastFmUrl: z.string().optional(), lastFmUrl: z.string().optional(),
@@ -173,18 +173,55 @@ const scrobbleParameters = z.object({
const scrobble = z.null(); const scrobble = z.null();
const search3 = z.object({
searchResult3: z.object({
album: z.array(album),
artist: z.array(albumArtist),
song: z.array(song),
}),
});
const search3Parameters = z.object({
albumCount: z.number().optional(),
albumOffset: z.number().optional(),
artistCount: z.number().optional(),
artistOffset: z.number().optional(),
musicFolderId: z.string().optional(),
query: z.string().optional(),
songCount: z.number().optional(),
songOffset: z.number().optional(),
});
const randomSongListParameters = z.object({
fromYear: z.number().optional(),
genre: z.string().optional(),
musicFolderId: z.string().optional(),
size: z.number().optional(),
toYear: z.number().optional(),
});
const randomSongList = z.object({
randomSongs: z.object({
song: z.array(song),
}),
});
export const ssType = { export const ssType = {
_parameters: { _parameters: {
albumList: albumListParameters, albumList: albumListParameters,
artistInfo: artistInfoParameters, artistInfo: artistInfoParameters,
authenticate: authenticateParameters, authenticate: authenticateParameters,
createFavorite: createFavoriteParameters, createFavorite: createFavoriteParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters, setRating: setRatingParameters,
topSongsList: topSongsListParameters, topSongsList: topSongsListParameters,
}, },
_response: { _response: {
album,
albumArtist,
albumArtistList, albumArtistList,
albumList, albumList,
artistInfo, artistInfo,
@@ -192,8 +229,10 @@ export const ssType = {
baseResponse, baseResponse,
createFavorite, createFavorite,
musicFolderList, musicFolderList,
randomSongList,
removeFavorite, removeFavorite,
scrobble, scrobble,
search3,
setRating, setRating,
song, song,
topSongsList, topSongsList,
+200 -69
View File
@@ -1,3 +1,5 @@
import { z } from 'zod';
import { jfType } from './jellyfin/jellyfin-types';
import { import {
JFSortOrder, JFSortOrder,
JFAlbumListSort, JFAlbumListSort,
@@ -5,7 +7,9 @@ import {
JFAlbumArtistListSort, JFAlbumArtistListSort,
JFArtistListSort, JFArtistListSort,
JFPlaylistListSort, JFPlaylistListSort,
} from '/@/renderer/api/jellyfin.types'; JFGenreListSort,
} from './jellyfin.types';
import { ndType } from './navidrome/navidrome-types';
import { import {
NDSortOrder, NDSortOrder,
NDOrder, NDOrder,
@@ -14,12 +18,14 @@ import {
NDPlaylistListSort, NDPlaylistListSort,
NDSongListSort, NDSongListSort,
NDUserListSort, NDUserListSort,
} from '/@/renderer/api/navidrome.types'; NDGenreListSort,
} from './navidrome.types';
export enum LibraryItem { export enum LibraryItem {
ALBUM = 'album', ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist', ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist', ARTIST = 'artist',
GENRE = 'genre',
PLAYLIST = 'playlist', PLAYLIST = 'playlist',
SONG = 'song', SONG = 'song',
} }
@@ -131,6 +137,8 @@ export type AuthenticationResponse = {
export type Genre = { export type Genre = {
albumCount?: number; albumCount?: number;
id: string; id: string;
imageUrl: string | null;
itemType: LibraryItem.GENRE;
name: string; name: string;
songCount?: number; songCount?: number;
}; };
@@ -163,8 +171,13 @@ export type Album = {
userRating: number | null; userRating: number | null;
} & { songs?: Song[] }; } & { songs?: Song[] };
export type GainInfo = {
album?: number;
track?: number;
};
export type Song = { export type Song = {
album: string; album: string | null;
albumArtists: RelatedArtist[]; albumArtists: RelatedArtist[];
albumId: string; albumId: string;
artistName: string; artistName: string;
@@ -177,15 +190,19 @@ export type Song = {
container: string | null; container: string | null;
createdAt: string; createdAt: string;
discNumber: number; discNumber: number;
discSubtitle: string | null;
duration: number; duration: number;
gain: GainInfo | null;
genres: Genre[]; genres: Genre[];
id: string; id: string;
imagePlaceholderUrl: string | null; imagePlaceholderUrl: string | null;
imageUrl: string | null; imageUrl: string | null;
itemType: LibraryItem.SONG; itemType: LibraryItem.SONG;
lastPlayedAt: string | null; lastPlayedAt: string | null;
lyrics: string | null;
name: string; name: string;
path: string | null; path: string | null;
peak: GainInfo | null;
playCount: number; playCount: number;
playlistItemId?: string; playlistItemId?: string;
releaseDate: string | null; releaseDate: string | null;
@@ -288,7 +305,40 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
export type GenreListQuery = null; export enum GenreListSort {
NAME = 'name',
}
export type GenreListQuery = {
_custom?: {
jellyfin?: null;
navidrome?: null;
};
limit?: number;
musicFolderId?: string;
searchTerm?: string;
sortBy: GenreListSort;
sortOrder: SortOrder;
startIndex: number;
};
type GenreListSortMap = {
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
subsonic: Record<UserListSort, undefined>;
};
export const genreListSortMap: GenreListSortMap = {
jellyfin: {
name: JFGenreListSort.NAME,
},
navidrome: {
name: NDGenreListSort.NAME,
},
subsonic: {
name: undefined,
},
};
// Album List // Album List
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined; export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
@@ -313,28 +363,11 @@ export enum AlbumListSort {
export type AlbumListQuery = { export type AlbumListQuery = {
_custom?: { _custom?: {
jellyfin?: { jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
albumArtistIds?: string; maxYear?: number;
artistIds?: string; minYear?: number;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
isFavorite?: boolean;
maxYear?: number; // Parses to years
minYear?: number; // Parses to years
tags?: string;
};
navidrome?: {
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
}; };
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
}; };
artistIds?: string[]; artistIds?: string[];
limit?: number; limit?: number;
@@ -415,7 +448,7 @@ export type AlbumDetailQuery = { id: string };
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs; export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
// Song List // Song List
export type SongListResponse = BasePaginatedResponse<Song[]>; export type SongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
export enum SongListSort { export enum SongListSort {
ALBUM = 'album', ALBUM = 'album',
@@ -440,32 +473,15 @@ export enum SongListSort {
export type SongListQuery = { export type SongListQuery = {
_custom?: { _custom?: {
jellyfin?: { jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
artistIds?: string; maxYear?: number;
contributingArtistIds?: string; minYear?: number;
filters?: string;
genreIds?: string;
genres?: string;
includeItemTypes: 'Audio';
isFavorite?: boolean;
maxYear?: number; // Parses to years
minYear?: number; // Parses to years
sortBy?: JFSongListSort;
years?: string;
};
navidrome?: {
album_id?: string[];
artist_id?: string[];
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
starred?: boolean;
title?: string;
year?: number;
}; };
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
}; };
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
imageSize?: number;
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
searchTerm?: string; searchTerm?: string;
@@ -553,7 +569,7 @@ export type SongDetailQuery = { id: string };
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs; export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
// Album Artist List // Album Artist List
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null; export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null | undefined;
export enum AlbumArtistListSort { export enum AlbumArtistListSort {
ALBUM = 'album', ALBUM = 'album',
@@ -571,11 +587,8 @@ export enum AlbumArtistListSort {
export type AlbumArtistListQuery = { export type AlbumArtistListQuery = {
_custom?: { _custom?: {
navidrome?: { jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
genre_id?: string; navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
name?: string;
starred?: boolean;
};
}; };
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
@@ -644,7 +657,7 @@ export type AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs; export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
// Artist List // Artist List
export type ArtistListResponse = BasePaginatedResponse<Artist[]>; export type ArtistListResponse = BasePaginatedResponse<Artist[]> | null | undefined;
export enum ArtistListSort { export enum ArtistListSort {
ALBUM = 'album', ALBUM = 'album',
@@ -662,11 +675,8 @@ export enum ArtistListSort {
export type ArtistListQuery = { export type ArtistListQuery = {
_custom?: { _custom?: {
navidrome?: { jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
genre_id?: string; navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
name?: string;
starred?: boolean;
};
}; };
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
@@ -835,7 +845,7 @@ export type DeletePlaylistArgs = {
} & BaseEndpointArgs; } & BaseEndpointArgs;
// Playlist List // Playlist List
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>; export type PlaylistListResponse = BasePaginatedResponse<Playlist[]> | null | undefined;
export enum PlaylistListSort { export enum PlaylistListSort {
DURATION = 'duration', DURATION = 'duration',
@@ -848,10 +858,8 @@ export enum PlaylistListSort {
export type PlaylistListQuery = { export type PlaylistListQuery = {
_custom?: { _custom?: {
navidrome?: { jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
owner_id?: string; navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
smart?: boolean;
};
}; };
limit?: number; limit?: number;
searchTerm?: string; searchTerm?: string;
@@ -905,7 +913,7 @@ export type PlaylistDetailQuery = {
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs; export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
// Playlist Songs // Playlist Songs
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>; export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
export type PlaylistSongListQuery = { export type PlaylistSongListQuery = {
id: string; id: string;
@@ -918,7 +926,7 @@ export type PlaylistSongListQuery = {
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs; export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
// Music Folder List // Music Folder List
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>; export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]> | null | undefined;
export type MusicFolderListQuery = null; export type MusicFolderListQuery = null;
@@ -926,7 +934,7 @@ export type MusicFolderListArgs = BaseEndpointArgs;
// User list // User list
// Playlist List // Playlist List
export type UserListResponse = BasePaginatedResponse<User[]>; export type UserListResponse = BasePaginatedResponse<User[]> | null | undefined;
export enum UserListSort { export enum UserListSort {
NAME = 'name', NAME = 'name',
@@ -966,7 +974,7 @@ export const userListSortMap: UserListSortMap = {
}; };
// Top Songs List // Top Songs List
export type TopSongListResponse = BasePaginatedResponse<Song[]>; export type TopSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
export type TopSongListQuery = { export type TopSongListQuery = {
artist: string; artist: string;
@@ -999,3 +1007,126 @@ export type ScrobbleQuery = {
position?: number; position?: number;
submission: boolean; submission: boolean;
}; };
export type SearchQuery = {
albumArtistLimit?: number;
albumArtistStartIndex?: number;
albumLimit?: number;
albumStartIndex?: number;
musicFolderId?: string;
query?: string;
songLimit?: number;
songStartIndex?: number;
};
export type SearchSongsQuery = {
musicFolderId?: string;
query?: string;
songLimit?: number;
songStartIndex?: number;
};
export type SearchAlbumsQuery = {
albumLimit?: number;
albumStartIndex?: number;
musicFolderId?: string;
query?: string;
};
export type SearchAlbumArtistsQuery = {
albumArtistLimit?: number;
albumArtistStartIndex?: number;
musicFolderId?: string;
query?: string;
};
export type SearchArgs = {
query: SearchQuery;
} & BaseEndpointArgs;
export type SearchResponse = {
albumArtists: AlbumArtist[];
albums: Album[];
songs: Song[];
};
export type RandomSongListQuery = {
genre?: string;
limit?: number;
maxYear?: number;
minYear?: number;
musicFolderId?: string;
};
export type RandomSongListArgs = {
query: RandomSongListQuery;
} & BaseEndpointArgs;
export type RandomSongListResponse = SongListResponse;
export type LyricsQuery = {
songId: string;
};
export type LyricsArgs = {
query: LyricsQuery;
} & BaseEndpointArgs;
export type SynchronizedLyricsArray = Array<[number, string]>;
export type LyricsResponse = SynchronizedLyricsArray | string;
export type InternetProviderLyricResponse = {
artist: string;
id: string;
lyrics: string;
name: string;
source: LyricSource;
};
export type InternetProviderLyricSearchResponse = {
artist: string;
id: string;
name: string;
score?: number;
source: LyricSource;
};
export type SynchronizedLyricMetadata = {
lyrics: SynchronizedLyricsArray;
remote: boolean;
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
export type UnsynchronizedLyricMetadata = {
lyrics: string;
remote: boolean;
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
export const instanceOfCancellationError = (error: any) => {
return 'revert' in error;
};
export type LyricSearchQuery = {
album?: string;
artist?: string;
duration?: number;
name?: string;
};
export type LyricGetQuery = {
remoteSongId: string;
remoteSource: LyricSource;
song: Song;
};
export enum LyricSource {
GENIUS = 'Genius',
LRCLIB = 'lrclib.net',
NETEASE = 'NetEase',
}
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
+17
View File
@@ -1,5 +1,8 @@
import { AxiosHeaders } from 'axios'; import { AxiosHeaders } from 'axios';
import { z } from 'zod'; import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object // Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => { export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@@ -21,3 +24,17 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
.extend(itemSchema), .extend(itemSchema),
}); });
}; };
export const authenticationFailure = (currentServer: ServerListItem | null) => {
toast.error({
message: 'Your session has expired.',
});
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
};

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