Compare commits

..

291 Commits

Author SHA1 Message Date
jeffvli b8ce0aac24 Fix username on navidrome login check 2022-11-21 15:15:17 -08:00
jeffvli 68b1134da5 Fix formatting and typos 2022-11-21 14:45:59 -08:00
jeffvli 46187acadc Fix typos 2022-11-21 14:42:00 -08:00
jeffvli 69c79f0620 Update README instructions 2022-11-21 14:40:30 -08:00
jeffvli 234c9546c4 Add server release scripts 2022-11-21 13:12:29 -08:00
jeffvli edc80bc3b4 Fix subsonic scanner 2022-11-21 11:27:39 -08:00
jeffvli 1d05966127 Add wait script to prod build 2022-11-21 11:23:47 -08:00
jeffvli 2013c46991 Schema fixes 2022-11-21 09:58:00 -08:00
jeffvli eb7e259c86 Prevent replacement of identical username fields 2022-11-21 09:57:32 -08:00
jeffvli 20cd34a38f Fix publish errors 2022-11-20 19:49:36 -08:00
jeffvli 154de4147c Bump retry version and remove shell 2022-11-20 19:39:41 -08:00
jeffvli 4215e0e1f1 Set workflow dispatch on publish 2022-11-20 19:36:20 -08:00
jeffvli b89fd340e7 Fix publish workflow 2022-11-20 19:33:19 -08:00
jeffvli 19792bc8d8 Update appid 2022-11-20 19:28:50 -08:00
jeffvli 118f416066 Split prod and public builds 2022-11-20 19:28:15 -08:00
jeffvli 693286a6a1 Enable publish 2022-11-20 19:21:13 -08:00
jeffvli c273dd9753 Set default repo settings for i18n and typescript 2022-11-20 17:11:26 -08:00
jeffvli 9c98f8aa01 Remove unused 2022-11-20 17:10:47 -08:00
jeffvli ad39919f3f Change default port 2022-11-20 17:10:25 -08:00
jeffvli 0a89e35a3e Fix credential required 2022-11-20 17:09:13 -08:00
jeffvli de1e6c16c0 Add logout to error fallback 2022-11-20 17:01:52 -08:00
jeffvli b0f031bedc Reject when connection failed 2022-11-20 17:01:18 -08:00
jeffvli 079a400e74 Optimize docker build 2022-11-20 16:07:54 -08:00
jeffvli 2cb54a068a Hide electron-only settings 2022-11-20 05:20:19 -08:00
jeffvli 44ffe1984b Increase default size of sidebar queue 2022-11-20 05:16:37 -08:00
jeffvli d5d0c29280 Move fonts to settings 2022-11-20 05:16:13 -08:00
jeffvli 96dc79a527 Remove unused mpv state set 2022-11-20 04:56:03 -08:00
jeffvli 9b2f9f6326 Align setting title to start 2022-11-20 04:46:47 -08:00
jeffvli 1c130e6c58 Add customizzable mpv parameters 2022-11-20 04:46:25 -08:00
jeffvli 01d327f9f1 Remove tab border 2022-11-20 04:45:25 -08:00
jeffvli 99f9e67500 Add Textarea and JsonInput components 2022-11-20 04:45:10 -08:00
jeffvli 892fa0e7b8 Reset default state on server change 2022-11-20 03:42:26 -08:00
jeffvli 9d7b595e41 Add shortcut for devtools in production build 2022-11-20 03:37:09 -08:00
jeffvli b1d06581ab Move login mutation 2022-11-20 02:03:21 -08:00
jeffvli 0cb2e40f2b Disable artist filters for subsonic 2022-11-20 02:02:05 -08:00
jeffvli 6f0c523559 Persist album list config 2022-11-20 01:53:20 -08:00
jeffvli b51a79c3cd Add scroll events to grid 2022-11-20 01:52:34 -08:00
jeffvli cf9187f548 Add sockets path 2022-11-19 20:51:19 -08:00
jeffvli 25f6c940bb Adjust default themes 2022-11-19 20:51:11 -08:00
jeffvli 449e11c2d4 Adjust object fit 2022-11-19 20:50:35 -08:00
jeffvli 9f4a58352d Use song type instead of server 2022-11-19 20:50:25 -08:00
jeffvli 4f3bd8f44a Scroll to row on grid ready 2022-11-19 20:50:12 -08:00
jeffvli 5811069a0a Add prop type 2022-11-19 20:49:47 -08:00
jeffvli 24b0d37bd1 Adjust grid card rows 2022-11-19 20:49:28 -08:00
jeffvli d66c756af5 Add playqueue table 2022-11-19 20:30:57 -08:00
jeffvli 4cb78bb656 Add multiselect component 2022-11-19 20:26:24 -08:00
jeffvli fe678b546e Update login styles 2022-11-17 02:51:58 -08:00
jeffvli 2a6167441a Add queue drawer 2022-11-17 01:42:53 -08:00
jeffvli b4825c35b5 Various fixes 2022-11-16 20:01:52 -08:00
jeffvli c89808fd14 Update socket api 2022-11-16 19:52:55 -08:00
jeffvli 04b45e26cd Bump packages 2022-11-16 19:52:42 -08:00
jeffvli 3827953b59 Update react-query implementation 2022-11-16 19:52:25 -08:00
jeffvli 82d4ad5502 Add empty placeholder 2022-11-16 10:42:29 -08:00
jeffvli b411ab13ac Prevent null value for skip 2022-11-15 15:54:18 -08:00
jeffvli 67e3a00b26 Base component fixes 2022-11-15 15:54:03 -08:00
jeffvli f40c3838a1 Add border for sidebar items 2022-11-15 15:53:42 -08:00
jeffvli da2ad0ac2e Animate activity song change 2022-11-15 15:53:27 -08:00
jeffvli bf5f464712 Adjust play status 2022-11-15 15:53:05 -08:00
jeffvli f83368874f Adjust skeleton height 2022-11-15 13:37:25 -08:00
jeffvli 2be651ed56 Set popover defaults 2022-11-15 13:36:56 -08:00
jeffvli d120057c8d Fix loading transient prop 2022-11-15 13:36:50 -08:00
jeffvli 99398d5675 Set player to web automatically if not electron 2022-11-15 13:36:35 -08:00
jeffvli f62aacf9d8 Remove unused util 2022-11-15 13:36:14 -08:00
jeffvli a8f0a20602 Export server map function 2022-11-15 13:35:42 -08:00
jeffvli f9c5e6f9fb Add user activity 2022-11-15 13:35:26 -08:00
jeffvli e76267be06 Fix spinner color on queue 2022-11-15 13:33:20 -08:00
jeffvli 2a29f33352 Remove required generic for query options 2022-11-15 13:33:04 -08:00
jeffvli 758e9d4d2c Add server map route 2022-11-15 13:31:11 -08:00
jeffvli 8d5a05c329 Update look and feel 2022-11-14 18:12:24 -08:00
jeffvli 8dad9a8109 Require link prop for styles 2022-11-14 14:25:50 -08:00
jeffvli 7061e16fb4 Add custom spinner to button 2022-11-14 14:25:38 -08:00
jeffvli 4029b149ee Update tab styles 2022-11-14 14:25:24 -08:00
jeffvli 5ee5a089c5 Add spinner component 2022-11-14 14:25:15 -08:00
jeffvli 9e6e8d7fd6 Re-enable navidrome scanner items 2022-11-14 14:24:54 -08:00
jeffvli 28cc1f9c30 Set max width for container 2022-11-14 03:03:58 -08:00
jeffvli 38993f3352 Reduce fade in duration 2022-11-14 03:03:49 -08:00
jeffvli 1bd538b6b4 Update text for link styles 2022-11-14 03:00:05 -08:00
jeffvli 295eb82f20 Adjust default themes 2022-11-14 02:59:51 -08:00
jeffvli 1cc40348cb Remove default staleTime for user detail 2022-11-14 01:15:49 -08:00
jeffvli 85e80be08b Fix overflow 2022-11-14 01:15:02 -08:00
jeffvli 420d4835be Update base components 2022-11-14 01:14:39 -08:00
jeffvli c54eea4382 Add server permission management 2022-11-14 01:13:54 -08:00
jeffvli 1babcc40ee Add user profile image 2022-11-13 20:18:23 -08:00
jeffvli 14c22c63a0 Add server permission routes 2022-11-13 12:35:26 -08:00
jeffvli 1a6c4af5df Fix album permissions by role 2022-11-13 04:56:36 -08:00
jeffvli 4e2325f05d Fix server folder permissions by role 2022-11-13 03:06:24 -08:00
jeffvli 135a8d7a45 Retrieve serverfolders by permission 2022-11-13 02:41:42 -08:00
jeffvli 74170185a2 Add max width to tooltip 2022-11-13 02:41:08 -08:00
jeffvli 3a8665fded Add info tooltips to server options 2022-11-13 02:34:55 -08:00
jeffvli d028af5ff0 Add mpv path option 2022-11-13 02:08:12 -08:00
jeffvli ab7787484c Move localSettings utility to different file 2022-11-13 02:08:02 -08:00
jeffvli c469e2e5cc Adjust paper styles 2022-11-13 02:07:43 -08:00
jeffvli 5de5b34ecf Add accordion component 2022-11-13 02:07:14 -08:00
jeffvli ec59d27c1d Fix password input styles 2022-11-13 01:58:56 -08:00
jeffvli 9a079ef263 Adjust placeholder input color 2022-11-13 01:54:18 -08:00
jeffvli 372fc7c1f6 Add edit profile form 2022-11-13 01:54:02 -08:00
jeffvli 1686005792 Change dropdown position 2022-11-13 01:53:43 -08:00
jeffvli b9c0b62cf8 Add link to mpv site 2022-11-13 01:53:16 -08:00
jeffvli 797e3eab3b Set maxWidth for required container 2022-11-13 01:53:08 -08:00
jeffvli b100f4f790 Add shadow to tooltip 2022-11-13 01:52:52 -08:00
jeffvli 6c5a70c03e Add styles to fileinput 2022-11-13 01:52:47 -08:00
jeffvli 353168ad4e Add light/dark type to theme 2022-11-13 01:52:39 -08:00
jeffvli a03f41b76d Bump mantine to v5.7.2 2022-11-12 18:45:49 -08:00
jeffvli 81fafd20d3 Web player fixes 2022-11-12 18:45:25 -08:00
jeffvli 8353640d05 Update various components 2022-11-12 18:44:51 -08:00
jeffvli 79fae63aaf Add theme selector / update defaults 2022-11-12 18:43:47 -08:00
jeffvli 544fd25f6b Add error boundary package 2022-11-12 12:39:23 -08:00
jeffvli 0e9953be13 Remove unused 2022-11-12 01:22:44 -08:00
jeffvli 258e5bb8f6 Init simple-img in main app 2022-11-12 01:22:29 -08:00
jeffvli 5c6cd2410e Set user profile in server state 2022-11-12 01:22:12 -08:00
jeffvli b08c7dfaf1 Set web volume to be logarithmic 2022-11-12 01:20:23 -08:00
jeffvli 46145fd3aa Adjust playerbar 2022-11-12 01:20:09 -08:00
jeffvli 0b5cea16e9 Handle shuffle/repeat playback for web 2022-11-12 01:19:17 -08:00
jeffvli 34f4458733 Add crossfade style setting 2022-11-12 01:08:20 -08:00
jeffvli d3d6db03d7 Add error boundary 2022-11-11 12:51:08 -08:00
jeffvli f7839d6ed6 Handle shuffle/repeat playback for mpv 2022-11-11 04:40:20 -08:00
jeffvli 581ef32845 Add per-server permissions 2022-11-09 01:52:08 -08:00
jeffvli 73e6002cc7 Fix toast types 2022-11-08 19:01:05 -08:00
jeffvli 048b5898b9 Set center loader 2022-11-08 19:01:00 -08:00
jeffvli ddc0355b1e Update forms 2022-11-08 19:00:48 -08:00
jeffvli 30a7bac59a Add prop to permissions 2022-11-08 19:00:22 -08:00
jeffvli 445e4b56b7 Add initial users manager 2022-11-08 18:46:56 -08:00
jeffvli df8e38cedd Add isSuperAdmin account 2022-11-08 18:40:44 -08:00
jeffvli 3bc0ea16bc Wrap queue handler in callback 2022-11-08 15:16:22 -08:00
jeffvli 84b031f126 Move add server form inside of server list 2022-11-08 15:16:05 -08:00
jeffvli 8357941bfa Add context modal 2022-11-08 15:13:59 -08:00
jeffvli 187ccad15b Add user routes 2022-11-08 15:13:47 -08:00
jeffvli a326355576 Add song skip/displayname to schema 2022-11-08 14:32:01 -08:00
jeffvli e6bf71dcfe Add state merge 2022-11-08 12:17:18 -08:00
jeffvli a964662ad4 Add albumartists to related album 2022-11-08 12:04:10 -08:00
jeffvli 2652ab927f Add playButtonBehavior type 2022-11-08 12:03:36 -08:00
jeffvli 30e9543544 Remove select on sidebar 2022-11-08 04:00:36 -08:00
jeffvli d6fe3038e0 Update server forms 2022-11-08 04:00:22 -08:00
jeffvli d3e4c7d975 Display album artist for subsonic servers 2022-11-08 03:58:52 -08:00
jeffvli 1df07dd12a Adjust component styles 2022-11-08 03:58:23 -08:00
jeffvli 49eb78b03c Add configurable web audio device 2022-11-08 03:58:13 -08:00
jeffvli 7b1940e1f5 Add configurable play button behavior 2022-11-08 03:57:56 -08:00
jeffvli b2cc85d368 Settings fixes 2022-11-08 03:56:59 -08:00
jeffvli dbba5f9f95 Fix skip forward 2022-11-08 03:55:37 -08:00
jeffvli 03278d2624 Fix action required for mpv 2022-11-08 01:36:57 -08:00
jeffvli 9022f05463 Move handlers to separate functions 2022-11-08 01:36:26 -08:00
jeffvli 3cdd08fe89 Add slider component 2022-11-08 01:36:08 -08:00
jeffvli f7ea6c45f5 Add lazy image component 2022-11-08 01:35:21 -08:00
jeffvli ecb090d324 Add modal transitions 2022-11-08 00:23:16 -08:00
jeffvli 587fa2422a Handle web player 2022-11-08 00:20:39 -08:00
jeffvli e774cdf031 Add tabs component 2022-11-07 04:04:32 -08:00
jeffvli cd56783c96 Add media keys 2022-11-07 03:46:55 -08:00
jeffvli 6ac949bf88 Replace @emotion/styled with styled-components 2022-11-06 01:53:31 -08:00
jeffvli de91f75203 Fix card keys 2022-11-06 01:25:53 -08:00
jeffvli d69221f8a4 Fix styles 2022-11-06 00:52:03 -07:00
jeffvli 39d98c5066 Remove checkbox folder 2022-11-06 00:51:48 -07:00
jeffvli 4304a2ae84 Various updates 2022-11-06 00:46:51 -07:00
jeffvli fc1ab03118 Fix props 2022-11-06 00:12:04 -07:00
jeffvli f76cc2f230 Adjust skeleton row width 2022-11-06 00:11:48 -07:00
jeffvli 70ce493f5e Add menu styles 2022-11-06 00:11:37 -07:00
jeffvli 633c6416df Album list updates 2022-11-06 00:08:22 -07:00
jeffvli 07123615ca Move fadein directly to component 2022-11-05 12:00:46 -07:00
jeffvli 385ec5f856 Remove disabled cursor styles 2022-11-05 03:25:59 -07:00
jeffvli 608518ac1c Add link text styles 2022-11-05 03:25:42 -07:00
jeffvli cee6ff4df5 Misc fixes 2022-11-05 03:25:30 -07:00
jeffvli c8da4f8146 Fix action item check if not authenticated 2022-11-05 03:24:38 -07:00
jeffvli 63dba7b379 Increase dropdown size 2022-11-05 03:12:04 -07:00
jeffvli e014ac0a4b Update grid card styles and props 2022-11-05 03:11:51 -07:00
jeffvli ae53b17214 Fix null imageUrl on credential 2022-11-04 19:18:35 -07:00
jeffvli fd53f90db2 Order genres by name 2022-11-04 18:38:00 -07:00
jeffvli d88e99e38c Adjust placeholder defaults 2022-11-04 18:37:42 -07:00
jeffvli 096a7713da Add placeholder images 2022-11-04 18:21:22 -07:00
jeffvli 7aa89e8ad2 Add genres api 2022-11-03 14:45:43 -07:00
jeffvli f284b29052 Add default ops, handle releaseDate 2022-11-03 14:45:00 -07:00
jeffvli 5908554f38 Set all popovers withinPortal by default 2022-11-03 14:41:34 -07:00
jeffvli 53a7d728b3 Progress on advanced filters 2022-11-03 03:25:10 -07:00
jeffvli be05c1df79 Update base components 2022-11-03 03:19:15 -07:00
jeffvli e9142ffaa5 Add paper component 2022-11-03 03:17:27 -07:00
jeffvli aeefbf8f7f Add scroll area component 2022-11-03 02:26:01 -07:00
jeffvli 061e61b7d3 Add packages 2022-11-02 22:05:40 -07:00
jeffvli a888007bfa Update base components 2022-11-02 22:05:16 -07:00
jeffvli 94b40178aa Progress on advanced filters 2022-11-02 22:04:46 -07:00
jeffvli 73fff64a75 Add genres route 2022-11-01 11:30:40 -07:00
jeffvli 97486b23ee Use img tag instead of background-image 2022-11-01 03:47:28 -07:00
jeffvli 005a30e0f4 Add advanced filters base component 2022-11-01 02:56:35 -07:00
jeffvli 1cdcde010c Add shell layout 2022-10-30 20:21:55 -07:00
jeffvli 895356701f Update server list 2022-10-30 20:21:31 -07:00
jeffvli 689560a7a5 Adjust playerbar 2022-10-30 20:20:04 -07:00
jeffvli bc9e6a9a73 Add animated page 2022-10-30 20:19:11 -07:00
jeffvli eb73c87933 Add specific toast types in export 2022-10-30 20:18:50 -07:00
jeffvli 7725d3dfbb Add fileinput, popover 2022-10-30 20:18:38 -07:00
jeffvli 659a9b949b Set as polymorphic 2022-10-30 20:18:16 -07:00
jeffvli 04afa13eae Fix year data 2022-10-30 20:17:06 -07:00
jeffvli df1844b74c Adjust styling and add links 2022-10-30 20:16:53 -07:00
jeffvli b2fc76203d Add 404 route 2022-10-30 15:00:54 -07:00
jeffvli a60a053b6b Add action-required route 2022-10-30 01:43:32 -07:00
jeffvli 699ed268e6 Set MPV path from local settings file 2022-10-30 01:37:05 -07:00
jeffvli e4f797debc Fix type 2022-10-30 01:34:09 -07:00
jeffvli 852a4297a3 Fix names 2022-10-29 19:14:40 -07:00
jeffvli 2489622d90 Rename 2022-10-29 19:14:13 -07:00
jeffvli 19090a0ed8 Update scanner (frontend) 2022-10-29 19:13:40 -07:00
jeffvli 0200b92860 Update scanner (server) 2022-10-29 19:12:02 -07:00
jeffvli ff6882a6cd Handle logout on invalid token 2022-10-29 18:57:23 -07:00
jeffvli 97b383ff0b Fix scripts based on new server path 2022-10-28 16:01:37 -07:00
jeffvli 4fb963d689 Add initial queue/album list routes 2022-10-28 13:11:29 -07:00
jeffvli c6d80831f8 Add ND type 2022-10-28 13:10:07 -07:00
jeffvli 32fe11d3de Handle right sidebar 2022-10-28 13:09:42 -07:00
jeffvli 5bcb0a3824 Add hook to fetch current server cred 2022-10-28 13:07:56 -07:00
jeffvli 91536f1bc9 Add isAdmin to permission set 2022-10-28 13:04:36 -07:00
jeffvli 4fb8d4ebd9 Handle album add with local creds 2022-10-28 13:04:09 -07:00
jeffvli b3f4cfee5d Add switch component 2022-10-28 13:03:10 -07:00
jeffvli 00710125d3 Improve grid 2022-10-28 13:02:55 -07:00
jeffvli 2352c136a1 Add fadeIn style 2022-10-28 13:02:00 -07:00
jeffvli ee8cc14e7e Fix missing song album details 2022-10-28 13:00:31 -07:00
jeffvli ca664d9430 Add server-side credential requirement 2022-10-27 20:33:42 -07:00
jeffvli cbbf3087ff Update serverfolder enable/disable 2022-10-27 00:58:59 -07:00
jeffvli 88e716b970 Add sidebar image option 2022-10-26 21:44:25 -07:00
jeffvli 17258e950e Update frontend linter 2022-10-26 16:28:07 -07:00
jeffvli 5b349083c9 Remove old file 2022-10-26 16:26:24 -07:00
jeffvli 1130006f0f Set min sidebar width to 200 2022-10-26 16:26:01 -07:00
jeffvli e31d8c1cc4 Fix types reference 2022-10-26 16:25:48 -07:00
jeffvli 834e311253 Remove sidebar index 2022-10-26 16:25:37 -07:00
jeffvli 82a47c42b8 Add sidebar state to app store 2022-10-26 16:24:28 -07:00
jeffvli 46eb5e4e5d Fix import 2022-10-26 16:24:16 -07:00
jeffvli 9204cdbe6a Fix casing 2 2022-10-26 16:23:57 -07:00
jeffvli 8258bbe5b3 Fix casing 2022-10-26 16:22:29 -07:00
jeffvli 8f82d001f0 Move types up 2022-10-26 16:21:51 -07:00
jeffvli 6763f4439d Update package name references 2022-10-26 16:13:32 -07:00
jeffvli 1622c12dfa Update server management 2022-10-26 16:12:57 -07:00
jeffvli 02e3b96384 Update titlebar 2022-10-26 16:12:18 -07:00
jeffvli c4765ba2d1 Add permissions check hook 2022-10-26 16:06:22 -07:00
jeffvli 1f261a95ad Update default routing 2022-10-26 16:05:58 -07:00
jeffvli cf17ce6e9d Update login flow 2022-10-26 16:04:39 -07:00
jeffvli 146a03cb3c Update package name 2022-10-26 15:59:33 -07:00
jeffvli f8e1a7d79e Update base components 2022-10-26 15:59:18 -07:00
jeffvli dc891e1b79 Set project name to feishin 2022-10-25 16:54:23 -07:00
jeffvli 0438f2d5f2 Move server directory outside of frontend src 2022-10-25 16:52:45 -07:00
jeffvli 863dce88b7 Add local settings 2022-10-24 22:38:06 -07:00
jeffvli f09abdb4c6 Update styles 2022-10-24 22:34:13 -07:00
jeffvli 781adb7c4d Update libs 2022-10-24 22:30:49 -07:00
jeffvli dd3de66232 Update player/shared components 2022-10-24 22:30:16 -07:00
jeffvli 8973571147 Update store/routes 2022-10-24 22:20:35 -07:00
jeffvli f8e7d02daf Update frontend base 2022-10-24 22:19:52 -07:00
jeffvli 921c688c94 Update utils 2022-10-24 22:09:58 -07:00
jeffvli 663b951cd7 Set jsxImportSource 2022-10-24 22:01:34 -07:00
jeffvli d4e4bdb858 Add lint rules 2022-10-24 22:01:23 -07:00
jeffvli 33e4526caa Update packages 2022-10-24 22:01:02 -07:00
jeffvli df3418120a Update base components 2022-10-24 21:56:58 -07:00
jeffvli dfdf53f6ee Update login 2022-10-24 21:47:18 -07:00
jeffvli d5bbff5eb6 Update frontend API structure 2022-10-24 21:47:03 -07:00
jeffvli 76b6eed4bb Update scanner imports 2022-10-24 21:42:15 -07:00
jeffvli 4a3ce02805 Redo server functionality 2022-10-24 21:41:47 -07:00
jeffvli db8a7d6a63 Update passport/prisma libs 2022-10-24 21:28:22 -07:00
jeffvli c09e9b6583 Add tsconfig-paths 2022-10-24 21:27:00 -07:00
jeffvli 730b72e64f Update initial schema 2022-10-24 21:25:51 -07:00
jeffvli 3aab920c71 Cleanup packages and structure
- Remove unused packages
- Swap styled-components for emotion/styled
- Add tsconfig baseUrl and src path
- Upgrade and add mantine packages
2022-10-19 21:23:31 -07:00
jeffvli 9675fd1d8e Remove unused packages 2022-10-19 21:21:09 -07:00
jeffvli 3e80f71833 Remove unused utils 2022-10-18 18:52:40 -07:00
jeffvli 9424203960 Add serverCredentials to passport user output 2022-10-18 01:46:41 -07:00
jeffvli 27b4b36cbf Add validations, req.authUser 2022-10-18 01:45:46 -07:00
jeffvli 238c90478e Reset schema and migrations 2022-10-17 20:02:29 -07:00
jeffvli a0c634da2f Update validation middleware
- Move to separate directory
- Add TypedRequest utility fn
- Utilize as middleware instead of function
2022-10-16 02:29:28 -07:00
jeffvli 7b55ca2fa8 Remove unused 2022-10-14 20:34:23 -07:00
jeffvli e2808e0bd4 Update scanners 2022-10-14 20:26:53 -07:00
jeffvli 5f844ef975 Set many album artists 2022-10-14 19:57:08 -07:00
jeffvli 968e80a6d8 Update subsonic scanner for new schema 2022-10-12 20:47:23 -07:00
jeffvli 0772566637 Set size to int 2022-10-12 18:21:51 -07:00
jeffvli 99f30439e1 Add server scripts 2022-10-12 13:52:50 -07:00
jeffvli 6433ccd750 Set error message optional 2022-10-12 13:52:44 -07:00
jeffvli ea2d3ea8f1 Adjust route structure
- Items now accessed through servers/:serverId
- Auth middleware blanketly applied on routes
- Merge params on routes
2022-10-12 13:52:35 -07:00
jeffvli 21bf5ce523 Bump server packages 2022-10-12 13:52:29 -07:00
jeffvli 95421698da Remove hard-coded types
- Use auto-generated prisma types
2022-10-12 13:52:22 -07:00
jeffvli 02ef79dcb2 Optimize jellyfin scanner
- Include changes / unfinished subsonic scanner
2022-10-12 13:52:16 -07:00
jeffvli 8aedd94033 Update auth object and middleware 2022-10-12 13:52:08 -07:00
jeffvli b5c7abb566 Update schema and add initial migration 2022-10-12 13:52:00 -07:00
jeffvli 9d09b830f9 Add initial rest client files 2022-10-12 13:51:16 -07:00
jeffvli dd6b80795e Normalize types 2022-08-20 02:06:00 -07:00
jeffvli a087dbdea3 Add ag-grid package 2022-08-02 10:53:56 -07:00
jeffvli 3d677188b5 Fix initial volume 2022-08-02 10:47:12 -07:00
jeffvli d11051bbc1 Adjust album/song types 2022-08-02 10:45:27 -07:00
jeffvli e1bc6ecf30 Add queue handler
Initial support for play "now", "next", and "last"
2022-07-31 05:24:33 -07:00
jeffvli 9c9cf3a978 Add dnd-kit package 2022-07-31 02:14:54 -07:00
jeffvli e1977b291e Add basic development instructions 2022-07-30 19:31:32 -07:00
jeffvli c100bbb341 Linting, misc fixes 2022-07-30 19:31:14 -07:00
jeffvli bb4576390d Change TS import baseUrl 2022-07-30 17:44:57 -07:00
jeffvli df5f8c08f3 Remove boilerplate file 2022-07-30 16:57:30 -07:00
jeffvli 95ff874702 Swap tabler-icons for react-icons 2022-07-30 16:57:16 -07:00
jeffvli 1eed976747 Remove isScrolling 2022-07-30 16:27:47 -07:00
jeffvli fe422897ab Update dev docker deployment
Rename environment variables, add tokens
2022-07-30 15:47:06 -07:00
jeffvli bc86da7762 Update pagination request on client 2022-07-30 15:41:18 -07:00
jeffvli aa673ac854 Use skip/take cursors instead of page number 2022-07-30 15:28:30 -07:00
jeffvli b8cf1d8283 Disable no-shadow rule
Resolves a conflict with typescript enum values
2022-07-30 07:04:01 -07:00
jeffvli b9a171b096 Add filter functionality for infinite album list 2022-07-30 07:03:10 -07:00
jeffvli fa9cf2efda Migrate Mantine components to v5 2022-07-27 21:20:41 -07:00
jeffvli 34ee3222f4 Bump react to 18.2.0 2022-07-27 21:19:49 -07:00
jeffvli f13427022e add mantine dependency 2022-07-25 19:53:26 -07:00
439 changed files with 44965 additions and 16057 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules
release/app/node_modules
release/app/dist
src/server/node_modules
server/node_modules
+2
View File
@@ -5,6 +5,7 @@
import webpack from 'webpack';
import { dependencies as externals } from '../../release/app/package.json';
import webpackPaths from './webpack.paths';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
@@ -48,6 +49,7 @@ const configuration: webpack.Configuration = {
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
+8 -1
View File
@@ -1,5 +1,7 @@
module.exports = {
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 2020,
@@ -7,10 +9,14 @@ module.exports = {
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['import', 'sort-keys-fix'],
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'],
'import/extensions': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
@@ -41,6 +47,7 @@ module.exports = {
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-underscore-dangle': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
-4
View File
@@ -1,5 +1 @@
# These are supported funding model platforms
github: [electron-react-boilerplate, amilajack]
patreon: amilajack
open_collective: electron-react-boilerplate-594
+12 -19
View File
@@ -1,16 +1,9 @@
name: Publish
name: Publish (Manual)
on:
push:
branches:
- main
on: workflow_dispatch
jobs:
publish:
# To enable auto publishing to github, update your electron publisher
# config in package.json > "build" and remove the conditional below
if: ${{ github.repository_owner == 'electron-react-boilerplate' }}
runs-on: ${{ matrix.os }}
strategy:
@@ -33,14 +26,14 @@ jobs:
- name: Publish releases
env:
# These values are used for auto updates signing
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASS: ${{ secrets.APPLE_ID_PASS }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
# This is used for uploading release assets to github
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac --linux
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 exec electron-builder -- --publish always --win --mac --linux
on_retry_command: npm cache clean --force
+3
View File
@@ -17,6 +17,9 @@
"empty-line-between-groups": false
}
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [
true,
+25 -1
View File
@@ -4,6 +4,11 @@
".prettierrc": "jsonc",
".eslintignore": "ignore"
},
"eslint.validate": ["typescript"],
"eslint.workingDirectories": [
{ "directory": "./", "changeProcessCWD": true },
{ "directory": "./server", "changeProcessCWD": true }
],
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
@@ -26,5 +31,24 @@
"test/**/__snapshots__": true,
"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"
}
+25 -16
View File
@@ -2,41 +2,50 @@
FROM node:16.5-alpine as ui-builder
WORKDIR /app
COPY . .
RUN npm install && npm run build:renderer
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 src/server .
RUN ls -lh
RUN npm install
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 sonixd-server
RUN mkdir sonixd-client
RUN mkdir feishin-server
RUN mkdir feishin-client
# Install server modules
COPY src/server/package.json ./sonixd-server
RUN cd ./sonixd-server && npm install --production
RUN npm cache clean --force
RUN npm prune --production
# Add server build files
COPY --from=server-builder /app/dist ./sonixd-server
COPY --from=server-builder /app/prisma ./sonixd-server/prisma
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 ./sonixd-client
COPY --from=ui-builder /app/release/app/dist/renderer ./feishin-client
COPY docker-entrypoint.sh ./sonixd-server/docker-entrypoint.sh
RUN chmod +x ./sonixd-server/docker-entrypoint.sh
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 cd ./sonixd-server && npx prisma generate
RUN npm install pm2 -g
WORKDIR /root/sonixd-server
WORKDIR /root/feishin-server
EXPOSE 9321
CMD ["sh", "docker-entrypoint.sh"]
+145 -89
View File
@@ -1,129 +1,185 @@
<img src="assets/icon.png" alt="sonixd logo" title="sonixd" align="right" height="60px" />
# Feishin
# Sonixd
<p align="center">
<a href="https://github.com/jeffvli/feishin/blob/main/LICENSE">
<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">
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
alt="Release">
</a>
<a href="https://github.com/jeffvli/feishin/releases">
<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">
<img src="https://img.shields.io/discord/922656312888811530?color=black&label=discord&logo=discord&logoColor=white"
alt="Discord">
</a>
<a href="https://matrix.to/#/#sonixd:matrix.org">
<img src="https://img.shields.io/matrix/sonixd:matrix.org?color=black&label=matrix&logo=matrix&logoColor=white"
alt="Matrix">
</a>
</p>
<a href="https://github.com/jeffvli/sonixd/releases">
<img src="https://img.shields.io/github/v/release/jeffvli/sonixd?style=flat-square&color=blue"
alt="Release">
</a>
<a href="https://github.com/jeffvli/sonixd/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jeffvli/sonixd?style=flat-square&color=brightgreen"
alt="License">
</a>
<a href="https://github.com/jeffvli/sonixd/releases">
<img src="https://img.shields.io/github/downloads/jeffvli/sonixd/total?style=flat-square&color=orange"
alt="Downloads">
</a>
<a href="https://discord.gg/FVKpcMDy5f">
<img src="https://img.shields.io/discord/922656312888811530?color=red&label=discord&logo=discord&logoColor=white"
alt="Discord">
</a>
<a href="https://matrix.to/#/#sonixd:matrix.org">
<img src="https://img.shields.io/matrix/sonixd:matrix.org?color=red&label=matrix&logo=matrix&logoColor=white"
alt="Matrix">
</a>
Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
Sonixd is a cross-platform desktop client built for Subsonic-API (and Jellyfin in 0.8.0+) compatible music servers. This project was inspired by the many existing clients, but aimed to address a few key issues including <strong>scalability</strong>, <strong>library management</strong>, and <strong>user experience</strong>.
## Getting Started
- [**Usage documentation & FAQ**](https://github.com/jeffvli/sonixd/discussions/15)
- [**Theming documentation**](https://github.com/jeffvli/sonixd/discussions/61)
The default credentials to login will be `admin/admin`.
Sonixd has been tested on the following: [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/), [Jellyfin](https://github.com/jellyfin/jellyfin)
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
### [Demo Sonixd using Navidrome](https://github.com/jeffvli/sonixd/discussions/244)
### Docker Compose
## Features
**Warning:** Check the environment variable configuration before running the commands below.
- HTML5 audio with crossfading and gapless\* playback
- Drag and drop rows with multi-select
- Modify and save playlists intuitively
- Handles large playlists and queues
- Global mediakeys (and partial MPRIS) support
- Multi-theme support
- Supports all Subsonic/Jellyfin API compatible servers
- Built with Electron, React with the [rsuite v4](https://github.com/rsuite/rsuite) component library
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`
<h5>* Gapless playback is artifically created using the crossfading players so it may not be perfect, YMMV.</h5>
### Docker
## Screenshots
**Warning:** Check the environment variable configuration before running the commands below.
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/album.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/album.png" width="49.5%"/></a>
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/artist.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/artist.png" width="49.5%"/></a>
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/search.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/search.png" width="49.5%"/></a>
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/now_playing.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/now_playing.png" width="49.5%"/></a>
## Install
You can install sonixd by downloading the [latest release](https://github.com/jeffvli/sonixd/releases) for your specified operating system.
---
### Windows
If you prefer not to download the release binary, you can install using `winget`.
Using your favorite terminal (cmd/pwsh):
**Run a postgres database container:**
```
winget install sonixd
docker run postgres:13 \
-p 5432:5432 \
-e POSTGRES_USER=admin \
-e POSTGRES_PASSWORD=admin \
-e POSTGRES_DB=feishin
```
---
### Arch Linux
There is an AUR package of the latest AppImage release available [here](https://aur.archlinux.org/packages/sonixd-appimage).
To install it you can use your favourite AUR package manager and install the package: `sonixd-appimage`
For example using `yay`:
**Run the Feishin server container:**
```
yay -S sonixd-appimage
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
```
If you encounter any problems please comment on the [AUR](https://aur.archlinux.org/packages/sonixd-appimage) or contact the [maintainer](mailto:robin@blckct.io) directly before you open an issue here.
**Docker Environment Variables**
---
```
APP_BASE_URL — The URL the site will be accessible at from your server (needed for CORS)
Once installed, run the application and sign in to your music server with the following details. If you are using [airsonic-advanced](https://github.com/airsonic-advanced/airsonic-advanced), you will need to make sure that you create a `decodable` credential for your login user within the admin control panel.
DATABASE_PORT — The port of your running postgres container
- Server - `e.g. http://localhost:4040/`
- User name - `e.g. admin`
- Password - `e.g. supersecret!`
DATABASE_URL — The connection string to your postgres instance following this format: postgresql://<DB_USERNAME>:<DB_PASSWORD>@<DB_URL>/<DB_NAME>?schema=public
If you have any questions, feel free to check out the [Usage Documentation & FAQ](https://github.com/jeffvli/sonixd/discussions/15).
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
## Development / Contributing
Example: postgresql://admin:password@192.168.0.1:5432/feishin?schema=public
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v2.3.0.
If you want to contribute to this project, please first create an [issue](https://github.com/jeffvli/sonixd/issues/new) or [discussion](https://github.com/jeffvli/sonixd/discussions/new) so that we can both discuss the idea and its feasability for integration.
TOKEN_SECRET — The string used to sign auth tokens
First, clone the repo via git and install dependencies (Windows development now requires additional setup, see [#232](https://github.com/jeffvli/sonixd/issues/232)):
(optional) TOKEN_EXPIRATION — The time before the auth JWT expires
```bash
git clone https://github.com/jeffvli/sonixd.git
yarn install
(optional) TOKEN_REFRESH_EXPIRATION - The time before the auth JWT refresh token expires
```
Start the app in the `dev` environment:
### After installing the server and database
```bash
yarn start
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.
- [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.
## Development
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
```
To package apps for the local platform:
### NPM Scripts:
```bash
yarn package
```
$ npm run package — Packages the application for the local system
If you receive errors while packaging the application, try upgrading/downgrading your Node version (tested on v14.18.0).
$ npm run start — Runs the development Electron and web client
If you are unable to run via debug in VS Code, check troubleshooting steps [here](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/2757#issuecomment-784200527).
$ npm run start:web — Runs the development web client
If your devtools extensions are failing to run/install, check troubleshooting steps [here](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/2788).
$ 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/blob/main/LICENSE)
[GNU General Public License v3.0 ©](https://github.com/jeffvli/sonixd-rewrite/blob/dev/LICENSE)
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+10 -8
View File
@@ -1,7 +1,7 @@
version: '3'
services:
db:
container_name: sonixd_db
container_name: feishin_db
image: postgres:13
volumes:
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
@@ -13,12 +13,12 @@ services:
- '${DATABASE_PORT}:5432'
restart: unless-stopped
server:
container_name: sonixd_server
container_name: feishin_server
volumes:
- ./src/server:/app # Synchronise docker container with local change
- ./server:/app # Synchronise docker container with local change
- /app/node_modules # Avoid re-copying local node_modules. Cache in container.
build:
context: ./src/server
context: ./server
dockerfile: Dockerfile
depends_on:
- db
@@ -27,15 +27,17 @@ services:
- 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:
- '9321:9321'
- '8643:9321'
restart: unless-stopped
prisma:
container_name: sonixd_prisma_studio
container_name: feishin_prisma_studio
volumes:
- ./src/server/prisma:/app/prisma
- ./server/prisma:/app/prisma
build:
context: ./src/server/prisma
context: ./server/prisma
dockerfile: Dockerfile
depends_on:
- db
+32
View File
@@ -0,0 +1,32 @@
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
+17 -13
View File
@@ -1,25 +1,29 @@
version: '3'
services:
db:
container_name: sonixd_db
container_name: feishin_db
image: postgres:13
ports:
- '5432:5432'
volumes:
- ${DB_PERSIST_PATH}:/var/lib/postgresql/data
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DATABASE_USERNAME}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
ports:
- '${DATABASE_PORT}:5432'
restart: unless-stopped
server:
container_name: sonixd
image: sonixd:latest
container_name: feishin
image: jeffvictorli/feishin:latest
depends_on:
- db
environment:
- APP_BASE_URL=${APP_BASE_URL}
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@db/${DB_NAME}?schema=public&connection_limit=14&pool_timeout=20
- DATABASE_SECRET=${DB_SECRET}
- 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:
- '9321:9321'
restart: always
- '8643:9321'
restart: unless-stopped
+2
View File
@@ -1,3 +1,5 @@
./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
@@ -0,0 +1,9 @@
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
+1937 -1563
View File
File diff suppressed because it is too large Load Diff
+59 -35
View File
@@ -1,7 +1,8 @@
{
"name": "sonixd",
"productName": "Sonixd",
"description": "A full-featured Subsonic/Jellyfin compatible music player",
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.0.1-alpha1",
"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",
@@ -19,10 +20,14 @@
"test": "jest",
"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 sonixd_prisma",
"docker:migrate": "cd src/server && npx prisma generate && docker exec -ti sonixd_server sh -c \"npx prisma generate && npx prisma db push\"",
"docker:reset": "docker exec -ti sonixd_server sh -c \"npx prisma migrate reset && npx prisma db push && npx ts-node prisma/seed.ts\""
"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\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -39,8 +44,8 @@
]
},
"build": {
"productName": "Sonixd",
"appId": "org.erb.sonixd",
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
@@ -103,12 +108,12 @@
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "sonixd"
"repo": "feishin"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/sonixd.git"
"url": "git+https://github.com/jeffvli/feishin.git"
},
"author": {
"name": "jeffvli",
@@ -117,7 +122,7 @@
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/sonixd/issues"
"url": "https://github.com/jeffvli/feishin/issues"
},
"keywords": [
"subsonic",
@@ -127,7 +132,7 @@
"react",
"electron"
],
"homepage": "https://github.com/jeffvli/sonixd",
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
@@ -162,19 +167,19 @@
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.182",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14",
"@types/react-lazy-load-image-component": "^1.5.2",
"@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",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.25",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
@@ -188,7 +193,7 @@
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^18.0.1",
"electron": "^21.2.0",
"electron-builder": "^23.0.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
@@ -236,6 +241,7 @@
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.6.4",
"typescript-plugin-styled-components": "^2.0.0",
"url-loader": "^4.1.1",
@@ -246,13 +252,26 @@
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@emotion/react": "^11.10.4",
"@jellyfin/client-axios": "^10.7.8",
"@mantine/core": "^5.0.0",
"@mantine/form": "^5.0.0",
"@mantine/hooks": "^5.0.0",
"axios": "^0.26.1",
"@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",
"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",
"format-duration": "^2.0.0",
"framer-motion": "^6.4.2",
@@ -265,24 +284,23 @@
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "^2.0.0-beta.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-helmet-async": "^1.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-lazy-load-image-component": "^1.5.4",
"react-player": "^2.10.0",
"react-query": "^4.0.0-beta.23",
"react-icons": "^4.6.0",
"react-player": "^2.11.0",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"react-slider": "^2.0.0",
"react-spaces": "^0.3.4",
"react-use": "^17.3.2",
"react-simple-img": "^3.0.0",
"react-slider": "^2.0.4",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.7",
"react-window": "^1.8.8",
"react-window-infinite-loader": "^1.0.8",
"styled-components": "^5.3.5",
"tabler-icons-react": "^1.46.0",
"zustand": "^4.0.0-rc.1"
"socket.io-client": "^4.5.3",
"styled-components": "^5.3.6",
"zod": "^3.19.1",
"zustand": "^4.1.4"
},
"resolutions": {
"styled-components": "^5"
@@ -305,5 +323,11 @@
}
],
"singleQuote": true
},
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
}
}
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "sonixd",
"version": "1.0.0-alpha1",
"name": "feishin",
"version": "0.0.1-alpha1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "sonixd",
"version": "1.0.0-alpha1",
"name": "feishin",
"version": "0.0.1-alpha1",
"hasInstallScript": true,
"license": "MIT"
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "sonixd",
"version": "1.0.0-alpha1",
"description": "A full-featured Subsonic/Jellyfin compatible desktop client",
"name": "feishin",
"version": "0.0.1-alpha1",
"description": "",
"main": "./dist/main/main.js",
"author": {
"name": "jeffvli",
+15
View File
@@ -0,0 +1,15 @@
$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
@@ -0,0 +1,18 @@
$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
+58
View File
@@ -0,0 +1,58 @@
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: {},
},
},
};
+22
View File
@@ -0,0 +1,22 @@
@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
@@ -0,0 +1,22 @@
@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
@@ -0,0 +1,22 @@
@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
@@ -0,0 +1,52 @@
###
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
@@ -0,0 +1,66 @@
@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
@@ -0,0 +1,14 @@
@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
@@ -0,0 +1,21 @@
@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}}
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,110 @@
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
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,365 @@
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
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,63 @@
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,
};
+355
View File
@@ -0,0 +1,355 @@
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
@@ -0,0 +1,685 @@
/* 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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,125 @@
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
@@ -0,0 +1,50 @@
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,
};
@@ -9,16 +9,30 @@ import {
import { Strategy as LocalStrategy } from 'passport-local';
import { prisma } from './prisma';
export const generateToken = (userId: number) => {
return jwt.sign({ id: userId }, String(process.env.TOKEN_SECRET), {
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
});
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 = (userId: number) => {
return jwt.sign({ id: userId }, String(process.env.TOKEN_SECRET), {
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
});
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 (
@@ -54,12 +68,13 @@ passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
await prisma.user
.findUnique({
where: {
id: jwt_payload.id,
include: {
serverFolderPermissions: true,
serverPermissions: true,
},
where: { id: jwt_payload.id },
})
.then((user) => {
// eslint-disable-next-line promise/no-callback-in-promise
return done(null, user);
})
.catch((err) => {
@@ -72,7 +87,7 @@ passport.serializeUser((user: any, done) => {
return done(null, user.id);
});
passport.deserializeUser(async (id: number, done) => {
passport.deserializeUser(async (id: string, done) => {
return done(
null,
await prisma.user.findUnique({
+53
View File
@@ -0,0 +1,53 @@
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;
// });
+20
View File
@@ -0,0 +1,20 @@
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();
};
@@ -0,0 +1,40 @@
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();
};
@@ -0,0 +1,43 @@
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();
};
@@ -0,0 +1,40 @@
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();
};
@@ -0,0 +1,20 @@
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();
};
@@ -1,7 +1,10 @@
import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
export const authenticateLocal = (
export type AuthUser = Request['authUser'];
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
@@ -33,15 +36,32 @@ export const authenticateLocal = (
});
}
req.auth = {
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);
};
@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from 'express';
import { isJsonString } from '../utils';
import { isJsonString } from '@utils/is-json-string';
export const errorHandler = (
err: any,
@@ -9,12 +9,16 @@ export const errorHandler = (
) => {
let message = '';
const trace = err.stack.match(/at .* \(.*\)/g).map((e: string) => {
const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
return e.replace(/\(|\)/g, '');
});
if (err.message) {
message = isJsonString(err.message) ? JSON.parse(err.message) : 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({
+7
View File
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,83 @@
{
"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"
}
}
@@ -0,0 +1,938 @@
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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Server" ADD COLUMN "noCredential" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,16 @@
/*
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;
@@ -0,0 +1,14 @@
/*
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");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,29 @@
-- 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;
@@ -0,0 +1,14 @@
/*
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;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+543
View File
@@ -0,0 +1,543 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,2 @@
export * from './subsonic';
export * from './jellyfin';
+7
View File
@@ -0,0 +1,7 @@
import { jellyfinApi } from './jellyfin.api';
import { jellyfinScanner } from './jellyfin.scanner';
export const jellyfin = {
api: jellyfinApi,
scanner: jellyfinScanner,
};
@@ -1,17 +1,41 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import { Server } from '../../types/types';
import {
JFAlbumArtistsResponse,
JFAlbumsResponse,
JFArtistsResponse,
JFAuthenticate,
JFCollectionType,
JFGenreResponse,
JFItemType,
JFMusicFoldersResponse,
JFRequestParams,
JFSongsResponse,
} from './jellyfin-types';
} 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`,
@@ -19,7 +43,7 @@ export const getMusicFolders = async (server: Partial<Server>) => {
);
const musicFolders = data.Items.filter(
(folder) => folder.CollectionType === 'music'
(folder) => folder.CollectionType === JFCollectionType.MUSIC
);
return musicFolders;
@@ -63,7 +87,7 @@ export const getAlbums = async (server: Server, params: JFRequestParams) => {
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: 'MusicAlbum', ...params },
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
}
);
@@ -75,7 +99,7 @@ export const getSongs = async (server: Server, params: JFRequestParams) => {
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: 'Audio', ...params },
params: { includeItemTypes: JFItemType.AUDIO, ...params },
}
);
@@ -83,6 +107,7 @@ export const getSongs = async (server: Server, params: JFRequestParams) => {
};
export const jellyfinApi = {
authenticate,
getAlbumArtists,
getAlbums,
getArtists,
+500
View File
@@ -0,0 +1,500 @@
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,
};
@@ -49,7 +49,7 @@ export interface JFRequestParams {
}
export interface JFMusicFolder {
BackdropImageTags: any[];
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
@@ -68,7 +68,7 @@ export interface JFGenre {
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
@@ -84,7 +84,7 @@ export interface JFAlbumArtist {
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
@@ -113,13 +113,13 @@ export interface JFArtist {
export interface JFAlbum {
AlbumArtist: string;
AlbumArtists: GenericItem[];
ArtistItems: GenericItem[];
AlbumArtists: JFGenericItem[];
ArtistItems: JFGenericItem[];
Artists: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenericItem[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
@@ -139,16 +139,16 @@ export interface JFAlbum {
export interface JFSong {
Album: string;
AlbumArtist: string;
AlbumArtists: GenericItem[];
AlbumArtists: JFGenericItem[];
AlbumId: string;
AlbumPrimaryImageTag: string;
ArtistItems: GenericItem[];
ArtistItems: JFGenericItem[];
Artists: string[];
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenericItem[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
@@ -164,6 +164,7 @@ export interface JFSong {
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
SortName: string;
Type: string;
}
@@ -196,7 +197,7 @@ interface GenreItem {
Name: string;
}
interface GenericItem {
export interface JFGenericItem {
Id: string;
Name: string;
}
@@ -261,3 +262,143 @@ interface MediaStream {
Type: string;
Width?: number;
}
export enum JFExternalType {
MUSICBRAINZ = 'MusicBrainz',
THEAUDIODB = 'TheAudioDb',
}
export enum JFImageType {
LOGO = 'Logo',
PRIMARY = 'Primary',
}
export enum JFItemType {
AUDIO = 'Audio',
MUSICALBUM = 'MusicAlbum',
}
export enum JFCollectionType {
MUSIC = 'music',
PLAYLISTS = 'playlists',
}
export interface JFAuthenticate {
AccessToken: string;
ServerId: string;
SessionInfo: SessionInfo;
User: User;
}
interface SessionInfo {
AdditionalUsers: any[];
ApplicationVersion: string;
Capabilities: Capabilities;
Client: string;
DeviceId: string;
DeviceName: string;
HasCustomDeviceName: boolean;
Id: string;
IsActive: boolean;
LastActivityDate: string;
LastPlaybackCheckIn: string;
NowPlayingQueue: any[];
NowPlayingQueueFullItems: any[];
PlayState: PlayState;
PlayableMediaTypes: any[];
RemoteEndPoint: string;
ServerId: string;
SupportedCommands: any[];
SupportsMediaControl: boolean;
SupportsRemoteControl: boolean;
UserId: string;
UserName: string;
}
interface Capabilities {
PlayableMediaTypes: any[];
SupportedCommands: any[];
SupportsContentUploading: boolean;
SupportsMediaControl: boolean;
SupportsPersistentIdentifier: boolean;
SupportsSync: boolean;
}
interface PlayState {
CanSeek: boolean;
IsMuted: boolean;
IsPaused: boolean;
RepeatMode: string;
}
interface User {
Configuration: Configuration;
EnableAutoLogin: boolean;
HasConfiguredEasyPassword: boolean;
HasConfiguredPassword: boolean;
HasPassword: boolean;
Id: string;
LastActivityDate: string;
LastLoginDate: string;
Name: string;
Policy: Policy;
ServerId: string;
}
interface Configuration {
DisplayCollectionsView: boolean;
DisplayMissingEpisodes: boolean;
EnableLocalPassword: boolean;
EnableNextEpisodeAutoPlay: boolean;
GroupedFolders: any[];
HidePlayedInLatest: boolean;
LatestItemsExcludes: any[];
MyMediaExcludes: any[];
OrderedViews: any[];
PlayDefaultAudioTrack: boolean;
RememberAudioSelections: boolean;
RememberSubtitleSelections: boolean;
SubtitleLanguagePreference: string;
SubtitleMode: string;
}
interface Policy {
AccessSchedules: any[];
AuthenticationProviderId: string;
BlockUnratedItems: any[];
BlockedChannels: any[];
BlockedMediaFolders: any[];
BlockedTags: any[];
EnableAllChannels: boolean;
EnableAllDevices: boolean;
EnableAllFolders: boolean;
EnableAudioPlaybackTranscoding: boolean;
EnableContentDeletion: boolean;
EnableContentDeletionFromFolders: any[];
EnableContentDownloading: boolean;
EnableLiveTvAccess: boolean;
EnableLiveTvManagement: boolean;
EnableMediaConversion: boolean;
EnableMediaPlayback: boolean;
EnablePlaybackRemuxing: boolean;
EnablePublicSharing: boolean;
EnableRemoteAccess: boolean;
EnableRemoteControlOfOtherUsers: boolean;
EnableSharedDeviceControl: boolean;
EnableSyncTranscoding: boolean;
EnableUserPreferenceAccess: boolean;
EnableVideoPlaybackTranscoding: boolean;
EnabledChannels: any[];
EnabledDevices: any[];
EnabledFolders: any[];
ForceRemoteSourceTranscoding: boolean;
InvalidLoginAttemptCount: number;
IsAdministrator: boolean;
IsDisabled: boolean;
IsHidden: boolean;
LoginAttemptsBeforeLockout: number;
MaxActiveSessions: number;
PasswordResetProviderId: string;
RemoteClientBitrateLimit: number;
SyncPlayAccess: string;
}
+304
View File
@@ -0,0 +1,304 @@
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
@@ -0,0 +1,7 @@
import { navidromeApi } from './navidrome.api';
import { navidromeScanner } from './navidrome.scanner';
export const navidrome = {
api: navidromeApi,
scanner: navidromeScanner,
};
+87
View File
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,412 @@
/* 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
@@ -0,0 +1,169 @@
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
@@ -0,0 +1,123 @@
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
@@ -0,0 +1,5 @@
import { scannerQueue } from './scanner.queue';
export const queue = {
scanner: scannerQueue,
};
+51
View File
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,7 @@
import { subsonicApi } from './subsonic.api';
import { subsonicScanner } from './subsonic.scanner';
export const subsonic = {
api: subsonicApi,
scanner: subsonicScanner,
};
@@ -1,5 +1,7 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import { Server } from '../../types/types';
import md5 from 'md5';
import { randomString } from '../../utils/random-string';
import {
SSAlbumListEntry,
SSAlbumListResponse,
@@ -10,7 +12,7 @@ import {
SSArtistsResponse,
SSGenresResponse,
SSMusicFoldersResponse,
} from './subsonic-types';
} from './subsonic.types';
const api = axios.create({
validateStatus: (status) => status >= 200,
@@ -26,9 +28,34 @@ api.interceptors.response.use(
}
);
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=sonixd&f=json&${server.token}`
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=Feishin&f=json&${server.token}`
);
return data.musicFolders.musicFolder;
@@ -36,7 +63,7 @@ const getMusicFolders = async (server: Partial<Server>) => {
const getArtists = async (server: Server, musicFolderId: string) => {
const { data } = await api.get<SSArtistsResponse>(
`${server.url}/rest/getArtists.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
`${server.url}/rest/getArtists.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { musicFolderId } }
);
@@ -49,7 +76,7 @@ const getArtists = async (server: Server, musicFolderId: string) => {
const getGenres = async (server: Server) => {
const { data: genres } = await api.get<SSGenresResponse>(
`${server.url}/rest/getGenres.view?v=1.13.0&c=sonixd&f=json&${server.token}`
`${server.url}/rest/getGenres.view?v=1.13.0&c=Feishin&f=json&${server.token}`
);
return genres;
@@ -57,7 +84,7 @@ const getGenres = async (server: Server) => {
const getAlbum = async (server: Server, id: string) => {
const { data: album } = await api.get<SSAlbumResponse>(
`${server.url}/rest/getAlbum.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
`${server.url}/rest/getAlbum.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } }
);
@@ -71,13 +98,13 @@ const getAlbums = async (
) => {
const albums: any = api
.get<SSAlbumListResponse>(
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
`${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
!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);
@@ -104,7 +131,7 @@ const getAlbums = async (
const getArtistInfo = async (server: Server, id: string) => {
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
{ params: { id } }
);
@@ -120,6 +147,7 @@ const getArtistInfo = async (server: Server, id: string) => {
};
export const subsonicApi = {
authenticate,
getAlbum,
getAlbums,
getArtistInfo,
+319
View File
@@ -0,0 +1,319 @@
/* 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,
};
@@ -54,8 +54,8 @@ export interface SSMusicFolder {
}
export interface SSGenre {
albumCount: number;
songCount: number;
albumCount?: number;
songCount?: number;
value: string;
}
@@ -127,7 +127,7 @@ export interface SSSong {
export interface SSAlbumsParams {
fromYear?: number;
genre?: string;
musicFolderId?: number;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
+36
View File
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,51 @@
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);

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