Compare commits

..

368 Commits

Author SHA1 Message Date
jeffvli 8eec6b6b8a Bump to version 0.0.1-alpha5 2023-02-09 00:37:28 -08:00
jeffvli 60219c2522 Minor player adjustments 2023-02-09 00:36:55 -08:00
jeffvli cdb5cdf442 Fix sizing of drawer queue, add border 2023-02-09 00:18:25 -08:00
Jeff 23f84d68e8 Add MPRIS support (#25)
* Stop mpv on app close for linux/macOS (#20)

* Add initial MPRIS support

* Fix mpv path check
2023-02-08 23:57:06 -08:00
jeffvli 0f7f4b969f Fix drawer border radius 2023-02-08 17:00:07 -08:00
jeffvli 563a4b3a7c Add button to open browser devtools 2023-02-08 14:42:13 -08:00
jeffvli 4700772e6d Add padding for dropdown label 2023-02-08 14:39:59 -08:00
jeffvli ffb7f915c3 Add context menu to queue items 2023-02-08 11:46:39 -08:00
jeffvli 17d5ef1f6b Use flex instead of grid for context menu item 2023-02-08 11:46:02 -08:00
jeffvli 9dcc42ff28 Add border radius for all dropdown items (#22) 2023-02-08 11:45:29 -08:00
jeffvli 2845476d83 Migrate sidebar playlist to react-window 2023-02-08 03:44:37 -08:00
jeffvli 147b155d60 Add hook for hideable scrollbar 2023-02-08 03:44:05 -08:00
jeffvli 8b5e463546 Remove tanstack/react-virtual package 2023-02-08 03:43:18 -08:00
jeffvli 822dcd8429 Fix error on paginated table persistence 2023-02-08 10:05:10 -08:00
Jeff 9f2e873366 Redesign sidebar / header and other misc. improvements (#24)
* Remove 1920px max width

* Fix position of list controls menu

* Match size and color of search input

* Adjust library header sizing

* Move app menu to sidebar

* Increase row buffer on play queue list

* Fix query builder styles

* Fix playerbar slider track bg

* Adjust titlebar styles

* Fix invalid modal prop

* Various adjustments to detail pages

* Fix sidebar height calculation

* Fix list null indicators, add filter indicator

* Adjust playqueue styles

* Fix jellyfin releaseYear normalization

* Suppress browser context menu on ag-grid

* Add radius to drawer queue -- normalize layout

* Add modal styles to provider theme

* Fix playlist song list pagination

* Add disc number to albums with more than one disc

* Fix query builder boolean values

* Adjust input placeholder color

* Properly handle rating/favorite from context menu on table

* Conform dropdown menu styles to context menu

* Increase sort type select width

* Fix drawer queue radius

* Change primary color

* Prevent volume wheel from invalid values

* Add icons to query builder dropdowns

* Update notification styles

* Update scrollbar thumb styles

* Remove "add to playlist" on smart playlists

* Fix "add to playlist" from context menu
2023-02-07 22:47:23 -08:00
jeffvli d2c0d4c11f Fix modal styles for mantine v6 2023-02-06 02:21:11 -08:00
jeffvli 48b6e8bf93 Remove box shadow from filter header 2023-02-06 02:17:47 -08:00
jeffvli 17a6b37545 Fix active tab color 2023-02-06 02:17:25 -08:00
jeffvli eedcef8f52 Remove custom image component for grid images 2023-02-06 01:57:49 -08:00
jeffvli 757eddd6f1 Fix disabled input styles for mantine v6 2023-02-06 01:52:07 -08:00
jeffvli 13f48711a9 Use local seekvalue to smooth out slider drag 2023-02-06 01:45:56 -08:00
jeffvli 1bbdf09dcc Add padding to playlist list items 2023-02-05 23:18:42 -08:00
jeffvli 3b7c6ce25e Set transparent window control background 2023-02-05 23:13:44 -08:00
jeffvli 38118e74ae Update to new list header style 2023-02-05 22:41:47 -08:00
jeffvli 6872a7e8b2 Adjust various base components 2023-02-05 20:52:25 -08:00
jeffvli ab3236230b Use virtualized list on sidebar playlists 2023-02-05 18:59:39 -08:00
jeffvli 6ef88e56ec Adjust scrollarea to add styles and omit header 2023-02-05 18:02:27 -08:00
jeffvli 18c18ea322 Bump packages 2023-02-05 17:59:37 -08:00
Jeff 22fec8f9d3 Add ratings support (#21)
* Update rating types for multiserver support

* Add rating mutation

* Add rating support to table views

* Add rating support on playerbar

* Add hovercard component

* Handle rating from context menu

- Improve context menu components
- Allow left / right icons
- Allow nested menus

* Add selected item count

* Fix context menu auto direction

* Add transition and move portal for context menu

* Re-use context menu for all item dropdowns

* Add ratings to detail pages / double click to clear

* Bump react-query package
2023-02-05 05:19:01 -08:00
jeffvli f50ec5cf31 Fix error boundary styles 2023-01-30 21:34:56 -08:00
jeffvli 4cbc28a087 Add volume wheel scroll & new slider component 2023-01-30 21:34:27 -08:00
jeffvli 01fdd25406 Remove react-slider dependency 2023-01-30 21:28:37 -08:00
jeffvli 320f583660 Fix misc. styles 2023-01-30 20:16:43 -08:00
jeffvli 8cc5ec6797 Fix workflow name for PR binary comment 2023-01-30 20:03:18 -08:00
jeffvli edfbc2538d Set PR publish to upload binaries separately by OS 2023-01-30 20:03:08 -08:00
Jeff 484c96187c Add scrobble functionality (#19)
* Fix slider bar background to use theme

* Add "scrobbleAtDuration" to settings store

* Add subscribeWithSelector and playCount incrementor

* Add scrobbling API and mutation

* Add scrobble settings

* Begin support for multi-server queue handling

* Dynamically set version on auth header

* Add scrobbling functionality for navidrome/jellyfin
2023-01-30 20:01:57 -08:00
jeffvli 85bf910d65 Add additional controls to playerbar 2023-01-30 02:39:25 -08:00
jeffvli 5ddd0872ef Adjust various styles 2023-01-30 01:36:36 -08:00
jeffvli 2700774469 Fix player button styles 2023-01-30 01:05:23 -08:00
jeffvli c79a992829 Increase text size of table cells 2023-01-30 01:05:02 -08:00
jeffvli 5e693313d8 Adjust context menu styles 2023-01-30 01:04:38 -08:00
Jeff 59f4f43e84 Add ability to add/remove songs from playlist (#17)
* Add api for add/remove playlist items

* Add playlistItemId property to normalized Song

- This is used for Navidrome to delete songs from playlists

* Add mutations for add/remove from playlist

* Add context modal for playlist add

* Add remove from playlist from context menu

* Set jellyfin to use playlistItemId

* Adjust font sizing

* Add playlist add from detail pages

* Bump mantine to v6-alpha.2
2023-01-29 18:40:26 -08:00
jeffvli be39c2bc1f Add pr package script 2023-01-29 18:27:14 -08:00
jeffvli b49ba2d04c Fix pr build 2023-01-29 18:10:39 -08:00
jeffvli d7c87efe10 Add PR build workflows 2023-01-28 21:06:41 -08:00
Jeff 44a4b88809 Migrate to mantine v6 (#15)
* Add letter spacing to cell text

* Set window control height in px

* Add temp unused routes

* Migrate text title font weights

* Bump mantine to v6 alpha

* Migrate modals / notifications

* Increase header bar to 65px

* Adjust play button props

* Migrate various components

* Migrate various pages and root styles

* Adjust default badge padding

* Fix sidebar spacing

* Fix list header badges

* Adjust default theme
2023-01-28 20:46:07 -08:00
jeffvli 768269f074 Bump to v0.0.1-alpha4 2023-01-15 22:22:22 -08:00
jeffvli d23ae2a8db Replace default navidrome artist image placeholder path 2023-01-15 22:08:50 -08:00
jeffvli 88591697a2 Set route button to uppercase for consistency 2023-01-15 22:01:56 -08:00
jeffvli dcbb00f7c4 Remove filters button from album artist list 2023-01-15 21:58:50 -08:00
jeffvli e063ee0c29 Add smart playlist builder to create playlist form 2023-01-15 21:58:25 -08:00
jeffvli e5f478218e Forward playlist query filters 2023-01-15 21:57:44 -08:00
jeffvli 9a809a61dd Fix query not setting properly on render after save 2023-01-15 21:20:11 -08:00
jeffvli 4058ab7491 Change default smart playlist sort to album asc 2023-01-15 21:15:35 -08:00
jeffvli 48ccebd4c2 Clean up persisted list state when switching servers 2023-01-15 21:10:06 -08:00
jeffvli fcf00b9de1 Use native img on table images 2023-01-15 21:03:24 -08:00
jeffvli f7919b296b Change modal bg color 2023-01-15 20:48:34 -08:00
jeffvli 0b6af1fd21 Add additional padding to context menu items 2023-01-15 20:46:45 -08:00
jeffvli 3f424b72f6 Remove scroll persistence when viewing artist songs 2023-01-15 20:41:58 -08:00
jeffvli fc9d4616ba Adjust max height for settings modal 2023-01-15 20:40:23 -08:00
jeffvli 2e74f7533a Reuse song list for artist songs 2023-01-15 20:39:43 -08:00
jeffvli 784da2f8b9 Adjust context menu styles 2023-01-15 16:38:38 -08:00
jeffvli dbc29568ca Fix table params 2023-01-15 16:34:30 -08:00
jeffvli 5614ad54f2 Add view artist discography 2023-01-15 16:22:07 -08:00
jeffvli 67523f1e7b Adjust spacing between sections 2023-01-15 16:16:13 -08:00
jeffvli fc57605219 Select router type based on desktop/web 2023-01-15 16:11:26 -08:00
jeffvli a31a2ffdbf Increase page transition animation speed from 0.8 -> 0.5 2023-01-15 16:10:00 -08:00
jeffvli 900d47d6f9 Fix various types 2023-01-15 16:07:02 -08:00
jeffvli 6bdf0736ec Add genres to album detail page 2023-01-13 14:07:57 -08:00
jeffvli 92cde507d9 Add artist top songs list 2023-01-13 13:51:19 -08:00
jeffvli 8afd626806 Adjust filters 2023-01-13 01:54:35 -08:00
jeffvli 53f3758d2a Set static width for drawer queue 2023-01-13 01:53:34 -08:00
jeffvli 1e6eb33408 Link to album artist page instead of artist 2023-01-13 01:50:48 -08:00
jeffvli 51e20a81b7 Add artist song list page 2023-01-13 01:44:47 -08:00
jeffvli 4e8dc868bb Add routes to artist song list / discography 2023-01-13 01:09:34 -08:00
jeffvli 9b8bcb05bd Add initial album artist detail route 2023-01-12 18:43:25 -08:00
jeffvli 55e2a9bf37 Fix delimiter color 2023-01-12 18:41:41 -08:00
jeffvli a82b087969 Add itemtype and optional pagination for carousel 2023-01-12 13:31:25 -08:00
jeffvli 45aef104fe Update album artist base route 2023-01-12 12:45:44 -08:00
jeffvli 6746903808 Increase white ignore threshold 2023-01-12 12:28:19 -08:00
jeffvli 36c1f4e736 Set text children to optional 2023-01-12 00:44:33 -08:00
jeffvli b0ca7ab127 Use updated cardrows component 2023-01-12 00:43:39 -08:00
jeffvli 2026bc8f48 Add react-virtual package 2023-01-08 21:11:47 -08:00
jeffvli 6da8663a1d Fix card row array id assignment 2023-01-08 20:55:36 -08:00
jeffvli b4e9f48667 Add prop to force transparent grid header 2023-01-08 20:47:05 -08:00
jeffvli d58ba92cbd Add customizable scrollbar offset on scrollarea 2023-01-08 20:46:20 -08:00
jeffvli c51194cd03 Update album artist detail endpoints 2023-01-08 20:45:38 -08:00
jeffvli ba0ec909c8 Fix list header wrap on item count 2023-01-08 02:47:38 -08:00
jeffvli 0db1c36d86 Fix ND recently added sort 2023-01-08 02:39:35 -08:00
jeffvli 3e9fb521f0 Adjust sticky header to align with sidebar 2023-01-08 02:18:05 -08:00
jeffvli b5ad30a9bc Fix header being squished by table 2023-01-08 02:13:39 -08:00
jeffvli 5344493845 Fix mis-imported components 2023-01-08 02:08:19 -08:00
jeffvli 6f2108940e Adjust fixed table header 2023-01-08 02:04:07 -08:00
jeffvli 4f3e732891 Fix positioning of page header 2023-01-08 02:01:12 -08:00
jeffvli 0e2678575a Add favorite functionality to album detail 2023-01-08 01:45:56 -08:00
jeffvli d6035a5f97 Transform default number field value
- Navidrome allows for "string" number query values which will not work with the numberinput
2023-01-08 01:07:17 -08:00
jeffvli b82a5eda78 Fix undefined type for favorites 2023-01-08 00:53:30 -08:00
jeffvli d17f30f5e6 Add favorite handler to grid cards 2023-01-08 00:52:53 -08:00
jeffvli 7a3bdb531d Readd missing scroll restoration 2023-01-08 00:08:26 -08:00
jeffvli c6f3b49a6e Increase width of duration cell 2023-01-07 23:45:40 -08:00
jeffvli 99cd48ca6d Adjust color and size of favorite cell 2023-01-07 23:44:53 -08:00
jeffvli 02ed9b7a5c Move ag-grid header margin to specific components 2023-01-07 23:37:33 -08:00
jeffvli 0f66687843 Misc. optimizations 2023-01-07 23:09:58 -08:00
jeffvli 586f42867d Fix lodash import 2023-01-07 18:25:36 -08:00
jeffvli 14a7f0254d Move some ag-grid styles back to theme file
- Styles were not applying properly from the global
2023-01-07 18:23:49 -08:00
jeffvli 0aa0e51daa Add favoriting from context menu 2023-01-07 18:23:10 -08:00
jeffvli f4f06abd72 Use global libraryitem type on favorite query 2023-01-07 18:21:54 -08:00
jeffvli 7d8cb0bb45 Refactor context menu handler into hook 2023-01-07 18:16:19 -08:00
jeffvli 2edffa02d0 Add favoriting from table rows 2023-01-07 16:33:14 -08:00
jeffvli cfa4e5e45c Update favorite/rating endpoints
- Refactor subsonic api endpoints to set the default auth params
- The beforeRequest hook is unable to dynamically set existing params
2023-01-07 16:09:40 -08:00
jeffvli f879171398 Add renderer for last played date column 2023-01-07 14:30:17 -08:00
jeffvli 6bfebd2923 Adjust position of track/disc number cells 2023-01-07 04:09:32 -08:00
jeffvli 3c60f406ea Adjust ag-grid styles 2023-01-07 03:50:34 -08:00
jeffvli af1c16ee51 Adjust dropdown styling 2023-01-07 03:49:56 -08:00
jeffvli 1b25d88692 Rename add to queue text 2023-01-07 03:49:18 -08:00
jeffvli 8a48abbbc8 Additional changes to column defaults 2023-01-07 03:30:48 -08:00
jeffvli 6deab38c67 Add undefined check on scrollTo 2023-01-07 03:28:28 -08:00
jeffvli 915b0eb372 Add play handlers and item count to list pages 2023-01-07 03:28:03 -08:00
jeffvli 6bb0474d62 Change ND ALBUM_SONGS sort value 2023-01-07 03:27:13 -08:00
jeffvli 2df96c0d31 Add song filter as add queue type 2023-01-07 03:26:18 -08:00
jeffvli d94d7b5ee5 Export selector for list filters 2023-01-07 03:21:03 -08:00
jeffvli 2f7f6bead9 Remove persisted playqueue in localstorage
- This will break if there are enough songs in the queue. A new implementation will be added in the future
2023-01-07 01:42:20 -08:00
jeffvli 3bbddcf092 Move grid-specific styling out of theme styles 2023-01-06 23:52:05 -08:00
jeffvli 838c6a8b6a Adjust titlebar / window controls styling 2023-01-06 23:51:26 -08:00
jeffvli 5889b8976c Force header bg color in detail lists 2023-01-06 23:34:31 -08:00
jeffvli d06ddc9560 Update themes 2023-01-06 23:33:18 -08:00
jeffvli 9b1f4e7154 Add infinite table defaults 2023-01-06 18:24:31 -08:00
jeffvli b569ec31ae Move common table functions into base component 2023-01-06 17:59:02 -08:00
jeffvli f7b8e34905 Improve semantic html of default layout and add ids 2023-01-06 14:49:41 -08:00
jeffvli 3cf7127f56 Optimize current song image layout transition 2023-01-06 14:27:31 -08:00
jeffvli cb823d94e5 Add dynamic message for infinite scroll handler 2023-01-06 13:53:02 -08:00
jeffvli 4d0620c5df Update misc on detail pages 2023-01-06 13:50:40 -08:00
jeffvli 01371d0227 Add pageIndex on infinite query results
- Result selector runs on every page on every fetch, which means that the uniqueId is not immutable. We need a static index on each item since a playlist can have duplicate song ids
2023-01-06 13:48:29 -08:00
jeffvli 9e6a81cb62 Use rem instead of px for sticky header 2023-01-06 13:34:41 -08:00
jeffvli 7b616b44fa Add prop to deselect rows on outside click 2023-01-06 13:28:10 -08:00
jeffvli 4c275ea878 Add sticky table header to detail pages 2023-01-06 11:46:17 -08:00
jeffvli b59c86f78f Add hook to fix table header to detail header 2023-01-06 11:44:50 -08:00
jeffvli 14e6b4e7d6 Change sidebar icons 2023-01-06 10:46:09 -08:00
jeffvli 63cdefcb27 Increase white ignore threshold 2023-01-06 10:29:49 -08:00
jeffvli 11f9721abe Reduce global query cache time from 15min to 3min 2023-01-06 03:33:43 -08:00
jeffvli 0a82438beb Set 1 minute cache time on manual query fetches 2023-01-06 03:33:11 -08:00
jeffvli 8bd1cc80bc Fix rating property on card row 2023-01-06 03:32:35 -08:00
jeffvli 624b1bb94d Add tooltip delay for expand/collapse of player image 2023-01-06 01:59:37 -08:00
jeffvli cf904b5d51 Set outline color for rating component 2023-01-06 01:52:01 -08:00
jeffvli 2ab48f5c97 Set size/color of custom table headers 2023-01-06 01:51:29 -08:00
jeffvli d56799e519 Adjust sidebar style 2023-01-06 01:48:56 -08:00
jeffvli 083e219ed2 Improve average color matching
- Add threshold to ignored white/black values
- Add ignored transparency colors
2023-01-06 01:05:54 -08:00
jeffvli 1b379882f5 Reduce size of song imageUrl
- Increase efficiency of loading times for song lists
2023-01-06 01:03:58 -08:00
jeffvli ab031820f6 Add favorite/rating table columns 2023-01-06 00:39:49 -08:00
jeffvli d1dfbaedaa Move LibraryItem type 2023-01-05 21:59:07 -08:00
jeffvli 3070586104 Improve default table column/header defaults 2023-01-05 20:33:12 -08:00
jeffvli bcfb9dbec3 Fix various api types 2023-01-05 20:32:02 -08:00
jeffvli 888bab50c9 Adjust title line height to better fit 2023-01-05 20:14:46 -08:00
jeffvli 715ee0fa3f Reduce font-weight of table header 2023-01-05 19:57:21 -08:00
jeffvli 1963e93d2e Export non-polymorphic text component 2023-01-05 19:54:59 -08:00
jeffvli ad3728a55d Prevent blank screen on grid-view render
- Add loading prop to before rendering to check for itemCount
2023-01-05 11:03:24 -08:00
jeffvli df4f05b14c Finalize base features for smart playlist editor 2023-01-05 02:27:29 -08:00
jeffvli 0c7a0cc88a Add danger prop for menu items 2023-01-04 23:56:09 -08:00
jeffvli 98ef0b44ec Add error boundaries to individual routes 2023-01-04 22:38:27 -08:00
jeffvli 24f06db2b8 Add playlist "save as" form 2023-01-04 18:37:25 -08:00
jeffvli d63e5f5784 Add owner to playlist update query
- Support smart playlist rules
- Add user list query
2023-01-04 18:33:49 -08:00
jeffvli 75ef43dffb Add initial nd smart playlist ui 2023-01-04 15:54:25 -08:00
jeffvli 65974dbf28 temp 2023-01-04 04:09:24 -08:00
jeffvli 16433457ad Use global state for grid card views
- Prevent re-render when fetching already cached state
2023-01-03 17:41:03 -08:00
jeffvli 19eaf44394 Fix header link route 2023-01-03 17:28:41 -08:00
jeffvli 861fcec14f Change playlist detail bg calc algorithm 2023-01-03 11:48:02 -08:00
jeffvli 72cbd23089 Increase default sidebar width 2023-01-03 11:44:20 -08:00
jeffvli 1048431742 Bump to v0.0.1-alpha3 2023-01-03 03:29:14 -08:00
jeffvli 633c9f59d9 Add update playlist for jellyfin 2023-01-03 03:25:21 -08:00
jeffvli 0ed13c75af Fix stale state on playqueue when switching server 2023-01-03 03:16:53 -08:00
jeffvli b0bc4c3cf3 Wait for load before setting background color 2023-01-03 03:15:51 -08:00
jeffvli b8b8ca9f66 Add separate filter for album song list fetch 2023-01-03 03:15:09 -08:00
jeffvli f2e6a418b0 Add fallback to average color calculation 2023-01-03 02:28:59 -08:00
jeffvli 7fef7e4689 Adjust sidebar theme 2023-01-03 02:28:40 -08:00
jeffvli 21bf995335 Move toast notifications to bottom-center 2023-01-03 02:28:10 -08:00
jeffvli bd13fb63ae Add window reload on first server add
- Fixes controller server type
2023-01-03 02:27:28 -08:00
jeffvli 67ccc20147 Adjust duration normalization to ms 2023-01-03 02:27:00 -08:00
jeffvli 83991cf5a1 Remove placeholder 2023-01-03 02:26:43 -08:00
jeffvli dfb0ff42b3 Navigate home after switching servers 2023-01-03 02:13:40 -08:00
jeffvli 008c12626d Add play controls to playlist song list 2023-01-03 02:13:21 -08:00
jeffvli 19e3f435c4 Fix add from card 2023-01-03 02:13:04 -08:00
jeffvli ac6242ea94 Navigate to home if no issues resolved 2023-01-03 02:00:21 -08:00
jeffvli b87d7778df Remove image placeholders (performance issues?) 2023-01-03 01:49:01 -08:00
jeffvli acb906aad9 Remove sidebar play button, increase fw for labels 2023-01-03 01:48:07 -08:00
jeffvli 196cb1bd48 Fix scroll area display type 2023-01-03 01:34:18 -08:00
jeffvli 3981ad3eb5 Adjust sidebar playlist styles 2023-01-03 01:34:00 -08:00
jeffvli d54131b34a Remove console logs 2023-01-03 00:51:24 -08:00
jeffvli 6ad6617d88 Add delete playlist to context menu 2023-01-03 00:50:09 -08:00
jeffvli 52163534db Add update/delete playlist forms 2023-01-03 00:28:09 -08:00
jeffvli 5dd65b18b7 Add description property to playlist 2023-01-03 00:27:11 -08:00
jeffvli 1e77e1074a Add loading/disabled props for confirm modal 2023-01-03 00:19:33 -08:00
jeffvli 9537309fe2 Add custom confirm modal component 2023-01-03 00:12:07 -08:00
jeffvli 4dc8920ff4 Set overlay opacity based on theme 2023-01-02 18:54:48 -08:00
jeffvli 26e6f479b7 Implement new header on home page 2023-01-02 18:20:45 -08:00
jeffvli d93e6a612e Lighten overlay on header bg
- Increase support for light styles
2023-01-02 18:20:17 -08:00
jeffvli 0baa6f4488 Adjust header styles 2023-01-02 18:17:06 -08:00
jeffvli 6490118741 Fix ellipsis overflow styles 2023-01-02 18:14:44 -08:00
jeffvli 58827a1dcf Set header target to optional 2023-01-02 18:08:19 -08:00
jeffvli a3804808b4 Update album/playlist headers with shared styles 2023-01-02 17:57:49 -08:00
jeffvli d49bba42ef Bump framer motion to v8 2023-01-02 17:57:24 -08:00
jeffvli c56f6a355d Add duration string util 2023-01-02 17:56:09 -08:00
jeffvli 7b13e24ce4 Calculate duration playlist duration in ms 2023-01-02 17:55:50 -08:00
jeffvli 088f1d0f99 Adjust title style 2023-01-02 17:55:14 -08:00
jeffvli d7d611c6d1 Add missing overflow style value 2023-01-02 17:53:27 -08:00
jeffvli 65465d6cae Support dynamic page headers 2023-01-02 17:03:33 -08:00
jeffvli 3d8ba2e808 Add native scroll area component 2023-01-02 16:59:21 -08:00
jeffvli 152be5d7e6 Add library detail header component 2023-01-02 03:47:05 -08:00
jeffvli 4326f6cf91 Various cleanup 2023-01-02 02:05:30 -08:00
jeffvli 90dec929f4 Add playlist detail page 2023-01-02 02:04:23 -08:00
jeffvli d6dc880ef4 Add playlist image to type 2023-01-02 01:58:31 -08:00
jeffvli d0e2a798fe Account for playlist items in cover art url 2023-01-01 15:05:26 -08:00
jeffvli fecaa2e6b8 Use song-specific cover and add placeholder (#6) 2023-01-01 14:16:57 -08:00
jeffvli cdbd3f8c7b Remove dynamic queue header color 2023-01-01 14:04:16 -08:00
jeffvli b037329377 Handle jellyfin playlist creation 2023-01-01 14:02:03 -08:00
jeffvli 8b04f70106 Add dedicated playlist song list page 2023-01-01 13:58:05 -08:00
jeffvli 737a05e2c5 Update pagination
- Support id pages
- Set proper list max
2023-01-01 03:16:27 -08:00
jeffvli 78a30c2db4 Add ND playlist song type 2022-12-31 20:08:39 -08:00
jeffvli 5cef23944f Add playlist queue handler 2022-12-31 20:07:44 -08:00
jeffvli aa1cd742ad Move play queue handler to context 2022-12-31 19:26:58 -08:00
jeffvli 0f364f7c5c Add initial playlist detail page 2022-12-31 18:03:26 -08:00
jeffvli 11be5c811f Use size props for play button 2022-12-31 17:50:22 -08:00
jeffvli 6174dc128d Adjust base page headers 2022-12-31 17:50:05 -08:00
jeffvli 81455602ef Forward scrollarea ref 2022-12-31 16:50:20 -08:00
jeffvli d6936634db Update querykeys 2022-12-31 12:43:32 -08:00
jeffvli 88f53c17db Add create/update playlist mutations and form 2022-12-31 12:40:11 -08:00
jeffvli 82f107d835 Fix store name 2022-12-31 04:03:05 -08:00
jeffvli 1fee4c1946 Restore scroll on infinite lists 2022-12-31 04:02:47 -08:00
jeffvli ec79d91d30 Add playlist list 2022-12-31 03:46:12 -08:00
jeffvli 00a21269dd Set default color to undefined 2022-12-31 03:41:18 -08:00
jeffvli 58ed2f3706 Wait for background color before rendering content 2022-12-31 03:16:05 -08:00
jeffvli 0a9dcf36b9 Use prop for scrollbar width 2022-12-31 03:15:11 -08:00
jeffvli dc1e728a2e Increase minimum width from 200 -> 225 2022-12-31 01:00:51 -08:00
jeffvli 085a3856e0 Add search param to album artist list 2022-12-30 22:54:00 -08:00
jeffvli a693981333 Add query key to custom query options 2022-12-30 22:35:49 -08:00
jeffvli 2a797bd6c9 Add genre filter to navidrome song list 2022-12-30 22:34:59 -08:00
jeffvli 4a64f5fe9b Add play on double click for song list rows 2022-12-30 21:31:35 -08:00
jeffvli 1f232fa4da Add card placeholder images 2022-12-30 21:31:13 -08:00
jeffvli b3d95f765c Add page key for album detail page
- Fixes animation render when switching between detail pages
2022-12-30 21:12:27 -08:00
jeffvli f298e60929 Fix context menu add 2022-12-30 21:11:35 -08:00
jeffvli 4745c4a42d Add card/table types for album artists 2022-12-30 21:11:09 -08:00
jeffvli 6fddea552d Change default font to poppins 2022-12-30 21:04:30 -08:00
jeffvli 24af17b8fe Add album artist list route 2022-12-30 21:04:06 -08:00
jeffvli 185175aa89 Handle album artist play 2022-12-30 21:02:17 -08:00
jeffvli 38a4e1b749 Bump version to alpha2 2022-12-29 20:20:25 -08:00
jeffvli 5929360fd8 Add placeholder icon 2022-12-29 20:07:45 -08:00
jeffvli 25c96f2b18 Fix queue song color 2022-12-29 19:34:21 -08:00
jeffvli 93960d4605 Fix restart function 2022-12-29 19:30:55 -08:00
jeffvli 73fd57cf33 Remove unused 2022-12-29 19:25:37 -08:00
jeffvli 5ff89aff8e Fix textarea placeholder color 2022-12-29 19:25:22 -08:00
jeffvli b3b17013bf Adjust theme primary color 2022-12-29 19:25:11 -08:00
jeffvli bd9cbea9b7 Adjust play button styles 2022-12-29 19:23:07 -08:00
jeffvli 8f5115b9c6 Remove border 2022-12-29 19:14:07 -08:00
jeffvli 5aae7180e7 Navigate to album detail page on double click 2022-12-29 19:06:53 -08:00
jeffvli e7ccee4634 Clean trailing slash on server url 2022-12-29 19:06:29 -08:00
jeffvli 2e42e134e4 Fix title spacing / display name 2022-12-29 18:52:37 -08:00
jeffvli be52f61fdb Fix various issues 2022-12-29 18:50:57 -08:00
jeffvli 46a23318af Remove recently played carousel for jellyfin 2022-12-29 18:46:09 -08:00
jeffvli 1d82c84c9e Add initial album detail page 2022-12-29 18:45:01 -08:00
jeffvli b2f9c73300 Add additional song list column defaults 2022-12-29 18:41:35 -08:00
jeffvli f2e03266c2 Export play button behavior in its own hook 2022-12-29 18:36:55 -08:00
jeffvli d5435686bf Add error catch when fetching for playqueue 2022-12-29 18:29:24 -08:00
jeffvli d5ccf64bda Fix id type of play queue add from card 2022-12-29 18:28:56 -08:00
jeffvli a24816ad6d Fix error if data id udnefined 2022-12-29 17:52:40 -08:00
jeffvli 35c4f85085 Set dynamic height on feature carousel 2022-12-29 17:52:11 -08:00
jeffvli 8dd920b294 Remove unused import 2022-12-29 17:12:36 -08:00
jeffvli 3c86e6e28c Adjust style of context menu items 2022-12-29 17:11:51 -08:00
jeffvli f4ba82531c Add album detail api 2022-12-29 17:11:25 -08:00
jeffvli dc6936b22c Add shared items
- Play button
- Play types
2022-12-29 17:07:39 -08:00
jeffvli 131d7c5e3b Add dividers option to context menu 2022-12-29 17:03:49 -08:00
jeffvli a423a45352 Rename play to 'add to queue' 2022-12-29 17:03:02 -08:00
jeffvli 3ff46ce724 Add hook for average color 2022-12-29 16:50:05 -08:00
jeffvli e50c59c903 Add missing font files for Poppins 2022-12-29 16:48:39 -08:00
jeffvli 28c4646708 Preserve row order of selected context menu items 2022-12-28 19:50:35 -08:00
jeffvli 131e3b3c65 Add album list context menu
- Fix stale selected value when selecting single row with right click
2022-12-28 19:19:51 -08:00
jeffvli 9836d548a6 Accept string of ids by itemtype in playqueue add 2022-12-28 19:19:05 -08:00
jeffvli 694969cf41 Fix drag region name 2022-12-28 19:18:37 -08:00
jeffvli c86b452c90 Support fetching song list by album id 2022-12-28 19:17:55 -08:00
jeffvli b39d11c0cc Add song list context menu 2022-12-28 15:32:50 -08:00
jeffvli 4d5e4082bb Add base context menu provider/component 2022-12-28 15:32:02 -08:00
jeffvli 8ebe882236 Add 'byData' as playqueue add option 2022-12-28 15:31:04 -08:00
jeffvli 0edba7e222 Remove organize imports 2022-12-28 01:58:40 -08:00
jeffvli 3c39bdaa29 Adjust sidebar styles 2022-12-28 01:58:25 -08:00
jeffvli 04275a272b Adjust default themes 2022-12-28 01:57:59 -08:00
jeffvli 5c479e96ad Use initialWidth for persisted width 2022-12-28 01:45:08 -08:00
jeffvli b967c8cb19 Add album table view 2022-12-28 01:44:49 -08:00
jeffvli e5ad41b9da Adjust playqueue styles 2022-12-28 01:29:37 -08:00
jeffvli 7500046ac6 Misc fixes to song list page 2022-12-28 01:23:54 -08:00
jeffvli b9a03fc412 Add persisted width on colDef generator 2022-12-28 00:41:34 -08:00
jeffvli 552ad1b2a6 Adjust table pagination responsiveness 2022-12-28 00:39:36 -08:00
jeffvli 728b177e7a Adjust list search handler
- Prevent re-render when search value does not change
2022-12-27 14:22:57 -08:00
jeffvli 363f597e17 Remove unused packages 2022-12-27 14:22:57 -08:00
jeffvli b57ec3c966 Adjust pagination style 2022-12-27 14:22:57 -08:00
jeffvli 026b1f6ec2 Fix styles on control section 2022-12-27 14:22:57 -08:00
jeffvli 6bcc984b44 Bump packages 2022-12-27 14:22:57 -08:00
jeffvli 8a42a1bc6c Add song list functionality 2022-12-27 14:22:57 -08:00
jeffvli c7f588539d Update song list queries 2022-12-27 14:22:57 -08:00
jeffvli 2b0d4c44a6 Update table component 2022-12-27 14:22:54 -08:00
jeffvli d1c038ea6f Add additional song types 2022-12-27 02:00:39 -08:00
jeffvli e94783820e Add base motion containers 2022-12-26 16:43:37 -08:00
jeffvli 9841fa3c63 Use URLSearchParams for parser 2022-12-26 05:16:57 -08:00
jeffvli df5eba629a Change default md breakpoint 2022-12-26 05:16:57 -08:00
jeffvli feb4839ccd Decrease width of hidden icon 2022-12-26 05:16:57 -08:00
jeffvli d2ab8b470d Sort genre list in alphabetical order 2022-12-26 05:16:57 -08:00
jeffvli 926d7f714e Adjust album list header/filters 2022-12-26 05:16:57 -08:00
jeffvli 5ed06f79b3 Change type name, add paginated table 2022-12-26 05:16:52 -08:00
jeffvli 1883164150 Add prop to hide pagination dividers 2022-12-26 05:16:51 -08:00
jeffvli 69a10f4677 Fix number input button styles 2022-12-26 05:16:51 -08:00
jeffvli c673218a8b Add table pagination component 2022-12-26 05:16:51 -08:00
jeffvli d1507604f2 Add pagination component 2022-12-26 05:16:51 -08:00
jeffvli be3cc74e5d Add organizeImports to save action 2022-12-26 05:16:51 -08:00
jeffvli a8243c476c Allow deselect of music folder 2022-12-26 05:16:51 -08:00
jeffvli df9aad36c4 Add cell image placeholder 2022-12-26 05:16:51 -08:00
jeffvli 3b769233cf Use subtle color for search icon 2022-12-26 05:16:51 -08:00
jeffvli e08ffcf20d Set theme border on popover 2022-12-26 05:16:51 -08:00
jeffvli 84bdb30b57 Adjust default theme 2022-12-26 05:16:51 -08:00
jeffvli c10a4a9fd3 Add card row for releasedate 2022-12-26 05:16:51 -08:00
jeffvli 7cc3adedc4 Add responsive styles to header 2022-12-26 05:16:46 -08:00
jeffvli 7f62b583af Adjust card shadow 2022-12-25 01:59:00 -08:00
jeffvli 4353e81df3 Add checks for player type 2022-12-25 01:55:00 -08:00
jeffvli ae5afe868e Fix web player data 2022-12-25 01:26:01 -08:00
jeffvli 1fbdfe725c Add conditional for preload functions 2022-12-25 01:25:46 -08:00
jeffvli 4614358163 Force quit mpv on app close (#4) 2022-12-25 00:56:16 -08:00
jeffvli b6fd3a4f66 Adjust cache/stale times for other home items 2022-12-24 21:15:56 -08:00
jeffvli 58d557e4e8 Set consistent random carousel items when paging
- Set stale/cache time to 1 minute
2022-12-24 21:13:08 -08:00
jeffvli 6d037e4760 Fix order for recently played and recently added 2022-12-24 21:09:46 -08:00
jeffvli d203d287df Set title text for header 2022-12-24 21:07:20 -08:00
jeffvli 2c8057df70 Increase page transition duration
- Some pages benefit from having the extra load time to finish loading
2022-12-24 21:06:23 -08:00
jeffvli bc15bee747 Adjust modal style
- Decrease opacity
- Remove overlay blur
2022-12-24 21:05:16 -08:00
jeffvli 74b513f31a Adjust style of floating queue
- Increase transition speed
- Set max width
2022-12-24 21:03:18 -08:00
jeffvli ad3cfb01ce Fix refetch on search change 2022-12-24 20:49:59 -08:00
jeffvli 2e8d033614 Return updated filters on change 2022-12-24 20:20:30 -08:00
jeffvli 39e2212d1d Fix refetch on filter change 2022-12-24 20:20:17 -08:00
jeffvli 26ea4c0cc9 Add ref controls to infinite grid 2022-12-24 20:19:56 -08:00
jeffvli 520b7ce136 Fix favorited defaultOrder 2022-12-24 18:16:44 -08:00
jeffvli d011c29ce8 Add stricter loading check for cells 2022-12-24 18:12:49 -08:00
jeffvli ed12d59564 Adjust style for popover 2022-12-24 17:54:00 -08:00
jeffvli 8699bba067 Adjust style for active menu item 2022-12-24 14:10:34 -08:00
jeffvli f786b6f01b Add dynamic card rows based on sort type
- Split album list content into separate component
2022-12-24 14:09:24 -08:00
jeffvli 7d1083d1f7 Move card rows to separate component 2022-12-24 14:09:24 -08:00
jeffvli 6eb08243b7 Add generic to CardRow type
squash cardrow type

squash cardrow type
2022-12-24 14:09:02 -08:00
jeffvli 747633fb25 Add lastPlayedAt to album type 2022-12-24 13:32:27 -08:00
jeffvli a4584ecd5c Adjust base component styles 2022-12-22 01:59:02 -08:00
jeffvli 57c34637cf Add server-specific album filters 2022-12-22 01:58:11 -08:00
jeffvli 223cf469f4 Add genre list query 2022-12-22 01:56:59 -08:00
jeffvli b4301486f3 Add escape handler 2022-12-21 01:37:12 -08:00
jeffvli 226fea2c6d Fix imports 2022-12-21 01:29:00 -08:00
jeffvli 3dc3d6fe28 Set default context to empty object 2022-12-21 01:29:00 -08:00
jeffvli ae3c331061 Increase default header height 2022-12-21 01:29:00 -08:00
jeffvli 19f55b4a2e Add album list search 2022-12-21 01:29:00 -08:00
jeffvli b742b814c0 Split stores 2022-12-21 01:29:00 -08:00
jeffvli 2a858f3107 Add key for carousel artist items 2022-12-21 01:29:00 -08:00
jeffvli 9100b6197a Set current time on local state for prev 2022-12-21 01:29:00 -08:00
jeffvli a354cab797 Add music folders query 2022-12-21 01:28:59 -08:00
jeffvli 3399fc6bf6 Fix imports 2022-12-21 01:28:59 -08:00
jeffvli 45ecdadae2 Fix dependency cycle on imports 2022-12-21 01:28:59 -08:00
jeffvli 39a114aad9 Add loading skeleton to table cell rows 2022-12-21 01:28:59 -08:00
jeffvli a147b56485 Update lint rules 2022-12-21 01:28:59 -08:00
jeffvli 33f1e8d70a Fix skeleton animation loop 2022-12-21 01:28:59 -08:00
jeffvli 6685bfe9d3 Use relative imports on main 2022-12-21 01:28:59 -08:00
jeffvli e0883e7eb0 Fix toast dependency cycle 2022-12-21 01:28:59 -08:00
jeffvli c858479d57 Redo queue handler as hook 2022-12-21 01:28:59 -08:00
jeffvli 3dd9e620a8 Change default fonts 2022-12-21 01:28:56 -08:00
jeffvli 3c889d87ef Adjust settings defaults 2022-12-19 17:44:58 -08:00
jeffvli 93c6d046ee Restore MPV check 2022-12-19 17:44:49 -08:00
jeffvli b0cd967ae6 Adjust styles and remove subsonic server option 2022-12-19 17:44:48 -08:00
jeffvli 2445b334eb Fix display of action required route 2022-12-19 17:44:47 -08:00
jeffvli 325bf54abe Remove server files 2022-12-19 17:44:45 -08:00
jeffvli e87c814068 Add files 2022-12-19 17:44:40 -08:00
509 changed files with 31317 additions and 41994 deletions
-4
View File
@@ -1,4 +0,0 @@
node_modules
release/app/node_modules
release/app/dist
server/node_modules
+4 -9
View File
@@ -22,21 +22,16 @@ if (process.env.NODE_ENV === 'production') {
const port = process.env.PORT || 4343;
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const requiredByDLLConfig = module.parent!.filename.includes(
'webpack.config.renderer.dev.dll'
);
const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.renderer.dev.dll');
/**
* Warn if the DLL is not built
*/
if (
!requiredByDLLConfig &&
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
) {
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"'
)
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
+13 -5
View File
@@ -4,21 +4,24 @@ module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 2020,
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
sourceType: 'module',
tsconfigRootDir: __dirname,
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
root: true,
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
@@ -48,6 +51,7 @@ module.exports = {
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-underscore-dangle': 'off',
'prefer-destructuring': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
@@ -60,8 +64,9 @@ module.exports = {
shorthandLast: false,
},
],
// Since React 17 and typescript 4.1 you can safely disable the rule
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
@@ -73,7 +78,10 @@ module.exports = {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {},
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
+54
View File
@@ -0,0 +1,54 @@
name: Comment on pull request
on:
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}
+60
View File
@@ -0,0 +1,60 @@
name: Publish (PR)
on:
pull_request:
branches:
- development
jobs:
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg
+16 -4
View File
@@ -1,8 +1,20 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"semi": true,
"singleQuote": true,
"printWidth": 100,
"arrowParens": "always"
"overrides": [
{
"files": ["**/*.css", "**/*.scss", "**/*.html"],
"options": {
"singleQuote": false
}
}
],
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "lf",
"singleAttributePerLine": true
}
+7 -18
View File
@@ -32,23 +32,12 @@
"package-lock.json": true,
"*.{css,sass,scss}.d.ts": true
},
"rest-client.environmentVariables": {
"$shared": {
"host": "http://localhost:9321"
},
"dev-user": {
"token": "",
"refreshToken": "",
"authUsername": "user",
"authPassword": "user"
},
"dev-admin": {
"token": "",
"refreshToken": "",
"authUsername": "admin",
"authPassword": "admin"
}
},
"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",
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"],
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true
}
+1 -582
View File
@@ -2,585 +2,4 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
[0.15.0] - 2022-04-13
### Added
- Added setting to save and resume the current queue between sessions (#130) (Thanks @kgarner7)
- Added a simple "play random" button to the player bar (#276)
- Added new seek/volume sliders (#272)
- Seeking/dragging is now more responsive
- Added improved discord rich presence (#286)
- Added download button on the playlist view (#266)
- (Jellyfin) Added "genre" column to the artist list
### Changed
- Swapped the order of "Seek Forward/Backward" and "Next/Prev Track" buttons on the player bar
- Global volume is now calculated logarithmically (#275) (Thanks @gelaechter)
- "Auto playlist" is now named "Play Random" (#276)
- "Now playing" option is now available on the "Start page" setting
### Fixed
- Playing songs by double clicking on a list should now play in the proper order (#279)
- (Linux) Fixed MPRIS metadata not updating when player automatically increments (#263)
- Application fonts now loaded locally instead of from Google CDN (#284)
- Enabling "Default to Album List on Artist Page" no longer performs a double redirect when entering the artist page (#271)
- Stop button is no longer disabled when playback is stopped (#273)
- Various package updates (#288) (Thanks @kgarner7)
- Top control bar show no longer be accessible when not logged in (#267)
[0.14.0] - 2022-03-12
### Added
- Added zoom options via hotkeys (#252)
- Zoom in: CTRL + SHIFT + =
- Zoom out: CTRL + SHIFT + -
- Added PLAY context menu options to the Genre view (#239)
- Added STOP button to the main player controls (#252)
- Added "System Notifications" option to display native notifications when the song automatically changes (#245)
- Added arm64 build (#238)
- New languages
- Spanish (Thanks @ami-sc) (#250)
- Sinhala (Thanks @hirusha-adi) (#254)
### Fixed
- (Jellyfin) Fixed the order of returned songs when playing from the Folder view using the context menu (#240)
- (Linux) Reset MPRIS position to 0 when using "previous track" resets the song 0 (#249)
- Fixed JavaScript error when removing all songs from the queue using the context menu (#248)
- Fixed Ampache server support by adding .view to all Subsonic API endpoints (#253)
### Removed
- (Windows) Removed the cover art display when hovering Sonixd on the taskbar (due to new sidebar position) (#242)
[0.13.1] - 2022-02-16
### Fixed
- Fixed startup crash on all OS if the default settings file is not present (#237)
[0.13.0] - 2022-02-16
### Added
- Added new searchbar and search UI (#227, #228)
- Added playback controls to the Sonixd tray menu (#225)
- Added playlist selections to the `Start Page` config option
### Changed
- Sidebar changes (#206)
- Allow resizing of the sidebar when expanded
- Allow a toggle of the playerbar's cover art to the sidebar when expanded
- Display playlist list on the sidebar under the navigation
- Allow configuration of the display of sidebar elements
- Changed the `Artist` row on the playerbar to use a comma delimited list of the song's artists rather than the album artist (#218)
### Fixed
- Fixed the player volume not resetting to its default value when resetting a song while crossfading (#228)
- (Jellyfin) Fixed artist list not displaying user favorites
- (Jellyfin) Fixed `bitrate` column not properly by its numeric value (#220)
- Fixed javascript exception when incrementing/decrementing the queue (#230)
- Fixed popups/tooltips not using the configured font
[0.12.1] - 2022-02-02
### Fixed
- Fixed translation syntax error causing application to crash when deleting playlists from the context menu (#216)
- Fixed Player behavior (#217)
- No longer scrobbles an additional time after the last song ends when repeat is off
- (Jellyfin) Properly handles scrobbling the player's pause/resume and time position
[0.12.0] - 2022-01-31
### Added
- Added support for language/translations (#146) (Thanks @gelaechter)
- German translation added (Thanks @gelaechter)
- Simplified Chinese translation added (Thanks @fangxx3863)
- (Windows) Added media keys with desktop overlay (#79) (Thanks @GermanDarknes)
- (Subsonic) Added support for `/getLyrics` to display the current song's lyrics in a popup (#151)
- (Jellyfin) Added song list page
- Added config to choose the default Album/Song list sort on startup (#169)
- Added config to choose the application start page (#176) (Thanks @GermanDarknes)
- Added config for pagination for Album/Song list pages
- (Windows) Added option to set custom directory on installation (#184)
- Added config to set the default artist page to the album list (#199)
- Added info mode for the Now Playing page (#160)
- Added release notes popup
### Changed
- Player behavior
- `Media Stop` now stops the track and resets it instead of clearing the queue (#200)
- `Media Prev` now resets to the start of the song if pressed after 5 seconds (#207)
- `Media Prev` now resets to the start of the song if repeat is off and is the first song of the queue (#207)
- `Media Next` now does nothing if repeat is off and is the last song of the queue (#207)
- Playing a single track in the queue without repeat no longer plays the track twice (#205)
- Scrobbling
- (Jellyfin) Scrobbling has been reverted to use the `/sessions/playing` endpoint to support the Playback Reporting plugin (#187)
- Scrobbling occurs after 5 seconds has elapsed for the current track as to not instantly mark the song as played
- Pressing `CTRL + F` or the search button now focuses the text in the searchbar (#203) (Thanks @WeekendWarrior1)
- Changed loading indicators for all pages
- OBS scrobble now outputs an image.txt file instead of the downloading the cover image (#136)
- Player Bar
- Album name now appears under the artist
- (Subsonic) 5-star rating is available
- Clicking on the cover art now displays a full-size image
- Clicking on the song name now redirects to the Now Playing queue
- (Jellyfin) Removed track limit for "Auto Playlist"
### Fixed
- (macOS) Fixed macOS exit behavior (#198) (Thanks @zackslash)
- (Linux) Fixed MPRIS `position` result (#162)
- (Subsonic) Fixed artist page crashing the application if server does not support `/getArtistInfo2` (#170)
- (Jellyfin) Fixed `View all songs` returning songs out of their album track order
- (Jellyfin) Fixed the "Latest Albums" on the album artist page displaying no albums
- Fixed card overlay button color on click
- Fixed buttons on the Album page to work better with light mode
- Fixed unfavorite button on Album page
[0.11.0] - 2022-01-01
### Added
- Added external integrations
- Added Discord rich presence to display the currently playing song (#155)
- Added OBS (Open Broadcaster Software) scrobbling to send current track metadata to desktop or the Tuna plugin (#136)
- Added a `Native` option for Titlebar Style (#148) (Thanks @gelaechter)
- (Jellyfin) Added toggle to allow transcoding for non-directplay compatible filetypes (#158)
- Additional MPRIS support
- Added metadata:
- `albumArtist`, `discNumber`, `trackNumber`, `useCount`, `genre`
- Added events:
- `seek`, `position`, `volume`, `repeat`, `shuffle`
### Changed
- Overhauled the Artist page
- (Jellyfin) Split albums by album artist OR compilation
- (Jellyfin) Added artist genres
- (Subsonic) Added Top Songs section
- Moved related artists to the main page scrolling menu
- Added `View All Songs` button to view all songs by the artist
- Added artist radio (mix) button
- Horizontal scrolling menu no longer displays scrollbar
- Changed button styling on Playlist/Album/Artist pages
- Changed page image styling to use the card on Playlist/Album/Artist pages
### Fixed
- Fixed various MPRIS features
- Synchronized the play/pause state between the player and MPRIS client when pausing from Sonixd (#152)
- Fixed the identity of Sonixd to use the app name instead of description (#163)
- Fixed various submenus opening in the right-click context menu when the option is disabled (#164)
- Fixed compatibility with older Subsonic API servers (now targets Subsonic v1.13.0) (#144)
- Fixed playback causing heavily increased CPU/Power usage #145)
[0.10.0] - 2021-12-15
### Added
- Added 2 new default themes
- City Lights
- One Dark
- Added additional album filters (#66)
- Genres (AND/OR)
- Artists (AND/OR)
- Years (FROM/TO)
- Added external column sort filters for multiple pages (#66)
- Added item counter to page titles
- `Play Count` column has been added to albums (only works for Navidrome)
### Changed
- Config page has been fully refreshed to a new look
- Config popover on the action bar now includes all config tabs
- Tooltips
- Increased default tooltip delay from 250ms -> 500ms
- Increased tooltip delay on card overlay buttons to 1000ms
- Grid view
- Placeholder images for playlists, albums, and artists have been updated (inspired from Jellyfin Web UI)
- Card title/subtitle width decreased from 100% to default length
- Separate card info section from image/overlay buttons on hover
- Popovers (config, auto playlist, etc)
- Now have decreased opacity
- Enabling/disabling global media keys no longer requires app restart
### Fixed
- (Jellyfin) Fixed `Recently Played` and `Most Played` filters on the Dashboard page (#114)
- (Jellyfin) Fixed server scrobble (#126)
- No longer sends the `/playing` request on song start (prevents song being marked as played when it starts)
- Fixed song play count increasing multiple times per play
- (Jellyfin) Fixed tracks without embedded art displaying placeholder (#128)
- (Jellyfin) Fixed song `Path` property not displaying data
- (Subsonic) Fixed login check for Funkwhale servers (#135)
- Fixed persistent grid-view scroll position
- Fixed list-view columns
- `Visibility` column now properly displays data
- Selected media folder is now cleared from settings on disconnect (prevents errors when signing into a new server)
- Fixed adding/removing artist as favorite on the Artist page not updating
- Fixed search bar not properly handling Asian keyboard inputs
## [0.9.1] - 2021-12-07
### Changed
- List-view scroll position is now persistent for the following:
- Now Playing
- Playlist list
- Favorites (all)
- Album list
- Artist list
- Genre list
- Grid-view scroll position is now persistent for the following:
- Playlist list
- Favorites (album/artist)
- Album list
- Artist list
- (Jellyfin) Changed audio stream URL to force transcoding off (#108)
### Fixed
- (Jellyfin) Fixed the player not sending the "finish" condition when the song meets the scrobble condition (unresolved from 0.9.0) (#111)
## [0.9.0] - 2021-12-06
### Added
- Added 2 new default themes
- Plex-like
- Spotify-like
- Added volume control improvements
- Volume value tooltip while hovering the slider
- Mouse scroll wheel controls volume while hovering the slider
- Clicking the volume icon will mute/unmute
### Changed
- Overhauled all default themes
- Rounded buttons, inputs, etc.
- Changed grid card hover effects
- Removed hover scale
- Removed default background on overlay buttons
- Moved border to only the image instead of full card
- Album page
- Genre(s) are now listed on a line separate from the artists
- Album artist is now distinct from track artists
- Increased length of the genre/artist line from 70% -> 80%
- The genre/artist line is now scrollable using the mouse wheel
- (Jellyfin) List view
- `Artist` column now uses the album artist property
- `Title (Combined)` column now displays all track artists, comma-delimited instead of the album artist
- `Genre` column now displays all genres, comma-delimited, left-aligned
### Fixed
- (Jellyfin) Fixed the player not sending the "finish" condition when the song meets the scrobble condition
- (Jellyfin) Fixed album lists not sorting by the `genre` column
- (Jellyfin)(API) Fixed the A-Z(Artist) not sorting by Album Artist on the album list
- (Jellyfin)(API) Fixed auto playlist not respecting the selected music folder
- (Jellyfin)(API) Fixed the artist page not respecting the selected music folder
## [0.8.5] - 2021-11-25
### Fixed
- Fixed default (OOBE) title column not display data (#104)
## [0.8.4] - 2021-11-25
### Fixed
- (Jellyfin)(Linux) Fixed JS MPRIS error when switching tracks due to unrounded song duration
- (Linux) Fixed MPRIS artist, genre, and coverart not updating on track change
## [0.8.3] - 2021-11-25
### Fixed
- (Subsonic) Fixed playing a folder from the folder view
- Fixed rating context menu option available from the Genre page
## [0.8.2] - 2021-11-25
### Added
- Added option to disable auto updates
### Fixed
- Fixed gapless playback on certain \*sonic servers (#100)
- Fixed playerbar coverart not redirecting to `Now Playing` page
## [0.8.1] - 2021-11-24
### Fixed
- (Subsonic) Fixed errors blocking playlists from being deleted
## [0.8.0] - 2021-11-24
### Added
- Added Jellyfin server support (#87)
- Supports full Sonixd feature-set (except ratings)
- Added a mini config popover to change list/grid view options on the top action bar
- Added system audio device selector (#96)
- Added context menu option `Set rating` to bulk set ratings for songs (and albums/artists on Navidrome) (#95)
### Changed
- Reduced cached image from 500px -> 350px (to match max grid size)
- Grid/header images now respect image aspect ratio returned by the server
- Playback filter input now uses a regex validation before allowing you to add
- Renamed all `Name` columns to `Title`
- Search bar now clears after pressing enter to globally search
- Added borders to popovers
### Fixed
- Fixed application performance issues when player is crossfading to the next track
- Fixed null entries showing at the beginning of descending sort on playlist/now playing lists
- Tooltips no longer pop up on the artist/playlist description when null
## [0.7.0] - 2021-11-15
### Added
- Added download buttons on the Album and Artist pages (#29)
- Allows you to download (via browser) or copy download links to your clipboard (to use with a download manager)
### Changed
- Changed default tooltip delay from `500ms` -> `250ms`
- Moved search bar from page header to the main layout action bar
- Added notice for macOS media keys to require trusted accessibility in the client
### Fixed
- Fixed auto playlist and album fetch in Gonic servers
- Fixed the macOS titlebar styling to better match the original (#83)
- Fixed thumbnailclip error when resizing the application in macOS (#84)
- Fixed playlist page not using cached image
## [0.6.0] - 2021-11-09
### Added
- Added additional grid-view customization options (#74)
- Gap size (spaces between cards)
- Alignment (left-align, center-align)
### Changed
- Changed default album/artist uncached image sizes from `150px` -> `350px`
### Fixed
- (Windows) Fixed default taskbar thumbnail on Windows10 when minimized to use window instead of album cover (#73)
- Fixed playback settings unable to change via the UI
- Crossfade duration
- Polling interval
- Volume fade
- Fixed header styling on the Config page breaking at smaller window widths (#72)
- Fixed the position of the description tooltip on the Artist page
- Fixed the `Add to playlist` popover showing underneath the modal in modal-view
### Removed
- Removed unused `fonts.size.pageTitle` theme property
## [0.5.0] - 2021-11-05
### Added
- Added extensible theming (#60)
- Added playback presets (gapless, fade, normal) to the config
- Added persistence for column sort for all list-views (except playlist and search) (#47)
- Added playback filters to the config to filter out songs based on regex (#53)
- Added music folder selector in auto playlist (this may or may not work depending on your server)
- Added improved playlist, artist, and album pages
- Added dynamic images on the Playlist page for servers that don't support playlist images (e.g. Navidrome)
- Added link to open the local `settings.json` file
- Added setting to use legacy authentication (#63)
### Changed
- Improved overall application keyboard accessibility
- Playback no longer automatically starts if adding songs to the queue using `Add to queue`
- Prevent accidental page navigation when using [Ctrl/Shift + Click] when multi-selecting rows in list-view
- Standardized buttons between the Now Playing page and the mini player
- "Add random" renamed to "Auto playlist"
- Increased 'info' notification timeout from 1500ms -> 2000ms
- Changed default mini player columns to better fit
- Updated default themes to more modern standards (Default Dark, Default Light)
### Fixed
- Fixed title sort on the `Title (Combined)` column on the album list
- Fixed 2nd song in queue being skipped when using the "Play" button multiple pages (album, artist, auto playlist)
- Fixed `Title` column not showing the title on the Folder page (#69)
- Fixed context menu windows showing underneath the mini player
- Fixed `Add to queue (next)` adding songs to the wrong unshuffled index when shuffle is enabled
- Fixed local search on the root Folder page
- Fixed input picker dropdowns following the page on scroll
- Fixed the current playing song not highlighted when using `Add to queue` on an empty play queue
- Fixed artist list not using the `artistImageUrl` returned by Navidrome
## [0.4.1] - 2021-10-27
### Added
- Added links to the genre column on the list-view
- Added page forward/back buttons to main layout
### Changed
- Increase delay when completing mouse drag select in list view from `100ms` -> `200ms`
- Change casing for main application name `sonixd` -> `Sonixd`
### Fixed
- Fixed Linux media hotkey support (MPRIS)
- Added commands for additional events `play` and `pause` (used by KDE's media player overlay)
- Set status to `Playing` when initially starting a song
- Set current song metadata when track automatically changes instead of only when it manually changes
- Fixed filtered link to Album List on the Album page
- Fixed filtered link to Album List on the Dashboard page
- Fixed font color for lists/tables in panels
- Affects the search view song list and column selector list
## [0.4.0] - 2021-10-26
### Added
- Added music folder selector (#52)
- Added media hotkeys / MPRIS support for Linux (#50)
- This is due to dbus overriding the global shortcuts that electron sends
- Added advanced column selector component
- Drag-n-drop list
- Individual resizable columns
- (Windows) Added tray (Thanks @ncarmic4) (#45)
- Settings to minimize/exit to tray
### Changed
- Page selections are now persistent
- Active tab on config page
- Active tab on favorites page
- Filter selector on album list page
- Playlists can now be saved after being sorted using column filters
- Folder view
- Now shows all root folders in the list instead of in the input picker
- Now shows music folders in the input picker
- Now uses loader when switching pages
- Changed styling for various views/components
- Look & Feel setting page now split up into multiple panels
- Renamed context menu button `Remove from current` -> `Remove selected`
- Page header titles width increased from `45%` -> `80%`
- Renamed `Scan library` -> `Scan`
- All pages no longer refetch data when clicking back into the application
### Fixed
- Fixed shift-click multi select on a column-sorted list-view
- Fixed right-click context menu showing up behind all modals (#55)
- Fixed mini player showing up behind tag picker elements
- Fixed duration showing up as `NaN:NaN` when duration is null or invalid
- Fixed albums showing as a folder in Navidrome instances
## [0.3.0] - 2021-10-16
### Added
- Added folder browser (#1)
- Added context menu button `View in folder`
- Requires that your server has support for the original `/getIndexes` and `/getMusicDirectory` endpoints
- Added configurable row-hover highlight for list-view
- (Windows) Added playback controls in thumbnail toolbar (#32)
- (Windows/macOS) Added window size/position remembering on application close (#31)
### Changed
- Changed styling for various views/components
- Tooltips added on grid-view card hover buttons
- Mini-player removed rounded borders and increased opacity
- Mini-player removed animation on open/close
- Search bar now activated from button -> input on click / CTRL+F
- Page header toolbar buttons styling consistency
- Album list filter moved from right -> left
- Reordered context menu button `Move selected to [...]`
- Decreased horizontal width of expanded sidebar from 193px -> 165px
### Fixed
- Fixed duplicate scrobble requests when pause/resuming a song after the scrobble threshold (#30)
- Fixed genre column not applying in the song list-view
- Fixed default titlebar set on first run
## [0.2.1] - 2021-10-11
### Fixed
- Fixed using play buttons on the artist view not starting playback
- Fixed favoriting on horizontal scroll menu on dashboard/search views
- Fixed typo on default artist list viewtype
- Fixed artist image selection on artist view
## [0.2.0] - 2021-10-11
### Added
- Added setting to enable scrobbling playing/played tracks to your server (#17)
- Added setting to change between macOS and Windows styled titlebar (#23)
- Added app/build versions and update checker on the config page (#18)
- Added 'view in modal' button on the list-view context menu (#8)
- Added a persistent indicator on grid-view cards for favorited albums/artists (#7)
- Added buttons for 'Add to queue (next)' and 'Add to queue (later)' (#6)
- Added left/right scroll buttons to the horizontal scrolling menu (dashboard/search)
- Added last.fm link to artist page
- Added link to cache location to open in local file explorer
- Added reset to default for cache location
- Added additional tooltips
- Grid-view card title and subtitle buttons
- Cover art on the player bar
- Header titles on album/artist pages
### Changed
- Changed starring logic on grid-view card to update local cache instead of refetch
- Changed styling for various views/components
- Use dynamically sized hover buttons on grid-view cards depending on the card size
- Decreased size of buttons on album/playlist/artist pages
- Input picker text color changed from primary theme color to primary text color
- Crossfade type config changed from radio buttons to input picker
- Disconnect button color from red to default
- Tooltip styling updated to better match default theme
- Changed tag links to text links on album page
- Changed page header images to use cache (album/artist)
- Artist image now falls back to last.fm if no local image
### Fixed
- Fixed song & image caching (#16)
- Fixed set default artist list view type on first startup
## [0.1.0] - 2021-10-06
### Added
- Initial release
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-51
View File
@@ -1,51 +0,0 @@
# Stage 1 - Build frontend
FROM node:16.5-alpine as ui-builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build:renderer
RUN npm prune --production
RUN npm cache clean --force
RUN rm -rf /root/.cache
# Stage 2 - Build server
FROM node:16.5-alpine as server-builder
WORKDIR /app
COPY server .
RUN npm install && npx prisma generate
RUN npm run build
RUN npm prune --production
RUN npm cache clean --force
RUN rm -rf /root/.cache
# Stage 3 - Deploy
FROM node:16.5-alpine
WORKDIR /root
RUN mkdir appdata
RUN mkdir feishin-server
RUN mkdir feishin-client
RUN npm cache clean --force
RUN npm prune --production
# Add server build files
COPY --from=server-builder /app/dist ./feishin-server
COPY --from=server-builder /app/node_modules ./feishin-server/node_modules
COPY --from=server-builder /app/prisma ./feishin-server/prisma
# Add client build files
COPY --from=ui-builder /app/release/app/dist/renderer ./feishin-client
COPY docker-entrypoint.sh ./feishin-server/docker-entrypoint.sh
RUN chmod +x ./feishin-server/docker-entrypoint.sh
COPY ./server/wait-for-it.sh ./feishin-server/wait-for-it.sh
RUN chmod +x ./feishin-server/wait-for-it.sh
RUN npm install pm2 -g
WORKDIR /root/feishin-server
EXPOSE 9321
CMD ["sh", "docker-entrypoint.sh"]
+8 -133
View File
@@ -5,7 +5,7 @@
<img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen"
alt="License">
</a>
<a href="https://github.com/jeffvli/feishin/releases">
<a href="https://github.com/jeffvli/feishin/releases">
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
alt="Release">
</a>
@@ -13,15 +13,6 @@
<img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange"
alt="Downloads">
</a>
<a href="https://hub.docker.com/r/jeffvictorli/feishin">
<img src="https://img.shields.io/docker/v/jeffvictorli/feishin?style=flat-square&color=orange"
alt="Docker">
</a>
</a>
<a href="https://hub.docker.com/r/jeffvictorli/feishin">
<img src="https://img.shields.io/docker/pulls/jeffvictorli/feishin?style=flat-square&color=orange"
alt="Docker pulls">
</a>
</p>
<p align="center">
<a href="https://discord.gg/FVKpcMDy5f">
@@ -38,95 +29,25 @@ Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Getting Started
The default credentials to login will be `admin/admin`.
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
### Docker Compose
**Warning:** Check the environment variable configuration before running the commands below.
1. Copy and rename [example.env](https://github.com/jeffvli/feishin/blob/dev/example.env) to `.env` and make any changes necessary
2. Run the compose file: `docker compose --file docker-compose.yml --env-file .env up`
### Docker
**Warning:** Check the environment variable configuration before running the commands below.
**Run a postgres database container:**
```
docker run postgres:13 \
-p 5432:5432 \
-e POSTGRES_USER=admin \
-e POSTGRES_PASSWORD=admin \
-e POSTGRES_DB=feishin
```
**Run the Feishin server container:**
```
docker run jeffvictorli/feishin:latest \
-p 8643:9321 \
-e APP_BASE_URL=http://192.168.0.1:8643 \
-e DATABASE_PORT=5432 \
-e DATABASE_URL=postgresql://admin:admin@localhost:5432/feishin?schema=public \
-e TOKEN_SECRET=secret
```
**Docker Environment Variables**
```
APP_BASE_URL — The URL the site will be accessible at from your server (needed for CORS)
DATABASE_PORT — The port of your running postgres container
DATABASE_URL — The connection string to your postgres instance following this format: postgresql://<DB_USERNAME>:<DB_PASSWORD>@<DB_URL>/<DB_NAME>?schema=public
Replace the following:
<DB_USERNAME> — The admin username of your postgres container (POSTGRES_USER)
<DB_PASSWORD> — The admin password of your postgres container (POSTGRES_PASSWORD)
<DB_NAME> — The name of the database created in your postgres container (POSTGRES_DB)
<DB_URL> — The URL the postgres container is reachable from
Example: postgresql://admin:password@192.168.0.1:5432/feishin?schema=public
TOKEN_SECRET — The string used to sign auth tokens
(optional) TOKEN_EXPIRATION — The time before the auth JWT expires
(optional) TOKEN_REFRESH_EXPIRATION - The time before the auth JWT refresh token expires
```
### After installing the server and database
You can access the desktop client via the [latest release](https://github.com/jeffvli/feishin/releases), or you can visit the web client at your server URL (e.g http://192.168.0.1:8643).
## FAQ
### Why is there a red lock next to the server I want to select?
If the server is specified to "require user credentials", you will need to add and enable your own credentials to access it. Since the songs and images aren't proxied by the Feishin backend, the server credentials would otherwise be leaked to any user that has access to it. The added credentials are stored locally in the browser and are then used to generate the audio and image URLs in the client.
### What music servers does Feishin support?
Feishin supports any music server that implements a [Subsonic](http://www.subsonic.org/pages/api.jsp), [Navidrome](https://www.navidrome.org/), or [Jellyfin](https://jellyfin.org/) API.
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API.
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Navidrome](https://github.com/navidrome/navidrome)
- [Airsonic](https://github.com/airsonic/airsonic)
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
- [Gonic](https://github.com/sentriz/gonic)
- [Astiga](https://asti.ga/)
- [Supysonic](https://github.com/spl0k/supysonic)
### Why does Feishin use its own database and backend instead of just use (insert music server)'s API?
Feishin was an idea I had after I ran into usage limitations while building out [Sonixd](https://github.com/jeffvli/sonixd). Each music server has their own quirks, and I decided I wanted to consolidate and extend their features with my own backend implemntation which includes: web/desktop clients, advanced filtering, smart playlists, desktop MPV player, and more.
### Can I use (insert database) instead of Postgresql?
Due to [Prisma limitations](https://www.prisma.io/docs/concepts/components/prisma-migrate/prisma-migrate-limitations-issues#you-cannot-automatically-switch-database-providers), there is no easy way to switch to a different database provider at this time.
- ~~[Airsonic](https://github.com/airsonic/airsonic)~~
- ~~[Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)~~
- ~~[Gonic](https://github.com/sentriz/gonic)~~
- ~~[Astiga](https://asti.ga/)~~
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
## Development
@@ -134,52 +55,6 @@ Built and tested using Node `v16.15.0`.
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v4.6.0.
### Developing with Docker Compose
1. Copy and rename the `example.env` to `.env.dev` and make any changes necessary
2. **Run the server**: Use `npm run docker:up` to build and run the dev server
1. Prisma studio available on `http://localhost:5555`
2. Server available on `http://localhost:8643`
3. Default seeded login credentials are `admin/admin`
3. **Run the client**: Use `npm run start` to run the development Electron client
1. The web version of the client is available on `http://localhost:4343`
**Docker Compose files**
```
docker-compose.yml — The public compose file for running the latest release
docker-compose.dev.yml - Build and run the development environment locally (includes Prisma studio)
docker-compose.prod.yml - Build and run the production environment locally
```
### NPM Scripts:
```
$ npm run package — Packages the application for the local system
$ npm run start — Runs the development Electron and web client
$ npm run start:web — Runs the development web client
$ npm run docker:up — Builds and starts the docker development environment using the 'docker-compose.dev.yml' file
$ npm run docker:down — Stops the running docker development environment
$ npm run docker:dbpush — Pushes any schema changes made in 'schema.prisma' to the docker development database without migrating
$ npm run docker:migrate - Migrates any schema changes made in 'schema.prisma' and creates a migration file
$ npm run docker:createmigrate - Creates a migration file for any schema changes made in 'schema.prisma' without applying the migration
$ npm run docker:reset - Resets the docker development database and applies the default seed
$ npm run prod:buildserver - Builds and tags the server docker images locally with the 'latest' and '$VERSION' tags
$ npm run prod:publishserver - Pushes the locally build server docker images to docker hub
```
## License
[GNU General Public License v3.0 ©](https://github.com/jeffvli/sonixd-rewrite/blob/dev/LICENSE)
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 139 KiB

-49
View File
@@ -1,49 +0,0 @@
version: '3'
services:
db:
container_name: feishin_db
image: postgres:13
volumes:
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DATABASE_USERNAME}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
ports:
- '${DATABASE_PORT}:5432'
restart: unless-stopped
server:
container_name: feishin_server
volumes:
- ./server:/app # Synchronise docker container with local change
- /app/node_modules # Avoid re-copying local node_modules. Cache in container.
build:
context: ./server
dockerfile: Dockerfile
depends_on:
- db
environment:
- APP_BASE_URL=${APP_BASE_URL}
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
- DATABASE_PORT=${DATABASE_PORT}
- TOKEN_SECRET=${TOKEN_SECRET}
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
ports:
- '8643:9321'
restart: unless-stopped
prisma:
container_name: feishin_prisma_studio
volumes:
- ./server/prisma:/app/prisma
build:
context: ./server/prisma
dockerfile: Dockerfile
depends_on:
- db
- server
environment:
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public
ports:
- '5555:5555'
restart: unless-stopped
-32
View File
@@ -1,32 +0,0 @@
version: '3'
services:
db:
container_name: feishin_db
image: postgres:13
volumes:
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DATABASE_USERNAME}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
ports:
- '${DATABASE_PORT}:5432'
restart: unless-stopped
server:
container_name: feishin
build:
context: .
dockerfile: Dockerfile
image: feishin
depends_on:
- db
environment:
- APP_BASE_URL=${APP_BASE_URL}
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
- DATABASE_PORT=${DATABASE_PORT}
- TOKEN_SECRET=${TOKEN_SECRET}
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
ports:
- '8643:9321'
restart: unless-stopped
-29
View File
@@ -1,29 +0,0 @@
version: '3'
services:
db:
container_name: feishin_db
image: postgres:13
volumes:
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DATABASE_USERNAME}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
ports:
- '${DATABASE_PORT}:5432'
restart: unless-stopped
server:
container_name: feishin
image: jeffvictorli/feishin:latest
depends_on:
- db
environment:
- APP_BASE_URL=${APP_BASE_URL}
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
- DATABASE_PORT=${DATABASE_PORT}
- TOKEN_SECRET=${TOKEN_SECRET}
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
ports:
- '8643:9321'
restart: unless-stopped
-5
View File
@@ -1,5 +0,0 @@
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
npx prisma migrate deploy
npx ts-node prisma/seed.ts
pm2-runtime server.js
-9
View File
@@ -1,9 +0,0 @@
DATABASE_USERNAME=admin
DATABASE_PASSWORD=admin
DATABASE_NAME=feishin
DATABASE_PORT=5432
DATABASE_PERSIST_PATH=C:/docker/feishin/db
TOKEN_SECRET=SUPERSECRET
TOKEN_EXPIRATION=30m
TOKEN_REFRESH_EXPIRATION=90d
APP_BASE_URL=http://localhost:8643
+2467 -2279
View File
File diff suppressed because it is too large Load Diff
+32 -48
View File
@@ -2,7 +2,7 @@
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.0.1-alpha1",
"version": "0.0.1-alpha5",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
@@ -11,9 +11,11 @@
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:styles": "npx stylelint **/*.tsx",
"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:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"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 electronmon -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: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",
@@ -21,13 +23,7 @@
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\"",
"docker:up": "docker compose --file docker-compose.dev.yml --env-file .env.dev up --detach && docker compose --file docker-compose.dev.yml --env-file .env.dev logs -f",
"docker:down": "docker compose --file docker-compose.dev.yml --env-file .env.dev down && docker image rm feishin_prisma",
"docker:dbpush": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma generate && npx prisma db push\"",
"docker:migrate": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma migrate dev\"",
"docker:createmigrate": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma migrate dev --create-only\"",
"docker:reset": "docker exec -ti feishin_server sh -c \"npx prisma migrate reset && npx prisma db push && npx ts-node prisma/seed.ts\""
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -174,7 +170,6 @@
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-slider": "^1.3.1",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
@@ -183,8 +178,8 @@
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.18.0",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
@@ -199,7 +194,7 @@
"electron-notarize": "^1.2.1",
"electron-rebuild": "^3.2.7",
"electronmon": "^2.0.2",
"eslint": "^8.12.0",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
@@ -242,7 +237,7 @@
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.6.4",
"typescript": "^4.8.4",
"typescript-plugin-styled-components": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
@@ -252,35 +247,40 @@
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
"@ag-grid-community/core": "^28.2.1",
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@jellyfin/client-axios": "^10.7.8",
"@mantine/core": "^5.8.0",
"@mantine/dates": "^5.8.0",
"@mantine/dropzone": "^5.8.0",
"@mantine/form": "^5.8.0",
"@mantine/hooks": "^5.8.0",
"@mantine/modals": "^5.8.0",
"@mantine/notifications": "^5.8.0",
"@mantine/spotlight": "^5.8.0",
"@tanstack/react-query": "^4.16.1",
"@tanstack/react-query-devtools": "^4.16.1",
"ag-grid-community": "^28.2.1",
"ag-grid-react": "^28.2.1",
"axios": "^0.27.2",
"@mantine/core": "^6.0.0-alpha.5",
"@mantine/dates": "^6.0.0-alpha.5",
"@mantine/dropzone": "^6.0.0-alpha.5",
"@mantine/form": "^6.0.0-alpha.5",
"@mantine/hooks": "^6.0.0-alpha.5",
"@mantine/modals": "^6.0.0-alpha.5",
"@mantine/notifications": "^6.0.0-alpha.5",
"@mantine/utils": "^6.0.0-alpha.5",
"@tanstack/react-query": "^4.24.4",
"@tanstack/react-query-devtools": "^4.24.4",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.2.0",
"format-duration": "^2.0.0",
"framer-motion": "^6.4.2",
"framer-motion": "^8.1.3",
"history": "^5.3.0",
"i18next": "^21.6.16",
"immer": "^9.0.15",
"is-electron": "^2.2.1",
"ky": "^0.33.0",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "^2.0.0-beta.2",
@@ -288,16 +288,14 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-icons": "^4.6.0",
"react-icons": "^4.7.1",
"react-player": "^2.11.0",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-slider": "^2.0.4",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.8",
"react-window-infinite-loader": "^1.0.8",
"socket.io-client": "^4.5.3",
"styled-components": "^5.3.6",
"zod": "^3.19.1",
"zustand": "^4.1.4"
@@ -310,20 +308,6 @@
"npm": ">=7.x"
},
"browserslist": [],
"prettier": {
"overrides": [
{
"files": [
".prettierrc",
".eslintrc"
],
"options": {
"parser": "json"
}
}
],
"singleQuote": true
},
"electronmon": {
"patterns": [
"!server",
+732 -3
View File
@@ -1,14 +1,743 @@
{
"name": "feishin",
"version": "0.0.1-alpha1",
"version": "0.0.1-alpha5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "feishin",
"version": "0.0.1-alpha1",
"version": "0.0.1-alpha5",
"hasInstallScript": true,
"license": "MIT"
"license": "GPL-3.0",
"dependencies": {
"mpris-service": "^2.1.2"
}
},
"node_modules/@nornagon/put": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz",
"integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==",
"engines": {
"node": ">=0.3.0"
}
},
"node_modules/abstract-socket": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
"hasInstallScript": true,
"optional": true,
"os": [
"linux"
],
"dependencies": {
"bindings": "^1.2.1",
"nan": "^2.12.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"dependencies": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dbus-next": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
"dependencies": {
"@nornagon/put": "0.0.8",
"event-stream": "3.3.4",
"hexy": "^0.2.10",
"jsbi": "^2.0.5",
"long": "^4.0.0",
"safe-buffer": "^5.1.1",
"xml2js": "^0.4.17"
},
"optionalDependencies": {
"abstract-socket": "^2.0.0"
}
},
"node_modules/deep-equal": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
"dependencies": {
"is-arguments": "^1.0.4",
"is-date-object": "^1.0.1",
"is-regex": "^1.0.4",
"object-is": "^1.0.1",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.2.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
"integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
"dependencies": {
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/event-stream": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
"dependencies": {
"duplexer": "~0.1.1",
"from": "~0",
"map-stream": "~0.1.0",
"pause-stream": "0.0.11",
"split": "0.3",
"stream-combiner": "~0.0.4",
"through": "~2.3.1"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
},
"node_modules/function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
"dependencies": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.3"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dependencies": {
"function-bind": "^1.1.1"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
"integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
"dependencies": {
"get-intrinsic": "^1.1.1"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
"dependencies": {
"has-symbols": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hexy": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==",
"bin": {
"hexy": "bin/hexy_cmd.js"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-date-object": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
"integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
"dependencies": {
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-regex": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsbi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
"integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ=="
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"node_modules/map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
},
"node_modules/mpris-service": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mpris-service/-/mpris-service-2.1.2.tgz",
"integrity": "sha512-AC6WepCnFWwOME9OWplHZ8ps/BB+g9QrEpUKCv7wX82fDPzR3nPrypOFmL/Fm0JloEAu6QTWSfDLLc6mM/jinw==",
"dependencies": {
"dbus-next": "^0.9.2",
"deep-equal": "^1.0.1",
"source-map-support": "^0.5.11"
}
},
"node_modules/nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
"optional": true
},
"node_modules/object-is": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
"integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
"dependencies": {
"through": "~2.3"
}
},
"node_modules/regexp.prototype.flags": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
"integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.1.3",
"functions-have-names": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/split": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
"dependencies": {
"through": "2"
},
"engines": {
"node": "*"
}
},
"node_modules/stream-combiner": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
"dependencies": {
"duplexer": "~0.1.1"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
}
},
"dependencies": {
"@nornagon/put": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz",
"integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow=="
},
"abstract-socket": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
"optional": true,
"requires": {
"bindings": "^1.2.1",
"nan": "^2.12.1"
}
},
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
}
},
"dbus-next": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
"requires": {
"@nornagon/put": "0.0.8",
"abstract-socket": "^2.0.0",
"event-stream": "3.3.4",
"hexy": "^0.2.10",
"jsbi": "^2.0.5",
"long": "^4.0.0",
"safe-buffer": "^5.1.1",
"xml2js": "^0.4.17"
}
},
"deep-equal": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
"requires": {
"is-arguments": "^1.0.4",
"is-date-object": "^1.0.1",
"is-regex": "^1.0.4",
"object-is": "^1.0.1",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.2.0"
}
},
"define-properties": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz",
"integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==",
"requires": {
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
}
},
"duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"event-stream": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
"integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==",
"requires": {
"duplexer": "~0.1.1",
"from": "~0",
"map-stream": "~0.1.0",
"pause-stream": "0.0.11",
"split": "0.3",
"stream-combiner": "~0.0.4",
"through": "~2.3.1"
}
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="
},
"get-intrinsic": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
"integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.3"
}
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"requires": {
"function-bind": "^1.1.1"
}
},
"has-property-descriptors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz",
"integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==",
"requires": {
"get-intrinsic": "^1.1.1"
}
},
"has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
},
"has-tostringtag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
"requires": {
"has-symbols": "^1.0.2"
}
},
"hexy": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
},
"is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"requires": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
}
},
"is-date-object": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
"integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
"requires": {
"has-tostringtag": "^1.0.0"
}
},
"is-regex": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
"requires": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
}
},
"jsbi": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz",
"integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ=="
},
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
"integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="
},
"mpris-service": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mpris-service/-/mpris-service-2.1.2.tgz",
"integrity": "sha512-AC6WepCnFWwOME9OWplHZ8ps/BB+g9QrEpUKCv7wX82fDPzR3nPrypOFmL/Fm0JloEAu6QTWSfDLLc6mM/jinw==",
"requires": {
"dbus-next": "^0.9.2",
"deep-equal": "^1.0.1",
"source-map-support": "^0.5.11"
}
},
"nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
"optional": true
},
"object-is": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
"integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.1.3"
}
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
},
"pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
"requires": {
"through": "~2.3"
}
},
"regexp.prototype.flags": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
"integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
"requires": {
"call-bind": "^1.0.2",
"define-properties": "^1.1.3",
"functions-have-names": "^1.2.2"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"split": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
"integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==",
"requires": {
"through": "2"
}
},
"stream-combiner": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
"integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==",
"requires": {
"duplexer": "~0.1.1"
}
},
"through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
}
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "feishin",
"version": "0.0.1-alpha1",
"version": "0.0.1-alpha5",
"description": "",
"main": "./dist/main/main.js",
"author": {
@@ -12,6 +12,8 @@
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules"
},
"dependencies": {},
"license": "MIT"
"dependencies": {
"mpris-service": "^2.1.2"
},
"license": "GPL-3.0"
}
-15
View File
@@ -1,15 +0,0 @@
$repositoryRootDirectory = Join-Path -Path Get-Location -ChildPath '..'
$packageJson = Get-Content -Path (Join-Path -Path $repositoryRootDirectory -ChildPath 'package.json') | ConvertFrom-Json
if (!packageJson.version) {
throw 'package.json does not contain a version'
}
$version = $packageJson.version
$appName = $packageJson.name
$dockerRepo = 'jeffvictorli'
Write-Host "Building [${appname}:latest] & [${appName}:${version}]"
docker build -t "${dockerRepo}/${appName}:latest" -t "${dockerRepo}/${appName}:${version}" -f "${repositoryRootDirectory}/Dockerfile" .
-18
View File
@@ -1,18 +0,0 @@
$repositoryRootDirectory = Join-Path -Path Get-Location -ChildPath '..'
try {
$script:packageJson = Get-Content -Path (Join-Path -Path $repositoryRootDirectory -ChildPath 'package.json') | ConvertFrom-Json
} catch {
throw 'package.json does not exist'
}
if (!$script:packageJson.version) {
throw 'package.json does not contain a version'
}
$version = $script:packageJson.version
$appName = $script:packageJson.name
$dockerRepo = 'jeffvictorli'
Write-Host "Pushing [${appname}:latest] & [${appName}:${version}]"
docker push "${dockerRepo}/${appName}" --all-tags
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-58
View File
@@ -1,58 +0,0 @@
module.exports = {
extends: ['plugin:typescript-sort-keys/recommended'],
ignorePatterns: [],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 2020,
project: './tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
root: true,
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
},
],
'import/prefer-default-export': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.ts'],
paths: ['node_modules/', 'node_modules/@types'],
},
typescript: {},
},
},
};
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-22
View File
@@ -1,22 +0,0 @@
@serverId =
@albumArtistId =
###
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
# take: The number of rows to return. Must be a non-negative integer.
# orderBy: asc | desc
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
GET {{host}}/api/servers/{{serverId}}/albumArtists
?skip=0
&take=100
&sortBy=title
&orderBy=desc
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}/albumArtists/{{albumArtistId}}
Content-Type: application/json
Authorization: Bearer {{token}}
-22
View File
@@ -1,22 +0,0 @@
@serverId =
@albumId =
###
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
# take: The number of rows to return. Must be a non-negative integer.
# orderBy: asc | desc
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
GET {{host}}/api/servers/{{serverId}}/albums
?skip=0
&take=100
&sortBy=title
&orderBy=desc
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}/albums/{{albumId}}
Content-Type: application/json
Authorization: Bearer {{token}}
-22
View File
@@ -1,22 +0,0 @@
@serverId =
@artistId =
###
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
# take: The number of rows to return. Must be a non-negative integer.
# orderBy: asc | desc
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
GET {{host}}/api/servers/{{serverId}}/artists
?skip=0
&take=100
&sortBy=title
&orderBy=desc
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}/artists/{{artistId}}
Content-Type: application/json
Authorization: Bearer {{token}}
-52
View File
@@ -1,52 +0,0 @@
###
POST {{host}}/api/auth/login
Content-Type: application/json
{
"username": "{{authUsername}}",
"password": "{{authPassword}}"
}
###
POST {{host}}/api/auth/logout
Content-Type: application/json
Authorization: {{token}}
{
"username": "{{authUsername}}",
"password": "{{authPassword}}"
}
###
POST {{host}}/api/auth/refresh
Content-Type: application/json
{
"refreshToken": "{{refreshToken}}"
}
###
# @prompt username Login username
# @prompt password Login password
POST {{host}}/api/auth/register
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
###
GET {{host}}/api/auth/ping
Content-Type: application/json
@contentType = application/json
@serverId =
-66
View File
@@ -1,66 +0,0 @@
@serverId =
###
GET {{host}}/api/servers
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}/folder
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}/refresh
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/servers/{{serverId}}
Content-Type: application/json
Authorization: Bearer {{token}}
###
# name: Nickname for the server
# type: SUBSONIC | JELLYFIN | NAVIDROME
# url: The URL of the server e.g. http://192.168.1.1:8096
# @prompt username The user which will be used to login and scan from the server
# @prompt password The password for the user
POST {{host}}/api/servers/
Content-Type: application/json
Authorization: Bearer {{token}}
{
"name": "My Jellyfin Server",
"type": "JELLYFIN",
"url": "http://192.168.14.11:8097",
"username": "{{username}}",
"password": "{{password}}"
}
###
POST {{host}}/api/servers/{{serverId}}/scan
Content-Type: application/json
Authorization: Bearer {{token}}
{
"serverFolderIds": [""]
}
-14
View File
@@ -1,14 +0,0 @@
@serverId =
###
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
# take: The number of rows to return. Must be a non-negative integer.
# orderBy: asc | desc
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
GET {{host}}/api/servers/{{serverId}}/songs
?skip=0
&take=100
&sortBy=title
&orderBy=desc
Content-Type: application/json
Authorization: Bearer {{token}}
-21
View File
@@ -1,21 +0,0 @@
@userId =
###
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
# take: The number of rows to return. Must be a non-negative integer.
# orderBy: asc | desc
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
GET {{host}}/api/users
?skip=0
&take=100
&sortBy=title
&orderBy=desc
Content-Type: application/json
Authorization: Bearer {{token}}
###
GET {{host}}/api/users/{{userId}}
Content-Type: application/json
Authorization: Bearer {{token}}
-28
View File
@@ -1,28 +0,0 @@
FROM node:16.5-alpine
ARG DATABASE_PORT
ADD docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
COPY ./wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh
# Change directory so that our commands run inside this new directory
WORKDIR /app
# Copy dependency definitions
COPY package.*json ./
COPY prisma ./
# Install dependecies
RUN npm install
# Get all the code needed to run the app
COPY . .
# Expose the port the app runs in
EXPOSE 9321
# Serve the app
ENTRYPOINT ./docker-entrypoint.sh $DATABASE_PORT
@@ -1,45 +0,0 @@
import { Request, Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { service } from '@services/index';
import { validation, TypedRequest } from '@validations/index';
const getList = async (req: Request, res: Response) => {
const { take, skip, serverFolderIds } = req.query;
const albumArtists = await service.albumArtists.findMany(req, {
serverFolderIds: String(serverFolderIds),
skip: Number(skip),
take: Number(take),
user: req.authUser,
});
const success = ApiSuccess.ok({
data: albumArtists.data,
paginationItems: {
skip: Number(skip),
take: Number(take),
totalEntries: albumArtists.totalEntries,
url: req.originalUrl,
},
});
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getDetail = async (
req: TypedRequest<typeof validation.albumArtists.detail>,
res: Response
) => {
const { id } = req.params;
const albumArtist = await service.albumArtists.findById({
id,
user: req.authUser,
});
const success = ApiSuccess.ok({ data: albumArtist });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const albumArtistsController = {
getDetail,
getList,
};
-110
View File
@@ -1,110 +0,0 @@
import { Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
import { TypedRequest, validation } from '@validations/index';
const getDetail = async (
req: TypedRequest<typeof validation.albums.detail>,
res: Response
) => {
const { albumId, serverId } = req.params;
const album = await service.albums.findById(req.authUser, {
id: albumId,
serverId,
});
const success = ApiSuccess.ok({
data: toApiModel.albums({ items: [album], user: req.authUser })[0],
});
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getList = async (
req: TypedRequest<typeof validation.albums.list>,
res: Response
) => {
const { serverId } = req.params;
const { take, skip, serverUrlId, advancedFilters } = req.query;
const decodedAdvancedFilters =
advancedFilters && JSON.parse(decodeURI(advancedFilters));
const albums = await service.albums.findMany({
...req.query,
advancedFilters: decodedAdvancedFilters,
serverId,
skip: Number(skip),
take: Number(take),
user: req.authUser,
});
const serverUrl = serverUrlId
? await service.servers.findServerUrlById({
id: serverUrlId,
})
: undefined;
const success = ApiSuccess.ok({
data: toApiModel.albums({
items: albums.data,
serverUrl: serverUrl?.url,
user: req.authUser,
}),
paginationItems: {
skip: Number(skip),
take: Number(take),
totalEntries: albums.totalEntries,
url: req.originalUrl,
},
});
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getDetailSongList = async (
req: TypedRequest<typeof validation.albums.list>,
res: Response
) => {
const { serverId } = req.params;
const { take, skip, serverUrlId } = req.query;
const albums = await service.albums.findMany({
...req.query,
advancedFilters: undefined,
serverId,
skip: Number(skip),
take: Number(take),
user: req.authUser,
});
const serverUrl = serverUrlId
? await service.servers.findServerUrlById({
id: serverUrlId,
})
: undefined;
const success = ApiSuccess.ok({
data: toApiModel.albums({
items: albums.data,
serverUrl: serverUrl?.url,
user: req.authUser,
}),
paginationItems: {
skip: Number(skip),
take: Number(take),
totalEntries: albums.totalEntries,
url: req.originalUrl,
},
});
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const albumsController = {
getDetail,
getDetailSongList,
getList,
};
-50
View File
@@ -1,50 +0,0 @@
import { Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { service } from '@services/index';
import { validation, TypedRequest } from '@validations/index';
const getDetail = async (
req: TypedRequest<typeof validation.artists.detail>,
res: Response
) => {
const { id } = req.params;
const artist = await service.artists.findById({
id,
user: req.authUser,
});
const success = ApiSuccess.ok({ data: artist });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getList = async (
req: TypedRequest<typeof validation.artists.list>,
res: Response
) => {
const { take, skip, serverFolderId } = req.query;
// const artists = await service.artists.findMany(req, {
// serverFolderIds: String(serverFolderIds),
// skip: Number(skip),
// take: Number(take),
// user: req.authUser,
// });
// const success = ApiSuccess.ok({
// data: artists,
// paginationItems: {
// skip: Number(skip),
// take: Number(take),
// totalEntries,
// url: req.originalUrl,
// },
// });
// return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const artistsController = {
getDetail,
getList,
};
-67
View File
@@ -1,67 +0,0 @@
import { Request, Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
import { validation, TypedRequest } from '@validations/index';
import packageJson from '../package.json';
const login = async (
req: TypedRequest<typeof validation.auth.login>,
res: Response
) => {
const { username } = req.body;
const user = await service.auth.login({ username });
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const register = async (
req: TypedRequest<typeof validation.auth.register>,
res: Response
) => {
const { username, password } = req.body;
const user = await service.auth.register({
password,
username,
});
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const logout = async (req: Request, res: Response) => {
await service.auth.logout({
user: req.authUser,
});
const success = ApiSuccess.noContent({ data: {} });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const ping = async (_req: Request, res: Response) => {
return res.status(200).json(
getSuccessResponse({
data: {
description: packageJson.description,
name: packageJson.name,
version: packageJson.version,
},
statusCode: 200,
})
);
};
const refresh = async (
req: TypedRequest<typeof validation.auth.refresh>,
res: Response
) => {
const refresh = await service.auth.refresh({
refreshToken: req.body.refreshToken,
});
const success = ApiSuccess.ok({ data: refresh });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const authController = { login, logout, ping, refresh, register };
-23
View File
@@ -1,23 +0,0 @@
import { Response } from 'express';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
import { ApiSuccess } from '@utils/api-success';
import { getSuccessResponse } from '@utils/get-success-response';
import { validation } from '@validations/index';
import { TypedRequest } from '@validations/shared.validation';
const getList = async (
req: TypedRequest<typeof validation.genres.list>,
res: Response
) => {
const { serverId } = req.params;
const data = await service.genres.findManyByServer({ serverId });
const success = ApiSuccess.ok({ data: toApiModel.genres(data) });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const genresController = {
getList,
};
-21
View File
@@ -1,21 +0,0 @@
import { albumArtistsController } from '@controllers/album-artists.controller';
import { albumsController } from '@controllers/albums.controller';
import { artistsController } from '@controllers/artists.controller';
import { authController } from '@controllers/auth.controller';
import { genresController } from '@controllers/genres.controller';
import { serversController } from '@controllers/servers.controller';
import { songsController } from '@controllers/songs.controller';
import { tasksController } from '@controllers/tasks.controller';
import { usersController } from '@controllers/users.controller';
export const controller = {
albumArtists: albumArtistsController,
albums: albumsController,
artists: artistsController,
auth: authController,
genres: genresController,
servers: serversController,
songs: songsController,
tasks: tasksController,
users: usersController,
};
-365
View File
@@ -1,365 +0,0 @@
import { ServerType } from '@prisma/client';
import { Request, Response } from 'express';
import { ApiError, ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
import { TypedRequest, validation } from '@validations/index';
const getServerListMap = async (req: Request, res: Response) => {
const data = await service.servers.getServerListMap();
const success = ApiSuccess.ok({ data });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getServerDetail = async (
req: TypedRequest<typeof validation.servers.detail>,
res: Response
) => {
const { serverId } = req.params;
const data = await service.servers.findById(req.authUser, { id: serverId });
const success = ApiSuccess.ok({ data: toApiModel.servers([data]) });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getServerList = async (
req: TypedRequest<typeof validation.servers.list>,
res: Response
) => {
const { enabled } = req.query;
const data = await service.servers.findMany(req.authUser, {
enabled: Boolean(enabled),
});
const success = ApiSuccess.ok({ data: toApiModel.servers(data) });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteServer = async (
req: TypedRequest<typeof validation.servers.deleteServer>,
res: Response
) => {
const { serverId } = req.params;
await service.servers.deleteById({ id: serverId });
const success = ApiSuccess.noContent({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const createServer = async (
req: TypedRequest<typeof validation.servers.create>,
res: Response
) => {
const remoteServerLoginRes = await service.servers.remoteServerLogin(
req.body
);
const data = await service.servers.create({
name: req.body.name,
...remoteServerLoginRes,
});
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const updateServer = async (
req: TypedRequest<typeof validation.servers.update>,
res: Response
) => {
const { serverId } = req.params;
const { username, password, name, legacy, type, url, noCredential } =
req.body;
if (type && username && password && url) {
const remoteServerLoginRes = await service.servers.remoteServerLogin({
legacy,
password,
type,
url,
username,
});
const data = await service.servers.update(
{ id: serverId },
{
name,
remoteUserId: remoteServerLoginRes.remoteUserId,
token:
type === ServerType.NAVIDROME
? `${remoteServerLoginRes.token}||${remoteServerLoginRes?.altToken}`
: remoteServerLoginRes.token,
type,
url: remoteServerLoginRes.url,
username: remoteServerLoginRes.username,
}
);
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}
const data = await service.servers.update(
{ id: serverId },
{ name, noCredential, url }
);
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const refreshServer = async (
req: TypedRequest<typeof validation.servers.refresh>,
res: Response
) => {
const { serverId } = req.params;
const data = await service.servers.refresh({ id: serverId });
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const fullScanServer = async (
req: TypedRequest<typeof validation.servers.scan>,
res: Response
) => {
const { serverId } = req.params;
const { serverFolderId } = req.body;
// TODO: Check that server is accessible first with the saved token, otherwise throw error
const scansInProgress = await service.servers.findScanInProgress({
serverId,
});
if (scansInProgress.length > 0) {
throw ApiError.badRequest('Scan already in progress');
}
const io = req.app.get('socketio');
await io.emit('task:started');
const data = await service.servers.fullScan(req.authUser, {
id: serverId,
serverFolderId,
});
// return res.status(200).json({ data: null });
const success = ApiSuccess.ok({ data });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const quickScanServer = async (
req: TypedRequest<typeof validation.servers.scan>,
res: Response
) => {
const { serverId } = req.params;
const { serverFolderId } = req.body;
// TODO: Check that server is accessible first with the saved token, otherwise throw error
const scansInProgress = await service.servers.findScanInProgress({
serverId,
});
if (scansInProgress.length > 0) {
throw ApiError.badRequest('Scan already in progress');
}
const io = req.app.get('socketio');
await io.emit('task:started');
// await service.servers.fullScan({
// id: serverId,
// serverFolderId,
// });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const createServerUrl = async (
req: TypedRequest<typeof validation.servers.createUrl>,
res: Response
) => {
const { serverId } = req.params;
const { url } = req.body;
const data = await service.servers.createUrl({
serverId,
url,
});
const success = ApiSuccess.ok({ data });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteServerUrl = async (
req: TypedRequest<typeof validation.servers.deleteUrl>,
res: Response
) => {
const { urlId } = req.params;
await service.servers.deleteUrlById({
id: urlId,
});
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const enableServerUrl = async (
req: TypedRequest<typeof validation.servers.enableUrl>,
res: Response
) => {
const { serverId, urlId } = req.params;
await service.servers.enableUrlById(req.authUser, {
id: urlId,
serverId,
});
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const disableServerUrl = async (
req: TypedRequest<typeof validation.servers.disableUrl>,
res: Response
) => {
await service.servers.disableUrlById(req.authUser);
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteServerFolder = async (
req: TypedRequest<typeof validation.servers.deleteFolder>,
res: Response
) => {
const { folderId } = req.params;
await service.servers.deleteFolderById({ id: folderId });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const enableServerFolder = async (
req: TypedRequest<typeof validation.servers.enableFolder>,
res: Response
) => {
const { folderId } = req.params;
await service.servers.enableFolderById({ id: folderId });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const disableServerFolder = async (
req: TypedRequest<typeof validation.servers.disableFolder>,
res: Response
) => {
const { folderId } = req.params;
await service.servers.disableFolderById({ id: folderId });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const addServerPermission = async (
req: TypedRequest<typeof validation.servers.addServerPermission>,
res: Response
) => {
const { serverId } = req.params;
const { userId, type } = req.body;
const data = await service.servers.addPermission({
serverId,
type,
userId,
});
const success = ApiSuccess.ok({ data });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteServerPermission = async (
req: TypedRequest<typeof validation.servers.deleteServerPermission>,
res: Response
) => {
const { permissionId } = req.params;
await service.servers.deletePermission({
id: permissionId,
});
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const updateServerPermission = async (
req: TypedRequest<typeof validation.servers.updateServerPermission>,
res: Response
) => {
const { permissionId } = req.params;
const { type } = req.body;
await service.servers.updateServerPermission({
id: permissionId,
type,
});
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const addServerFolderPermission = async (
req: TypedRequest<typeof validation.servers.addServerFolderPermission>,
res: Response
) => {
const { folderId } = req.params;
const { userId } = req.body;
const data = await service.servers.addFolderPermission({
serverFolderId: folderId,
userId,
});
const success = ApiSuccess.ok({ data });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteServerFolderPermission = async (
req: TypedRequest<typeof validation.servers.deleteServerFolderPermission>,
res: Response
) => {
const { folderPermissionId } = req.params;
await service.servers.deleteFolderPermission({ id: folderPermissionId });
const success = ApiSuccess.ok({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const serversController = {
addServerFolderPermission,
addServerPermission,
createServer,
createServerUrl,
deleteServer,
deleteServerFolder,
deleteServerFolderPermission,
deleteServerPermission,
deleteServerUrl,
disableServerFolder,
disableServerUrl,
enableServerFolder,
enableServerUrl,
fullScanServer,
getServerDetail,
getServerList,
getServerListMap,
quickScanServer,
refreshServer,
updateServer,
updateServerPermission,
};
-33
View File
@@ -1,33 +0,0 @@
import { Request, Response } from 'express';
const getSongList = async (req: Request, res: Response) => {
const { serverId } = req.params;
const { take, skip, serverFolderId } = req.query;
// const songs = await songsService.findMany(req, {
// serverFolderIds: String(serverFolderId),
// serverId,
// skip: Number(skip),
// take: Number(take),
// user: req.authUser,
// });
// const success = ApiSuccess.ok({
// // data: toRes.songs(songs.data, req.authUser),
// data: songs.data,
// paginationItems: {
// skip: Number(skip),
// take: Number(take),
// totalEntries: songs.totalEntries,
// url: req.originalUrl,
// },
// });
return {};
// return res.status(data.statusCode).json(getSuccessResponse(data));
};
export const songsController = {
getSongList,
};
-108
View File
@@ -1,108 +0,0 @@
import { Request, Response } from 'express';
import { queue } from '@/queue/queues';
import { toApiModel } from '@helpers/api-model';
import { prisma } from '@lib/prisma';
import { ApiSuccess } from '@utils/api-success';
import { getSuccessResponse } from '@utils/get-success-response';
import { validation } from '@validations/index';
import { TypedRequest } from '@validations/shared.validation';
import { SortOrder } from '../types/types';
const getActiveTasks = async (_req: Request, res: Response) => {
const tasks = await prisma.task.findMany({
include: {
server: true,
user: true,
},
orderBy: {
createdAt: SortOrder.ASC,
},
where: {
completed: false,
isError: false,
},
});
if (queue.scanner.length === 0) {
await prisma.task.updateMany({
data: { completed: true, isError: true, message: 'Task not found' },
where: { completed: false },
});
}
const success = ApiSuccess.ok({ data: toApiModel.tasks({ items: tasks }) });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const cancelAllTasks = async (
_req: TypedRequest<typeof validation.tasks.cancelAll>,
res: Response
) => {
const runningTasks = await prisma.task.findMany({
include: {
server: true,
user: true,
},
where: {
completed: false,
isError: false,
},
});
for (const task of runningTasks) {
queue.scanner.push({
fn: async () => {
return {};
},
id: task.id,
});
}
await prisma.task.updateMany({
data: {
completed: true,
message: 'Task was cancelled by user',
},
where: { completed: false },
});
const success = ApiSuccess.noContent({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const cancelTaskById = async (
req: TypedRequest<typeof validation.tasks.cancel>,
res: Response
) => {
const { taskId } = req.params;
const task = await prisma.task.update({
data: {
completed: true,
message: 'Task was cancelled by user',
},
include: {
server: true,
user: true,
},
where: { id: taskId },
});
queue.scanner.push({
fn: async () => {
return {};
},
id: taskId,
});
const success = ApiSuccess.ok({
data: toApiModel.tasks({ items: [task] })[0],
});
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const tasksController = {
cancelAllTasks,
cancelTaskById,
getActiveTasks,
};
-63
View File
@@ -1,63 +0,0 @@
import { Request, Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
import { validation } from '@validations/index';
import { TypedRequest } from '@validations/shared.validation';
const getUserDetail = async (
req: TypedRequest<typeof validation.users.detail>,
res: Response
) => {
const { userId } = req.params;
const user = await service.users.findById(req.authUser, { id: userId });
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const getUserList = async (_req: Request, res: Response) => {
const users = await service.users.findMany();
const success = ApiSuccess.ok({ data: toApiModel.users(users) });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const createUser = async (
req: TypedRequest<typeof validation.users.createUser>,
res: Response
) => {
const user = await service.users.createUser(req.authUser, req.body);
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const updateUser = async (
req: TypedRequest<typeof validation.users.updateUser>,
res: Response
) => {
const { userId } = req.params;
const user = await service.users.updateUser(
{ userId },
{ ...req.body, image: req.file }
);
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
const deleteUser = async (
req: TypedRequest<typeof validation.users.deleteUser>,
res: Response
) => {
const { userId } = req.params;
await service.users.deleteUser({ userId });
const success = ApiSuccess.noContent({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const usersController = {
createUser,
deleteUser,
getUserDetail,
getUserList,
updateUser,
};
-10
View File
@@ -1,10 +0,0 @@
apk add --no-cache bash
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
npx prisma generate
npx prisma migrate deploy
npx ts-node prisma/seed.ts
npm run dev
-355
View File
@@ -1,355 +0,0 @@
import { AuthUser } from '@/middleware';
import { SortOrder } from '@/types/types';
import { songHelpers } from '@helpers/songs.helpers';
export enum AlbumSort {
DATE_ADDED = 'added',
DATE_ADDED_REMOTE = 'addedRemote',
DATE_RELEASED = 'released',
DATE_RELEASED_YEAR = 'year',
FAVORITE = 'favorite',
NAME = 'name',
RANDOM = 'random',
RATING = 'rating',
}
const include = (user: AuthUser, options: { songs?: boolean }) => {
// Prisma.AlbumInclude
const props = {
_count: {
select: {
favorites: true,
songs: true,
},
},
albumArtists: true,
artists: true,
favorites: { where: { userId: user?.id } },
genres: true,
images: true,
ratings: {
where: {
userId: user?.id,
},
},
server: true,
serverFolders: { where: { enabled: true } },
songs: options?.songs && songHelpers.findMany(user),
};
return props;
};
const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
let order;
switch (sortBy) {
case AlbumSort.NAME:
order = { name: orderBy };
break;
case AlbumSort.DATE_ADDED:
order = { createdAt: orderBy };
break;
case AlbumSort.DATE_ADDED_REMOTE:
order = { remoteCreatedAt: orderBy };
break;
case AlbumSort.DATE_RELEASED:
order = { releaseDate: orderBy };
break;
case AlbumSort.DATE_RELEASED_YEAR:
order = { releaseYear: orderBy };
break;
case AlbumSort.RATING:
order = { rating: orderBy };
break;
case AlbumSort.FAVORITE:
order = { favorite: orderBy };
break;
default:
order = { title: orderBy };
break;
}
return order;
};
export enum FilterGroupType {
AND = 'AND',
OR = 'OR',
}
export type AdvancedFilterRule = {
field: string | null;
operator: string | null;
uniqueId: string;
value: string | number | Date | undefined | null | any;
};
export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
uniqueId: string;
};
const operatorMap = {
'!=': 'not',
'!~': 'contains',
$: 'endsWith',
'<': 'lt',
'<=': 'lte',
'=': 'equals',
'>': 'gt',
'>=': 'gte',
'^': 'startsWith',
'~': 'contains',
};
const insensitiveFields = ['name'];
const advancedFilterGroup = (
groups: AdvancedFilterGroup[],
user: AuthUser,
data: any[]
) => {
if (groups.length === 0) {
return data;
}
const filterGroups: any[] = [];
for (const group of groups) {
const rootType = group.type.toUpperCase();
const query: any = {
[rootType]: [],
};
for (const rule of group.rules) {
if (rule.field && rule.operator) {
const [table, field, relationField] = rule.field.split('.');
const condition =
rule.operator === '!~' || rule.operator === '!=' ? 'none' : 'some';
const op = operatorMap[rule.operator as keyof typeof operatorMap];
const value =
field !== 'releaseDate' ? rule.value : new Date(rule.value);
switch (table) {
case 'albums':
if (field === 'ratings') {
query[rootType].push({
[field]: {
[condition]: {
[relationField]: {
[op]: value,
},
userId: user.id,
},
},
});
break;
}
if (field === 'genres') {
query[rootType].push({
[field]: {
[condition]: {
[relationField]: {
equals: value,
},
},
},
});
break;
}
query[rootType].push({
[field]: {
mode: insensitiveFields.includes(field)
? 'insensitive'
: undefined,
[op]: value,
},
});
break;
default:
if (field === 'ratings') {
query[rootType].push({
[table]: {
some: {
[field]: {
some: {
[relationField]: {
[op]: value,
},
userId: user.id,
},
},
},
},
});
break;
}
if (field === 'genres') {
query[rootType].push({
[table]: {
some: {
[field]: {
[condition]: {
[relationField]: {
equals: value,
},
},
},
},
},
});
break;
}
query[rootType].push({
[table]: {
[condition]: {
[field]: {
mode: 'insensitive',
[op]: value,
},
},
},
});
break;
}
}
}
if (group.group.length > 0) {
const b = advancedFilterGroup(group.group, user, data);
b.forEach((c) => query[rootType].push(c));
}
data.push(query);
filterGroups.push(query);
}
return filterGroups;
};
const advancedFilter = (filter: AdvancedFilterGroup, user: AuthUser) => {
const rootQueryType = filter.type.toUpperCase();
const rootQuery = {
[rootQueryType]: [] as any[],
};
for (const rule of filter.rules) {
if (rule.field && rule.operator) {
let [table, field, relationField] = rule.field.split('.');
const condition =
rule.operator === '!~' || rule.operator === '!=' ? 'none' : 'some';
const op = operatorMap[rule.operator as keyof typeof operatorMap];
const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);
switch (table) {
case 'albums':
if (field === 'ratings') {
rootQuery[rootQueryType].push({
[field]: {
[condition]: {
[relationField]: {
[op]: value,
},
userId: user.id,
},
},
});
break;
}
if (field === 'genres') {
rootQuery[rootQueryType].push({
[field]: {
[condition]: {
[relationField]: {
equals: value,
},
},
},
});
break;
}
rootQuery[rootQueryType].push({
[field]: {
mode: insensitiveFields.includes(field)
? 'insensitive'
: undefined,
[op]: value,
},
});
break;
default:
if (field === 'ratings') {
rootQuery[rootQueryType].push({
[table]: {
some: {
[field]: {
some: {
[relationField]: {
[op]: value,
},
userId: user.id,
},
},
},
},
});
break;
}
if (field === 'genres') {
rootQuery[rootQueryType].push({
[table]: {
some: {
[field]: {
[condition]: {
[relationField]: {
equals: value,
},
},
},
},
},
});
break;
}
rootQuery[rootQueryType].push({
[table]: {
[condition]: {
[field]: {
mode: 'insensitive',
[op]: value,
},
},
},
});
break;
}
}
}
const groups = advancedFilterGroup(filter.group, user, []);
for (const group of groups) {
rootQuery[rootQueryType].push(group);
}
return rootQuery;
};
export const albumHelpers = {
advancedFilter,
include,
sort,
};
-685
View File
@@ -1,685 +0,0 @@
/* eslint-disable no-underscore-dangle */
import {
Album,
AlbumArtist,
AlbumArtistRating,
AlbumRating,
Artist,
ArtistRating,
External,
File,
FileType,
Genre,
Image,
ImageType,
Server,
ServerFolder,
ServerFolderPermission,
ServerPermission,
ServerType,
ServerUrl,
Song,
SongRating,
Task,
User,
UserServerUrl,
} from '@prisma/client';
import { AuthUser } from '@middleware/authenticate';
const getSubsonicStreamUrl = (options: {
deviceId: string;
remoteId: string;
token?: string;
url: string;
}) => {
const { deviceId, remoteId, token, url } = options;
return (
`${url}/rest/stream.view` +
`?id=${remoteId}` +
`&v=1.13.0` +
`&c=Feishin_${deviceId}` +
`&${token ? `${token}` : ''}`
);
};
const getJellyfinStreamUrl = (options: {
deviceId: string;
remoteId: string;
token?: string;
url: string;
userId: string;
}) => {
const { deviceId, remoteId, token, url, userId } = options;
return (
`${url}/audio` +
`/${remoteId}/universal` +
`?userId=${userId}` +
`&audioCodec=aac` +
`&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg` +
`&transcodingContainer=ts` +
`&transcodingProtocol=hls` +
`&deviceId=Feishin_${deviceId}` +
`&playSessionId=${deviceId}` +
`&api_key=${token ? `${token}` : ''}`
);
};
const buildStreamUrl = (
type: ServerType,
options: {
deviceId: string;
noCredential: boolean;
remoteId: string;
token: string;
url: string;
userId?: string;
}
) => {
if (type === ServerType.JELLYFIN) {
return getJellyfinStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
token: options.noCredential ? undefined : options.token,
url: options.url,
userId: options.userId || '',
});
}
if (type === ServerType.SUBSONIC) {
return getSubsonicStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
token: options.noCredential ? undefined : options.token,
url: options.url,
});
}
if (type === ServerType.NAVIDROME) {
const [_ndToken, ssToken] = options.token.split('||');
if (options.noCredential) {
return getSubsonicStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
url: options.url,
});
}
return getSubsonicStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
token: ssToken,
url: options.url,
});
}
return null;
};
const imageUrl = (
type: ServerType,
imageType: ImageType,
baseUrl: string,
imageId: string,
token?: string
) => {
if (type === ServerType.JELLYFIN) {
if (imageType === ImageType.PRIMARY) {
return (
`${baseUrl}/Items` +
`/${imageId}` +
`/Images/Primary` +
'?fillHeight=250' +
`&fillWidth=250` +
'&quality=90'
);
}
return (
`${baseUrl}/Items` +
`/${imageId}` +
`/Images/Backdrop` +
'?fillHeight=250' +
`&fillWidth=250` +
'&quality=90'
);
}
if (type === ServerType.SUBSONIC || type === ServerType.NAVIDROME) {
return (
`${baseUrl}/rest/getCoverArt.view` +
`?id=${imageId}` +
`&size=250` +
`&v=1.13.0` +
`&c=Feishin` +
`&${token ? `${token}` : ''}`
);
}
return null;
};
const relatedAlbum = (
item: Album & {
albumArtists: AlbumArtist[];
}
) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
remoteId: item.remoteId,
albumArtists: item.albumArtists
? relatedAlbumArtists(item.albumArtists)
: [],
deleted: item.deleted,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
};
const relatedArtists = (items: Artist[]) => {
return (
items?.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
remoteId: item.remoteId,
deleted: item.deleted,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const relatedAlbumArtists = (items: AlbumArtist[]) => {
return (
items?.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
remoteId: item.remoteId,
deleted: item.deleted,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const relatedGenres = (items: Genre[]) => {
return (
items?.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const genres = (items: (Genre & { _count?: any })[]) => {
return (
items?.map((item) => {
const totalCount = Object.keys(item._count)
.map((key) => item._count[key])
.reduce((a, b) => a + b, 0);
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
songCount: item._count?.songs,
albumCount: item._count?.albums,
artistCount: item._count?.artists,
albumArtistCount: item._count?.albumArtists,
totalCount,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const relatedServerFolders = (items: ServerFolder[]) => {
const serverFolders = items?.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
enabled: item.enabled,
remoteId: item.remoteId,
lastScannedAt: item.lastScannedAt,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
});
return serverFolders || [];
};
const relatedServerUrls = (
items: (ServerUrl & {
userServerUrls?: UserServerUrl[];
})[]
) => {
const serverUrls = items?.map((item) => {
const userServerUrlIds = item.userServerUrls?.map(
(userServerUrl) => userServerUrl.serverUrlId
);
const enabled = userServerUrlIds?.some((id) => id === item.id);
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
url: item.url,
enabled,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
});
return serverUrls || [];
};
const rating = (
items: AlbumRating[] | SongRating[] | ArtistRating[] | AlbumArtistRating[]
) => {
if (items.length > 0) {
return items[0].value;
}
return null;
};
const buildImageUrl = (options: {
imageType: ImageType;
images: Image[];
noCredential?: boolean;
remoteId: string;
token?: string;
type: ServerType;
url: string;
}) => {
const { imageType, images, remoteId, token, type, url, noCredential } =
options;
const image = images.find((i) => i.type === imageType);
if (!image) return null;
if (type === ServerType.JELLYFIN) {
return imageUrl(type, imageType, url, remoteId);
}
if (type === ServerType.SUBSONIC) {
if (noCredential) {
return imageUrl(type, imageType, url, image.remoteUrl);
}
return imageUrl(type, imageType, url, image.remoteUrl, token);
}
if (type === ServerType.NAVIDROME) {
const [_ndToken, ssToken] = token!.split('||');
if (noCredential) {
return imageUrl(type, imageType, url, image.remoteUrl);
}
return imageUrl(type, imageType, url, image.remoteUrl, ssToken);
}
return null;
};
type DbSong = Song & DbSongInclude;
type DbSongInclude = {
album: Album & { albumArtists: AlbumArtist[]; images: Image[] };
artists: Artist[];
externals: External[];
genres: Genre[];
images: Image[];
ratings: SongRating[];
server: Server & { serverUrls: ServerUrl[] };
};
const songs = (
items: DbSong[],
options: {
deviceId: string;
imageUrl?: string;
serverFolderId?: number;
token: string;
type: ServerType;
url: string;
userId: string;
},
noCredential: boolean
) => {
return (
items?.map((item) => {
const customUrl = item.server.serverUrls[0]?.url;
const baseUrl = customUrl ? customUrl : options.url;
const streamUrl = buildStreamUrl(options.type, {
deviceId: options.deviceId,
noCredential,
remoteId: item.remoteId,
token: options.token,
url: baseUrl,
userId: options.userId,
});
let imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.images,
noCredential,
remoteId: item.remoteId,
token: options.token,
type: options.type,
url: baseUrl,
});
if (!imageUrl) {
imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.album.images,
noCredential,
remoteId: item.remoteId,
token: options.token,
type: options.type,
url: baseUrl,
});
}
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
artistName: item.artistName,
album: item.album && relatedAlbum(item.album),
artists: relatedArtists(item.artists),
bitRate: item.bitRate,
container: item.container,
createdAt: item.createdAt,
deleted: item.deleted,
discNumber: item.discNumber,
duration: item.duration,
genres: relatedGenres(item.genres),
imageUrl,
releaseDate: item.releaseDate,
releaseYear: item.releaseYear,
remoteCreatedAt: item.remoteCreatedAt,
remoteId: item.remoteId,
// serverFolderId: item.serverFolderId,
serverId: item.serverId,
streamUrl,
trackNumber: item.trackNumber,
updatedAt: item.updatedAt,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
type DbAlbum = Album & DbAlbumInclude;
type DbAlbumInclude = {
_count: {
favorites: number;
songs: number;
};
albumArtists: AlbumArtist[];
genres: Genre[];
images: Image[];
ratings: AlbumRating[];
server: Server;
serverFolders: ServerFolder[];
songs?: DbSong[];
};
const albums = (options: {
items: DbAlbum[] | any[];
serverUrl?: string;
user: AuthUser;
}) => {
const { items, serverUrl, user } = options;
return (
items?.map((item) => {
const { type, token, remoteUserId, noCredential } = item.server;
const url = serverUrl || item.server.url;
// Jellyfin does not require credentials for image url
const shouldBuildImage = type === ServerType.JELLYFIN || !noCredential;
const tokenForImage = shouldBuildImage ? token : undefined;
const imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.images,
noCredential,
remoteId: item.remoteId,
token,
type,
url,
});
const backdropImageUrl = buildImageUrl({
imageType: ImageType.BACKDROP,
images: item.images,
noCredential,
remoteId: item.remoteId,
token,
type,
url,
});
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
sortName: item.sortName,
releaseDate: item.releaseDate,
releaseYear: item.releaseYear,
isFavorite: item.favorites.length === 1,
rating: rating(item.ratings),
songCount: item._count.songs,
type,
imageUrl,
backdropImageUrl: backdropImageUrl,
deleted: item.deleted,
remoteId: item.remoteId,
remoteCreatedAt: item.remoteCreatedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
genres: item.genres ? relatedGenres(item.genres) : [],
albumArtists: item.albumArtists
? relatedAlbumArtists(item.albumArtists)
: [],
artists: item.artists ? relatedArtists(item.artists) : [],
serverFolders: relatedServerFolders(item.serverFolders),
songs:
item.songs &&
songs(
item?.songs?.map((s: any) => ({
...s,
album: { images: item?.images, ...relatedAlbum(item) },
})),
{
deviceId: user.deviceId,
token,
type,
url,
userId: remoteUserId,
},
noCredential
),
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const servers = (
items: (Server & {
serverFolders?: ServerFolder[];
serverUrls?: (ServerUrl & {
userServerUrls?: UserServerUrl[];
})[];
})[]
) => {
return (
items.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
url: item.url,
type: item.type,
noCredential: item.noCredential,
username: item.username,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
serverFolders:
item.serverFolders && relatedServerFolders(item.serverFolders),
serverUrls: item.serverUrls && relatedServerUrls(item.serverUrls),
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const relatedServers = (items: Server[]) => {
const result = items.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
url: item.url,
}));
return result || [];
};
const relatedServerFolderPermissions = (items: ServerFolderPermission[]) => {
return items.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
serverFolderId: item.serverFolderId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
});
};
const relatedServerPermissions = (items: ServerPermission[]) => {
return items.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
type: item.type,
serverId: item.serverId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
});
};
const relatedFile = (item: File) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.fileName,
path: item.path,
type: item.type,
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
};
const users = (
items: (User & {
accessToken?: string;
files?: File[];
refreshToken?: string;
serverFolderPermissions?: ServerFolderPermission[];
serverPermissions?: ServerPermission[];
})[]
) => {
return (
items.map((item) => {
const avatar = item.files?.find((f) => f.type === FileType.USER);
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
username: item.username,
displayName: item.displayName,
avatar: avatar ? relatedFile(avatar) : null,
accessToken: item.accessToken,
refreshToken: item.refreshToken,
enabled: item.enabled,
isAdmin: item.isAdmin,
isSuperAdmin: item.isSuperAdmin,
deviceId: item.deviceId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
flatServerPermissions:
item.serverPermissions && item.serverPermissions.map((s) => s.id),
serverFolderPermissions:
item.serverFolderPermissions &&
relatedServerFolderPermissions(item.serverFolderPermissions),
serverPermissions:
item.serverPermissions &&
relatedServerPermissions(item.serverPermissions),
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
const relatedUsers = (items: User[]) => {
const result = items.map((item) => ({
enabled: item.enabled,
id: item.id,
isAdmin: item.isAdmin,
username: item.username,
}));
return result || [];
};
type DbTask = Task & DbTaskInclude;
type DbTaskInclude = {
server: Server;
user: User;
};
const tasks = (options: { items: DbTask[] | any[] }) => {
const { items } = options;
const result = items.map((item) => ({
createdAt: item.createdAt,
id: item.id,
isCompleted: item.completed,
isError: item.isError,
message: item.message,
server: item.server ? relatedServers([item.server])[0] : null,
type: item.type,
updatedAt: item.updatedAt,
user: item.user ? relatedUsers([item.user])[0] : null,
}));
return result;
};
export const toApiModel = {
albums,
genres,
servers,
songs,
tasks,
users,
};
-9
View File
@@ -1,9 +0,0 @@
import { albumHelpers } from './albums.helpers';
import { sharedHelpers } from './shared.helpers';
import { songHelpers } from './songs.helpers';
export const helpers = {
albums: albumHelpers,
shared: sharedHelpers,
songs: songHelpers,
};
-125
View File
@@ -1,125 +0,0 @@
import { ServerPermissionType } from '@prisma/client';
import { AuthUser } from '@/middleware';
import { ApiError } from '@/utils';
import { prisma } from '@lib/prisma';
const checkServerPermissions = (
user: AuthUser,
options: { serverId?: string }
) => {
const { serverId } = options;
if (user.isAdmin || !serverId) {
return;
}
if (serverId && !user.flatServerPermissions.includes(serverId)) {
throw ApiError.forbidden();
}
};
const checkServerFolderPermissions = (
user: AuthUser,
options: { serverFolderId?: string[] | string; serverId: string }
) => {
const { serverFolderId, serverId } = options;
if (user.isAdmin || !serverFolderId) {
return;
}
const isServerAdmin =
user.serverPermissions.find((s) => s.serverId === serverId)?.type ===
ServerPermissionType.ADMIN;
if (isServerAdmin) {
return;
}
let ids: string[] = [];
if (typeof serverFolderId === 'string') {
ids = [serverFolderId];
} else if (typeof serverFolderId === 'object') {
ids = serverFolderId;
}
for (const id of ids) {
if (!user.flatServerFolderPermissions.includes(id)) {
throw ApiError.forbidden('');
}
}
};
const getAvailableServerFolderIds = async (
user: AuthUser,
options: { serverId: string }
) => {
const { serverId } = options;
if (user.isAdmin) {
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: { enabled: true, serverId },
});
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
return serverFoldersWithAccessIds;
}
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: {
OR: [
{
server: {
serverPermissions: {
some: { type: ServerPermissionType.ADMIN, userId: user.id },
},
},
},
{
AND: [
{
enabled: true,
serverFolderPermissions: {
some: { userId: { equals: user.id } },
},
},
],
},
],
},
});
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
return serverFoldersWithAccessIds;
};
const serverFolderFilter = (serverFolderIds: string[]) => {
return {
serverFolders: { every: { id: { in: serverFolderIds } } },
};
};
const paginationParams = (options: { skip: any; take: any }) => {
const { skip, take } = options;
return {
skip: Number(skip),
take: Number(take),
};
};
export const sharedHelpers = {
checkServerFolderPermissions,
checkServerPermissions,
getAvailableServerFolderIds,
params: {
pagination: paginationParams,
},
serverFolderFilter,
};
-50
View File
@@ -1,50 +0,0 @@
import { Prisma } from '@prisma/client';
import { AuthUser } from '@middleware/authenticate';
const include = () => {
const props: Prisma.SongInclude = {
album: true,
artists: true,
externals: true,
genres: true,
images: true,
ratings: true,
server: {
include: { serverUrls: true },
},
};
return props;
};
const findMany = (user: AuthUser) => {
const props: Prisma.SongFindManyArgs = {
include: {
album: true,
artists: true,
externals: true,
genres: true,
images: true,
ratings: true,
server: {
include: {
serverUrls: {
where: { userServerUrls: { some: { userId: user.id } } },
},
},
},
},
orderBy: [
// { albumId: Prisma.SortOrder.asc },
{ discNumber: Prisma.SortOrder.asc },
{ trackNumber: Prisma.SortOrder.asc },
],
};
return props;
};
export const songHelpers = {
findMany,
include,
};
-2
View File
@@ -1,2 +0,0 @@
export * from './prisma';
export { default as throttle } from './throttle';
-99
View File
@@ -1,99 +0,0 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import {
Strategy as JwtStrategy,
ExtractJwt,
StrategyOptions,
} from 'passport-jwt';
import { Strategy as LocalStrategy } from 'passport-local';
import { prisma } from './prisma';
export const generateToken = (
id: string,
otherProperties?: { [key: string]: any }
) => {
return jwt.sign(
{ id, ...otherProperties },
String(process.env.TOKEN_SECRET),
{
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
}
);
};
export const generateRefreshToken = (
id: string,
otherProperties?: { [key: string]: any }
) => {
return jwt.sign(
{ id, ...otherProperties },
String(process.env.TOKEN_SECRET),
{
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
}
);
};
const authenticateUser = async (
username: string,
password: string,
done: any
) => {
const user = await prisma.user.findUnique({ where: { username } });
if (user === null || user === undefined) {
return done(null, false);
}
if (!user.enabled) {
return done(null, false, { message: 'The user is not enabled.' });
}
if (await bcrypt.compare(password, user.password)) {
return done(null, user);
}
return done(null, false, { message: 'Invalid credentials.' });
};
passport.use(new LocalStrategy(authenticateUser));
const jwtOptions: StrategyOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: String(process.env.TOKEN_SECRET),
};
passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
await prisma.user
.findUnique({
include: {
serverFolderPermissions: true,
serverPermissions: true,
},
where: { id: jwt_payload.id },
})
.then((user) => {
return done(null, user);
})
.catch((err) => {
console.log(err.message);
});
})
);
passport.serializeUser((user: any, done) => {
return done(null, user.id);
});
passport.deserializeUser(async (id: string, done) => {
return done(
null,
await prisma.user.findUnique({
where: {
id,
},
})
);
});
-53
View File
@@ -1,53 +0,0 @@
import { Prisma, PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({ errorFormat: 'minimal' });
export const exclude = <T, Key extends keyof T>(
resultSet: T,
...keys: Key[]
): Omit<T, Key> => {
// eslint-disable-next-line no-restricted-syntax
for (const key of keys) {
delete resultSet[key];
}
return resultSet;
};
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
prisma.$use(async (params, next) => {
const maxRetries = 3;
let retries = 0;
do {
try {
const result = await next(params);
return result;
} catch (err) {
console.log('err', err);
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
retries = 3; // Don't retry on unique constraint violation
return null;
}
}
retries += 1;
return sleep(100);
}
} while (retries < maxRetries);
});
// prisma.$use(async (params, next) => {
// const before = Date.now();
// const result = await next(params);
// const after = Date.now();
// console.log(
// `Query ${params.model}.${params.action} took ${after - before}ms`
// );
// return result;
// });
-8
View File
@@ -1,8 +0,0 @@
import pThrottle from 'p-throttle';
const throttle = pThrottle({
interval: 1000,
limit: 10,
});
export default throttle;
-20
View File
@@ -1,20 +0,0 @@
import { NextFunction, Request, Response } from 'express';
export const authenticateAdmin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.authUser.isAdmin) {
return res.status(403).json({
error: {
message: 'This action requires an administrator account.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -1,40 +0,0 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerAdmin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (permission !== ServerPermissionType.ADMIN) {
return res.status(403).json({
error: {
message: 'This action requires "Admin" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -1,43 +0,0 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerEditor = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (
permission !== ServerPermissionType.EDITOR &&
permission !== ServerPermissionType.ADMIN
) {
return res.status(403).json({
error: {
message: 'This action requires "Editor" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -1,40 +0,0 @@
import { ServerPermission, ServerPermissionType } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
export const authenticateServerViewer = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.params.serverId) {
return res.status(403).json({
error: {
message: 'Server id is required.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
return next();
}
const permission = req.authUser.serverPermissions.find(
(p: ServerPermission) => p.serverId === req.params.serverId
)?.type;
if (permission === undefined) {
return res.status(403).json({
error: {
message: 'This action requires "Viewer" server permissions.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
@@ -1,20 +0,0 @@
import { NextFunction, Request, Response } from 'express';
export const authenticateSuperAdmin = (
req: Request,
res: Response,
next: NextFunction
) => {
if (!req.authUser.isSuperAdmin) {
return res.status(403).json({
error: {
message: 'This action requires an administrator account.',
path: req.path,
},
response: 'Error',
statusCode: 403,
});
}
return next();
};
-67
View File
@@ -1,67 +0,0 @@
import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
export type AuthUser = Request['authUser'];
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
) => {
passport.authenticate('jwt', { session: false }, (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({
error: {
message: info?.message || 'Invalid authorization.',
path: req.path,
},
response: 'Error',
statusCode: 401,
});
}
if (!user.enabled) {
return res.status(401).json({
error: {
message: 'Your account is not enabled.',
path: req.path,
},
response: 'Error',
statusCode: 401,
});
}
const flatServerFolderPermissions = user.serverFolderPermissions.map(
(permission: ServerFolderPermission) => permission.serverFolderId
);
const flatServerPermissions = user.serverPermissions.map(
(permission: ServerPermission) => permission.serverId
);
const props = {
createdAt: user?.createdAt,
deviceId: user?.deviceId,
enabled: user?.enabled,
flatServerFolderPermissions,
flatServerPermissions,
id: user?.id,
isAdmin: user?.isAdmin,
isSuperAdmin: user?.isSuperAdmin,
serverFolderPermissions: user?.serverFolderPermissions,
serverId: req.params.serverId,
serverPermissions: user?.serverPermissions,
updatedAt: user?.updatedAt,
username: user?.username,
};
req.authUser = props;
return next();
})(req, res, next);
};
-35
View File
@@ -1,35 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { isJsonString } from '@utils/is-json-string';
export const errorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
let message = '';
const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
return e.replace(/\(|\)/g, '');
});
if (err.message) {
message = isJsonString(err.message)
? Array.isArray(JSON.parse(err.message))
? JSON.parse(err.message)[0].message // Handles errors sent from zod preprocess
: JSON.parse(err.message)
: err.message;
}
res.status(err.statusCode || 500).json({
error: {
message,
path: req.path,
trace,
},
response: 'Error',
statusCode: err.statusCode || 500,
});
next();
};
-7
View File
@@ -1,7 +0,0 @@
export * from './error-handler';
export * from './authenticate';
export * from './authenticate-admin';
export * from './authenticate-super-admin';
export * from './authenticate-server-admin';
export * from './authenticate-server-editor';
export * from './authenticate-server-viewer';
-18108
View File
File diff suppressed because it is too large Load Diff
-83
View File
@@ -1,83 +0,0 @@
{
"name": "feishin-server",
"version": "0.0.1-alpha1",
"description": "A full-featured Subsonic/Jellyfin compatible music player",
"main": "server.js",
"scripts": {
"dev": "nodemon --legacy-watch -e ts,js --exec ts-node -r tsconfig-paths/register server.ts",
"prod": "ts-node --transpileOnly -r tsconfig-paths/register server.ts",
"dev:debug": "nodemon --config nodemon.json --inspect-brk server.ts",
"build": "tsc --project . && tsconfig-replace-paths --project tsconfig.json"
},
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"license": "GPL-3.0",
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/bcryptjs": "^2.4.2",
"@types/better-queue": "^3.8.3",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/lodash": "^4.14.186",
"@types/md5": "^2.3.2",
"@types/multer": "^1.4.7",
"@types/node": "^18.8.4",
"@types/passport-jwt": "^3.0.7",
"@types/passport-local": "^1.0.34",
"@types/sharp": "^0.31.0",
"@typescript-eslint/parser": "^5.40.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.3",
"eslint-plugin-n": "^15.3.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"nodemon": "^2.0.20",
"prisma": "^4.5.0",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"tsconfig-replace-paths": "^0.0.11",
"typescript": "^4.8.4"
},
"dependencies": {
"@prisma/client": "^4.5.0",
"axios": "^0.27.2",
"bcryptjs": "^2.4.3",
"better-queue": "^3.8.12",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"multer": "^1.4.5-lts.1",
"p-throttle": "^4.1.1",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"sharp": "^0.31.2",
"socket.io": "^4.5.3",
"zod": "^3.19.1"
}
}
-7
View File
@@ -1,7 +0,0 @@
FROM node:16.5-alpine
WORKDIR /app
COPY ./ ./prisma/
CMD ["npx", "prisma", "studio"]
@@ -1,938 +0,0 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- CreateEnum
CREATE TYPE "ServerType" AS ENUM ('SUBSONIC', 'JELLYFIN', 'NAVIDROME');
-- CreateEnum
CREATE TYPE "ServerPermissionType" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- CreateEnum
CREATE TYPE "ExternalSource" AS ENUM ('MUSICBRAINZ', 'LASTFM', 'THEAUDIODB', 'SPOTIFY');
-- CreateEnum
CREATE TYPE "ExternalType" AS ENUM ('ID', 'LINK');
-- CreateEnum
CREATE TYPE "ImageType" AS ENUM ('PRIMARY', 'BACKDROP', 'LOGO', 'SCREENSHOT');
-- CreateEnum
CREATE TYPE "TaskType" AS ENUM ('FULL_SCAN', 'QUICK_SCAN', 'REFRESH', 'SPOTIFY', 'MUSICBRAINZ', 'LASTFM');
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"token" TEXT NOT NULL,
"userId" UUID NOT NULL,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"deviceId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "History" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL,
CONSTRAINT "History_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Server" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"remoteUserId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"token" TEXT NOT NULL,
"type" "ServerType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Server_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Folder" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"path" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"parentId" UUID,
"serverId" UUID NOT NULL,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerPermission" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"type" "ServerPermissionType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "ServerPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerUrl" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "ServerUrl_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserServerUrl" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"serverUrlId" UUID NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "UserServerUrl_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerFolder" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"remoteId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastScannedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"serverId" UUID NOT NULL,
CONSTRAINT "ServerFolder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServerFolderPermission" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"serverFolderId" UUID NOT NULL,
CONSTRAINT "ServerFolderPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Genre" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Genre_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AlbumArtistFavorite" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"albumArtistId" UUID NOT NULL,
"userId" UUID NOT NULL,
CONSTRAINT "AlbumArtistFavorite_pkey" PRIMARY KEY ("userId","albumArtistId")
);
-- CreateTable
CREATE TABLE "ArtistFavorite" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"artistId" UUID NOT NULL,
"userId" UUID NOT NULL,
CONSTRAINT "ArtistFavorite_pkey" PRIMARY KEY ("userId","artistId")
);
-- CreateTable
CREATE TABLE "AlbumFavorite" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"albumId" UUID NOT NULL,
"userId" UUID NOT NULL,
CONSTRAINT "AlbumFavorite_pkey" PRIMARY KEY ("userId","albumId")
);
-- CreateTable
CREATE TABLE "SongFavorite" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"songId" UUID NOT NULL,
"userId" UUID NOT NULL,
CONSTRAINT "SongFavorite_pkey" PRIMARY KEY ("userId","songId")
);
-- CreateTable
CREATE TABLE "AlbumArtistRating" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"value" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"albumArtistId" UUID NOT NULL,
CONSTRAINT "AlbumArtistRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArtistRating" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"value" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"artistId" UUID NOT NULL,
CONSTRAINT "ArtistRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AlbumRating" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"value" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"albumId" UUID NOT NULL,
CONSTRAINT "AlbumRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SongRating" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"value" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" UUID NOT NULL,
"songId" UUID NOT NULL,
CONSTRAINT "SongRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Image" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"url" TEXT,
"remoteUrl" TEXT NOT NULL,
"type" "ImageType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Image_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "External" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"value" TEXT NOT NULL,
"type" "ExternalType" NOT NULL,
"source" "ExternalSource" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "External_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AlbumArtist" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"sortName" TEXT NOT NULL,
"biography" TEXT,
"remoteId" TEXT NOT NULL,
"remoteCreatedAt" TIMESTAMP(3),
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "AlbumArtist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Album" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"sortName" TEXT NOT NULL,
"releaseDate" TIMESTAMP(3),
"releaseYear" INTEGER,
"remoteId" TEXT NOT NULL,
"remoteCreatedAt" TIMESTAMP(3),
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Artist" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"sortName" TEXT NOT NULL,
"biography" TEXT,
"remoteId" TEXT NOT NULL,
"remoteCreatedAt" TIMESTAMP(3),
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "Artist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Song" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"sortName" TEXT NOT NULL,
"releaseDate" TIMESTAMP(3),
"releaseYear" INTEGER,
"duration" DOUBLE PRECISION NOT NULL,
"size" INTEGER,
"lyrics" TEXT,
"bitRate" INTEGER NOT NULL,
"container" TEXT NOT NULL,
"discNumber" INTEGER NOT NULL DEFAULT 1,
"trackNumber" INTEGER,
"artistName" TEXT,
"remoteId" TEXT NOT NULL,
"remoteCreatedAt" TIMESTAMP(3),
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"albumArtistId" UUID,
"albumId" UUID,
"serverId" UUID NOT NULL,
CONSTRAINT "Song_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"type" "TaskType" NOT NULL,
"message" TEXT,
"progress" TEXT,
"completed" BOOLEAN NOT NULL DEFAULT false,
"isError" BOOLEAN DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"serverId" UUID NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_HistoryToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_FolderToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_FolderToServerFolder" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ServerFolderToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_GenreToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ImageToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ExternalToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumArtistToGenre" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumArtistToExternal" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumArtistToServerFolder" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumArtistToImage" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToGenre" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToArtist" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToAlbumArtist" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToExternal" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToServerFolder" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_AlbumToImage" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ArtistToGenre" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ArtistToSong" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ArtistToExternal" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ArtistToServerFolder" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateTable
CREATE TABLE "_ArtistToImage" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_deviceId_key" ON "User"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "Server_url_key" ON "Server"("url");
-- CreateIndex
CREATE UNIQUE INDEX "Folder_path_key" ON "Folder"("path");
-- CreateIndex
CREATE UNIQUE INDEX "Folder_serverId_path_key" ON "Folder"("serverId", "path");
-- CreateIndex
CREATE UNIQUE INDEX "ServerPermission_userId_serverId_key" ON "ServerPermission"("userId", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerUrl_serverId_url_key" ON "ServerUrl"("serverId", "url");
-- CreateIndex
CREATE UNIQUE INDEX "UserServerUrl_userId_serverId_key" ON "UserServerUrl"("userId", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerFolder_remoteId_key" ON "ServerFolder"("remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerFolder_serverId_remoteId_key" ON "ServerFolder"("serverId", "remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "ServerFolderPermission_userId_serverFolderId_key" ON "ServerFolderPermission"("userId", "serverFolderId");
-- CreateIndex
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
-- CreateIndex
CREATE UNIQUE INDEX "AlbumArtistFavorite_userId_albumArtistId_key" ON "AlbumArtistFavorite"("userId", "albumArtistId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtistFavorite_userId_artistId_key" ON "ArtistFavorite"("userId", "artistId");
-- CreateIndex
CREATE UNIQUE INDEX "AlbumFavorite_userId_albumId_key" ON "AlbumFavorite"("userId", "albumId");
-- CreateIndex
CREATE UNIQUE INDEX "SongFavorite_userId_songId_key" ON "SongFavorite"("userId", "songId");
-- CreateIndex
CREATE UNIQUE INDEX "AlbumArtistRating_userId_albumArtistId_key" ON "AlbumArtistRating"("userId", "albumArtistId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtistRating_userId_artistId_key" ON "ArtistRating"("userId", "artistId");
-- CreateIndex
CREATE UNIQUE INDEX "AlbumRating_userId_albumId_key" ON "AlbumRating"("userId", "albumId");
-- CreateIndex
CREATE UNIQUE INDEX "SongRating_userId_songId_key" ON "SongRating"("userId", "songId");
-- CreateIndex
CREATE UNIQUE INDEX "Image_remoteUrl_type_key" ON "Image"("remoteUrl", "type");
-- CreateIndex
CREATE UNIQUE INDEX "External_value_source_key" ON "External"("value", "source");
-- CreateIndex
CREATE UNIQUE INDEX "AlbumArtist_serverId_remoteId_key" ON "AlbumArtist"("serverId", "remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "Album_serverId_remoteId_key" ON "Album"("serverId", "remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "Artist_serverId_remoteId_key" ON "Artist"("serverId", "remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "Song_serverId_remoteId_key" ON "Song"("serverId", "remoteId");
-- CreateIndex
CREATE UNIQUE INDEX "_HistoryToSong_AB_unique" ON "_HistoryToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_HistoryToSong_B_index" ON "_HistoryToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_FolderToSong_AB_unique" ON "_FolderToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_FolderToSong_B_index" ON "_FolderToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_FolderToServerFolder_AB_unique" ON "_FolderToServerFolder"("A", "B");
-- CreateIndex
CREATE INDEX "_FolderToServerFolder_B_index" ON "_FolderToServerFolder"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ServerFolderToSong_AB_unique" ON "_ServerFolderToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_ServerFolderToSong_B_index" ON "_ServerFolderToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_GenreToSong_AB_unique" ON "_GenreToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_GenreToSong_B_index" ON "_GenreToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ImageToSong_AB_unique" ON "_ImageToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_ImageToSong_B_index" ON "_ImageToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ExternalToSong_AB_unique" ON "_ExternalToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_ExternalToSong_B_index" ON "_ExternalToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumArtistToGenre_AB_unique" ON "_AlbumArtistToGenre"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumArtistToGenre_B_index" ON "_AlbumArtistToGenre"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumArtistToExternal_AB_unique" ON "_AlbumArtistToExternal"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumArtistToExternal_B_index" ON "_AlbumArtistToExternal"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumArtistToServerFolder_AB_unique" ON "_AlbumArtistToServerFolder"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumArtistToServerFolder_B_index" ON "_AlbumArtistToServerFolder"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumArtistToImage_AB_unique" ON "_AlbumArtistToImage"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumArtistToImage_B_index" ON "_AlbumArtistToImage"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToGenre_AB_unique" ON "_AlbumToGenre"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToGenre_B_index" ON "_AlbumToGenre"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToArtist_AB_unique" ON "_AlbumToArtist"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToArtist_B_index" ON "_AlbumToArtist"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToAlbumArtist_AB_unique" ON "_AlbumToAlbumArtist"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToAlbumArtist_B_index" ON "_AlbumToAlbumArtist"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToExternal_AB_unique" ON "_AlbumToExternal"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToExternal_B_index" ON "_AlbumToExternal"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToServerFolder_AB_unique" ON "_AlbumToServerFolder"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToServerFolder_B_index" ON "_AlbumToServerFolder"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_AlbumToImage_AB_unique" ON "_AlbumToImage"("A", "B");
-- CreateIndex
CREATE INDEX "_AlbumToImage_B_index" ON "_AlbumToImage"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ArtistToGenre_AB_unique" ON "_ArtistToGenre"("A", "B");
-- CreateIndex
CREATE INDEX "_ArtistToGenre_B_index" ON "_ArtistToGenre"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ArtistToSong_AB_unique" ON "_ArtistToSong"("A", "B");
-- CreateIndex
CREATE INDEX "_ArtistToSong_B_index" ON "_ArtistToSong"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ArtistToExternal_AB_unique" ON "_ArtistToExternal"("A", "B");
-- CreateIndex
CREATE INDEX "_ArtistToExternal_B_index" ON "_ArtistToExternal"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ArtistToServerFolder_AB_unique" ON "_ArtistToServerFolder"("A", "B");
-- CreateIndex
CREATE INDEX "_ArtistToServerFolder_B_index" ON "_ArtistToServerFolder"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_ArtistToImage_AB_unique" ON "_ArtistToImage"("A", "B");
-- CreateIndex
CREATE INDEX "_ArtistToImage_B_index" ON "_ArtistToImage"("B");
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "History" ADD CONSTRAINT "History_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerPermission" ADD CONSTRAINT "ServerPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerPermission" ADD CONSTRAINT "ServerPermission_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerUrl" ADD CONSTRAINT "ServerUrl_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_serverUrlId_fkey" FOREIGN KEY ("serverUrlId") REFERENCES "ServerUrl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerFolder" ADD CONSTRAINT "ServerFolder_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerFolderPermission" ADD CONSTRAINT "ServerFolderPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServerFolderPermission" ADD CONSTRAINT "ServerFolderPermission_serverFolderId_fkey" FOREIGN KEY ("serverFolderId") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumArtistFavorite" ADD CONSTRAINT "AlbumArtistFavorite_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumArtistFavorite" ADD CONSTRAINT "AlbumArtistFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtistFavorite" ADD CONSTRAINT "ArtistFavorite_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtistFavorite" ADD CONSTRAINT "ArtistFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumFavorite" ADD CONSTRAINT "AlbumFavorite_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumFavorite" ADD CONSTRAINT "AlbumFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SongFavorite" ADD CONSTRAINT "SongFavorite_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SongFavorite" ADD CONSTRAINT "SongFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumArtistRating" ADD CONSTRAINT "AlbumArtistRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumArtistRating" ADD CONSTRAINT "AlbumArtistRating_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtistRating" ADD CONSTRAINT "ArtistRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArtistRating" ADD CONSTRAINT "ArtistRating_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumRating" ADD CONSTRAINT "AlbumRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumRating" ADD CONSTRAINT "AlbumRating_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SongRating" ADD CONSTRAINT "SongRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SongRating" ADD CONSTRAINT "SongRating_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AlbumArtist" ADD CONSTRAINT "AlbumArtist_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Album" ADD CONSTRAINT "Album_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Artist" ADD CONSTRAINT "Artist_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Song" ADD CONSTRAINT "Song_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Song" ADD CONSTRAINT "Song_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Song" ADD CONSTRAINT "Song_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HistoryToSong" ADD CONSTRAINT "_HistoryToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "History"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HistoryToSong" ADD CONSTRAINT "_HistoryToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FolderToSong" ADD CONSTRAINT "_FolderToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FolderToSong" ADD CONSTRAINT "_FolderToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FolderToServerFolder" ADD CONSTRAINT "_FolderToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FolderToServerFolder" ADD CONSTRAINT "_FolderToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ServerFolderToSong" ADD CONSTRAINT "_ServerFolderToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ServerFolderToSong" ADD CONSTRAINT "_ServerFolderToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_GenreToSong" ADD CONSTRAINT "_GenreToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_GenreToSong" ADD CONSTRAINT "_GenreToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ImageToSong" ADD CONSTRAINT "_ImageToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ImageToSong" ADD CONSTRAINT "_ImageToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ExternalToSong" ADD CONSTRAINT "_ExternalToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ExternalToSong" ADD CONSTRAINT "_ExternalToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToGenre" ADD CONSTRAINT "_AlbumArtistToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToGenre" ADD CONSTRAINT "_AlbumArtistToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToExternal" ADD CONSTRAINT "_AlbumArtistToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToExternal" ADD CONSTRAINT "_AlbumArtistToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToServerFolder" ADD CONSTRAINT "_AlbumArtistToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToServerFolder" ADD CONSTRAINT "_AlbumArtistToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToImage" ADD CONSTRAINT "_AlbumArtistToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumArtistToImage" ADD CONSTRAINT "_AlbumArtistToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToGenre" ADD CONSTRAINT "_AlbumToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToGenre" ADD CONSTRAINT "_AlbumToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToArtist" ADD CONSTRAINT "_AlbumToArtist_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToArtist" ADD CONSTRAINT "_AlbumToArtist_B_fkey" FOREIGN KEY ("B") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToAlbumArtist" ADD CONSTRAINT "_AlbumToAlbumArtist_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToAlbumArtist" ADD CONSTRAINT "_AlbumToAlbumArtist_B_fkey" FOREIGN KEY ("B") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToExternal" ADD CONSTRAINT "_AlbumToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToExternal" ADD CONSTRAINT "_AlbumToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToServerFolder" ADD CONSTRAINT "_AlbumToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToServerFolder" ADD CONSTRAINT "_AlbumToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToImage" ADD CONSTRAINT "_AlbumToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AlbumToImage" ADD CONSTRAINT "_AlbumToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToGenre" ADD CONSTRAINT "_ArtistToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToGenre" ADD CONSTRAINT "_ArtistToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToSong" ADD CONSTRAINT "_ArtistToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToSong" ADD CONSTRAINT "_ArtistToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToExternal" ADD CONSTRAINT "_ArtistToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToExternal" ADD CONSTRAINT "_ArtistToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToServerFolder" ADD CONSTRAINT "_ArtistToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToServerFolder" ADD CONSTRAINT "_ArtistToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToImage" ADD CONSTRAINT "_ArtistToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArtistToImage" ADD CONSTRAINT "_ArtistToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Server" ADD COLUMN "noCredential" BOOLEAN NOT NULL DEFAULT true;
@@ -1,16 +0,0 @@
/*
Warnings:
- You are about to drop the column `name` on the `Task` table. All the data in the column will be lost.
- You are about to drop the column `progress` on the `Task` table. All the data in the column will be lost.
- Made the column `isError` on table `Task` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Task" DROP COLUMN "name",
DROP COLUMN "progress",
ADD COLUMN "userId" UUID,
ALTER COLUMN "isError" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,14 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[displayName]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "skip" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "displayName" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_displayName_key" ON "User"("displayName");
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false;
@@ -1,29 +0,0 @@
-- CreateEnum
CREATE TYPE "FileType" AS ENUM ('ALBUM', 'SONG', 'AUDIO', 'USER');
-- CreateTable
CREATE TABLE "File" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"path" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"type" "FileType" NOT NULL,
"userId" UUID,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "File_path_key" ON "File"("path");
-- CreateIndex
CREATE UNIQUE INDEX "File_fileName_key" ON "File"("fileName");
-- CreateIndex
CREATE UNIQUE INDEX "File_userId_type_key" ON "File"("userId", "type");
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,14 +0,0 @@
/*
Warnings:
- You are about to drop the column `skip` on the `Song` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "ServerFolder_remoteId_key";
-- AlterTable
ALTER TABLE "Song" DROP COLUMN "skip",
ALTER COLUMN "duration" DROP NOT NULL,
ALTER COLUMN "bitRate" DROP NOT NULL,
ALTER COLUMN "discNumber" DROP NOT NULL;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-543
View File
@@ -1,543 +0,0 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch", "orderByNulls", "filteredRelationCount", "fieldReference"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum ServerType {
SUBSONIC
JELLYFIN
NAVIDROME
}
enum ServerPermissionType {
ADMIN
EDITOR
VIEWER
}
enum ExternalSource {
MUSICBRAINZ
LASTFM
THEAUDIODB
SPOTIFY
}
enum ExternalType {
ID
LINK
}
enum ImageType {
PRIMARY
BACKDROP
LOGO
SCREENSHOT
}
enum TaskType {
FULL_SCAN
QUICK_SCAN
REFRESH
SPOTIFY
MUSICBRAINZ
LASTFM
}
enum FileType {
ALBUM
SONG
AUDIO
USER
}
model RefreshToken {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
token String @unique
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
}
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
displayName String? @unique
username String @unique
password String
enabled Boolean @default(false)
isAdmin Boolean @default(false)
isSuperAdmin Boolean @default(false)
deviceId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
histories History[]
albumArtistRatings AlbumArtistRating[]
artistRatings ArtistRating[]
albumRatings AlbumRating[]
songRatings SongRating[]
refreshTokens RefreshToken[]
files File[]
serverFolderPermissions ServerFolderPermission[]
serverPermissions ServerPermission[]
albumArtistFavorites AlbumArtistFavorite[]
artistFavorites ArtistFavorite[]
albumFavorites AlbumFavorite[]
songFavorites SongFavorite[]
userServerUrls UserServerUrl[]
tasks Task[]
}
model File {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
path String @unique
originalName String
fileName String @unique
size Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type FileType
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @db.Uuid
@@unique(fields: [userId, type], name: "uniqueFileId")
}
model History {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
songs Song[]
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
}
model Server {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
url String @unique
remoteUserId String
username String
token String
noCredential Boolean @default(true)
type ServerType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albumArtists AlbumArtist[]
artists Artist[]
albums Album[]
songs Song[]
serverFolders ServerFolder[]
serverUrls ServerUrl[]
folders Folder[]
serverPermissions ServerPermission[]
tasks Task[]
userServerUrls UserServerUrl[]
}
model Folder {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
path String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
songs Song[]
serverFolders ServerFolder[]
parentId String? @db.Uuid
parent Folder? @relation("FolderChildren", fields: [parentId], references: [id])
children Folder[] @relation("FolderChildren")
Server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, path], name: "uniqueFolderId")
}
model ServerPermission {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
type ServerPermissionType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [userId, serverId], name: "uniqueServerPermissionsId")
}
model ServerUrl {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
url String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
userServerUrls UserServerUrl[]
@@unique(fields: [serverId, url], name: "uniqueServerUrlId")
}
model UserServerUrl {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
serverUrl ServerUrl @relation(fields: [serverUrlId], references: [id], onDelete: Cascade)
serverUrlId String @db.Uuid
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [userId, serverId], name: "uniqueUserServerUrlId")
}
model ServerFolder {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
remoteId String
enabled Boolean @default(true)
lastScannedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted Boolean @default(false)
albumArtists AlbumArtist[]
artists Artist[]
albums Album[]
songs Song[]
folders Folder[]
serverFolderPermissions ServerFolderPermission[]
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, remoteId], name: "uniqueServerFolderId")
}
model ServerFolderPermission {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.Uuid
serverFolder ServerFolder @relation(fields: [serverFolderId], references: [id], onDelete: Cascade)
serverFolderId String @db.Uuid
@@unique(fields: [userId, serverFolderId], name: "uniqueServerFolderPermissionsId")
}
model Genre {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albumArtists AlbumArtist[]
artists Artist[]
albums Album[]
songs Song[]
}
model AlbumArtistFavorite {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albumArtist AlbumArtist @relation(fields: [albumArtistId], references: [id], onDelete: Cascade)
albumArtistId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
@@id([userId, albumArtistId])
@@unique(fields: [userId, albumArtistId], name: "uniqueAlbumArtistFavoriteId")
}
model ArtistFavorite {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
artistId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
@@id([userId, artistId])
@@unique(fields: [userId, artistId], name: "uniqueArtistFavoriteId")
}
model AlbumFavorite {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
albumId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
@@id([userId, albumId])
@@unique(fields: [userId, albumId], name: "uniqueAlbumFavoriteId")
}
model SongFavorite {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
songId String @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
@@id([userId, songId])
@@unique(fields: [userId, songId], name: "uniqueSongFavoriteId")
}
model AlbumArtistRating {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.Uuid
albumArtist AlbumArtist @relation(fields: [albumArtistId], references: [id])
albumArtistId String @db.Uuid
@@unique(fields: [userId, albumArtistId], name: "uniqueAlbumArtistRatingId")
}
model ArtistRating {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.Uuid
artist Artist @relation(fields: [artistId], references: [id])
artistId String @db.Uuid
@@unique(fields: [userId, artistId], name: "uniqueArtistRatingId")
}
model AlbumRating {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.Uuid
album Album @relation(fields: [albumId], references: [id])
albumId String @db.Uuid
@@unique(fields: [userId, albumId], name: "uniqueAlbumRatingId")
}
model SongRating {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
value Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.Uuid
song Song @relation(fields: [songId], references: [id])
songId String @db.Uuid
@@unique(fields: [userId, songId], name: "uniqueSongRatingId")
}
model Image {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
url String?
remoteUrl String
type ImageType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albumArtists AlbumArtist[]
artists Artist[]
albums Album[]
songs Song[]
@@unique(fields: [remoteUrl, type], name: "uniqueImageId")
}
model External {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
value String
type ExternalType
source ExternalSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albumArtists AlbumArtist[]
artists Artist[]
albums Album[]
songs Song[]
@@unique(fields: [value, source], name: "uniqueExternalId")
}
model AlbumArtist {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
sortName String
biography String?
remoteId String
remoteCreatedAt DateTime?
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
albums Album[]
genres Genre[]
externals External[]
serverFolders ServerFolder[]
ratings AlbumArtistRating[]
images Image[]
songs Song[]
albumArtistFavorites AlbumArtistFavorite[]
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, remoteId], name: "uniqueAlbumArtistId")
}
model Album {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
sortName String
releaseDate DateTime?
releaseYear Int?
remoteId String
remoteCreatedAt DateTime?
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
songs Song[]
genres Genre[]
artists Artist[]
albumArtists AlbumArtist[]
externals External[]
serverFolders ServerFolder[]
ratings AlbumRating[]
images Image[]
favorites AlbumFavorite[]
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, remoteId], name: "uniqueAlbumId")
}
model Artist {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
sortName String
biography String?
remoteId String
remoteCreatedAt DateTime?
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
genres Genre[]
albums Album[]
songs Song[]
externals External[]
serverFolders ServerFolder[]
ratings ArtistRating[]
images Image[]
favorites ArtistFavorite[]
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, remoteId], name: "uniqueArtistId")
}
model Song {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
sortName String
releaseDate DateTime?
releaseYear Int?
duration Float?
size Int?
lyrics String?
bitRate Int?
container String
discNumber Int? @default(1)
trackNumber Int?
artistName String?
remoteId String
remoteCreatedAt DateTime?
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
genres Genre[]
artists Artist[]
externals External[]
folders Folder[]
serverFolders ServerFolder[]
histories History[]
ratings SongRating[]
images Image[]
favorites SongFavorite[]
albumArtist AlbumArtist? @relation(fields: [albumArtistId], references: [id])
albumArtistId String? @db.Uuid
album Album? @relation(fields: [albumId], references: [id])
albumId String? @db.Uuid
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
@@unique(fields: [serverId, remoteId], name: "uniqueSongId")
}
model Task {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
type TaskType
message String?
completed Boolean @default(false)
isError Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
serverId String @db.Uuid
user User? @relation(fields: [userId], references: [id])
userId String? @db.Uuid
}
-42
View File
@@ -1,42 +0,0 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { randomString } from '../utils';
const prisma = new PrismaClient();
async function main() {
const hashedPassword =
'$2y$12$icIH42ono1yTBypZ34V/PuDMXIbMD04GtSB6pgYpcwbjjIvujzv2y';
let error;
do {
try {
await prisma.user.upsert({
create: {
deviceId: `admin_${randomString(10)}`,
enabled: true,
isAdmin: true,
isSuperAdmin: true,
password: hashedPassword,
username: 'admin',
},
update: {},
where: { username: 'admin' },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientInitializationError) {
error = 'retry';
}
error = undefined;
}
} while (error === 'retry');
}
main()
.catch((e) => {
console.error(e);
// process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-2
View File
@@ -1,2 +0,0 @@
export * from './subsonic';
export * from './jellyfin';
-7
View File
@@ -1,7 +0,0 @@
import { jellyfinApi } from './jellyfin.api';
import { jellyfinScanner } from './jellyfin.scanner';
export const jellyfin = {
api: jellyfinApi,
scanner: jellyfinScanner,
};
-117
View File
@@ -1,117 +0,0 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import {
JFAlbumArtistsResponse,
JFAlbumsResponse,
JFArtistsResponse,
JFAuthenticate,
JFCollectionType,
JFGenreResponse,
JFItemType,
JFMusicFoldersResponse,
JFRequestParams,
JFSongsResponse,
} from './jellyfin.types';
export const api = axios.create({});
export const authenticate = async (options: {
password: string;
url: string;
username: string;
}) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const { data } = await api.post<JFAuthenticate>(
`${cleanServerUrl}/users/authenticatebyname`,
{ pw: password, username },
{
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"`,
},
}
);
return data;
};
export const getMusicFolders = async (server: Partial<Server>) => {
const { data } = await api.get<JFMusicFoldersResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{ headers: { 'X-MediaBrowser-Token': server.token! } }
);
const musicFolders = data.Items.filter(
(folder) => folder.CollectionType === JFCollectionType.MUSIC
);
return musicFolders;
};
export const getGenres = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFGenreResponse>(`${server.url}/genres`, {
headers: { 'X-MediaBrowser-Token': server.token },
params,
});
return data;
};
export const getAlbumArtists = async (
server: Server,
params: JFRequestParams
) => {
const { data } = await api.get<JFAlbumArtistsResponse>(
`${server.url}/artists/albumArtists`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params,
}
);
return data;
};
export const getArtists = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFArtistsResponse>(`${server.url}/artists`, {
headers: { 'X-MediaBrowser-Token': server.token },
params,
});
return data;
};
export const getAlbums = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFAlbumsResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
}
);
return data;
};
export const getSongs = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFSongsResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: JFItemType.AUDIO, ...params },
}
);
return data;
};
export const jellyfinApi = {
authenticate,
getAlbumArtists,
getAlbums,
getArtists,
getGenres,
getMusicFolders,
getSongs,
};
-500
View File
@@ -1,500 +0,0 @@
import {
ExternalSource,
Folder,
ImageType,
Server,
ServerFolder,
Task,
} from '@prisma/client';
import uniqBy from 'lodash/uniqBy';
import { prisma } from '../../lib';
import { groupByProperty } from '../../utils';
import { queue } from '../queues';
import { jellyfinApi } from './jellyfin.api';
import { JFExternalType, JFImageType, JFItemType } from './jellyfin.types';
import { jellyfinUtils } from './jellyfin.utils';
const scanGenres = async (options: {
server: Server;
serverFolder: ServerFolder;
task: Task;
}) => {
await prisma.task.update({
data: { message: 'Scanning genres' },
where: { id: options.task.id },
});
const genres = await jellyfinApi.getGenres(options.server, {
parentId: options.serverFolder.remoteId,
});
const genresCreate = genres.Items.map((genre) => {
return { name: genre.Name };
});
await prisma.genre.createMany({
data: genresCreate,
skipDuplicates: true,
});
};
const scanAlbumArtists = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning album artists' },
where: { id: task.id },
});
// TODO: Possibly need to scan without the parentId to get all artists, since Jellyfin may link an album to an artist of a different folder
const albumArtists = await jellyfinApi.getAlbumArtists(server, {
fields: 'Genres,DateCreated,ExternalUrls,Overview',
parentId: serverFolder.remoteId,
});
await jellyfinUtils.insertGenres(albumArtists.Items);
await jellyfinUtils.insertImages(albumArtists.Items);
await jellyfinUtils.insertExternals(albumArtists.Items);
for (const albumArtist of albumArtists.Items) {
const genresConnect = albumArtist.Genres.map((genre) => ({ name: genre }));
const imagesConnectOrCreate = [];
for (const backdrop of albumArtist.BackdropImageTags) {
imagesConnectOrCreate.push({
create: { remoteUrl: backdrop, type: ImageType.BACKDROP },
where: {
uniqueImageId: { remoteUrl: backdrop, type: ImageType.BACKDROP },
},
});
}
for (const [key, value] of Object.entries(albumArtist.ImageTags)) {
if (key === JFImageType.PRIMARY) {
imagesConnectOrCreate.push({
create: { remoteUrl: value, type: ImageType.PRIMARY },
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
},
});
}
if (key === JFImageType.LOGO) {
imagesConnectOrCreate.push({
create: { remoteUrl: value, type: ImageType.LOGO },
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
},
});
}
}
const externalsConnect = albumArtist.ExternalUrls.map((external) => ({
uniqueExternalId: {
source:
external.Name === JFExternalType.MUSICBRAINZ
? ExternalSource.MUSICBRAINZ
: ExternalSource.THEAUDIODB,
value: external.Url.split('/').pop() || '',
},
}));
await prisma.albumArtist.upsert({
create: {
biography: albumArtist.Overview,
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
images: {
connectOrCreate: imagesConnectOrCreate,
},
name: albumArtist.Name,
remoteCreatedAt: albumArtist.DateCreated,
remoteId: albumArtist.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: albumArtist.Name,
},
update: {
biography: albumArtist.Overview,
deleted: false,
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
images: {
connectOrCreate: imagesConnectOrCreate,
},
name: albumArtist.Name,
remoteCreatedAt: albumArtist.DateCreated,
remoteId: albumArtist.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: albumArtist.Name,
},
where: {
uniqueAlbumArtistId: {
remoteId: albumArtist.Id,
serverId: server.id,
},
},
});
}
};
const scanAlbums = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
const check = await jellyfinApi.getAlbums(server, {
enableUserData: false,
includeItemTypes: JFItemType.MUSICALBUM,
limit: 1,
parentId: serverFolder.remoteId,
recursive: true,
});
const albumCount = check.TotalRecordCount;
const chunkSize = 5000;
const albumChunkCount = Math.ceil(albumCount / chunkSize);
await prisma.task.update({
data: { message: 'Scanning albums' },
where: { id: task.id },
});
for (let i = 0; i < albumChunkCount; i += 1) {
const albums = await jellyfinApi.getAlbums(server, {
enableImageTypes: 'Primary,Logo,Backdrop',
enableUserData: false,
fields: 'Genres,DateCreated,ExternalUrls,Overview',
imageTypeLimit: 1,
limit: chunkSize,
parentId: serverFolder.remoteId,
recursive: true,
startIndex: i * chunkSize,
});
await jellyfinUtils.insertGenres(albums.Items);
await jellyfinUtils.insertImages(albums.Items);
await jellyfinUtils.insertExternals(albums.Items);
for (const album of albums.Items) {
const genresConnect = album.Genres.map((genre) => ({ name: genre }));
const imagesConnectOrCreate = [];
for (const [key, value] of Object.entries(album.ImageTags)) {
if (key === JFImageType.PRIMARY) {
imagesConnectOrCreate.push({
create: { remoteUrl: value, type: ImageType.PRIMARY },
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
},
});
}
if (key === JFImageType.LOGO) {
imagesConnectOrCreate.push({
create: { remoteUrl: value, type: ImageType.LOGO },
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
},
});
}
}
const externalsConnect = album.ExternalUrls.map((external) => ({
uniqueExternalId: {
source:
external.Name === JFExternalType.MUSICBRAINZ
? ExternalSource.MUSICBRAINZ
: ExternalSource.THEAUDIODB,
value: external.Url.split('/').pop() || '',
},
}));
const remoteAlbumArtists = album.AlbumArtists;
const albumArtists = await prisma.albumArtist.findMany({
where: {
remoteId: { in: remoteAlbumArtists.map((artist) => artist.Id) },
},
});
const albumArtistsConnect = [];
for (const albumArtist of remoteAlbumArtists) {
const invalid = !albumArtists.find(
(artist) => artist.remoteId === albumArtist.Id
);
if (invalid) {
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
const foundAlternate = await prisma.albumArtist.findFirst({
where: {
name: albumArtist.Name,
serverId: server.id,
},
});
if (foundAlternate) {
albumArtistsConnect.push({
uniqueAlbumArtistId: {
remoteId: foundAlternate.remoteId,
serverId: server.id,
},
});
}
} else {
albumArtistsConnect.push({
uniqueAlbumArtistId: {
remoteId: albumArtist.Id,
serverId: server.id,
},
});
}
}
await prisma.album.upsert({
create: {
albumArtists: { connect: albumArtistsConnect },
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
images: { connectOrCreate: imagesConnectOrCreate },
name: album.Name,
releaseDate: album.PremiereDate,
releaseYear: album.ProductionYear,
remoteCreatedAt: album.DateCreated,
remoteId: album.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.Name,
},
update: {
albumArtists: { connect: albumArtistsConnect },
deleted: false,
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
images: { connectOrCreate: imagesConnectOrCreate },
name: album.Name,
releaseDate: album.PremiereDate,
releaseYear: album.ProductionYear,
remoteCreatedAt: album.DateCreated,
remoteId: album.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.Name,
},
where: {
uniqueAlbumId: {
remoteId: album.Id,
serverId: server.id,
},
},
});
}
}
};
const scanSongs = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
const check = await jellyfinApi.getSongs(server, {
enableUserData: false,
limit: 0,
parentId: serverFolder.remoteId,
recursive: true,
});
const songCount = check.TotalRecordCount;
const chunkSize = 5000;
const songChunkCount = Math.ceil(songCount / chunkSize);
await prisma.task.update({
data: { message: 'Scanning songs' },
where: { id: task.id },
});
for (let i = 0; i < songChunkCount; i += 1) {
const songs = await jellyfinApi.getSongs(server, {
enableImageTypes: 'Primary,Logo,Backdrop',
enableUserData: false,
fields: 'Genres,DateCreated,ExternalUrls,MediaSources,SortName',
imageTypeLimit: 1,
limit: chunkSize,
parentId: serverFolder.remoteId,
recursive: true,
sortBy: 'DateCreated,Album',
sortOrder: 'Descending',
startIndex: i * chunkSize,
});
const folderGroups = songs.Items.map((song) => {
const songPaths = song.MediaSources[0].Path.split('/');
const paths = [];
for (let b = 0; b < songPaths.length - 1; b += 1) {
paths.push({
name: songPaths[b],
path: songPaths.slice(0, b + 1).join('/'),
});
}
return paths;
});
const uniqueFolders = uniqBy(
folderGroups.flatMap((folder) => folder).filter((f) => f.path !== ''),
'path'
);
const createdFolders: Folder[] = [];
for (const folder of uniqueFolders) {
const createdFolder = await prisma.folder.upsert({
create: {
name: folder.name,
path: folder.path,
serverFolders: {
connect: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: server.id,
},
},
},
serverId: server.id,
},
update: {
name: folder.name,
path: folder.path,
serverFolders: {
connect: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: server.id,
},
},
},
},
where: {
uniqueFolderId: {
path: folder.path,
serverId: server.id,
},
},
});
createdFolders.push(createdFolder);
}
for (const folder of createdFolders) {
if (folder.parentId) break;
const pathSplit = folder.path.split('/');
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
const parentPathData = createdFolders.find(
(save) => save.path === parentPath
);
if (parentPathData) {
await prisma.folder.update({
data: {
parentId: parentPathData.id,
},
where: { id: folder.id },
});
}
}
await jellyfinUtils.insertArtists(server, serverFolder, songs.Items);
await jellyfinUtils.insertImages(songs.Items);
await jellyfinUtils.insertExternals(songs.Items);
const albumSongGroups = groupByProperty(songs.Items, 'AlbumId');
const keys = Object.keys(albumSongGroups);
for (const key of keys) {
const songGroup = albumSongGroups[key];
await jellyfinUtils.insertSongGroup(server, serverFolder, songGroup, key);
}
}
};
const checkDeleted = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.$transaction([
prisma.albumArtist.updateMany({
data: { deleted: true },
where: {
serverFolders: { some: { id: serverFolder.id } },
serverId: server.id,
updatedAt: { lte: task.createdAt },
},
}),
prisma.artist.updateMany({
data: { deleted: true },
where: {
serverFolders: { some: { id: serverFolder.id } },
serverId: server.id,
updatedAt: { lte: task.createdAt },
},
}),
prisma.album.updateMany({
data: { deleted: true },
where: {
serverFolders: { some: { id: serverFolder.id } },
serverId: server.id,
updatedAt: { lte: task.createdAt },
},
}),
prisma.song.updateMany({
data: { deleted: true },
where: {
serverFolders: { some: { id: serverFolder.id } },
serverId: server.id,
updatedAt: { lte: task.createdAt },
},
}),
]);
};
const scanAll = async (
server: Server,
serverFolders: ServerFolder[],
task: Task
) => {
queue.scanner.push({
fn: async () => {
await prisma.task.update({
data: { message: 'Beginning scan...' },
where: { id: task.id },
});
for (const serverFolder of serverFolders) {
await scanGenres({ server, serverFolder, task });
await scanAlbumArtists(server, serverFolder, task);
await scanAlbums(server, serverFolder, task);
await scanSongs(server, serverFolder, task);
await checkDeleted(server, serverFolder, task);
await prisma.serverFolder.update({
data: { lastScannedAt: new Date() },
where: { id: serverFolder.id },
});
}
return { task };
},
id: task.id,
});
};
export const jellyfinScanner = {
scanAlbumArtists,
scanAlbums,
scanAll,
scanGenres,
scanSongs,
};
-304
View File
@@ -1,304 +0,0 @@
import { prisma } from '@lib/prisma';
import {
ExternalSource,
ExternalType,
ImageType,
Prisma,
Server,
ServerFolder,
} from '@prisma/client';
import uniqBy from 'lodash/uniqBy';
import { uniqueArray } from '../../utils/unique-array';
import {
JFAlbum,
JFAlbumArtist,
JFExternalType,
JFImageType,
JFSong,
} from './jellyfin.types';
const insertGenres = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
const genresCreateMany = items
.flatMap((item) => item.GenreItems)
.map((genre) => ({ name: genre.Name }));
await prisma.genre.createMany({
data: genresCreateMany,
skipDuplicates: true,
});
};
const insertArtists = async (
server: Server,
serverFolder: ServerFolder,
items: JFSong[] | JFAlbum[]
) => {
const artistItems = uniqBy(
items.flatMap((item) => item.ArtistItems),
'Id'
);
const createMany = artistItems.map((artist) => ({
name: artist.Name,
remoteId: artist.Id,
serverId: server.id,
sortName: artist.Name,
}));
await prisma.artist.createMany({
data: createMany,
skipDuplicates: true,
});
for (const artist of artistItems) {
await prisma.artist.update({
data: { serverFolders: { connect: { id: serverFolder.id } } },
where: {
uniqueArtistId: {
remoteId: artist.Id,
serverId: server.id,
},
},
});
}
};
const insertImages = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
const imageItems = uniqBy(
items.flatMap((item) => item.ImageTags),
'Id'
);
const createMany: Prisma.ImageCreateManyInput[] = [];
for (const image of imageItems) {
if (image.Logo) {
createMany.push({
remoteUrl: image.Logo,
type: ImageType.LOGO,
});
}
if (image.Primary) {
createMany.push({
remoteUrl: image.Primary,
type: ImageType.PRIMARY,
});
}
}
await prisma.image.createMany({
data: createMany,
skipDuplicates: true,
});
};
const insertExternals = async (
items: JFSong[] | JFAlbum[] | JFAlbumArtist[]
) => {
const externalItems = uniqBy(
items.flatMap((item) => item.ExternalUrls),
'Url'
);
const createMany: Prisma.ExternalCreateManyInput[] = [];
for (const external of externalItems) {
if (
external.Name === JFExternalType.MUSICBRAINZ ||
external.Name === JFExternalType.THEAUDIODB
) {
const source =
external.Name === JFExternalType.MUSICBRAINZ
? ExternalSource.MUSICBRAINZ
: ExternalSource.THEAUDIODB;
const value = external.Url.split('/').pop() || '';
createMany.push({ source, type: ExternalType.ID, value });
}
}
await prisma.external.createMany({
data: createMany,
skipDuplicates: true,
});
};
const insertSongGroup = async (
server: Server,
serverFolder: ServerFolder,
songs: JFSong[],
remoteAlbumId: string
) => {
const remoteAlbumArtist =
songs[0].AlbumArtists.length > 0 ? songs[0].AlbumArtists[0] : undefined;
let albumArtist = remoteAlbumArtist?.Id
? await prisma.albumArtist.findUnique({
where: {
uniqueAlbumArtistId: {
remoteId: remoteAlbumArtist.Id,
serverId: server.id,
},
},
})
: undefined;
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
if (remoteAlbumArtist && !albumArtist) {
albumArtist = await prisma.albumArtist.findFirst({
where: {
name: remoteAlbumArtist?.Name,
serverId: server.id,
},
});
}
const albumArtistId = albumArtist ? albumArtist.id : undefined;
const songsUpsert: Prisma.SongUpsertWithWhereUniqueWithoutAlbumInput[] =
songs.map((song) => {
const genresConnect = song.Genres.map((genre) => ({ name: genre }));
const artistsConnect = song.ArtistItems.map((artist) => ({
uniqueArtistId: {
remoteId: artist.Id,
serverId: server.id,
},
}));
const externalsConnect = song.ExternalUrls.map((external) => ({
uniqueExternalId: {
source:
external.Name === JFExternalType.MUSICBRAINZ
? ExternalSource.MUSICBRAINZ
: ExternalSource.THEAUDIODB,
value: external.Url.split('/').pop() || '',
},
}));
const imagesConnectOrCreate = [];
for (const [key, value] of Object.entries(song.ImageTags)) {
if (key === JFImageType.PRIMARY) {
imagesConnectOrCreate.push({
create: {
remoteUrl: value,
type: ImageType.PRIMARY,
},
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
},
});
}
if (key === JFImageType.LOGO) {
imagesConnectOrCreate.push({
create: {
remoteUrl: value,
type: ImageType.LOGO,
},
where: {
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
},
});
}
}
const pathSplit = song.MediaSources[0].Path.split('/');
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
return {
create: {
albumArtistId,
artists: { connect: artistsConnect },
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
container: song.MediaSources[0].Container,
deleted: false,
discNumber: song.ParentIndexNumber,
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
externals: { connect: externalsConnect },
folders: {
connect: {
uniqueFolderId: { path: parentPath, serverId: server.id },
},
},
genres: { connect: genresConnect },
images: { connectOrCreate: imagesConnectOrCreate },
name: song.Name,
releaseDate: song.PremiereDate,
releaseYear: song.ProductionYear,
remoteCreatedAt: song.DateCreated,
remoteId: song.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
size: song.MediaSources[0].Size,
sortName: song.Name,
trackNumber: song.IndexNumber,
},
update: {
albumArtistId,
artists: { connect: artistsConnect },
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
container: song.MediaSources[0].Container,
deleted: false,
discNumber: song.ParentIndexNumber,
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
externals: { connect: externalsConnect },
folders: {
connect: {
uniqueFolderId: { path: parentPath, serverId: server.id },
},
},
genres: { connect: genresConnect },
images: { connectOrCreate: imagesConnectOrCreate },
name: song.Name,
releaseDate: song.PremiereDate,
releaseYear: song.ProductionYear,
remoteCreatedAt: song.DateCreated,
remoteId: song.Id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
size: song.MediaSources[0].Size,
sortName: song.Name,
trackNumber: song.IndexNumber,
},
where: {
uniqueSongId: {
remoteId: song.Id,
serverId: server.id,
},
},
};
});
const uniqueArtistIds = songs
.flatMap((song) => song.ArtistItems.flatMap((artist) => artist.Id))
.filter(uniqueArray);
const artistsConnect = uniqueArtistIds.map((artistId) => ({
uniqueArtistId: {
remoteId: artistId,
serverId: server.id,
},
}));
await prisma.album.update({
data: {
artists: { connect: artistsConnect },
deleted: false,
songs: { upsert: songsUpsert },
},
where: {
uniqueAlbumId: {
remoteId: remoteAlbumId,
serverId: server.id,
},
},
});
};
export const jellyfinUtils = {
insertArtists,
insertExternals,
insertGenres,
insertImages,
insertSongGroup,
};
-7
View File
@@ -1,7 +0,0 @@
import { navidromeApi } from './navidrome.api';
import { navidromeScanner } from './navidrome.scanner';
export const navidrome = {
api: navidromeApi,
scanner: navidromeScanner,
};
-87
View File
@@ -1,87 +0,0 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import {
NDAlbumListResponse,
NDGenreListResponse,
NDAlbumListParams,
NDGenreListParams,
NDSongListParams,
NDSongListResponse,
NDArtistListResponse,
NDAuthenticate,
} from './navidrome.types';
const api = axios.create();
const authenticate = async (options: {
password: string;
url: string;
username: string;
}) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const { data } = await api.post<NDAuthenticate>(
`${cleanServerUrl}/auth/login`,
{ password, username }
);
return data;
};
const getGenres = async (server: Server, params?: NDGenreListParams) => {
const [ndToken] = server.token.split('||');
const { data } = await api.get<NDGenreListResponse>(
`${server.url}/api/genre`,
{
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
params,
}
);
return data;
};
const getArtists = async (server: Server, params?: NDGenreListParams) => {
const [ndToken] = server.token.split('||');
const { data } = await api.get<NDArtistListResponse>(
`${server.url}/api/artist`,
{
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
params,
}
);
return data;
};
const getAlbums = async (server: Server, params?: NDAlbumListParams) => {
const [ndToken] = server.token.split('||');
const { data } = await api.get<NDAlbumListResponse>(
`${server.url}/api/album`,
{
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
params,
}
);
return data;
};
const getSongs = async (server: Server, params?: NDSongListParams) => {
const [ndToken] = server.token.split('||');
const { data } = await api.get<NDSongListResponse>(`${server.url}/api/song`, {
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
params,
});
return data;
};
export const navidromeApi = {
authenticate,
getAlbums,
getArtists,
getGenres,
getSongs,
};
-412
View File
@@ -1,412 +0,0 @@
/* eslint-disable no-await-in-loop */
import {
ExternalSource,
ExternalType,
Folder,
ImageType,
Server,
ServerFolder,
Task,
} from '@prisma/client';
import uniqBy from 'lodash/uniqBy';
import { prisma } from '@lib/prisma';
import { groupByProperty } from '@utils/group-by-property';
import { queue } from '../queues/index';
import { navidromeApi } from './navidrome.api';
import { navidromeUtils } from './navidrome.utils';
const CHUNK_SIZE = 5000;
export const scanGenres = async (server: Server, task: Task) => {
await prisma.task.update({
data: { message: 'Scanning genres' },
where: { id: task.id },
});
const res = await navidromeApi.getGenres(server);
const genres = res.map((genre) => {
return { name: genre.name };
});
await prisma.genre.createMany({
data: genres,
skipDuplicates: true,
});
};
export const scanAlbumArtists = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning artists' },
where: { id: task.id },
});
const artists = await navidromeApi.getArtists(server);
const externalsCreateMany = artists
.filter((artist) => artist.mbzArtistId)
.map((artist) => ({
source: ExternalSource.MUSICBRAINZ,
type: ExternalType.ID,
value: artist.mbzArtistId,
}));
await prisma.external.createMany({
data: externalsCreateMany,
skipDuplicates: true,
});
for (const artist of artists) {
const genresConnect = artist.genres
? artist.genres.map((genre) => ({ name: genre.name }))
: undefined;
const externalsConnect = artist.mbzArtistId
? {
uniqueExternalId: {
source: ExternalSource.MUSICBRAINZ,
value: artist.mbzArtistId,
},
}
: undefined;
await prisma.albumArtist.upsert({
create: {
deleted: false,
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
name: artist.name,
remoteId: artist.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: artist.name,
},
update: {
deleted: false,
externals: { connect: externalsConnect },
genres: { connect: genresConnect },
name: artist.name,
remoteId: artist.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: artist.name,
},
where: {
uniqueAlbumArtistId: {
remoteId: artist.id,
serverId: server.id,
},
},
});
}
};
export const scanAlbums = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning albums' },
where: { id: task.id },
});
let start = 0;
let count = 5000;
do {
const albums = await navidromeApi.getAlbums(server, {
_end: start + CHUNK_SIZE,
_start: start,
});
const imagesCreateMany = albums
.filter((album) => album.coverArtId)
.map((album) => ({
remoteUrl: album.coverArtId,
type: ImageType.PRIMARY,
}));
await prisma.image.createMany({
data: imagesCreateMany,
skipDuplicates: true,
});
const artistIds = (
await prisma.artist.findMany({
select: { remoteId: true },
where: { serverId: server.id },
})
).map((artist) => artist.remoteId);
for (const album of albums) {
const imagesConnect = album.coverArtId
? {
uniqueImageId: {
remoteUrl: album.coverArtId,
type: ImageType.PRIMARY,
},
}
: undefined;
const genresConnect = album.genres
? album.genres.map((genre) => ({ name: genre.name }))
: undefined;
const validArtistIds = [];
const ndArtistIds = album.allArtistIds.split(' ');
for (const artistId of ndArtistIds) {
if (artistIds.includes(artistId)) {
validArtistIds.push(artistId);
}
}
// const artistsConnect = validArtistIds.map((id) => ({
// uniqueArtistId: {
// remoteId: id,
// serverId: server.id,
// },
// }));
const aaConnect = [];
const albumArtistConnect = album.albumArtistId
? {
uniqueAlbumArtistId: {
remoteId: album.albumArtistId,
serverId: server.id,
},
}
: undefined;
aaConnect.push(
...validArtistIds.map((id) => ({
uniqueAlbumArtistId: {
remoteId: id,
serverId: server.id,
},
}))
);
albumArtistConnect && aaConnect.push(albumArtistConnect);
const year = album.minYear === 0 ? null : album.minYear;
await prisma.album.upsert({
create: {
albumArtists: { connect: aaConnect },
// artists: { connect: artistsConnect },
deleted: false,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: album.name,
releaseDate: year ? new Date(year, 0).toISOString() : undefined,
releaseYear: year,
remoteCreatedAt: album.createdAt,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.name,
},
update: {
albumArtists: { connect: aaConnect },
// artists: { connect: artistsConnect },
deleted: false,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: album.name,
releaseDate: year ? new Date(year, 0).toISOString() : null,
releaseYear: year,
remoteCreatedAt: album.createdAt,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.name,
},
where: {
uniqueAlbumId: {
remoteId: album.id,
serverId: server.id,
},
},
});
}
start += CHUNK_SIZE;
count = albums.length;
} while (count === CHUNK_SIZE);
};
const scanSongs = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning songs' },
where: { id: task.id },
});
let start = 0;
let count = 5000;
do {
const songs = await navidromeApi.getSongs(server, {
_end: start + CHUNK_SIZE,
_start: start,
});
const externalsCreateMany = [];
const genresCreateMany = [];
for (const song of songs) {
if (song.mbzTrackId) {
externalsCreateMany.push({
source: ExternalSource.MUSICBRAINZ,
type: ExternalType.ID,
value: song.mbzTrackId,
});
}
if (song.genres?.length > 0) {
genresCreateMany.push(
...song.genres.map((genre) => ({ name: genre.name }))
);
}
}
await prisma.external.createMany({
data: externalsCreateMany,
skipDuplicates: true,
});
await prisma.genre.createMany({
data: genresCreateMany,
skipDuplicates: true,
});
const folderGroups = songs.map((song) => {
const songPaths = song.path.split('/');
const paths = [];
for (let b = 0; b < songPaths.length - 1; b += 1) {
paths.push({
name: songPaths[b],
path: songPaths.slice(0, b + 1).join('/'),
});
}
return paths;
});
const uniqueFolders = uniqBy(
folderGroups.flatMap((folder) => folder).filter((f) => f.path !== ''),
'path'
);
const createdFolders: Folder[] = [];
for (const folder of uniqueFolders) {
const createdFolder = await prisma.folder.upsert({
create: {
name: folder.name,
path: folder.path,
serverFolders: {
connect: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: server.id,
},
},
},
serverId: server.id,
},
update: {
name: folder.name,
path: folder.path,
serverFolders: {
connect: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: server.id,
},
},
},
},
where: {
uniqueFolderId: {
path: folder.path,
serverId: server.id,
},
},
});
createdFolders.push(createdFolder);
}
for (const folder of createdFolders) {
if (folder?.parentId || !folder) break;
const pathSplit = folder.path.split('/');
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
const parentPathData = createdFolders.find(
(save) => save.path === parentPath
);
if (parentPathData) {
await prisma.folder.update({
data: {
parentId: parentPathData.id,
},
where: { id: folder.id },
});
}
}
const albumSongGroups = groupByProperty(songs, 'albumId');
const albumIds = Object.keys(albumSongGroups);
for (const id of albumIds) {
const songGroup = albumSongGroups[id];
await navidromeUtils.insertSongGroup(server, serverFolder, songGroup, id);
}
start += CHUNK_SIZE;
count = songs.length;
} while (count === CHUNK_SIZE);
};
const scanAll = async (
server: Server,
serverFolders: ServerFolder[],
task: Task
) => {
queue.scanner.push({
fn: async () => {
await prisma.task.update({
data: { message: 'Beginning scan...' },
where: { id: task.id },
});
for (const serverFolder of serverFolders) {
await scanGenres(server, task);
await scanAlbumArtists(server, serverFolder, task);
await scanAlbums(server, serverFolder, task);
await scanSongs(server, serverFolder, task);
await prisma.serverFolder.update({
data: { lastScannedAt: new Date() },
where: { id: serverFolder.id },
});
}
return { task };
},
id: task.id,
});
};
export const navidromeScanner = {
scanAll,
scanGenres,
};
-169
View File
@@ -1,169 +0,0 @@
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDGenre = {
id: string;
name: string;
};
export type NDAlbum = {
albumArtist: string;
albumArtistId: string;
allArtistIds: string;
artist: string;
artistId: string;
compilation: boolean;
coverArtId: string;
coverArtPath: string;
createdAt: string;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
id: string;
maxYear: number;
mbzAlbumArtistId: string;
mbzAlbumId: string;
minYear: number;
name: string;
orderAlbumArtistName: string;
orderAlbumName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
songCount: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
updatedAt: string;
};
export type NDSong = {
album: string;
albumArtist: string;
albumArtistId: string;
albumId: string;
artist: string;
artistId: string;
bitRate: number;
bookmarkPosition: number;
channels: number;
compilation: boolean;
createdAt: string;
discNumber: number;
duration: number;
fullText: string;
genre: string;
genres: NDGenre[];
hasCoverArt: boolean;
id: string;
mbzAlbumArtistId: string;
mbzAlbumId: string;
mbzArtistId: string;
mbzTrackId: string;
orderAlbumArtistName: string;
orderAlbumName: string;
orderArtistName: string;
orderTitle: string;
path: string;
playCount: number;
playDate: string;
rating: number;
size: number;
sortAlbumArtistName: string;
sortArtistName: string;
starred: boolean;
starredAt: string;
suffix: string;
title: string;
trackNumber: number;
updatedAt: string;
year: number;
};
export type NDArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl: string;
mbzArtistId: string;
mediumImageUrl: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl: string;
songCount: number;
starred: boolean;
starredAt: string;
};
export type NDGenreListResponse = NDGenre[];
export type NDAlbumListResponse = NDAlbum[];
export type NDSongListResponse = NDSong[];
export type NDArtistListResponse = NDArtist[];
export type NDPagination = {
_end?: number;
_start?: number;
};
export type NDOrder = {
_order?: 'ASC' | 'DESC';
};
export enum NDGenreSort {
NAME = 'name',
}
export type NDGenreListParams = {
_sort?: NDGenreSort;
id?: string;
} & NDPagination &
NDOrder;
export enum NDAlbumSort {
ARTIST = 'artist',
MAX_YEAR = 'max_year',
NAME = 'name',
RANDOM = 'random',
RECENTLY_ADDED = 'recently_added',
}
export type NDAlbumListParams = {
_sort?: NDAlbumSort;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
} & NDPagination &
NDOrder;
export type NDSongListParams = {
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
-123
View File
@@ -1,123 +0,0 @@
import { ExternalSource, Server, ServerFolder } from '@prisma/client';
import { prisma } from '@lib/prisma';
import { NDSong } from './navidrome.types';
const insertSongGroup = async (
server: Server,
serverFolder: ServerFolder,
songs: NDSong[],
remoteAlbumId: string
) => {
const songsWithArtistIds = songs.filter((song) => song.artistId);
const artistId =
songsWithArtistIds.length > 0 ? songsWithArtistIds[0].artistId : undefined;
const albumArtist = artistId
? await prisma.albumArtist.findUnique({
where: {
uniqueAlbumArtistId: {
remoteId: artistId,
serverId: server.id,
},
},
})
: undefined;
const songsUpsert = songs.map((song) => {
const genresConnect = song.genres
? song.genres.map((genre) => ({ name: genre.name }))
: undefined;
const externalsConnect = song.mbzTrackId
? {
uniqueExternalId: {
source: ExternalSource.MUSICBRAINZ,
value: song.mbzTrackId,
},
}
: undefined;
const pathSplit = song.path.split('/');
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
const year = song.year === 0 ? null : song.year;
return {
create: {
albumArtistId: albumArtist?.id,
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate,
container: song.suffix,
deleted: false,
discNumber: song.discNumber,
duration: song.duration,
externals: { connect: externalsConnect },
folders: {
connect: {
uniqueFolderId: { path: parentPath, serverId: server.id },
},
},
genres: { connect: genresConnect },
name: song.title,
releaseDate: year ? new Date(year, 0).toISOString() : undefined,
releaseYear: year,
remoteCreatedAt: song.createdAt,
remoteId: song.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
size: song.size,
sortName: song.title,
trackNumber: song.trackNumber,
},
update: {
albumArtistId: albumArtist?.id,
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate,
container: song.suffix,
deleted: false,
discNumber: song.discNumber,
duration: song.duration,
externals: { connect: externalsConnect },
folders: {
connect: {
uniqueFolderId: { path: parentPath, serverId: server.id },
},
},
genres: { connect: genresConnect },
name: song.title,
releaseDate: year ? new Date(song.year, 0).toISOString() : undefined,
releaseYear: year,
remoteCreatedAt: song.createdAt,
remoteId: song.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
size: song.size,
sortName: song.title,
trackNumber: song.trackNumber,
},
where: {
uniqueSongId: {
remoteId: song.id,
serverId: server.id,
},
},
};
});
await prisma.album.update({
data: {
deleted: false,
songs: { upsert: songsUpsert },
},
where: {
uniqueAlbumId: {
remoteId: remoteAlbumId,
serverId: server.id,
},
},
});
};
export const navidromeUtils = {
insertSongGroup,
};
-5
View File
@@ -1,5 +0,0 @@
import { scannerQueue } from './scanner.queue';
export const queue = {
scanner: scannerQueue,
};
-51
View File
@@ -1,51 +0,0 @@
import { Task } from '@prisma/client';
import Queue from 'better-queue';
import { prisma } from '../../lib';
interface QueueTask {
fn: any;
id: string;
task: Task;
}
export const scannerQueue: Queue | any = new Queue(
async (task: QueueTask, cb: any) => {
const result = await task.fn();
return cb(null, result);
},
{
afterProcessDelay: 1000,
cancelIfRunning: true,
concurrent: 1,
filo: false,
}
);
scannerQueue.on('task_finish', async (taskId: string) => {
await prisma.task.update({
data: {
completed: true,
isError: false,
},
where: { id: taskId },
});
});
scannerQueue.on('task_failed', async (taskId: string, errorMessage: string) => {
console.log('errorMessage', errorMessage);
await prisma.task.update({
data: {
completed: true,
isError: true,
message: errorMessage,
},
where: { id: taskId },
});
});
scannerQueue.on('drain', async () => {
await prisma.task.updateMany({
data: { completed: true },
where: { completed: false },
});
});
-7
View File
@@ -1,7 +0,0 @@
import { subsonicApi } from './subsonic.api';
import { subsonicScanner } from './subsonic.scanner';
export const subsonic = {
api: subsonicApi,
scanner: subsonicScanner,
};
-157
View File
@@ -1,157 +0,0 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import md5 from 'md5';
import { randomString } from '../../utils/random-string';
import {
SSAlbumListEntry,
SSAlbumListResponse,
SSAlbumResponse,
SSAlbumsParams,
SSArtistIndex,
SSArtistInfoResponse,
SSArtistsResponse,
SSGenresResponse,
SSMusicFoldersResponse,
} from './subsonic.types';
const api = axios.create({
validateStatus: (status) => status >= 200,
});
api.interceptors.response.use(
(res: any) => {
res.data = res.data['subsonic-response'];
return res;
},
(err: any) => {
return Promise.reject(err);
}
);
const authenticate = async (options: {
legacy?: boolean;
password: string;
url: string;
username: string;
}) => {
let token;
const cleanServerUrl = options.url.replace(/\/$/, '');
if (options.legacy) {
token = `u=${options.username}&p=${options.password}`;
} else {
const salt = randomString(12);
const hash = md5(options.password + salt);
token = `u=${options.username}&s=${salt}&t=${hash}`;
}
const { data } = await api.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`
);
return { token, ...data };
};
const getMusicFolders = async (server: Partial<Server>) => {
const { data } = await api.get<SSMusicFoldersResponse>(
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=Feishin&f=json&${server.token}`
);
return data.musicFolders.musicFolder;
};
const getArtists = async (server: Server, musicFolderId: string) => {
const { data } = await api.get<SSArtistsResponse>(
`${server.url}/rest/getArtists.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { musicFolderId } }
);
const artists = (data.artists?.index || []).flatMap(
(index: SSArtistIndex) => index.artist
);
return artists;
};
const getGenres = async (server: Server) => {
const { data: genres } = await api.get<SSGenresResponse>(
`${server.url}/rest/getGenres.view?v=1.13.0&c=Feishin&f=json&${server.token}`
);
return genres;
};
const getAlbum = async (server: Server, id: string) => {
const { data: album } = await api.get<SSAlbumResponse>(
`${server.url}/rest/getAlbum.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } }
);
return album;
};
const getAlbums = async (
server: Server,
params: SSAlbumsParams,
recursiveData: any[] = []
) => {
const albums: any = api
.get<SSAlbumListResponse>(
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params }
)
.then((res) => {
if (
!res.data.albumList2?.album ||
res.data.albumList2?.album?.length === 0
) {
// Flatten and return once there are no more albums left
return recursiveData.flatMap((album) => album);
}
// On every iteration, push the existing combined album array and increase the offset
recursiveData.push(res.data.albumList2.album);
return getAlbums(
server,
{
musicFolderId: params.musicFolderId,
offset: (params.offset || 0) + (params.size || 0),
size: params.size,
type: 'newest',
},
recursiveData
);
})
.catch((err) => console.log(err));
return albums as SSAlbumListEntry[];
};
const getArtistInfo = async (server: Server, id: string) => {
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } }
);
return {
...artistInfo,
artistInfo2: {
...artistInfo.artistInfo2,
biography: artistInfo.artistInfo2.biography
.replaceAll(/<a target.*<\/a>/gm, '')
.replace('Biography not available', ''),
},
};
};
export const subsonicApi = {
authenticate,
getAlbum,
getAlbums,
getArtistInfo,
getArtists,
getGenres,
getMusicFolders,
};
-319
View File
@@ -1,319 +0,0 @@
/* eslint-disable no-await-in-loop */
import { ImageType, Server, ServerFolder, Task } from '@prisma/client';
import { prisma, throttle } from '../../lib/index';
import { uniqueArray } from '../../utils/index';
import { queue } from '../queues';
import { subsonicApi } from './subsonic.api';
import { subsonicUtils } from './subsonic.utils';
export const scanGenres = async (server: Server, task: Task) => {
await prisma.task.update({
data: { message: 'Scanning genres' },
where: { id: task.id },
});
const res = await subsonicApi.getGenres(server);
const genres = res.genres.genre.map((genre) => {
return { name: genre.value };
});
await prisma.genre.createMany({
data: genres,
skipDuplicates: true,
});
};
export const scanAlbumArtists = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning artists' },
where: { id: task.id },
});
const artists = await subsonicApi.getArtists(server, serverFolder.remoteId);
for (const artist of artists) {
await prisma.albumArtist.upsert({
create: {
name: artist.name,
remoteId: artist.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: artist.name,
},
update: {
name: artist.name,
remoteId: artist.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: artist.name,
},
where: {
uniqueAlbumArtistId: {
remoteId: artist.id,
serverId: server.id,
},
},
});
}
};
export const scanAlbums = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning albums' },
where: { id: task.id },
});
const albums = await subsonicApi.getAlbums(server, {
musicFolderId: serverFolder.remoteId,
offset: 0,
size: 500,
type: 'newest',
});
await subsonicUtils.insertImages(albums);
for (const album of albums) {
const imagesConnect = album.coverArt
? {
uniqueImageId: {
remoteUrl: album.coverArt,
type: ImageType.PRIMARY,
},
}
: undefined;
const albumArtistConnect = album.artistId
? {
uniqueAlbumArtistId: {
remoteId: album.artistId,
serverId: server.id,
},
}
: undefined;
await prisma.album.upsert({
create: {
albumArtists: { connect: albumArtistConnect },
deleted: false,
genres: { connect: album.genre ? { name: album.genre } : undefined },
images: { connect: imagesConnect },
name: album.name,
releaseDate: album?.year
? new Date(Number(String(album.year).slice(4)), 0).toISOString()
: undefined,
releaseYear: album.year,
remoteCreatedAt: album.created,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.name,
},
update: {
albumArtists: { connect: albumArtistConnect },
deleted: false,
genres: { connect: album.genre ? { name: album.genre } : undefined },
images: { connect: imagesConnect },
name: album.name,
releaseDate: album?.year
? new Date(Number(String(album.year).slice(4)), 0).toISOString()
: undefined,
releaseYear: album.year,
remoteCreatedAt: album.created,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.name,
},
where: {
uniqueAlbumId: {
remoteId: album.id,
serverId: server.id,
},
},
});
}
};
const throttledAlbumFetch = throttle(
async (server: Server, serverFolder: ServerFolder, album: any) => {
const albumRes = await subsonicApi.getAlbum(server, album.remoteId);
if (albumRes) {
await subsonicUtils.insertSongImages(albumRes);
const songsUpsert = albumRes.album.song.map((song) => {
const genresConnect = song.genre ? { name: song.genre } : undefined;
const imagesConnect = song.coverArt
? {
uniqueImageId: {
remoteUrl: song.coverArt,
type: ImageType.PRIMARY,
},
}
: undefined;
const albumArtistsConnect = song.artistId
? {
uniqueAlbumArtistId: {
remoteId: song.artistId,
serverId: server.id,
},
}
: undefined;
return {
create: {
// albumArtistId: song.artistId ? song.artistId : undefined,
albumArtist: { connect: albumArtistsConnect },
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate ? song.bitRate : undefined,
container: song.suffix,
deleted: false,
discNumber: song.discNumber,
duration: song.duration,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: song.title,
releaseDate: song?.year
? new Date(Number(String(song.year).slice(4)), 0).toISOString()
: undefined,
releaseYear: song.year,
remoteCreatedAt: song.created,
remoteId: song.id,
server: { connect: { id: server.id } },
serverFolders: { connect: { id: serverFolder.id } },
// serverId: server.id,
size: song.size,
sortName: song.title,
trackNumber: song.track,
},
update: {
albumArtist: { connect: albumArtistsConnect },
// albumArtistId: song.artistId ? song.artistId : undefined,
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate ? song.bitRate : undefined,
container: song.suffix,
deleted: false,
discNumber: song.discNumber,
duration: song.duration,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: song.title,
releaseDate: song?.year
? new Date(Number(String(song.year).slice(4)), 0).toISOString()
: undefined,
releaseYear: song.year,
remoteCreatedAt: song.created,
remoteId: song.id,
server: { connect: { id: server.id } },
serverFolders: { connect: { id: serverFolder.id } },
// serverId: server.id,
size: song.size,
sortName: song.title,
trackNumber: song.track,
},
where: {
uniqueSongId: {
remoteId: song.id,
serverId: server.id,
},
},
};
});
const uniqueArtistIds = albumRes.album.song
.map((song) => song.artistId)
.filter(uniqueArray);
const artistsConnect = uniqueArtistIds.map((artistId) => {
return {
uniqueAlbumArtistId: {
remoteId: artistId!,
serverId: server.id,
},
};
});
await prisma.album.update({
data: {
// albumArtists: { connect: artistsConnect },
songs: { upsert: songsUpsert },
},
where: {
uniqueAlbumId: {
remoteId: album.remoteId,
serverId: server.id,
},
},
});
}
}
);
export const scanAlbumDetail = async (
server: Server,
serverFolder: ServerFolder,
task: Task
) => {
await prisma.task.update({
data: { message: 'Scanning songs' },
where: { id: task.id },
});
const promises = [];
const dbAlbums = await prisma.album.findMany({
where: {
serverId: server.id,
},
});
for (let i = 0; i < dbAlbums.length; i += 1) {
await throttledAlbumFetch(server, serverFolder, dbAlbums[i]);
}
};
const scanAll = async (
server: Server,
serverFolders: ServerFolder[],
task: Task
) => {
queue.scanner.push({
fn: async () => {
await prisma.task.update({
data: { message: 'Beginning scan...' },
where: { id: task.id },
});
for (const serverFolder of serverFolders) {
await scanGenres(server, task);
await scanAlbumArtists(server, serverFolder, task);
await scanAlbums(server, serverFolder, task);
await scanAlbumDetail(server, serverFolder, task);
await prisma.serverFolder.update({
data: { lastScannedAt: new Date() },
where: { id: serverFolder.id },
});
}
return { task };
},
id: task.id,
});
};
export const subsonicScanner = {
scanAll,
scanGenres,
};
-139
View File
@@ -1,139 +0,0 @@
export interface SSBaseResponse {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
}
export interface SSMusicFoldersResponse extends SSBaseResponse {
musicFolders: {
musicFolder: SSMusicFolder[];
};
}
export interface SSGenresResponse extends SSBaseResponse {
genres: {
genre: SSGenre[];
};
}
export interface SSArtistsResponse extends SSBaseResponse {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
}
export interface SSAlbumListResponse extends SSBaseResponse {
albumList2: {
album: SSAlbumListEntry[];
};
}
export interface SSAlbumResponse extends SSBaseResponse {
album: SSAlbum;
}
export interface SSArtistInfoResponse extends SSBaseResponse {
artistInfo2: SSArtistInfo;
}
export interface SSArtistInfo {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
smallImageUrl?: string;
}
export interface SSMusicFolder {
id: number;
name: string;
}
export interface SSGenre {
albumCount?: number;
songCount?: number;
value: string;
}
export interface SSArtistIndex {
artist: SSArtistListEntry[];
name: string;
}
export interface SSArtistListEntry {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}
export interface SSAlbumListEntry {
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
}
export interface SSAlbum extends SSAlbumListEntry {
song: SSSong[];
}
export interface SSSong {
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
}
export interface SSAlbumsParams {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
}
export interface SSArtistsParams {
musicFolderId?: number;
}
-36
View File
@@ -1,36 +0,0 @@
import { ImageType } from '@prisma/client';
import { prisma } from '../../lib';
import { SSAlbumListEntry, SSAlbumResponse } from './subsonic.types';
const insertImages = async (items: SSAlbumListEntry[]) => {
const createMany = items
.filter((item) => item.coverArt)
.map((item) => ({
remoteUrl: item.coverArt,
type: ImageType.PRIMARY,
}));
await prisma.image.createMany({
data: createMany,
skipDuplicates: true,
});
};
const insertSongImages = async (item: SSAlbumResponse) => {
const createMany = item.album.song
.filter((song) => song.coverArt)
.map((song) => ({
remoteUrl: song.coverArt,
type: ImageType.PRIMARY,
}));
await prisma.image.createMany({
data: createMany,
skipDuplicates: true,
});
};
export const subsonicUtils = {
insertImages,
insertSongImages,
};
-16
View File
@@ -1,16 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { validateRequest, validation } from '@validations/index';
export const router: Router = express.Router({
mergeParams: true,
strict: true,
});
router.get('/', controller.albumArtists.getList);
router.get(
':serverId',
validateRequest(validation.albumArtists.detail),
controller.albumArtists.getDetail
);
-23
View File
@@ -1,23 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { validateRequest, validation } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router.get(
'/',
validateRequest(validation.albums.list),
controller.albums.getList
);
router.get(
'/:albumId',
validateRequest(validation.albums.detail),
controller.albums.getDetail
);
router.get(
'/:albumId/songs',
validateRequest(validation.albums.detail),
controller.albums.getDetailSongList
);
-8
View File
@@ -1,8 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.artists.getList);
router.get(':serverId', controller.artists.getDetail);
-30
View File
@@ -1,30 +0,0 @@
import express, { Router } from 'express';
import passport from 'passport';
import { controller } from '@controllers/index';
import { authenticate } from '@middleware/authenticate';
import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router.post(
'/login',
validateRequest(validation.auth.login),
passport.authenticate('local'),
controller.auth.login
);
router.post(
'/register',
validateRequest(validation.auth.register),
controller.auth.register
);
router.post('/logout', authenticate, controller.auth.logout);
router.post(
'/refresh',
validateRequest(validation.auth.refresh),
controller.auth.refresh
);
router.get('/ping', controller.auth.ping);
-12
View File
@@ -1,12 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { validation } from '@validations/index';
import { validateRequest } from '@validations/shared.validation';
export const router: Router = express.Router({ mergeParams: true });
router.get(
'/',
validateRequest(validation.genres.list),
controller.genres.getList
);
-51
View File
@@ -1,51 +0,0 @@
import { Router } from 'express';
import { helpers } from '../helpers';
import { authenticate } from '../middleware';
import { router as albumArtistsRouter } from './album-artists.route';
import { router as albumsRouter } from './albums.route';
import { router as artistsRouter } from './artists.route';
import { router as authRouter } from './auth.route';
import { router as genresRouter } from './genres.route';
import { router as serversRouter } from './servers.route';
import { router as songsRouter } from './songs.route';
import { router as tasksRouter } from './tasks.route';
import { router as usersRouter } from './users.route';
export const routes = Router({ mergeParams: true });
routes.use('/api/auth', authRouter);
routes.use(authenticate, (_req, _res, next) => {
next();
});
routes.use('/api/tasks', tasksRouter);
routes.use('/api/users', usersRouter);
routes.use('/api/servers', serversRouter);
routes.param('serverId', (req, _res, next, serverId) => {
const { serverFolderId } = req.query as {
serverFolderId?: string[] | string;
};
req.authUser.serverId = serverId;
helpers.shared.checkServerPermissions(req.authUser, { serverId });
helpers.shared.checkServerFolderPermissions(req.authUser, {
serverFolderId,
serverId,
});
if (typeof req.query.serverFolderId === 'string') {
req.query.serverFolderId = [req.query.serverFolderId];
}
next();
});
routes.use('/api/servers/:serverId/album-artists', albumArtistsRouter);
routes.use('/api/servers/:serverId/artists', artistsRouter);
routes.use('/api/servers/:serverId/albums', albumsRouter);
routes.use('/api/servers/:serverId/genres', genresRouter);
routes.use('/api/servers/:serverId/songs', songsRouter);
-163
View File
@@ -1,163 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { authenticateAdmin } from '@middleware/authenticate-admin';
import { authenticateServerAdmin } from '@middleware/authenticate-server-admin';
import { authenticateServerEditor } from '@middleware/authenticate-server-editor';
import { authenticateServerViewer } from '@middleware/authenticate-server-viewer';
import { service } from '@services/index';
import { validateRequest, validation } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router
.route('/')
.get(
validateRequest(validation.servers.list),
controller.servers.getServerList
)
.post(
authenticateAdmin,
validateRequest(validation.servers.create),
controller.servers.createServer
);
router.route('/map').get(controller.servers.getServerListMap);
router
.route('/:serverId')
.get(
validateRequest(validation.servers.detail),
controller.servers.getServerDetail
)
.patch(
authenticateServerAdmin,
validateRequest(validation.servers.update),
controller.servers.updateServer
)
.delete(
authenticateAdmin,
validateRequest(validation.servers.deleteServer),
controller.servers.deleteServer
);
router
.route('/:serverId/refresh')
.get(
authenticateServerEditor,
validateRequest(validation.servers.refresh),
controller.servers.refreshServer
);
router
.route('/:serverId/scan')
.post(
authenticateServerAdmin,
validateRequest(validation.servers.scan),
controller.servers.quickScanServer
);
router
.route('/:serverId/full-scan')
.post(
authenticateServerAdmin,
validateRequest(validation.servers.scan),
controller.servers.fullScanServer
);
router
.route('/:serverId/url')
.post(
authenticateServerEditor,
validateRequest(validation.servers.createUrl),
controller.servers.createServerUrl
);
router.param('urlId', async (_req, _res, next, urlId) => {
await service.servers.findUrlById({ id: urlId });
next();
});
router
.route('/:serverId/url/:urlId')
.delete(
authenticateServerEditor,
validateRequest(validation.servers.deleteUrl),
controller.servers.deleteServerUrl
);
router
.route('/:serverId/url/:urlId/enable')
.post(
authenticateServerViewer,
validateRequest(validation.servers.enableUrl),
controller.servers.enableServerUrl
);
router
.route('/:serverId/url/:urlId/disable')
.post(
authenticateServerViewer,
validateRequest(validation.servers.disableUrl),
controller.servers.disableServerUrl
);
router
.route('/:serverId/permissions')
.post(
authenticateServerAdmin,
validateRequest(validation.servers.addServerPermission),
controller.servers.addServerPermission
);
router
.route('/:serverId/permissions/:permissionId')
.patch(
authenticateServerAdmin,
validateRequest(validation.servers.updateServerPermission),
controller.servers.updateServerPermission
)
.delete(
authenticateServerAdmin,
validateRequest(validation.servers.deleteServerPermission),
controller.servers.deleteServerPermission
);
router.param('folderId', async (_req, _res, next, folderId) => {
await service.servers.findFolderById({ id: folderId });
next();
});
router
.route('/:serverId/folder/:folderId')
.delete(
authenticateServerAdmin,
validateRequest(validation.servers.deleteFolder),
controller.servers.deleteServerFolder
);
router
.route('/:serverId/folder/:folderId/enable')
.post(
authenticateServerAdmin,
validateRequest(validation.servers.enableFolder),
controller.servers.enableServerFolder
);
router
.route('/:serverId/folder/:folderId/disable')
.post(
authenticateServerAdmin,
validateRequest(validation.servers.disableFolder),
controller.servers.disableServerFolder
);
router
.route('/:serverId/folder/:folderId/permissions')
.post(authenticateServerAdmin, controller.servers.addServerFolderPermission);
router
.route('/:serverId/folder/:folderId/permissions/:folderPermissionId')
.delete(
authenticateServerAdmin,
controller.servers.deleteServerFolderPermission
);

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