271 Commits

Author SHA1 Message Date
Clément Le Bihan
0db8d49618 nothing important 2024-01-04 15:30:05 +01:00
Clément Le Bihan
4923fc72b2 reactènative-sounds 2023-12-31 17:59:56 +01:00
Arthur Jamet
60a73781bd Front: Lint + format 2023-12-29 18:13:40 +01:00
Arthur Jamet
4e3b378d6a Front: Lint + format 2023-12-29 18:13:40 +01:00
Clément Le Bihan
2bf1e783a9 removed unused var 2023-12-29 18:13:40 +01:00
Clément Le Bihan
375d36f6c5 Fixed google logo for mobile 2023-12-29 18:13:40 +01:00
Clément Le Bihan
495380ec43 Fix CI 2023-12-29 18:13:40 +01:00
Clément Le Bihan
af0531bb0c Fixed the like button and now desactivated the click on card to go to song and changed default display for score from '?' to '-' 2023-12-29 18:13:40 +01:00
Arthur Jamet
c5124fa6ad Front: MusicView: Fix Wrong Mutation 2023-12-29 18:13:40 +01:00
Arthur Jamet
962cf58e77 Front: DiscoveryView: USe Like status 2023-12-29 18:13:40 +01:00
Arthur Jamet
60988dd599 Front: Use Mutations to update 'liked' state 2023-12-29 18:13:40 +01:00
Arthur Jamet
004a541302 Front: Lint + format 2023-12-28 12:07:35 +01:00
Arthur Jamet
f4cd9e18ea Front: Explain how to DL the APK 2023-12-28 12:07:35 +01:00
Arthur Jamet
2dc301addf Front: add Button to Download APK From Web 2023-12-28 12:07:35 +01:00
Arthur Jamet
e85a959c26 Front: remove Visible IDs 2023-12-22 17:37:21 +01:00
Arthur Jamet
339e808d27 Front: SettingsView: Fox ordering of tabs 2023-12-21 17:17:47 +01:00
Arthur Jamet
22d1a97abd Front: SettingsView: Fox ordering of tabs 2023-12-21 17:17:47 +01:00
Arthur Jamet
ce4baa61dc Front: serve Google logo ourselves 2023-12-21 17:17:47 +01:00
Arthur Jamet
e90c7f05a8 Front: Remove use of external images for placeholders 2023-12-21 17:17:47 +01:00
Arthur Jamet
fb0e43af88 Front: Prettier 2023-12-21 17:17:47 +01:00
Arthur Jamet
4577997b1c Front :add spanish translations 2023-12-21 17:17:47 +01:00
Arthur Jamet
9bb256f2ee front: add missing translation components 2023-12-21 17:17:47 +01:00
Arthur Jamet
d3994ff26e Front: First Pass on translations + remove unused setting tabs 2023-12-21 17:17:47 +01:00
Clément Le Bihan
00d097f643 Fixes prettier 2023-12-20 12:01:55 +01:00
Arthur Jamet
99da77f23e Front: Fix cirular dependecy between validators 2023-12-20 12:01:55 +01:00
Arthur Jamet
7a6dc8b0c9 Front: Use history include to get best/last score for a song 2023-12-20 12:01:55 +01:00
Clément Le Bihan
b4f04f9b71 Fixed number of lignes on DiscoveryCard 2023-12-19 17:06:30 +01:00
Arthur Jamet
9df0c98100 Front: DiscoveryView: Remove Dummy Data 2023-12-19 15:03:18 +01:00
Bluub
a47f8744f8 Fixing ci :)
* wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* ci wip

* meili env var in example dotenv
2023-12-08 12:31:44 +01:00
80329e240e Format back 2023-12-07 17:11:34 +01:00
70b109e78b Allow songs to be searched and filtered by genres 2023-12-07 17:11:34 +01:00
a6a96d6a1e Implement search controler 2023-12-07 17:11:34 +01:00
cc4b69ca50 Add meilisearch 2023-12-07 17:11:34 +01:00
Clément Le Bihan
e733c6acc8 Merge pull request #336 from Chroma-Case/feat/adc/search-view-v2
implémentation de la search bar V2
2023-12-07 16:55:19 +01:00
Clément Le Bihan
afa6f421d3 Merge remote-tracking branch 'origin/main' into feat/adc/search-view-v2 2023-12-07 16:53:35 +01:00
Clément Le Bihan
7d7f886661 Fixed CI and good to merge 2023-12-07 16:52:35 +01:00
Clément Le Bihan
fd22b8afe5 Fixes of HIGH quality 2023-12-07 16:06:24 +01:00
danis
1c1596b44a fix(searchBarV2): mobile view adapted 2023-12-06 21:13:02 +01:00
danis
9b05dc3ae3 fix(searchBarV2): fix hidden scrollView when artist is selected 2023-12-06 20:31:08 +01:00
danis
d717269563 fix(searchBarV2): translation search bar placeholder 2023-12-06 20:08:47 +01:00
danis
cba8815cfc fix(searchBarV2): translations and genre none selection 2023-12-06 20:01:49 +01:00
Clément Le Bihan
647f7b2676 fixed CI 2023-12-06 16:00:49 +01:00
Clément Le Bihan
ef4f2355bf INput fixed styling 2023-12-06 15:57:37 +01:00
Clément Le Bihan
24a226b283 Adapted the layout and changed TextInput to Input from native base to support the theme 2023-12-06 15:42:15 +01:00
Clément Le Bihan
81717ec5b1 Renamed getGenres and getArtists to getAllGenres and getAllArtists and removed HomeNew from navigation 2023-12-06 15:03:32 +01:00
Clément Le Bihan
f9cb289eff Removed duplicate from bad merge (front) 2023-12-06 14:59:38 +01:00
Clément Le Bihan
022490ae10 removed duplicata from bad merge 2023-12-06 14:58:28 +01:00
Clément Le Bihan
ca4818c070 Merge remote-tracking branch 'origin/main' into feat/adc/search-view-v2 2023-12-06 14:52:44 +01:00
Clément Le Bihan
fe8e9cb262 Merge pull request #335 from Chroma-Case/front/redesign-score
Front: Score Modal
2023-12-05 16:24:43 +01:00
Clément Le Bihan
9683d83298 Fixed dev compose 2023-12-05 16:23:52 +01:00
Clément Le Bihan
69d9a4c499 CI fixes 2023-12-05 16:18:05 +01:00
7678776872 Readd song history's song 2023-12-05 15:00:18 +01:00
Clément Le Bihan
f590b573fb Fixed validator for songhistory 2023-12-05 12:26:52 +01:00
Clément Le Bihan
2c9ec4a7d3 added missing useAssets in Scaffold and did duplicate suppresion for play history 2023-12-05 11:24:15 +01:00
Arthur Jamet
393782b4b8 Front: Fix state management at end of play 2023-12-05 10:38:57 +01:00
Arthur Jamet
c33e1bbaa3 Front: Merge 2023-12-05 08:42:28 +01:00
GitBluub
63a9271617 fix: dotenv for scoro 2023-12-05 00:02:06 +01:00
GitBluub
6469d4763a fix: dotenv for scoro 2023-12-05 00:02:06 +01:00
Clément Le Bihan
922e36093e Merge remote-tracking branch 'origin/main' into feat/adc/search-view-v2 2023-12-04 23:48:14 +01:00
Clément Le Bihan
81976206f9 Merge branch 'main' into feat/adc/search-view-v2 2023-12-04 23:43:08 +01:00
Amaury
4ac6369deb Leaderboard View (#332)
* LeaderboardView init

* back scores handling

* blah

* add score controller

* commit score on end of play

* front and back fix

* podium component

* push the button

* ill be baaack

* flex css thing

* pretty

* migration leaderboard

* feat(leaderboard): wip

* feat(leaderboard): pretty

* feat(leaderboard): i might be dumb

* fix(leaderboard): misuse of nullable() for totalScore User validator
2023-12-04 23:37:06 +01:00
Clément Le Bihan
dc0c7fa4e7 removed the console.log that was polluting the log 2023-12-04 23:16:20 +01:00
GitBluub
61ebf58631 fix: public routes 2023-12-04 23:16:20 +01:00
Clément Le Bihan
1d61b1e652 eslint 2023-12-04 23:16:20 +01:00
Clément Le Bihan
d0f9c4a032 Fixed louis's PR 2023-12-04 23:16:20 +01:00
GitBluub
27119056a4 fix: env var for scoro 2023-12-04 23:16:20 +01:00
GitBluub
044dd59d8f fix: env var for scoro 2023-12-04 23:16:20 +01:00
GitBluub
e5ab9b9310 fix: env var for scoro 2023-12-04 23:16:20 +01:00
GitBluub
f11cddf55a fix: env var for scoro test 2023-12-04 23:16:20 +01:00
GitBluub
f076bf9794 fix: example dotenv for ci 2023-12-04 23:16:20 +01:00
GitBluub
fe510e148a fix: scoro and populate with apikey 2023-12-04 23:16:20 +01:00
GitBluub
0a84c9daac fix: robot tests with apikey 2023-12-04 23:16:20 +01:00
GitBluub
f496ae5bc1 Added imports and headers for jwt and apikey strategies 2023-12-04 23:16:20 +01:00
GitBluub
1379cbd3f6 feat: apikey strategy 2023-12-04 23:16:20 +01:00
GitBluub
ab221bd393 fix: scoro test maybe 2023-12-04 23:16:20 +01:00
GitBluub
e5fb1dfb7e fix: scoro test maybe 2023-12-04 23:16:20 +01:00
GitBluub
c113b70fee fix: scoro test maybe 2023-12-04 23:16:20 +01:00
GitBluub
d2a8f9a1ef fix: scoro test maybe 2023-12-04 23:16:20 +01:00
GitBluub
ee56a53b40 fix: populate script 2023-12-04 23:16:20 +01:00
ece93f79b2 Add a git blame ignore revs file for prettier 2023-12-04 13:28:58 +01:00
14e241db37 Format code with prettier 2023-12-04 13:28:07 +01:00
3becdcff46 Fix types 2023-12-04 13:28:07 +01:00
c0bc611268 Use includes on liked, music, score, search and fav pages 2023-12-04 13:28:07 +01:00
eff5eae706 Handle includes in the home page 2023-12-04 13:28:07 +01:00
59a48ad060 Handle includes in the front for the hisory 2023-12-04 13:28:07 +01:00
Arthur Jamet
d3f7eded41 Front: Merge 2023-12-04 09:58:28 +01:00
Arthur Jamet
bbf3a317ec Front: Score: Better iterations 2023-12-02 08:37:49 +01:00
Arthur Jamet
c6365113c4 Front: Prettier 2023-12-02 08:20:08 +01:00
Arthur Jamet
454835338f Front: Fix some mistakes 2023-12-02 08:18:12 +01:00
Clément Le Bihan
3f0c2472cb explcit any are now warning and fixed other ci problems in zoe's code 2023-12-01 16:46:58 +01:00
a36afa3a47 Fix input validation 2023-12-01 16:46:58 +01:00
9bce8d74c9 Fix tab index of interactive base 2023-12-01 16:46:58 +01:00
Clément Le Bihan
e5acd56b0f Removed andoid folder for eslint and type script and fixed apostrophes 2023-12-01 16:24:19 +01:00
Clément Le Bihan
685e79d76b Changed to the original React native midi lib and added some packages to build the app 2023-12-01 15:49:20 +01:00
danis
183dee193c pretty, lint, type check 2023-12-01 15:22:45 +01:00
danis
7167b49edc Merge branch 'feature/adc/#243-leaderboard' into feat/adc/search-view-v2 2023-12-01 15:03:26 +01:00
danis
8b465731f0 fix(leaderboard): lint removal + rows userAvatar wrong property induced by previous type any 2023-12-01 14:28:21 +01:00
danis
0e26dbfc65 fix(leaderboard): type check + headerShown set to false 2023-12-01 14:14:52 +01:00
danis
347c075ab1 fix(leaderboard): misuse of nullable() for totalScore User validator 2023-12-01 14:05:02 +01:00
danis
01829c7b8b feat(searchView): wip 2023-12-01 14:00:41 +01:00
danis
e148f9edb8 feat(searchBar): artist row list chip selection + genre dropdown select 2023-12-01 13:58:34 +01:00
danis
8a00b99f9a feat(searchBar): wip 2023-11-30 21:34:05 +01:00
Arthur Jamet
4d16723e38 Front: Score Button: Better redirect 2023-11-30 17:55:08 +01:00
Clément Le Bihan
683984efe9 Cleanup CI and added check for error in scoro messages 2023-11-30 15:34:58 +01:00
Clément Le Bihan
6018028afd Fixed API baseurl for nightly front and mobiles and fixed wrong start message to the scorometer 2023-11-30 15:34:58 +01:00
Clément Le Bihan
eac21844c4 Moved PopupCC in the dom and Removed react context in hopes to get better perf 2023-11-30 15:34:58 +01:00
Clément Le Bihan
0cb8dd2693 Added state for metronome and fixed onEndReached for mobile 2023-11-30 15:34:58 +01:00
Clément Le Bihan
a4a10eb7f2 Removed shadow elevation since we don't really see a change and its not working on mobile 2023-11-30 15:34:58 +01:00
Clément Le Bihan
ff4926fa80 Moved the Loading display to partition Magic to better display svg loading 2023-11-30 15:34:58 +01:00
Clément Le Bihan
dd581a8418 Added a tricked light theme for mobile 2023-11-30 15:34:58 +01:00
Clément Le Bihan
5f0d7dda59 Fix popup layout on mobile 2023-11-30 15:34:58 +01:00
Clément Le Bihan
dfdbbdc51c Fixed the /api usage in web prod 2023-11-30 15:34:58 +01:00
Arthur Jamet
72f17c018e Front: Score Modal 2023-11-30 14:24:48 +01:00
danis
397dfbcf5f Merge branch 'main' into feat/adc/search-view-v2 2023-11-30 13:41:27 +01:00
danis
df682327d6 feat(leaderboard): i might be dumb 2023-11-30 13:34:20 +01:00
danis
46d5614e4c Merge branch 'main' into feature/adc/#243-leaderboard 2023-11-30 11:56:19 +01:00
danis
7e1f03af57 feat(leaderboard): pretty 2023-11-30 11:49:39 +01:00
danis
b54032fe63 feat(leaderboard): wip 2023-11-30 10:51:18 +01:00
Clément Le Bihan
b417076ee6 CI compliance 2023-11-28 23:12:45 +01:00
Clément Le Bihan
95da2cc500 lint fix 2023-11-28 23:12:45 +01:00
Clément Le Bihan
84ea0b3743 Fix tsc 2023-11-28 23:12:45 +01:00
Clément Le Bihan
00433ee7ba Some fixes for API log error 2023-11-28 23:12:45 +01:00
Clément Le Bihan
7f282e2ec5 tried to desactivate SSR without success and fixed chromacase logo in scaffold auth 2023-11-28 23:12:45 +01:00
Clément Le Bihan
3b89387b12 Fixes, cleanup from first PR reread 2023-11-28 23:12:45 +01:00
Clément Le Bihan
3b24cefd3f Removed the header on the play page 2023-11-28 23:12:45 +01:00
Clément Le Bihan
4de420e4dc work on PlayView is almost done everything works fine the gameplay makes the mobile crash but it will be fixed later 2023-11-28 23:12:45 +01:00
Clément Le Bihan
36041369db now using dims from cursor file info to set correct ratios for the partition display independantly of the front end platform 2023-11-28 23:12:45 +01:00
Clément Le Bihan
b33ff55167 Succesfully displayed a partition with correct size 2023-11-28 23:12:45 +01:00
Clément Le Bihan
e8e6012bf2 updated react native svg 2023-11-28 23:12:45 +01:00
Clément Le Bihan
92169bf485 Removed every pointer event bounding-box due to no support with react native 2023-11-28 23:12:45 +01:00
Clément Le Bihan
9f57e8ac67 Working on first support of svg on mobiles but seems quite complicated 2023-11-28 23:12:45 +01:00
Clément Le Bihan
262353376c Reduced padding on mobile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
fd50b2268b Cleanup of the control bar code in the PLayview 2023-11-28 23:12:45 +01:00
Clément Le Bihan
6839cda5b8 Moved control bar into it's own component and fixed itslayout on mobile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
d2aca488ad Minor bugfixes for Desktop css compliance 2023-11-28 23:12:45 +01:00
Clément Le Bihan
1fe7491bcd Added scrollview for SettingsProfile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
6a8fe074e0 Removed Scrollview from ScaffoldMobile and now each view implement its scroll 2023-11-28 23:12:45 +01:00
Clément Le Bihan
624b640e01 Added a phone size for SongCardInfo fixed flex layout to display SongCardInfo in DiscoveryView and added first scrollview 2023-11-28 23:12:45 +01:00
Clément Le Bihan
ce4e09f1f6 Additional CSS for goldenratiocards 2023-11-28 23:12:45 +01:00
Clément Le Bihan
c085e9aa22 Tried to make work the golden ratio on phone but failed (it's better tho) 2023-11-28 23:12:45 +01:00
Clément Le Bihan
dc491983f5 Separated GoldenRatio comp from Discovery view 2023-11-28 23:12:45 +01:00
Clément Le Bihan
a0587fbad6 Fixed flex layout for ProfileView on mobile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
702caed232 Added must wondition for APIBase Url for dev modes 2023-11-28 23:12:45 +01:00
Clément Le Bihan
cb65e08465 Redid the implementation of the icon in the TextFieldBase to work on mobile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
c1e862e6bd Fixed ScaffoldAuth layout on mobile 2023-11-28 23:12:45 +01:00
Clément Le Bihan
533dc0e7ad Fixed css for mobile android 2023-11-28 23:12:45 +01:00
Clément Le Bihan
ecac53516e init branch 2023-11-28 23:12:45 +01:00
Arthur Jamet
9133a369d5 Front: Play piano sounds natively (#326)
* Fixed cache misimplementation and reinstalled canvas package with correct node version (17) works on prod docker compose but not on dev so :)

* Fixed type definition of SongCursorInfos fixed 'race conditions' in asset generation service removed hard coded cursor infos fixed tsc looking to build folders

* Front: Basic Load of piano sounds

* Front: Use store for piano notes

* WIP

* Front: Native Sound playing

* Front: fix type

* Front: Play all notes under cursor

* Docker: Force running backend on amd64

* Front: Rebase, and add native metronome sound

* Front: Metronome: Use icons from iconsax

* Poof, it typechecks

* Front: add missing ref

* Now callback is called with the first note

* Front: Fix Native build w/ requires

* Front: Try bumping rn version

* Front: CI: Attempt to make things work

* Front: Pretty

* Front: Make sounds sound better

---------

Co-authored-by: Clément Le Bihan <clement.lebihan773@gmail.com>
2023-11-28 18:16:17 +01:00
Clément Le Bihan
4c580f1693 Update generateImages_browserless.js
Added real dims inside cursor file
2023-11-27 22:54:58 +01:00
Clément Le Bihan
732f8e2577 Update generateImages_browserless.js 2023-11-27 17:58:51 +01:00
Clément Le Bihan
4b3ec157c2 Update CI.yml
Special commit that will be reverted after Louis decides to work
2023-11-22 13:12:19 +01:00
Arthur Jamet
58de04924e Merge pull request #329 from Chroma-Case/front/lobby-v2
Front: New (Pseudo) Lobby
2023-11-20 07:40:35 +01:00
Arthur Jamet
66048ca793 Front: Prettier 2023-11-19 16:27:40 +01:00
Arthur Jamet
b0b5579cb3 Front: Play Page: Add correct info in top-right coard 2023-11-19 11:52:26 +01:00
Arthur Jamet
005cc7410f Front: PlayView: Fix wrong theming of text color 2023-11-19 09:48:34 +01:00
Arthur Jamet
a4c2c4932d Front: Add popup on playview to select mode 2023-11-19 09:34:19 +01:00
Arthur Jamet
617d31cb22 Front: Remove Old Lobby Page 2023-11-19 09:13:22 +01:00
Clément Le Bihan
384fb10f54 Prettier and linter stuff 2023-11-18 23:29:22 +01:00
Clément Le Bihan
72c615ffed cleanup 2023-11-18 23:29:22 +01:00
Clément Le Bihan
ce2da1d859 Added louis's back modif and set public routes for cover and svg illustration 2023-11-18 23:29:22 +01:00
Clément Le Bihan
9d6beb74c0 Prrited 2023-11-18 23:29:22 +01:00
Clément Le Bihan
1f25521900 Removed unused code and dependancies (moti, opensheetmusicdisplay, phaser) 2023-11-18 23:29:22 +01:00
Clément Le Bihan
f5c0d6967b Removed pianomsgs 2023-11-18 23:29:22 +01:00
Clément Le Bihan
c5e5519426 Fixed metronome not repecting control 2023-11-18 23:29:22 +01:00
Clément Le Bihan
4c98759ded Put the score on asolute on top of partition 2023-11-18 23:29:22 +01:00
Clément Le Bihan
c910b0e617 Move metronome into the control bar and implemented a better handling for screen shrinking 2023-11-18 23:29:22 +01:00
Clément Le Bihan
94218558a7 removing readonly 2023-11-18 23:29:22 +01:00
Clément Le Bihan
f1662ca18b Fixed type definition of SongCursorInfos fixed 'race conditions' in asset generation service removed hard coded cursor infos fixed tsc looking to build folders 2023-11-18 23:29:22 +01:00
Clément Le Bihan
4a5658c4ca Fixed cache misimplementation and reinstalled canvas package with correct node version (17) works on prod docker compose but not on dev so :) 2023-11-18 23:29:22 +01:00
Clément Le Bihan
61ed8855ea Fixed some bugs and added Playinfocard in top right corner of playview 2023-11-18 23:29:22 +01:00
Clément Le Bihan
94838ef1fc Implemented starProgress the new layout for playview 2023-11-18 23:29:22 +01:00
Clément Le Bihan
3ce69228a8 Tested with short and worked 1st try added dynamic Image dimensions detection and now calling onEndreached on last cursorposition 2023-11-18 23:29:22 +01:00
Clément Le Bihan
c522258d04 Added some animations/transition time and move back to a static position cursor 2023-11-18 23:29:22 +01:00
Clément Le Bihan
f91ab4c430 Added first works on the V2 of the partitionview 2023-11-18 23:29:22 +01:00
Arthur Jamet
57cba61d1b Merge pull request #325 from Chroma-Case/design
Design
2023-11-17 13:58:27 +01:00
mathysPaul
3fbcb23089 [FIX] Reviwed comments on the RP 2023-11-17 13:32:35 +01:00
mathysPaul
9b0c633a87 [FIX] Reviwed comments on the RP 2023-11-17 13:22:54 +01:00
mathysPaul
c91bbfd2f1 [FIX] Reviwed comments on the RP 2023-11-17 13:19:39 +01:00
mathysPaul
9882fd240e [FIX] fix some type errors 2023-11-17 10:37:14 +01:00
mathysPaul
ea6073eb71 [REM] fit-content removed 2023-11-17 10:18:29 +01:00
mathysPaul
22722082eb [FIX] Reviwed comments on the RP 2023-11-17 00:28:22 +01:00
mathysPaul
36316b0333 [FIX] Reviwed comments on the RP 2023-11-17 00:23:28 +01:00
danis
a814eec2cf a little bit of this a little bit of that 2023-11-15 19:00:44 +01:00
danis
4c96f78a46 search bar 2023-11-14 21:07:25 +01:00
mathysPaul
cc65a3bd09 [REF] InteractiveBase refactoring logic 2023-11-14 12:29:00 +01:00
mathysPaul
5f9e9f5327 [REF] InteractiveBase refactoring logic 2023-11-14 00:46:01 +01:00
danis
b1d54d8665 migration leaderboard 2023-11-13 21:43:43 +01:00
mathysPaul
d01aabe788 [IMP] linter & prettier 2023-11-13 14:34:14 +01:00
mathysPaul
19d64c1bc5 [IMP] Color theme & MusicList optional property 2023-11-13 14:30:10 +01:00
mathysPaul
ee98e6e352 [FEAT] MusicList component:
Implemented MusicList for displaying music items with optimized rendering and dynamic loading (code with comment).
2023-11-13 02:15:39 +01:00
mathysPaul
bf52e7385b [FEAT] MusicList component:
Implemented MusicList for displaying music items with optimized rendering and dynamic loading.
2023-11-12 23:39:27 +01:00
mathysPaul
2d6fd3a3dc . 2023-11-07 20:26:22 +01:00
mathysPaul
4bb5a11fff [INIT] MusicList component: start (push to dev in another computer) 2023-11-03 18:11:43 +01:00
mathysPaul
d4a758d262 [FIX]:
- Prettier
- Linter
2023-11-02 21:53:21 +01:00
mathysPaul
9397de8cb9 [Merge] 2023-11-02 21:23:23 +01:00
mathysPaul
d2e1ba51c6 [ADD] LibCC ChromaCase:
- IconButton and MusicItem creation and documentation
- Update native base theme
2023-11-02 21:14:38 +01:00
Arthur Jamet
ebed646c07 Front: type-check navigator + lint and pretty 2023-10-28 08:25:23 +02:00
Arthur Jamet
7067fb9708 Front: Pretty 2023-10-28 08:09:21 +02:00
mathysPaul
e499bb2f9f [ADD] SettingsView: translation, Menu: collapsed mode && translation 2023-10-28 00:47:27 +02:00
mathysPaul
b87ec1dd44 [FIX] SettingsView: setting up the translation system for settings 2023-10-27 23:00:03 +02:00
mathysPaul
77f0c2f06f [add]: LinkBase && PopupCC, starting theme management (light and dark) and translation 2023-10-27 20:50:05 +02:00
danis
4c1891fb44 pretty 2023-10-27 13:48:22 +02:00
danis
b3dade1a38 flex css thing 2023-10-27 13:07:32 +02:00
danis
be2617e1ee ill be baaack 2023-10-27 11:45:23 +02:00
danis
35e1268f36 push the button 2023-10-25 10:11:10 +02:00
danis
a8a3ed0e7b Merge branch 'main' into feature/adc/#243-leaderboard 2023-10-20 11:32:54 +02:00
danis
0eef957a90 podium component 2023-10-20 11:32:22 +02:00
mathysPaul
6a8ca7d0fa [Purge profile view]: Remove skills filters and add filter by music 2023-10-15 21:09:56 +02:00
mathysPaul
ddd29f5530 merge main to design 2023-10-15 10:59:25 +02:00
mathysPaul
b6e8b20168 fix focusable field and pop-up style 2023-10-15 09:22:38 +02:00
bfb6cf5958 Disable jwt auth for images routes 2023-10-12 13:34:56 +02:00
a92ca75760 Fix dev nginx 2023-10-12 12:47:48 +02:00
8d8323e382 Cleanup inports 2023-10-12 12:47:48 +02:00
76d7e69d19 Add includable fields for all ressources 2023-10-12 12:47:48 +02:00
be58e932a9 Run prettier 2023-10-12 12:47:48 +02:00
38bbe56e9b Add robot tests 2023-10-12 12:47:48 +02:00
a65ce6595a Add a generic include system and implement it for songs 2023-10-12 12:47:48 +02:00
danis
96c43bcbad Merge branch 'main' into feature/adc/#243-leaderboard 2023-10-12 11:03:22 +02:00
danis
ab1ad17d21 front and back fix 2023-10-12 11:02:53 +02:00
Arthur Jamet
90f7890e5f Update README (#314)
* Update README

* README: Fixes cause me dumb
2023-10-09 16:46:35 +02:00
danis
5c85296810 Merge branch 'main' into feature/adc/#243-leaderboard 2023-10-08 21:54:42 +02:00
danis
06bfc181c7 commit score on end of play 2023-10-08 21:53:58 +02:00
danis
0473665bb4 add score controller 2023-10-08 20:54:45 +02:00
mathysPaul
f610de3045 Restore guest mode 2023-10-08 19:47:03 +02:00
Arthur Jamet
911e174aef Front: Update splashscreen (#312) 2023-10-08 06:56:16 +02:00
Arthur Jamet
6d7f46c425 Merge pull request #308 from Chroma-Case/front/fix-oops 2023-10-05 12:09:56 +02:00
Arthur Jamet
b72e7a54e5 Front: Fix Oops page 2023-10-05 10:25:51 +02:00
Arthur Jamet
d99d134382 Front: Fix Web Build + Improve CI (#302)
Co-authored-by: Clément Le Bihan <clement.lebihan773@gmail.com>
2023-10-03 14:56:09 +02:00
Arthur Jamet
576675411a Merge pull request #292 from Chroma-Case/front/fix-expo 2023-10-02 18:01:43 +02:00
Arthur Jamet
d214558bc4 Front: Remove unused import 2023-10-02 17:06:02 +02:00
Arthur Jamet
4299a93afe Front: Fix env var 2023-10-02 16:56:46 +02:00
Arthur Jamet
920126a392 Front: Set Env Vars 2023-10-02 14:09:17 +02:00
Arthur Jamet
16e6a5e21b Front: Fix Icon dimensions 2023-10-01 11:40:55 +02:00
Arthur Jamet
9539018b64 .env.example: add new env var 2023-10-01 11:22:06 +02:00
Arthur Jamet
0081eb2acd Front: EAS: Fix project slug 2023-10-01 11:21:20 +02:00
Arthur Jamet
bcb0825f5a Front: remove duplicate deps 2023-09-30 14:28:29 +02:00
Arthur Jamet
18a3fa518c Front: Add missing dependency 2023-09-30 14:07:14 +02:00
Arthur Jamet
0407f5c29e Front: Add missing dependency 2023-09-30 12:07:53 +02:00
Arthur Jamet
6dafe2a8e9 Front: try a custom fork 2023-09-30 11:52:10 +02:00
Arthur Jamet
4a8f0aa1af Front: Add missing eslint deps 2023-09-30 11:17:43 +02:00
Arthur Jamet
745b20358d Front: Add eslint in dev deps 2023-09-30 11:14:04 +02:00
Arthur Jamet
0f544b31f3 Front: Add prettier in dev deps 2023-09-30 11:08:56 +02:00
Arthur Jamet
76d70f3edd Front: Typecheck 2023-09-30 11:05:08 +02:00
Arthur Jamet
1c17ac8b13 Front: Fix missing dependencies 2023-09-30 10:45:23 +02:00
Arthur Jamet
232579e75b Front: Add Dependencies 2023-09-30 10:23:02 +02:00
Arthur Jamet
01221eda00 Front: Add dependencies 2023-09-29 18:34:46 +02:00
Arthur Jamet
b73c2fef58 Front: Add dependencies 2023-09-29 18:17:55 +02:00
Arthur Jamet
3c9c1b5ff7 Front: Add dependencies 2023-09-29 18:00:11 +02:00
Arthur Jamet
e50b1c1344 Front: Install Dev client 2023-09-29 16:03:50 +02:00
Arthur Jamet
6dfc531891 Front: Install Jest 2023-09-29 15:53:20 +02:00
Arthur Jamet
b4f268dee0 Front: Redump Expo 2023-09-29 15:47:16 +02:00
mathysPaul
1228eb603e [add] Scaffold for mobile & desktop 2023-09-27 17:31:09 +02:00
mathysPaul
3ca17338e8 [add] Scaffold redesign 2023-09-27 13:30:24 +02:00
mathysPaul
614ce105bd Merge branch 'main' of github.com:Chroma-Case/Chromacase into design 2023-09-26 15:34:06 +02:00
Arthur Jamet
e366fa4b32 Merge pull request #282 from Chroma-Case/feature/adc/retour-utilisateur
Feature/adc/retour utilisateur
2023-09-26 07:31:19 +02:00
Arthur Jamet
cd87451208 Front: Typechecking 2023-09-25 17:55:46 +02:00
danis
845c473ed5 removed useless function 2023-09-25 17:17:05 +02:00
danis
f4d75eef73 css whatever + pretty 2023-09-25 14:51:20 +02:00
danis
5395bbb03a Duration component 2023-09-25 14:24:08 +02:00
danis
a0ca945c72 blah 2023-09-25 13:31:49 +02:00
danis
291d7698d4 back scores handling 2023-09-22 22:44:50 +02:00
danis
e8956c50ee LeaderboardView init 2023-09-22 22:44:05 +02:00
danis
2d90c6eec1 pretty 2023-09-22 15:50:26 +02:00
danis
0b0fd0585d added DurationInfo 2023-09-22 15:49:12 +02:00
danis
6cf72dfcca Merge branch 'main' into feature/adc/retour-utilisateur 2023-09-22 15:11:33 +02:00
danis
a81c0b83bb song length SongRow 2023-09-22 15:08:28 +02:00
danis
b2fb497ecf populate.py updated with midi length 2023-09-22 14:53:36 +02:00
mathysPaul
450fe1e7bd CheckboxBase update design: color selectable 2023-09-21 23:39:27 +02:00
mathysPaul
fbf4dfcfa5 Merge branch 'main' of github.com:Chroma-Case/Chromacase into design 2023-09-21 23:11:53 +02:00
mathysPaul
d251929ede design init 2023-09-21 23:10:44 +02:00
445816dfad Fix log error for images 2023-09-21 17:03:18 +02:00
386 changed files with 16092 additions and 26105 deletions

View File

@@ -8,10 +8,17 @@ POSTGRES_DB=chromacase
API_URL=http://localhost:80/api
SCORO_URL=ws://localhost:6543
MINIO_ROOT_PASSWORD=12345678
EXPO_PUBLIC_API_URL=http://localhost:80/api
EXPO_PUBLIC_SCORO_URL=ws://localhost:6543
GOOGLE_CLIENT_ID=toto
GOOGLE_SECRET=tata
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
SMTP_TRANSPORT=smtps://toto:tata@relay
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
IGNORE_MAILS=true
API_KEYS=SCOROTEST,ROBOTO,SCORO
API_KEY_SCORO_TEST=SCOROTEST
API_KEY_ROBOT=ROBOTO
API_KEY_SCORO=SCORO
MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb"
# vi: ft=sh

1
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1 @@
14e241db37c4080bc0bd87363cf7a57ef8379f46

View File

@@ -1,143 +1,18 @@
name: CI
name: Deploy
on:
pull_request:
types:
- closed
branches:
- main
push:
branches:
- '*'
pull_request:
branches: [ main ]
- main
jobs:
## Build Back ##
Build_Back:
deployment:
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: ./back
environment: Staging
steps:
- uses: actions/checkout@v3
- name: Build Docker
run: docker build -t testback .
## Build App ##
Build_Front:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./front
environment: Staging
steps:
- uses: actions/checkout@v3
- name: Install Yarn
run: npm install -g yarn
- name: Install dependencies
run: yarn install
- name: Type Check
run: yarn tsc
- name: Check Prettier
run: yarn pretty:check .
- name: Run Linter
run: yarn lint
- name: 🏗 Setup Expo
uses: expo/expo-github-action@v7
with:
expo-version: latest
eas-version: 3.3.1
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android APK
run: |
eas build -p android --profile production --local --non-interactive
mv *.apk chromacase.apk
- name: Upload Artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v3
with:
name: chromacase.apk
path: front/
## Test Backend ##
Test_Back:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [ Build_Back ]
environment: Staging
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- name: Copy env file to github secret env file
run: cp .env.example .env
- name: Start the service
run: docker-compose up -d back db
- name: Perform healthchecks
run: |
docker-compose ps -a
docker-compose logs
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
- name: Run scorometer tests
run: |
pip install -r scorometer/requirements.txt
cd scorometer/tests && ./runner.sh
- name: Run robot tests
run: |
pip install -r back/test/robot/requirements.txt
robot -d out back/test/robot/
- uses: actions/upload-artifact@v3
if: always()
with:
name: results
path: out
- name: Write results to Pull Request and Summary
if: always() && github.event_name == 'pull_request'
uses: joonvena/robotframework-reporter-action@v2.1
with:
report_path: out/
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
only_summary: false
- name: Write results to Summary
if: always() && github.event_name != 'pull_request'
uses: joonvena/robotframework-reporter-action@v2.1
with:
report_path: out/
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
only_summary: true
- name: Remove .env && stop the service
run: docker-compose down && rm .env
## Test App ##
## Deployement ##
Deployement_Docker:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: [ Test_Back ]
environment: Production
if: github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v2
@@ -182,6 +57,7 @@ jobs:
build-args: |
API_URL=${{secrets.API_URL}}
SCORO_URL=${{secrets.SCORO_URL}}
- name: Docker meta scorometer
id: meta_scorometer
uses: docker/metadata-action@v4

98
.github/workflows/back.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: "Back"
on:
pull_request:
branches: [ main ]
jobs:
changes:
runs-on: ubuntu-latest
# Required permissions
permissions:
pull-requests: read
# Set job outputs to values from filter step
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
scoro: ${{ steps.filter.outputs.scoro }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
scoro:
- 'scorometer/**'
back_build:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
defaults:
run:
working-directory: ./back
steps:
- uses: actions/checkout@v3
- name: Build Docker
run: docker build -t testback .
back_test:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [ back_build ]
if: ${{ needs.changes.outputs.frontend == 'true' }}
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- name: Copy env file to github secret env file
run: cp .env.example .env
- name: Build and start the service
run: docker-compose up -d meilisearch back db
- name: Perform healthchecks
run: |
docker-compose ps -a
docker-compose logs
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
- name: Run robot tests
run: |
export API_KEY_ROBOT=ROBOTO
pip install -r back/test/robot/requirements.txt
robot -d out back/test/robot/
- uses: actions/upload-artifact@v3
if: always()
with:
name: results
path: out
- name: Write results to Pull Request and Summary
if: always() && github.event_name == 'pull_request'
uses: joonvena/robotframework-reporter-action@v2.1
with:
report_path: out/
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
only_summary: false
- name: Write results to Summary
if: always() && github.event_name != 'pull_request'
uses: joonvena/robotframework-reporter-action@v2.1
with:
report_path: out/
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
only_summary: true
- name: stop the service
run: docker-compose down

95
.github/workflows/front.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: "Front"
on:
pull_request:
branches: [ main ]
jobs:
changes:
runs-on: ubuntu-latest
# Required permissions
permissions:
pull-requests: read
# Set job outputs to values from filter step
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
scoro: ${{ steps.filter.outputs.scoro }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
scoro:
- 'scorometer/**'
front_check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./front
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'yarn'
cache-dependency-path: front/yarn.lock
- run: yarn install --frozen-lockfile
- name: type check
run: yarn tsc
- name: prettier
run: yarn pretty:check .
- name: eslint
run: yarn lint
front_build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./front
if: ${{ needs.changes.outputs.frontend == 'true' }}
needs: [ front_check ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'yarn'
cache-dependency-path: front/yarn.lock
- run: yarn install --frozen-lockfile
- name: 🏗 Setup Expo
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Web App
uses: docker/build-push-action@v3
with:
context: ./front
push: false
tags: ${{steps.meta_front.outputs.tags}}
build-args: |
API_URL=${{secrets.API_URL}}
SCORO_URL=${{secrets.SCORO_URL}}
- name: Build Android APK
run: |
eas build -p android --profile production --local --non-interactive
mv *.apk chromacase.apk
- name: Upload Artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v3
with:
name: chromacase.apk
path: front/

60
.github/workflows/scoro.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: "Scoro"
on:
pull_request:
branches: [ main ]
jobs:
changes:
runs-on: ubuntu-latest
# Required permissions
permissions:
pull-requests: read
# Set job outputs to values from filter step
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
scoro: ${{ steps.filter.outputs.scoro }}
steps:
# For pull requests it's not necessary to checkout the code
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
scoro:
- 'scorometer/**'
scoro_test:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.scoro == 'true' }}
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- name: Copy env file to github secret env file
run: cp .env.example .env
- name: Build and start the service
run: docker-compose up -d meilisearch back db
- name: Perform healthchecks
run: |
docker-compose ps -a
docker-compose logs
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
- name: Run scorometer tests
run: |
export API_KEY_SCORO_TEST=SCOROTEST
export API_KEY_SCORO=SCORO
pip install -r scorometer/requirements.txt
cd scorometer/tests && ./runner.sh
- name: stop the service
run: docker-compose down

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ node_modules/
.data
.DS_Store
_gen
venv

View File

@@ -1,9 +1,39 @@
# ![Chromacase](./assets/graphical/title.png)
# ![Chromacase](./assets/graphical/banner.png)
La principale raison pour laquelle on arrête de jouer d'un instrument est la perte de motivation. C'est un apprentissage long et vraiment demandant. ChromaCase propose d'accompagner les joueurs de piano grâce à une application mobile avec une expérience personnalisée. Celle-ci, générée par une IA, cible les goûts et identifie les difficultés du joueur.
Ça vous interesse? Rendez-vous sur notre [site](https://chromacase.studio/) pour prendre contact
Ça vous interesse? Rendez-vous sur notre [site](http://eip.epitech.eu/2024/chromacase) pour prendre contact
## Structure du Projet
## Comment lancer le projet
![Schéma Fonctionnel](./assets/docs/structure.png)
Pensez à remplir un `.env` (à la racine du projet), en se basant sur le `.env.example`.
### Development
```bash
docker-compose -f docker-compose.dev.yml up --build
```
### Production
```bash
docker-compose up --build
```
## Liens Utiles
- Site de Production: [Lien](http://chroma.octohub.app/)
- Site du Nightly: [Lien](http://nightly.chroma.octohub.app/)
- Site vitrine: [Lien](http://eip.epitech.eu/2024/chromacase)
- Documentation: [Github](https://github.com/Chroma-Case/DAteX)
## Membres du Projet
| Nom | Role | Contact |
|--------------------------|--------------------------------------|----------------------------------------------------|
| Zoé Roux | CEO, Responsable Back-end | [GitHub](https://github.com/zoriya) |
| Clément Le-Bihan | CTO, Responsable Front-end | [GitHub](https://github.com/Octopus773) |
| Arthur Jamet | Manager, Développeur Front-end | [GitHub](https://github.com/Arthi-chaud) |
| Louis Auzuret | Développeur Back-end, Responsable CI | [Github](https://github.com/GitBluub) |
| Aumaury Danis-Cousandier | Développeur Front-end | [Github](https://github.com/AmauryDanisCousandier) |
| Mathys Paul | Développeur Front-end, Designer | [GitHub](https://github.com/mathysPaul) |

BIN
assets/graphical/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

View File

@@ -4,9 +4,14 @@ import sys
import os
import requests
import glob
from mido import MidiFile
from configparser import ConfigParser
url = os.environ.get("API_URL")
api_key = os.environ.get("API_KEY_POPULATE")
auth_headers = {}
auth_headers["Authorization"] = f"API Key {api_key}"
def getOrCreateAlbum(name, artistId):
if not name:
@@ -14,27 +19,27 @@ def getOrCreateAlbum(name, artistId):
res = requests.post(f"{url}/album", json={
"name": name,
"artist": artistId,
})
},headers=auth_headers)
out = res.json()
print(out)
return out["id"]
def getOrCreateGenre(names):
ids = []
for name in names.split(","):
res = requests.post(f"{url}/genre", json={
"name": name,
})
out = res.json()
print(out)
ids += [out["id"]]
#TODO handle multiple genres
return ids[0]
ids = []
for name in names.split(","):
res = requests.post(f"{url}/genre", json={
"name": name,
},headers=auth_headers)
out = res.json()
print(out)
ids += [out["id"]]
#TODO handle multiple genres
return ids[0]
def getOrCreateArtist(name):
res = requests.post(f"{url}/artist", json={
"name": name,
})
},headers=auth_headers)
out = res.json()
print(out)
return out["id"]
@@ -42,10 +47,13 @@ def getOrCreateArtist(name):
def populateFile(path, midi, mxl):
config = ConfigParser()
config.read(path)
mid = MidiFile(midi)
metadata = config["Metadata"];
difficulties = dict(config["Difficulties"])
difficulties["length"] = round((mid.length), 2)
artistId = getOrCreateArtist(metadata["Artist"])
print(f"Populating {metadata['Name']}")
print(auth_headers)
res = requests.post(f"{url}/song", json={
"name": metadata["Name"],
"midiPath": f"/assets/{midi}",
@@ -55,7 +63,7 @@ def populateFile(path, midi, mxl):
"album": getOrCreateAlbum(metadata["Album"], artistId),
"genre": getOrCreateGenre(metadata["Genre"]),
"illustrationPath": f"/assets/{os.path.commonpath([midi, mxl])}/illustration.png"
})
}, headers=auth_headers)
print(res.json())

2
assets/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
mido
requests

View File

@@ -1,4 +1,4 @@
{
"singleQuote": true,
"singleQuote": false,
"trailingComma": "all"
}

View File

@@ -1,3 +1,3 @@
FROM node:17
WORKDIR /app
CMD npx prisma generate ; npx prisma migrate dev ; npm run start:dev
CMD npm i ; npx prisma generate ; npx prisma migrate dev ; npm run start:dev

11080
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,13 +35,19 @@
"@types/bcryptjs": "^2.4.2",
"@types/passport": "^1.0.12",
"bcryptjs": "^2.4.3",
"canvas": "^2.11.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"json-logger-service": "^9.0.1",
"class-validator": "^0.14.0",
"cross-blob": "^3.0.2",
"fs": "^0.0.1-security",
"jsdom": "^22.1.0",
"json-logger-service": "^9.0.1",
"meilisearch": "^0.35.0",
"node-fetch": "^2.6.12",
"nodemailer": "^6.9.5",
"opensheetmusicdisplay": "^1.8.4",
"passport-google-oauth20": "^2.0.0",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prisma-class-generator": "^0.2.7",
@@ -81,7 +87,8 @@
"moduleFileExtensions": [
"js",
"json",
"ts"
"ts",
"mjs"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totalScore" INTEGER NOT NULL DEFAULT 0;

View File

@@ -24,6 +24,7 @@ model User {
googleID String? @unique
isGuest Boolean @default(false)
partyPlayed Int @default(0)
totalScore Int @default(0)
LessonHistory LessonHistory[]
SongHistory SongHistory[]
searchHistory SearchHistory[]

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -12,25 +11,36 @@ import {
Post,
Query,
Req,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateAlbumDto } from './dto/create-album.dto';
import { AlbumService } from './album.service';
import { Request } from 'express';
import { Prisma, Album } from '@prisma/client';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Album as _Album } from 'src/_gen/prisma-class/album';
UseGuards,
} from "@nestjs/common";
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
import { CreateAlbumDto } from "./dto/create-album.dto";
import { AlbumService } from "./album.service";
import { Request } from "express";
import { Prisma, Album } from "@prisma/client";
import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger";
import { FilterQuery } from "src/utils/filter.pipe";
import { Album as _Album } from "src/_gen/prisma-class/album";
import { IncludeMap, mapInclude } from "src/utils/include";
import { AuthGuard } from "@nestjs/passport";
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
@Controller('album')
@ApiTags('album')
@Controller("album")
@ApiTags("album")
@UseGuards(ChromaAuthGuard)
export class AlbumController {
static filterableFields: string[] = ['+id', 'name', '+artistId'];
static filterableFields: string[] = ["+id", "name", "+artistId"];
static includableFields: IncludeMap<Prisma.AlbumInclude> = {
artist: true,
Song: true,
};
constructor(private readonly albumService: AlbumService) {}
@Post()
@ApiOperation({ description: "Register a new album, should not be used by frontend"})
@ApiOperation({
description: "Register a new album, should not be used by frontend",
})
async create(@Body() createAlbumDto: CreateAlbumDto) {
try {
return await this.albumService.createAlbum({
@@ -46,41 +56,50 @@ export class AlbumController {
}
}
@Delete(':id')
@ApiOperation({ description: "Delete an album by id"})
async remove(@Param('id', ParseIntPipe) id: number) {
@Delete(":id")
@ApiOperation({ description: "Delete an album by id" })
async remove(@Param("id", ParseIntPipe) id: number) {
try {
return await this.albumService.deleteAlbum({ id });
} catch {
throw new NotFoundException('Invalid ID');
throw new NotFoundException("Invalid ID");
}
}
@Get()
@ApiOkResponsePlaginated(_Album)
@ApiOperation({ description: "Get all albums paginated"})
@ApiOperation({ description: "Get all albums paginated" })
async findAll(
@Req() req: Request,
@FilterQuery(AlbumController.filterableFields)
where: Prisma.AlbumWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Album>> {
const ret = await this.albumService.albums({
skip,
take,
where,
include: mapInclude(include, req, AlbumController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get an album by id"})
@ApiOkResponse({ type: _Album})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.albumService.album({ id });
@Get(":id")
@ApiOperation({ description: "Get an album by id" })
@ApiOkResponse({ type: _Album })
async findOne(
@Req() req: Request,
@Query("include") include: string,
@Param("id", ParseIntPipe) id: number,
) {
const res = await this.albumService.album(
{ id },
mapInclude(include, req, AlbumController.includableFields),
);
if (res === null) throw new NotFoundException('Album not found');
if (res === null) throw new NotFoundException("Album not found");
return res;
}
}

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AlbumController } from './album.controller';
import { AlbumService } from './album.service';
import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module";
import { AlbumController } from "./album.controller";
import { AlbumService } from "./album.service";
@Module({
imports: [PrismaModule],

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma, Album } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Prisma, Album } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class AlbumService {
@@ -14,9 +14,11 @@ export class AlbumService {
async album(
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
include?: Prisma.AlbumInclude,
): Promise<Album | null> {
return this.prisma.album.findUnique({
where: albumWhereUniqueInput,
include,
});
}
@@ -26,14 +28,16 @@ export class AlbumService {
cursor?: Prisma.AlbumWhereUniqueInput;
where?: Prisma.AlbumWhereInput;
orderBy?: Prisma.AlbumOrderByWithRelationInput;
include?: Prisma.AlbumInclude;
}): Promise<Album[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.album.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";
export class CreateAlbumDto {
@IsNotEmpty()

View File

@@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Test, TestingModule } from "@nestjs/testing";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
describe('AppController', () => {
describe("AppController", () => {
let appController: AppController;
beforeEach(async () => {
@@ -14,9 +14,9 @@ describe('AppController', () => {
appController = app.get<AppController>(AppController);
});
describe('root', () => {
describe("root", () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
expect(appController.getHello()).toBe("Hello World!");
});
});
});

View File

@@ -1,13 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOkResponse } from '@nestjs/swagger';
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";
import { ApiOkResponse } from "@nestjs/swagger";
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ApiOkResponse({ description: 'Return a hello world message, used as a health route' })
@ApiOkResponse({
description: "Return a hello world message, used as a health route",
})
getHello(): string {
return this.appService.getHello();
}

View File

@@ -1,20 +1,21 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma/prisma.service';
import { UsersModule } from './users/users.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { SongModule } from './song/song.module';
import { LessonModule } from './lesson/lesson.module';
import { SettingsModule } from './settings/settings.module';
import { ArtistService } from './artist/artist.service';
import { GenreModule } from './genre/genre.module';
import { ArtistModule } from './artist/artist.module';
import { AlbumModule } from './album/album.module';
import { SearchModule } from './search/search.module';
import { HistoryModule } from './history/history.module';
import { MailerModule } from '@nestjs-modules/mailer';
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { PrismaService } from "./prisma/prisma.service";
import { UsersModule } from "./users/users.module";
import { PrismaModule } from "./prisma/prisma.module";
import { AuthModule } from "./auth/auth.module";
import { SongModule } from "./song/song.module";
import { LessonModule } from "./lesson/lesson.module";
import { SettingsModule } from "./settings/settings.module";
import { ArtistService } from "./artist/artist.service";
import { GenreModule } from "./genre/genre.module";
import { ArtistModule } from "./artist/artist.module";
import { AlbumModule } from "./album/album.module";
import { SearchModule } from "./search/search.module";
import { HistoryModule } from "./history/history.module";
import { MailerModule } from "@nestjs-modules/mailer";
import { ScoresModule } from "./scores/scores.module";
@Module({
imports: [
@@ -29,6 +30,7 @@ import { MailerModule } from '@nestjs-modules/mailer';
SearchModule,
SettingsModule,
HistoryModule,
ScoresModule,
MailerModule.forRoot({
transport: process.env.SMTP_TRANSPORT,
defaults: {

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
return "Hello World!";
}
}

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
@@ -14,26 +13,43 @@ import {
Query,
Req,
StreamableFile,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateArtistDto } from './dto/create-artist.dto';
import { Request } from 'express';
import { ArtistService } from './artist.service';
import { Prisma, Artist } from '@prisma/client';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Artist as _Artist} from 'src/_gen/prisma-class/artist';
UseGuards,
} from "@nestjs/common";
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
import { CreateArtistDto } from "./dto/create-artist.dto";
import { Request } from "express";
import { ArtistService } from "./artist.service";
import { Prisma, Artist } from "@prisma/client";
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import { createReadStream, existsSync } from "fs";
import { FilterQuery } from "src/utils/filter.pipe";
import { Artist as _Artist } from "src/_gen/prisma-class/artist";
import { IncludeMap, mapInclude } from "src/utils/include";
import { Public } from "src/auth/public";
import { AuthGuard } from "@nestjs/passport";
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
@Controller('artist')
@ApiTags('artist')
@Controller("artist")
@ApiTags("artist")
@UseGuards(ChromaAuthGuard)
export class ArtistController {
static filterableFields = ['+id', 'name'];
static filterableFields = ["+id", "name"];
static includableFields: IncludeMap<Prisma.ArtistInclude> = {
Song: true,
Album: true,
};
constructor(private readonly service: ArtistService) {}
@Post()
@ApiOperation({ description: "Register a new artist, should not be used by frontend"})
@ApiOperation({
description: "Register a new artist, should not be used by frontend",
})
async create(@Body() dto: CreateArtistDto) {
try {
return await this.service.create(dto);
@@ -42,25 +58,26 @@ export class ArtistController {
}
}
@Delete(':id')
@ApiOperation({ description: "Delete an artist by id"})
async remove(@Param('id', ParseIntPipe) id: number) {
@Delete(":id")
@ApiOperation({ description: "Delete an artist by id" })
async remove(@Param("id", ParseIntPipe) id: number) {
try {
return await this.service.delete({ id });
} catch {
throw new NotFoundException('Invalid ID');
throw new NotFoundException("Invalid ID");
}
}
@Get(':id/illustration')
@ApiOperation({ description: "Get an artist's illustration"})
@ApiNotFoundResponse({ description: "Artist or illustration not found"})
async getIllustration(@Param('id', ParseIntPipe) id: number) {
@Get(":id/illustration")
@ApiOperation({ description: "Get an artist's illustration" })
@ApiNotFoundResponse({ description: "Artist or illustration not found" })
@Public()
async getIllustration(@Param("id", ParseIntPipe) id: number) {
const artist = await this.service.get({ id });
if (!artist) throw new NotFoundException('Artist not found');
if (!artist) throw new NotFoundException("Artist not found");
const path = `/assets/artists/${artist.name}/illustration.png`;
if (!existsSync(path))
throw new NotFoundException('Illustration not found');
throw new NotFoundException("Illustration not found");
try {
const file = createReadStream(path);
@@ -71,30 +88,39 @@ export class ArtistController {
}
@Get()
@ApiOperation({ description: "Get all artists paginated"})
@ApiOperation({ description: "Get all artists paginated" })
@ApiOkResponsePlaginated(_Artist)
async findAll(
@Req() req: Request,
@FilterQuery(ArtistController.filterableFields)
where: Prisma.ArtistWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Artist>> {
const ret = await this.service.list({
skip,
take,
where,
include: mapInclude(include, req, ArtistController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get an artist by id"})
@ApiOkResponse({ type: _Artist})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
@Get(":id")
@ApiOperation({ description: "Get an artist by id" })
@ApiOkResponse({ type: _Artist })
async findOne(
@Req() req: Request,
@Query("include") include: string,
@Param("id", ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, ArtistController.includableFields),
);
if (res === null) throw new NotFoundException('Artist not found');
if (res === null) throw new NotFoundException("Artist not found");
return res;
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { ArtistController } from './artist.controller';
import { ArtistService } from './artist.service';
import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module";
import { ArtistController } from "./artist.controller";
import { ArtistService } from "./artist.service";
import { SearchModule } from "src/search/search.module";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, SearchModule],
controllers: [ArtistController],
providers: [ArtistService],
})

View File

@@ -1,20 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Prisma, Artist } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Prisma, Artist } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { MeiliService } from "src/search/meilisearch.service";
@Injectable()
export class ArtistService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private search: MeiliService,
) {}
async create(data: Prisma.ArtistCreateInput): Promise<Artist> {
return this.prisma.artist.create({
const ret = await this.prisma.artist.create({
data,
});
await this.search.index("artists").addDocuments([ret]);
return ret;
}
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
async get(
where: Prisma.ArtistWhereUniqueInput,
include?: Prisma.ArtistInclude,
): Promise<Artist | null> {
return this.prisma.artist.findUnique({
where,
include,
});
}
@@ -24,20 +34,24 @@ export class ArtistService {
cursor?: Prisma.ArtistWhereUniqueInput;
where?: Prisma.ArtistWhereInput;
orderBy?: Prisma.ArtistOrderByWithRelationInput;
include?: Prisma.ArtistInclude;
}): Promise<Artist[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.artist.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}
async delete(where: Prisma.ArtistWhereUniqueInput): Promise<Artist> {
return this.prisma.artist.delete({
const ret = await this.prisma.artist.delete({
where,
});
await this.search.index("artists").deleteDocument(ret.id);
return ret;
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";
export class CreateArtistDto {
@IsNotEmpty()

View File

@@ -0,0 +1,794 @@
// import Blob from "cross-blob";
import FS from "fs";
import jsdom from "jsdom";
//import headless_gl from "gl"; // this is now imported dynamically in a try catch, in case gl install fails, see #1160
import * as OSMD from "opensheetmusicdisplay"; // window needs to be available before we can require OSMD
let Blob;
/*
Render each OSMD sample, grab the generated images, andg
dump them into a local directory as PNG or SVG files.
inspired by Vexflow's generate_png_images and vexflow-tests.js
This can be used to generate PNGs or SVGs from OSMD without a browser.
It's also used with the visual regression test system (using PNGs) in
`tools/visual_regression.sh`
(see package.json, used with npm run generate:blessed and generate:current, then test:visual).
Note: this script needs to "fake" quite a few browser elements, like window, document,
and a Canvas HTMLElement (for PNG) or the DOM (for SVG) ,
which otherwise are missing in pure nodejs, causing errors in OSMD.
For PNG it needs the canvas package installed.
There are also some hacks needed to set the container size (offsetWidth) correctly.
Otherwise you'd need to run a headless browser, which is way slower,
see the semi-obsolete generateDiffImagesPuppeteerLocalhost.js
*/
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const timestampToMs = (timestamp, wholeNoteLength) => {
return timestamp.RealValue * wholeNoteLength;
};
const getActualNoteLength = (note, wholeNoteLength) => {
let duration = timestampToMs(note.Length, wholeNoteLength);
if (note.NoteTie) {
const firstNote = note.NoteTie.Notes.at(1);
if (Object.is(note.NoteTie.StartNote, note) && firstNote) {
duration += timestampToMs(firstNote.Length, wholeNoteLength);
} else {
duration = 0;
}
}
return duration;
};
function getCursorPositions(osmd, filename, partitionDims) {
osmd.cursor.show();
const bpm = osmd.Sheet.HasBPMInfo
? osmd.Sheet.getExpressionsStartTempoInBPM()
: 60;
const wholeNoteLength = Math.round((60 / bpm) * 4000);
const curPos = [];
while (!osmd.cursor.iterator.EndReached) {
const notesToPlay = osmd.cursor
.NotesUnderCursor()
.filter((note) => {
return note.isRest() == false && note.Pitch;
})
.map((note) => {
const fixedKey =
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)
?.fixedKey ?? 0;
const midiNumber = note.halfTone - fixedKey * 12;
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
return {
note: midiNumber,
gain: gain,
duration: getActualNoteLength(note, wholeNoteLength),
};
});
const shortestNotes = osmd.cursor
.NotesUnderCursor()
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
.at(0);
const ts = timestampToMs(
shortestNotes?.getAbsoluteTimestamp() ?? new OSMD.Fraction(-1),
wholeNoteLength,
);
const sNL = timestampToMs(
shortestNotes?.Length ?? new OSMD.Fraction(-1),
wholeNoteLength,
);
curPos.push({
x: parseFloat(osmd.cursor.cursorElement.style.left),
y: parseFloat(osmd.cursor.cursorElement.style.top),
width: osmd.cursor.cursorElement.width,
height: osmd.cursor.cursorElement.height,
notes: notesToPlay,
timestamp: ts,
timing: sNL,
});
osmd.cursor.next();
}
osmd.cursor.reset();
osmd.cursor.hide();
const cursorsFilename = `${imageDir}/${filename}.json`;
FS.writeFileSync(
cursorsFilename,
JSON.stringify({
pageWidth: partitionDims[0],
pageHeight: partitionDims[1],
cursors: curPos,
}),
);
console.log(`Saved cursor positions to ${cursorsFilename}`);
}
// global variables
// (without these being global, we'd have to pass many of these values to the generateSampleImage function)
// eslint-disable-next-line prefer-const
let assetName;
let sampleDir;
let imageDir;
let imageFormat;
let pageWidth;
let pageHeight;
let filterRegex;
let mode;
let debugSleepTimeString;
let skyBottomLinePreference;
let pageFormat;
export async function generateSongAssets(
assetName_,
sampleDir_,
imageDir_,
imageFormat_,
pageWidth_,
pageHeight_,
filterRegex_,
mode_,
debugSleepTimeString_,
skyBottomLinePreference_,
) {
assetName = assetName_;
sampleDir = sampleDir_;
imageDir = imageDir_;
imageFormat = imageFormat_;
pageWidth = pageWidth_;
pageHeight = pageHeight_;
filterRegex = filterRegex_;
mode = mode_;
debugSleepTimeString = debugSleepTimeString_;
skyBottomLinePreference = skyBottomLinePreference_;
imageFormat = imageFormat?.toLowerCase();
eval(`import("cross-blob")`).then((module) => {
Blob = module.default;
});
debug("" + sampleDir + " " + imageDir + " " + imageFormat);
if (!mode) {
mode = "";
}
if (
!assetName ||
!sampleDir ||
!imageDir ||
(imageFormat !== "png" && imageFormat !== "svg")
) {
console.log(
"usage: " +
// eslint-disable-next-line max-len
"node test/Util/generateImages_browserless.mjs osmdBuildDir sampleDirectory imageDirectory svg|png [width|0] [height|0] [filterRegex|all|allSmall] [--debug|--osmdtesting] [debugSleepTime]",
);
console.log(
" (use pageWidth and pageHeight 0 to not divide the rendering into pages (endless page))",
);
console.log(
' (use "all" to skip filterRegex parameter. "allSmall" with --osmdtesting skips two huge OSMD samples that take forever to render)',
);
console.log(
"example: node test/Util/generateImages_browserless.mjs ../../build ./test/data/ ./export png",
);
console.log(
"Error: need osmdBuildDir, sampleDir, imageDir and svg|png arguments. Exiting.",
);
Promise.reject(
"Error: need osmdBuildDir, sampleDir, imageDir and svg|png arguments. Exiting.",
);
}
await init();
}
// let OSMD; // can only be required once window was simulated
// eslint-disable-next-line @typescript-eslint/no-var-requires
async function init() {
debug("init");
const osmdTestingMode = mode.includes("osmdtesting"); // can also be --debugosmdtesting
const osmdTestingSingleMode = mode.includes("osmdtestingsingle");
const DEBUG = mode.startsWith("--debug");
// const debugSleepTime = Number.parseInt(process.env.GENERATE_DEBUG_SLEEP_TIME) || 0; // 5000 works for me [sschmidTU]
if (DEBUG) {
// debug(' (note that --debug slows down the script by about 0.3s per file, through logging)')
const debugSleepTimeMs = Number.parseInt(debugSleepTimeString, 10);
if (debugSleepTimeMs > 0) {
debug("debug sleep time: " + debugSleepTimeString);
await sleep(Number.parseInt(debugSleepTimeMs, 10));
// [VSCode] apparently this is necessary for the debugger to attach itself in time before the program closes.
// sometimes this is not enough, so you may have to try multiple times or increase the sleep timer. Unfortunately debugging nodejs isn't easy.
}
}
debug("sampleDir: " + sampleDir, DEBUG);
debug("imageDir: " + imageDir, DEBUG);
debug("imageFormat: " + imageFormat, DEBUG);
pageFormat = "Endless";
pageWidth = Number.parseInt(pageWidth, 10);
pageHeight = Number.parseInt(pageHeight, 10);
const endlessPage = !(pageHeight > 0 && pageWidth > 0);
if (!endlessPage) {
pageFormat = `${pageWidth}x${pageHeight}`;
}
// ---- hacks to fake Browser elements OSMD and Vexflow need, like window, document, and a canvas HTMLElement ----
// eslint-disable-next-line @typescript-eslint/no-var-requires
const dom = new jsdom.JSDOM("<!DOCTYPE html></html>");
// eslint-disable-next-line no-global-assign
// window = dom.window;
// eslint-disable-next-line no-global-assign
// document = dom.window.document;
// eslint-disable-next-line no-global-assign
global.window = dom.window;
// eslint-disable-next-line no-global-assign
global.document = window.document;
//window.console = console; // probably does nothing
global.HTMLElement = window.HTMLElement;
global.HTMLAnchorElement = window.HTMLAnchorElement;
global.XMLHttpRequest = window.XMLHttpRequest;
global.DOMParser = window.DOMParser;
global.Node = window.Node;
if (imageFormat === "png") {
global.Canvas = window.Canvas;
}
// For WebGLSkyBottomLineCalculatorBackend: Try to import gl dynamically
// this is so that the script doesn't fail if gl could not be installed,
// which can happen in some linux setups where gcc-11 is installed, see #1160
try {
const { default: headless_gl } = await import("gl");
const oldCreateElement = document.createElement.bind(document);
document.createElement = function (tagName, options) {
if (tagName.toLowerCase() === "canvas") {
const canvas = oldCreateElement(tagName, options);
const oldGetContext = canvas.getContext.bind(canvas);
canvas.getContext = function (contextType, contextAttributes) {
if (
contextType.toLowerCase() === "webgl" ||
contextType.toLowerCase() === "experimental-webgl"
) {
const gl = headless_gl(
canvas.width,
canvas.height,
contextAttributes,
);
gl.canvas = canvas;
return gl;
} else {
return oldGetContext(contextType, contextAttributes);
}
};
return canvas;
} else {
return oldCreateElement(tagName, options);
}
};
} catch {
if (skyBottomLinePreference === "--webgl") {
debug(
"WebGL image generation was requested but gl is not installed; using non-WebGL generation.",
);
}
}
// fix Blob not found (to support external modules like is-blob)
global.Blob = Blob;
const div = document.createElement("div");
div.id = "browserlessDiv";
document.body.appendChild(div);
// const canvas = document.createElement('canvas')
// div.canvas = document.createElement('canvas')
const zoom = 1.0;
// width of the div / PNG generated
let width = pageWidth * zoom;
// TODO sometimes the width is way too small for the score, may need to adjust zoom.
if (endlessPage) {
width = 1440;
}
let height = pageHeight;
if (endlessPage) {
height = 32767;
}
div.width = width;
div.height = height;
// div.offsetWidth = width; // doesn't work, offsetWidth is always 0 from this. see below
// div.clientWidth = width;
// div.clientHeight = height;
// div.scrollHeight = height;
// div.scrollWidth = width;
div.setAttribute("width", width);
div.setAttribute("height", height);
div.setAttribute("offsetWidth", width);
// debug('div.offsetWidth: ' + div.offsetWidth, DEBUG) // 0 here, set correctly later
// debug('div.height: ' + div.height, DEBUG)
// hack: set offsetWidth reliably
Object.defineProperties(window.HTMLElement.prototype, {
offsetLeft: {
get: function () {
return parseFloat(window.getComputedStyle(this).marginTop) || 0;
},
},
offsetTop: {
get: function () {
return parseFloat(window.getComputedStyle(this).marginTop) || 0;
},
},
offsetHeight: {
get: function () {
return height;
},
},
offsetWidth: {
get: function () {
return width;
},
},
});
debug("div.offsetWidth: " + div.offsetWidth, DEBUG);
debug("div.height: " + div.height, DEBUG);
// ---- end browser hacks (hopefully) ----
// load globally
// Create the image directory if it doesn't exist.
FS.mkdirSync(imageDir, { recursive: true });
// const sampleDirFilenames = FS.readdirSync(sampleDir);
let samplesToProcess = []; // samples we want to process/generate pngs of, excluding the filtered out files/filenames
// sampleDir is the direct path to a single file but is then only keept as a the directory containing the file
if (sampleDir.match("^.*(.xml)|(.musicxml)|(.mxl)$")) {
let pathParts = sampleDir.split("/");
let filename = pathParts[pathParts.length - 1];
sampleDir = pathParts.slice(0, pathParts.length - 1).join("/");
samplesToProcess.push(filename);
} else {
debug("not a correct extension sampleDir: " + sampleDir, DEBUG);
}
// for (const sampleFilename of sampleDirFilenames) {
// if (osmdTestingMode && filterRegex === "allSmall") {
// if (sampleFilename.match("^(Actor)|(Gounod)")) {
// // TODO maybe filter by file size instead
// debug("filtering big file: " + sampleFilename, DEBUG);
// continue;
// }
// }
// // eslint-disable-next-line no-useless-escape
// if (sampleFilename.match("^.*(.xml)|(.musicxml)|(.mxl)$")) {
// // debug('found musicxml/mxl: ' + sampleFilename)
// samplesToProcess.push(sampleFilename);
// } else {
// debug("discarded file/directory: " + sampleFilename, DEBUG);
// }
// }
// filter samples to process by regex if given
if (
filterRegex &&
filterRegex !== "" &&
filterRegex !== "all" &&
!(osmdTestingMode && filterRegex === "allSmall")
) {
debug("filtering samples for regex: " + filterRegex, DEBUG);
samplesToProcess = samplesToProcess.filter((filename) =>
filename.match(filterRegex),
);
debug(`found ${samplesToProcess.length} matches: `, DEBUG);
for (let i = 0; i < samplesToProcess.length; i++) {
debug(samplesToProcess[i], DEBUG);
}
}
const backend = imageFormat === "png" ? "canvas" : "svg";
const osmdInstance = new OSMD.OpenSheetMusicDisplay(div, {
autoResize: false,
backend: backend,
pageBackgroundColor: "#FFFFFF",
pageFormat: pageFormat,
// defaultFontFamily: 'Arial',
drawTitle: false,
renderSingleHorizontalStaffline: true,
drawComposer: false,
drawCredits: false,
drawLyrics: false,
drawPartNames: false,
followCursor: false,
cursorsOptions: [{ type: 0, color: "green", alpha: 0.5, follow: false }],
});
// for more options check OSMDOptions.ts
// you can set finer-grained rendering/engraving settings in EngravingRules:
// osmdInstance.EngravingRules.TitleTopDistance = 5.0 // 5.0 is default
// (unless in osmdTestingMode, these will be reset with drawingParameters default)
// osmdInstance.EngravingRules.PageTopMargin = 5.0 // 5 is default
// osmdInstance.EngravingRules.PageBottomMargin = 5.0 // 5 is default. <5 can cut off scores that extend in the last staffline
// note that for now the png and canvas will still have the height given in the script argument,
// so even with a margin of 0 the image will be filled to the full height.
// osmdInstance.EngravingRules.PageLeftMargin = 5.0 // 5 is default
// osmdInstance.EngravingRules.PageRightMargin = 5.0 // 5 is default
// osmdInstance.EngravingRules.MetronomeMarkXShift = -8; // -6 is default
// osmdInstance.EngravingRules.DistanceBetweenVerticalSystemLines = 0.15; // 0.35 is default
// for more options check EngravingRules.ts (though not all of these are meant and fully supported to be changed at will)
if (DEBUG) {
osmdInstance.setLogLevel("debug");
// debug(`osmd PageFormat: ${osmdInstance.EngravingRules.PageFormat.width}x${osmdInstance.EngravingRules.PageFormat.height}`)
debug(
`osmd PageFormat idString: ${osmdInstance.EngravingRules.PageFormat.idString}`,
);
debug("PageHeight: " + osmdInstance.EngravingRules.PageHeight);
} else {
osmdInstance.setLogLevel("info"); // doesn't seem to work, log.debug still logs
}
debug(
"[OSMD.generateImages] starting loop over samples, saving images to " +
imageDir,
DEBUG,
);
for (let i = 0; i < samplesToProcess.length; i++) {
const sampleFilename = samplesToProcess[i];
debug("sampleFilename: " + sampleFilename, DEBUG);
await generateSampleImage(
sampleFilename,
sampleDir,
osmdInstance,
osmdTestingMode,
{},
DEBUG,
);
if (
osmdTestingMode &&
!osmdTestingSingleMode &&
sampleFilename.startsWith("Beethoven") &&
sampleFilename.includes("Geliebte")
) {
// generate one more testing image with skyline and bottomline. (startsWith 'Beethoven' don't catch the function test)
await generateSampleImage(
sampleFilename,
sampleDir,
osmdInstance,
osmdTestingMode,
{ skyBottomLine: true },
DEBUG,
);
// generate one more testing image with GraphicalNote positions
await generateSampleImage(
sampleFilename,
sampleDir,
osmdInstance,
osmdTestingMode,
{ boundingBoxes: "VexFlowGraphicalNote" },
DEBUG,
);
}
}
debug("done, exiting.");
return Promise.resolve();
}
// eslint-disable-next-line
// let maxRss = 0, maxRssFilename = '' // to log memory usage (debug)
async function generateSampleImage(
sampleFilename,
directory,
osmdInstance,
osmdTestingMode,
options = {},
DEBUG = false,
) {
function makeSkyBottomLineOptions() {
const preference = skyBottomLinePreference ?? "";
if (preference === "--batch") {
return {
preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
skyBottomLineBatchCriteria: 0, // use batch algorithm only
};
} else if (preference === "--webgl") {
return {
preferredSkyBottomLineBatchCalculatorBackend: 1, // webgl
skyBottomLineBatchCriteria: 0, // use batch algorithm only
};
} else {
return {
preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
skyBottomLineBatchCriteria: Infinity, // use non-batch algorithm only
};
}
}
const samplePath = directory + "/" + sampleFilename;
let loadParameter = FS.readFileSync(samplePath);
if (sampleFilename.endsWith(".mxl")) {
loadParameter = await OSMD.MXLHelper.MXLtoXMLstring(loadParameter);
} else {
loadParameter = loadParameter.toString();
}
// debug('loadParameter: ' + loadParameter)
// debug('typeof loadParameter: ' + typeof loadParameter)
// set sample-specific options for OSMD visual regression testing
let includeSkyBottomLine = false;
let drawBoundingBoxString;
let isTestOctaveShiftInvisibleInstrument;
let isTestInvisibleMeasureNotAffectingLayout;
if (osmdTestingMode) {
const isFunctionTestAutobeam = sampleFilename.startsWith(
"OSMD_function_test_autobeam",
);
const isFunctionTestAutoColoring = sampleFilename.startsWith(
"OSMD_function_test_auto-custom-coloring",
);
const isFunctionTestSystemAndPageBreaks = sampleFilename.startsWith(
"OSMD_Function_Test_System_and_Page_Breaks",
);
const isFunctionTestDrawingRange = sampleFilename.startsWith(
"OSMD_function_test_measuresToDraw_",
);
const defaultOrCompactTightMode = sampleFilename.startsWith(
"OSMD_Function_Test_Container_height",
)
? "compacttight"
: "default";
const isTestFlatBeams = sampleFilename.startsWith("test_drum_tuplet_beams");
const isTestEndClefStaffEntryBboxes = sampleFilename.startsWith(
"test_end_measure_clefs_staffentry_bbox",
);
const isTestPageBreakImpliesSystemBreak = sampleFilename.startsWith(
"test_pagebreak_implies_systembreak",
);
const isTestPageBottomMargin0 =
sampleFilename.includes("PageBottomMargin0");
const isTestTupletBracketTupletNumber = sampleFilename.includes(
"test_tuplet_bracket_tuplet_number",
);
const isTestCajon2NoteSystem = sampleFilename.includes(
"test_cajon_2-note-system",
);
isTestOctaveShiftInvisibleInstrument = sampleFilename.includes(
"test_octaveshift_first_instrument_invisible",
);
const isTextOctaveShiftExtraGraphicalMeasure = sampleFilename.includes(
"test_octaveshift_extragraphicalmeasure",
);
isTestInvisibleMeasureNotAffectingLayout = sampleFilename.includes(
"test_invisible_measure_not_affecting_layout",
);
const isTestWedgeMultilineCrescendo = sampleFilename.includes(
"test_wedge_multiline_crescendo",
);
const isTestWedgeMultilineDecrescendo = sampleFilename.includes(
"test_wedge_multiline_decrescendo",
);
osmdInstance.EngravingRules.loadDefaultValues(); // note this may also be executed in setOptions below via drawingParameters default
if (isTestEndClefStaffEntryBboxes) {
drawBoundingBoxString = "VexFlowStaffEntry";
} else {
drawBoundingBoxString = options.boundingBoxes; // undefined is also a valid value: no bboxes
}
osmdInstance.setOptions({
autoBeam: isFunctionTestAutobeam, // only set to true for function test autobeam
coloringMode: isFunctionTestAutoColoring ? 2 : 0,
// eslint-disable-next-line max-len
coloringSetCustom: isFunctionTestAutoColoring
? [
"#d82c6b",
"#F89D15",
"#FFE21A",
"#4dbd5c",
"#009D96",
"#43469d",
"#76429c",
"#ff0000",
]
: undefined,
colorStemsLikeNoteheads: isFunctionTestAutoColoring,
drawingParameters: defaultOrCompactTightMode, // note: default resets all EngravingRules. could be solved differently
drawFromMeasureNumber: isFunctionTestDrawingRange ? 9 : 1,
drawUpToMeasureNumber: isFunctionTestDrawingRange
? 12
: Number.MAX_SAFE_INTEGER,
newSystemFromXML: isFunctionTestSystemAndPageBreaks,
newSystemFromNewPageInXML: isTestPageBreakImpliesSystemBreak,
newPageFromXML: isFunctionTestSystemAndPageBreaks,
pageBackgroundColor: "#FFFFFF", // reset by drawingparameters default
pageFormat: pageFormat, // reset by drawingparameters default,
...makeSkyBottomLineOptions(),
});
// note that loadDefaultValues() may be executed in setOptions with drawingParameters default
//osmdInstance.EngravingRules.RenderSingleHorizontalStaffline = true; // to use this option here, place it after setOptions(), see above
osmdInstance.EngravingRules.AlwaysSetPreferredSkyBottomLineBackendAutomatically = false; // this would override the command line options (--plain etc)
includeSkyBottomLine = options.skyBottomLine
? options.skyBottomLine
: false; // apparently es6 doesn't have ?? operator
osmdInstance.drawSkyLine = includeSkyBottomLine; // if includeSkyBottomLine, draw skyline and bottomline, else not
osmdInstance.drawBottomLine = includeSkyBottomLine;
osmdInstance.setDrawBoundingBox(drawBoundingBoxString, false); // false: don't render (now). also (re-)set if undefined!
if (isTestFlatBeams) {
osmdInstance.EngravingRules.FlatBeams = true;
// osmdInstance.EngravingRules.FlatBeamOffset = 30;
osmdInstance.EngravingRules.FlatBeamOffset = 10;
osmdInstance.EngravingRules.FlatBeamOffsetPerBeam = 10;
} else {
osmdInstance.EngravingRules.FlatBeams = false;
}
if (isTestPageBottomMargin0) {
osmdInstance.EngravingRules.PageBottomMargin = 0;
}
if (isTestTupletBracketTupletNumber) {
osmdInstance.EngravingRules.TupletNumberLimitConsecutiveRepetitions = true;
osmdInstance.EngravingRules.TupletNumberMaxConsecutiveRepetitions = 2;
osmdInstance.EngravingRules.TupletNumberAlwaysDisableAfterFirstMax = true; // necessary to trigger bug
}
if (isTestCajon2NoteSystem) {
osmdInstance.EngravingRules.PercussionUseCajon2NoteSystem = true;
}
if (
isTextOctaveShiftExtraGraphicalMeasure ||
isTestOctaveShiftInvisibleInstrument ||
isTestWedgeMultilineCrescendo ||
isTestWedgeMultilineDecrescendo
) {
osmdInstance.EngravingRules.NewSystemAtXMLNewSystemAttribute = true;
}
}
try {
debug("loading sample " + sampleFilename, DEBUG);
await osmdInstance.load(loadParameter, sampleFilename); // if using load.then() without await, memory will not be freed up between renders
if (isTestOctaveShiftInvisibleInstrument) {
osmdInstance.Sheet.Instruments[0].Visible = false;
}
if (isTestInvisibleMeasureNotAffectingLayout) {
if (osmdInstance.Sheet.Instruments[1]) {
// some systems can't handle ?. in this script (just a safety check anyways)
osmdInstance.Sheet.Instruments[1].Visible = false;
}
}
} catch (ex) {
debug(
"couldn't load sample " + sampleFilename + ", skipping. Error: \n" + ex,
);
return Promise.reject(ex);
}
debug("xml loaded", DEBUG);
try {
osmdInstance.render();
// there were reports that await could help here, but render isn't a synchronous function, and it seems to work. see #932
} catch (ex) {
debug("renderError: " + ex);
}
debug("rendered", DEBUG);
const markupStrings = []; // svg
const dataUrls = []; // png
let canvasImage;
// intended to use only for the chromacase partition use case (always 1 page in svg)
let partitionDims = [-1, -1];
for (
let pageNumber = 1;
pageNumber < Number.POSITIVE_INFINITY;
pageNumber++
) {
if (imageFormat === "png") {
canvasImage = document.getElementById(
"osmdCanvasVexFlowBackendCanvas" + pageNumber,
);
if (!canvasImage) {
break;
}
if (!canvasImage.toDataURL) {
debug(
`error: could not get canvas image for page ${pageNumber} for file: ${sampleFilename}`,
);
break;
}
dataUrls.push(canvasImage.toDataURL());
} else if (imageFormat === "svg") {
const svgElement = document.getElementById("osmdSvgPage" + pageNumber);
if (!svgElement) {
break;
}
// The important xmlns attribute is not serialized unless we set it here
svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg");
const width = svgElement.getAttribute("width");
const height = svgElement.getAttribute("height");
partitionDims = [width, height];
markupStrings.push(svgElement.outerHTML);
}
}
// create the cursor positions file
getCursorPositions(osmdInstance, assetName, partitionDims);
for (
let pageIndex = 0;
pageIndex < Math.max(dataUrls.length, markupStrings.length);
pageIndex++
) {
const pageNumberingString = `${pageIndex + 1}`;
const skybottomlineString = includeSkyBottomLine ? "skybottomline_" : "";
const graphicalNoteBboxesString = drawBoundingBoxString
? "bbox" + drawBoundingBoxString + "_"
: "";
// pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
const pageFilename = `${imageDir}/${assetName}.${imageFormat}`;
if (imageFormat === "png") {
const dataUrl = dataUrls[pageIndex];
if (!dataUrl || !dataUrl.split) {
debug(
`error: could not get dataUrl (imageData) for page ${
pageIndex + 1
} of sample: ${sampleFilename}`,
);
continue;
}
const imageData = dataUrl.split(";base64,").pop();
const imageBuffer = Buffer.from(imageData, "base64");
debug("got image data, saving to: " + pageFilename, DEBUG);
FS.writeFileSync(pageFilename, imageBuffer, { encoding: "base64" });
} else if (imageFormat === "svg") {
const markup = markupStrings[pageIndex];
if (!markup) {
debug(
`error: could not get markup (SVG data) for page ${
pageIndex + 1
} of sample: ${sampleFilename}`,
);
continue;
}
debug("got svg markup data, saving to: " + pageFilename, DEBUG);
// replace every bounding-box by none (react native doesn't support bounding-box)
FS.writeFileSync(pageFilename, markup.replace(/bounding-box/g, "none"), {
encoding: "utf-8",
});
}
// debug: log memory usage
// const usage = process.memoryUsage()
// for (const entry of Object.entries(usage)) {
// if (entry[0] === 'rss') {
// if (entry[1] > maxRss) {
// maxRss = entry[1]
// maxRssFilename = pageFilename
// }
// }
// debug(entry[0] + ': ' + entry[1] / (1024 * 1024) + 'mb')
// }
// debug('maxRss: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
}
// debug('maxRss total: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
// await sleep(5000)
// }) // end read file
}
function debug(msg, debugEnabled = true) {
if (debugEnabled) {
console.log("[generateImages] " + msg);
}
}
// init();

View File

@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class ApiKeyAuthGuard extends AuthGuard("api-key") {}

View File

@@ -0,0 +1,31 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { PassportStrategy } from "@nestjs/passport";
import Strategy from "passport-headerapikey";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class HeaderApiKeyStrategy extends PassportStrategy(
Strategy,
"api-key",
) {
constructor(private readonly configService: ConfigService) {
super(
{ header: "Authorization", prefix: "API Key " },
true,
async (apiKey, done) => {
return this.validate(apiKey, done);
},
);
}
public validate = (apiKey: string, done: (error: Error, data) => {}) => {
if (
this.configService.get<string>("API_KEYS")?.split(",").includes(apiKey)
) {
//@ts-expect-error
done(null, true);
}
done(new UnauthorizedException(), null);
};
}

View File

@@ -21,39 +21,39 @@ import {
Response,
Query,
Param,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { LocalAuthGuard } from './local-auth.guard';
import { RegisterDto } from './dto/register.dto';
import { UsersService } from 'src/users/users.service';
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "./jwt-auth.guard";
import { LocalAuthGuard } from "./local-auth.guard";
import { RegisterDto } from "./dto/register.dto";
import { UsersService } from "src/users/users.service";
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConflictResponse,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiResponse,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { User } from '../models/user';
import { JwtToken } from './models/jwt';
import { LoginDto } from './dto/login.dto';
import { Profile } from './dto/profile.dto';
import { Setting } from 'src/models/setting';
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
import { SettingsService } from 'src/settings/settings.service';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { writeFile } from 'fs';
import { PasswordResetDto } from './dto/password_reset.dto ';
} from "@nestjs/swagger";
import { User } from "../models/user";
import { JwtToken } from "./models/jwt";
import { LoginDto } from "./dto/login.dto";
import { Profile } from "./dto/profile.dto";
import { Setting } from "src/models/setting";
import { UpdateSettingDto } from "src/settings/dto/update-setting.dto";
import { SettingsService } from "src/settings/settings.service";
import { AuthGuard } from "@nestjs/passport";
import { FileInterceptor } from "@nestjs/platform-express";
import { writeFile } from "fs";
import { PasswordResetDto } from "./dto/password_reset.dto ";
import { mapInclude } from "src/utils/include";
import { SongController } from "src/song/song.controller";
import { ChromaAuthGuard } from "./chroma-auth.guard";
@ApiTags('auth')
@Controller('auth')
@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(
private authService: AuthService,
@@ -61,14 +61,17 @@ export class AuthController {
private settingsService: SettingsService,
) {}
@Get('login/google')
@UseGuards(AuthGuard('google'))
@ApiOperation({description: 'Redirect to google login page'})
@Get("login/google")
@UseGuards(AuthGuard("google"))
@ApiOperation({ description: "Redirect to google login page" })
googleLogin() {}
@Get('logged/google')
@ApiOperation({description: 'Redirect to the front page after connecting to the google account'})
@UseGuards(AuthGuard('google'))
@Get("logged/google")
@ApiOperation({
description:
"Redirect to the front page after connecting to the google account",
})
@UseGuards(AuthGuard("google"))
async googleLoginCallbakc(@Req() req: any) {
let user = await this.usersService.user({ googleID: req.user.googleID });
if (!user) {
@@ -78,11 +81,13 @@ export class AuthController {
return this.authService.login(user);
}
@Post('register')
@ApiOperation({description: 'Register a new user'})
@ApiConflictResponse({ description: 'Username or email already taken' })
@ApiOkResponse({ description: 'Successfully registered, email sent to verify' })
@ApiBadRequestResponse({ description: 'Invalid data or database error' })
@Post("register")
@ApiOperation({ description: "Register a new user" })
@ApiConflictResponse({ description: "Username or email already taken" })
@ApiOkResponse({
description: "Successfully registered, email sent to verify",
})
@ApiBadRequestResponse({ description: "Invalid data or database error" })
async register(@Body() registerDto: RegisterDto): Promise<void> {
try {
const user = await this.usersService.createUser(registerDto);
@@ -90,100 +95,102 @@ export class AuthController {
await this.authService.sendVerifyMail(user);
} catch (e) {
// check if the error is a duplicate key error
if (e.code === 'P2002') {
throw new ConflictException('Username or email already taken');
if (e.code === "P2002") {
throw new ConflictException("Username or email already taken");
}
console.error(e);
throw new BadRequestException();
}
}
@Put('verify')
@Put("verify")
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({description: 'Verify the email of the user'})
@ApiOkResponse({ description: 'Successfully verified' })
@ApiBadRequestResponse({ description: 'Invalid or expired token' })
async verify(@Request() req: any, @Query('token') token: string): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token))
return;
@ApiOperation({ description: "Verify the email of the user" })
@ApiOkResponse({ description: "Successfully verified" })
@ApiBadRequestResponse({ description: "Invalid or expired token" })
async verify(
@Request() req: any,
@Query("token") token: string,
): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token)) return;
throw new BadRequestException("Invalid token. Expired or invalid.");
}
@Put('reverify')
@Put("reverify")
@UseGuards(JwtAuthGuard)
@HttpCode(200)
@ApiOperation({description: 'Resend the verification email'})
@ApiOperation({ description: "Resend the verification email" })
async reverify(@Request() req: any): Promise<void> {
const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new BadRequestException('Invalid user');
if (!user) throw new BadRequestException("Invalid user");
await this.authService.sendVerifyMail(user);
}
@HttpCode(200)
@Put('password-reset')
@Put("password-reset")
async password_reset(
@Body() resetDto: PasswordResetDto,
@Query('token') token: string,
@Query("token") token: string,
): Promise<void> {
if (await this.authService.changePassword(resetDto.password, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.');
throw new BadRequestException("Invalid token. Expired or invalid.");
}
@HttpCode(200)
@Put('forgot-password')
async forgot_password(@Query('email') email: string): Promise<void> {
@Put("forgot-password")
async forgot_password(@Query("email") email: string): Promise<void> {
console.log(email);
const user = await this.usersService.user({ email: email });
if (!user) throw new BadRequestException('Invalid user');
if (!user) throw new BadRequestException("Invalid user");
await this.authService.sendPasswordResetMail(user);
}
@Post('login')
@Post("login")
@ApiBody({ type: LoginDto })
@HttpCode(200)
@UseGuards(LocalAuthGuard)
@ApiBody({ type: LoginDto })
@ApiOperation({ description: 'Login with username and password' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
@ApiOperation({ description: "Login with username and password" })
@ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
@ApiUnauthorizedResponse({ description: "Invalid credentials" })
async login(@Request() req: any): Promise<JwtToken> {
return this.authService.login(req.user);
}
@Post('guest')
@Post("guest")
@HttpCode(200)
@ApiOperation({ description: 'Login as a guest account' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
@ApiOperation({ description: "Login as a guest account" })
@ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
async guest(): Promise<JwtToken> {
const user = await this.usersService.createGuest();
await this.settingsService.createUserSetting(user.id);
return this.authService.login(user);
}
@UseGuards(JwtAuthGuard)
@UseGuards(ChromaAuthGuard)
@ApiBearerAuth()
@ApiOperation({ description: 'Get the profile picture of connected user' })
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/picture')
@ApiOperation({ description: "Get the profile picture of connected user" })
@ApiOkResponse({ description: "The user profile picture" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Get("me/picture")
async getProfilePicture(@Request() req: any, @Response() res: any) {
return await this.usersService.getProfilePicture(req.user.id, res);
}
@UseGuards(JwtAuthGuard)
@UseGuards(ChromaAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/picture')
@ApiOperation({ description: 'Upload a new profile picture' })
@UseInterceptors(FileInterceptor('file'))
@ApiOkResponse({ description: "The user profile picture" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Post("me/picture")
@ApiOperation({ description: "Upload a new profile picture" })
@UseInterceptors(FileInterceptor("file"))
async postProfilePicture(
@Request() req: any,
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({
fileType: 'jpeg',
fileType: "jpeg",
})
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
@@ -199,22 +206,22 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully logged in', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me')
@ApiOperation({ description: 'Get the user info of connected user' })
@ApiOkResponse({ description: "Successfully logged in", type: User })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Get("me")
@ApiOperation({ description: "Get the user info of connected user" })
async getProfile(@Request() req: any): Promise<User> {
const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new InternalServerErrorException();
return user;
}
@UseGuards(JwtAuthGuard)
@UseGuards(ChromaAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Put('me')
@ApiOperation({ description: 'Edit the profile of connected user' })
@ApiOkResponse({ description: "Successfully edited profile", type: User })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Put("me")
@ApiOperation({ description: "Edit the profile of connected user" })
editProfile(
@Request() req: any,
@Body() profile: Partial<Profile>,
@@ -237,20 +244,20 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully deleted', type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Delete('me')
@ApiOperation({ description: 'Delete the profile of connected user' })
@ApiOkResponse({ description: "Successfully deleted", type: User })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Delete("me")
@ApiOperation({ description: "Delete the profile of connected user" })
deleteSelf(@Request() req: any): Promise<User> {
return this.usersService.deleteUser({ id: req.user.id });
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Patch('me/settings')
@ApiOperation({ description: 'Edit the settings of connected user' })
@ApiOkResponse({ description: "Successfully edited settings", type: Setting })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Patch("me/settings")
@ApiOperation({ description: "Edit the settings of connected user" })
udpateSettings(
@Request() req: any,
@Body() settingUserDto: UpdateSettingDto,
@@ -263,10 +270,10 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/settings')
@ApiOperation({ description: 'Get the settings of connected user' })
@ApiOkResponse({ description: "Successfully edited settings", type: Setting })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Get("me/settings")
@ApiOperation({ description: "Get the settings of connected user" })
async getSettings(@Request() req: any): Promise<Setting> {
const result = await this.settingsService.getUserSetting({
userId: +req.user.id,
@@ -277,42 +284,40 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully added liked song'})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/likes/:id')
addLikedSong(
@Request() req: any,
@Param('id') songId: number
) {
return this.usersService.addLikedSong(
@ApiOkResponse({ description: "Successfully added liked song" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Post("me/likes/:id")
addLikedSong(@Request() req: any, @Param("id") songId: number) {
return this.usersService.addLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: "Successfully removed liked song" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Delete("me/likes/:id")
removeLikedSong(@Request() req: any, @Param("id") songId: number) {
return this.usersService.removeLikedSong(+req.user.id, +songId);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: "Successfully retrieved liked song" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Get("me/likes")
getLikedSongs(@Request() req: any, @Query("include") include: string) {
return this.usersService.getLikedSongs(
+req.user.id,
+songId,
mapInclude(include, req, SongController.includableFields),
);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully removed liked song'})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Delete('me/likes/:id')
removeLikedSong(
@Request() req: any,
@Param('id') songId: number,
) {
return this.usersService.removeLikedSong(
+req.user.id,
+songId,
);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully retrieved liked song'})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/likes')
getLikedSongs(
@Request() req: any,
) {
return this.usersService.getLikedSongs(+req.user.id)
@ApiOkResponse({ description: "Successfully added score" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@Patch("me/score/:score")
addScore(@Request() req: any, @Param("id") score: number) {
return this.usersService.addScore(+req.user.id, score);
}
}

View File

@@ -1,15 +1,16 @@
import { Module } from '@nestjs/common';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { SettingsModule } from 'src/settings/settings.module';
import { GoogleStrategy } from './google.strategy';
import { Module } from "@nestjs/common";
import { UsersModule } from "src/users/users.module";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { LocalStrategy } from "./local.strategy";
import { JwtModule } from "@nestjs/jwt";
import { ConfigModule } from "@nestjs/config";
import { ConfigService } from "@nestjs/config";
import { JwtStrategy } from "./jwt.strategy";
import { SettingsModule } from "src/settings/settings.module";
import { GoogleStrategy } from "./google.strategy";
import { HeaderApiKeyStrategy } from "./apikey.strategy";
@Module({
imports: [
@@ -20,13 +21,19 @@ import { GoogleStrategy } from './google.strategy';
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: '365d' },
secret: configService.get("JWT_SECRET"),
signOptions: { expiresIn: "365d" },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
GoogleStrategy,
HeaderApiKeyStrategy,
],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import PayloadInterface from './interface/payload.interface';
import { User } from 'src/models/user';
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable } from "@nestjs/common";
import { UsersService } from "../users/users.service";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcryptjs";
import PayloadInterface from "./interface/payload.interface";
import { User } from "src/models/user";
import { MailerService } from "@nestjs-modules/mailer";
@Injectable()
export class AuthService {
constructor(
@@ -13,6 +13,12 @@ export class AuthService {
private emailService: MailerService,
) {}
validateApiKey(apikey: string): boolean {
if (process.env.API_KEYS == null) return false;
const keys = process.env.API_KEYS.split(",");
return keys.includes(apikey);
}
async validateUser(
username: string,
password: string,
@@ -36,37 +42,37 @@ export class AuthService {
}
async sendVerifyMail(user: User) {
if (process.env.IGNORE_MAILS === 'true') return;
if (process.env.IGNORE_MAILS === "true") return;
if (user.email == null) return;
console.log('Sending verification mail to', user.email);
console.log("Sending verification mail to", user.email);
const token = await this.jwtService.signAsync(
{
userId: user.id,
},
{ expiresIn: '10h' },
{ expiresIn: "10h" },
);
await this.emailService.sendMail({
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Mail verification for Chromacase',
from: "chromacase@octohub.app",
subject: "Mail verification for Chromacase",
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
});
}
async sendPasswordResetMail(user: User) {
if (process.env.IGNORE_MAILS === 'true') return;
if (process.env.IGNORE_MAILS === "true") return;
if (user.email == null) return;
console.log('Sending password reset mail to', user.email);
console.log("Sending password reset mail to", user.email);
const token = await this.jwtService.signAsync(
{
userId: user.id,
},
{ expiresIn: '10h' },
{ expiresIn: "10h" },
);
await this.emailService.sendMail({
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Password reset for Chromacase',
from: "chromacase@octohub.app",
subject: "Password reset for Chromacase",
html: `To reset your password, please click on this <a href="${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
});
}
@@ -76,10 +82,10 @@ export class AuthService {
try {
verified = await this.jwtService.verifyAsync(token);
} catch (e) {
console.log('Password reset token failure', e);
console.log("Password reset token failure", e);
return false;
}
console.log(verified)
console.log(verified);
await this.userService.updateUser({
where: { id: verified.userId },
data: { password: new_password },
@@ -91,7 +97,7 @@ export class AuthService {
try {
await this.jwtService.verifyAsync(token);
} catch (e) {
console.log('Verify mail token failure', e);
console.log("Verify mail token failure", e);
return false;
}
await this.userService.updateUser({

View File

@@ -0,0 +1,22 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "./public";
@Injectable()
export class ChromaAuthGuard extends AuthGuard(["jwt", "api-key"]) {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class Constants {
constructor(private configService: ConfigService) {}
getSecret = () => {
return this.configService.get('JWT_SECRET');
return this.configService.get("JWT_SECRET");
};
}

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class LoginDto {
@ApiProperty()

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class PasswordResetDto {
@ApiProperty()

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class Profile {
@ApiProperty()

View File

@@ -1,5 +1,5 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class RegisterDto {
@ApiProperty()

View File

@@ -1,7 +1,7 @@
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { PassportStrategy } from "@nestjs/passport";
import { Strategy, VerifyCallback } from "passport-google-oauth20";
import { Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
@@ -10,7 +10,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['email', 'profile'],
scope: ["email", "profile"],
});
}

View File

@@ -1,5 +1,22 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { IS_PUBLIC_KEY } from "./public";
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -1,7 +1,7 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -9,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
secretOrKey: configService.get("JWT_SECRET"),
});
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
export class LocalAuthGuard extends AuthGuard("local") {}

View File

@@ -1,8 +1,8 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import PayloadInterface from './interface/payload.interface';
import { Strategy } from "passport-local";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service";
import PayloadInterface from "./interface/payload.interface";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class JwtToken {
@ApiProperty()

4
back/src/auth/public.ts Normal file
View File

@@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";
export class CreateGenreDto {
@IsNotEmpty()

View File

@@ -13,21 +13,30 @@ import {
Query,
Req,
StreamableFile,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateGenreDto } from './dto/create-genre.dto';
import { Request } from 'express';
import { GenreService } from './genre.service';
import { Prisma, Genre } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { createReadStream, existsSync } from 'fs';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
UseGuards,
} from "@nestjs/common";
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
import { CreateGenreDto } from "./dto/create-genre.dto";
import { Request } from "express";
import { GenreService } from "./genre.service";
import { Prisma, Genre } from "@prisma/client";
import { ApiTags } from "@nestjs/swagger";
import { createReadStream, existsSync } from "fs";
import { FilterQuery } from "src/utils/filter.pipe";
import { Genre as _Genre } from "src/_gen/prisma-class/genre";
import { IncludeMap, mapInclude } from "src/utils/include";
import { Public } from "src/auth/public";
import { AuthGuard } from "@nestjs/passport";
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
@Controller('genre')
@ApiTags('genre')
@Controller("genre")
@ApiTags("genre")
@UseGuards(ChromaAuthGuard)
export class GenreController {
static filterableFields: string[] = ['+id', 'name'];
static filterableFields: string[] = ["+id", "name"];
static includableFields: IncludeMap<Prisma.GenreInclude> = {
Song: true,
};
constructor(private readonly service: GenreService) {}
@@ -40,22 +49,23 @@ export class GenreController {
}
}
@Delete(':id')
async remove(@Param('id', ParseIntPipe) id: number) {
@Delete(":id")
async remove(@Param("id", ParseIntPipe) id: number) {
try {
return await this.service.delete({ id });
} catch {
throw new NotFoundException('Invalid ID');
throw new NotFoundException("Invalid ID");
}
}
@Get(':id/illustration')
async getIllustration(@Param('id', ParseIntPipe) id: number) {
@Get(":id/illustration")
@Public()
async getIllustration(@Param("id", ParseIntPipe) id: number) {
const genre = await this.service.get({ id });
if (!genre) throw new NotFoundException('Genre not found');
if (!genre) throw new NotFoundException("Genre not found");
const path = `/assets/genres/${genre.name}/illustration.png`;
if (!existsSync(path))
throw new NotFoundException('Illustration not found');
throw new NotFoundException("Illustration not found");
try {
const file = createReadStream(path);
@@ -71,22 +81,31 @@ export class GenreController {
@Req() req: Request,
@FilterQuery(GenreController.filterableFields)
where: Prisma.GenreWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Genre>> {
const ret = await this.service.list({
skip,
take,
where,
include: mapInclude(include, req, GenreController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.service.get({ id });
@Get(":id")
async findOne(
@Req() req: Request,
@Query("include") include: string,
@Param("id", ParseIntPipe) id: number,
) {
const res = await this.service.get(
{ id },
mapInclude(include, req, GenreController.includableFields),
);
if (res === null) throw new NotFoundException('Genre not found');
if (res === null) throw new NotFoundException("Genre not found");
return res;
}
}

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { GenreController } from './genre.controller';
import { GenreService } from './genre.service';
import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module";
import { GenreController } from "./genre.controller";
import { GenreService } from "./genre.service";
@Module({
imports: [PrismaModule],

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma, Genre } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Prisma, Genre } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class GenreService {
@@ -12,9 +12,13 @@ export class GenreService {
});
}
async get(where: Prisma.GenreWhereUniqueInput): Promise<Genre | null> {
async get(
where: Prisma.GenreWhereUniqueInput,
include?: Prisma.GenreInclude,
): Promise<Genre | null> {
return this.prisma.genre.findUnique({
where,
include,
});
}
@@ -24,14 +28,16 @@ export class GenreService {
cursor?: Prisma.GenreWhereUniqueInput;
where?: Prisma.GenreWhereInput;
orderBy?: Prisma.GenreOrderByWithRelationInput;
include?: Prisma.GenreInclude;
}): Promise<Genre[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.genre.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}

View File

@@ -1,9 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class SearchHistoryDto {
@ApiProperty()
query: string;
@ApiProperty()
type: 'song' | 'artist' | 'album' | 'genre';
type: "song" | "artist" | "album" | "genre";
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber } from "class-validator";
export class SongHistoryDto {
@ApiProperty()

View File

@@ -9,62 +9,75 @@ import {
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { SearchHistory, SongHistory } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { HistoryService } from './history.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
import { SongHistory as _SongHistory } from 'src/_gen/prisma-class/song_history';
import { SearchHistory as _SearchHistory} from 'src/_gen/prisma-class/search_history';
} from "@nestjs/common";
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { SearchHistory, SongHistory } from "@prisma/client";
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
import { SongHistoryDto } from "./dto/SongHistoryDto";
import { HistoryService } from "./history.service";
import { SearchHistoryDto } from "./dto/SearchHistoryDto";
import { SongHistory as _SongHistory } from "src/_gen/prisma-class/song_history";
import { SearchHistory as _SearchHistory } from "src/_gen/prisma-class/search_history";
import { SongController } from "src/song/song.controller";
import { mapInclude } from "src/utils/include";
@Controller('history')
@ApiTags('history')
@Controller("history")
@ApiTags("history")
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
@Get()
@HttpCode(200)
@ApiOperation({ description: "Get song history of connected user"})
@ApiOperation({ description: "Get song history of connected user" })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SongHistory, isArray: true})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOkResponse({ type: _SongHistory, isArray: true })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async getHistory(
@Request() req: any,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
): Promise<SongHistory[]> {
return this.historyService.getHistory(req.user.id, { skip, take });
return this.historyService.getHistory(
req.user.id,
{ skip, take },
mapInclude(include, req, SongController.includableFields),
);
}
@Get('search')
@Get("search")
@HttpCode(200)
@ApiOperation({ description: "Get search history of connected user"})
@ApiOperation({ description: "Get search history of connected user" })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SearchHistory, isArray: true})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiOkResponse({ type: _SearchHistory, isArray: true })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async getSearchHistory(
@Request() req: any,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<SearchHistory[]> {
return this.historyService.getSearchHistory(req.user.id, { skip, take });
}
@Post()
@HttpCode(201)
@ApiOperation({ description: "Create a record of a song played by a user"})
@ApiCreatedResponse({ description: "Succesfully created a record"})
@ApiOperation({ description: "Create a record of a song played by a user" })
@ApiCreatedResponse({ description: "Succesfully created a record" })
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
return this.historyService.createSongHistoryRecord(record);
}
@Post('search')
@Post("search")
@HttpCode(201)
@ApiOperation({ description: "Creates a search record in the users history"})
@ApiOperation({ description: "Creates a search record in the users history" })
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async createSearchHistory(
@Request() req: any,
@Body() record: SearchHistoryDto,

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { HistoryService } from './history.service';
import { HistoryController } from './history.controller';
import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module";
import { HistoryService } from "./history.service";
import { HistoryController } from "./history.controller";
@Module({
imports: [PrismaModule],

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HistoryService } from './history.service';
import { Test, TestingModule } from "@nestjs/testing";
import { HistoryService } from "./history.service";
describe('HistoryService', () => {
describe("HistoryService", () => {
let service: HistoryService;
beforeEach(async () => {
@@ -12,7 +12,7 @@ describe('HistoryService', () => {
service = module.get<HistoryService>(HistoryService);
});
it('should be defined', () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { SearchHistory, SongHistory } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { Injectable } from "@nestjs/common";
import { Prisma, SearchHistory, SongHistory } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { SearchHistoryDto } from "./dto/SearchHistoryDto";
import { SongHistoryDto } from "./dto/SongHistoryDto";
@Injectable()
export class HistoryService {
@@ -45,12 +45,14 @@ export class HistoryService {
async getHistory(
playerId: number,
{ skip, take }: { skip?: number; take?: number },
include?: Prisma.SongInclude,
): Promise<SongHistory[]> {
return this.prisma.songHistory.findMany({
where: { user: { id: playerId } },
orderBy: { playDate: 'desc' },
orderBy: { playDate: "desc" },
skip,
take,
include: { song: include ? { include } : true },
});
}
@@ -63,7 +65,7 @@ export class HistoryService {
}): Promise<{ best: number; history: SongHistory[] }> {
const history = await this.prisma.songHistory.findMany({
where: { user: { id: playerId }, song: { id: songId } },
orderBy: { playDate: 'desc' },
orderBy: { playDate: "desc" },
});
return {
@@ -95,7 +97,7 @@ export class HistoryService {
): Promise<SearchHistory[]> {
return this.prisma.searchHistory.findMany({
where: { user: { id: playerId } },
orderBy: { searchDate: 'desc' },
orderBy: { searchDate: "desc" },
skip,
take,
});

View File

@@ -3,7 +3,6 @@ import {
Get,
Query,
Req,
Request,
Param,
ParseIntPipe,
DefaultValuePipe,
@@ -12,13 +11,18 @@ import {
Body,
Delete,
NotFoundException,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { LessonService } from './lesson.service';
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import { Prisma, Skill } from '@prisma/client';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Lesson as _Lesson} from 'src/_gen/prisma-class/lesson';
UseGuards,
} from "@nestjs/common";
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
import { LessonService } from "./lesson.service";
import { ApiOperation, ApiProperty, ApiTags } from "@nestjs/swagger";
import { Prisma, Skill } from "@prisma/client";
import { FilterQuery } from "src/utils/filter.pipe";
import { Lesson as _Lesson } from "src/_gen/prisma-class/lesson";
import { IncludeMap, mapInclude } from "src/utils/include";
import { Request } from "express";
import { AuthGuard } from "@nestjs/passport";
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
export class Lesson {
@ApiProperty()
@@ -33,20 +37,24 @@ export class Lesson {
mainSkill: Skill;
}
@ApiTags('lessons')
@Controller('lesson')
@ApiTags("lessons")
@Controller("lesson")
@UseGuards(ChromaAuthGuard)
export class LessonController {
static filterableFields: string[] = [
'+id',
'name',
'+requiredLevel',
'mainSkill',
"+id",
"name",
"+requiredLevel",
"mainSkill",
];
static includableFields: IncludeMap<Prisma.LessonInclude> = {
LessonHistory: true,
};
constructor(private lessonService: LessonService) {}
@ApiOperation({
summary: 'Get all lessons',
summary: "Get all lessons",
})
@Get()
@ApiOkResponsePlaginated(_Lesson)
@@ -54,29 +62,38 @@ export class LessonController {
@Req() request: Request,
@FilterQuery(LessonController.filterableFields)
where: Prisma.LessonWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Lesson>> {
const ret = await this.lessonService.getAll({
skip,
take,
where,
include: mapInclude(include, request, LessonController.includableFields),
});
return new Plage(ret, request);
}
@ApiOperation({
summary: 'Get a particular lessons',
summary: "Get a particular lessons",
})
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
const ret = await this.lessonService.get(id);
@Get(":id")
async get(
@Req() req: Request,
@Query("include") include: string,
@Param("id", ParseIntPipe) id: number,
): Promise<Lesson> {
const ret = await this.lessonService.get(
id,
mapInclude(include, req, LessonController.includableFields),
);
if (!ret) throw new NotFoundException();
return ret;
}
@ApiOperation({
summary: 'Create a lessons',
summary: "Create a lessons",
})
@Post()
async post(@Body() lesson: Lesson): Promise<Lesson> {
@@ -89,10 +106,10 @@ export class LessonController {
}
@ApiOperation({
summary: 'Delete a lessons',
summary: "Delete a lessons",
})
@Delete(':id')
async delete(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
@Delete(":id")
async delete(@Param("id", ParseIntPipe) id: number): Promise<Lesson> {
try {
return await this.lessonService.delete(id);
} catch {

View File

@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { LessonController } from './lesson.controller';
import { LessonService } from './lesson.service';
import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module";
import { LessonController } from "./lesson.controller";
import { LessonService } from "./lesson.service";
@Module({
imports: [PrismaModule],

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LessonService } from './lesson.service';
import { Test, TestingModule } from "@nestjs/testing";
import { LessonService } from "./lesson.service";
describe('LessonService', () => {
describe("LessonService", () => {
let service: LessonService;
beforeEach(async () => {
@@ -12,7 +12,7 @@ describe('LessonService', () => {
service = module.get<LessonService>(LessonService);
});
it('should be defined', () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Lesson, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Lesson, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class LessonService {
@@ -12,22 +12,28 @@ export class LessonService {
cursor?: Prisma.LessonWhereUniqueInput;
where?: Prisma.LessonWhereInput;
orderBy?: Prisma.LessonOrderByWithRelationInput;
include?: Prisma.LessonInclude;
}): Promise<Lesson[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.lesson.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}
async get(id: number): Promise<Lesson | null> {
async get(
id: number,
include?: Prisma.LessonInclude,
): Promise<Lesson | null> {
return this.prisma.lesson.findFirst({
where: {
id: id,
},
include,
});
}

View File

@@ -1,17 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
ValidationPipe,
} from '@nestjs/common';
import { RequestLogger, RequestLoggerOptions } from 'json-logger-service';
import { tap } from 'rxjs';
import { PrismaModel } from './_gen/prisma-class';
import { PrismaService } from './prisma/prisma.service';
} from "@nestjs/common";
import { RequestLogger, RequestLoggerOptions } from "json-logger-service";
import { tap } from "rxjs";
import { PrismaModel } from "./_gen/prisma-class";
import { PrismaService } from "./prisma/prisma.service";
@Injectable()
export class AspectLogger implements NestInterceptor {
@@ -27,8 +27,8 @@ export class AspectLogger implements NestInterceptor {
params,
query,
body,
userId: user?.id ?? 'not logged in',
username: user?.username ?? 'not logged in',
userId: user?.id ?? "not logged in",
username: user?.username ?? "not logged in",
};
return next.handle().pipe(
@@ -48,24 +48,24 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
RequestLogger.buildExpressRequestLogger({
doNotLogPaths: ['/health'],
doNotLogPaths: ["/health"],
} as RequestLoggerOptions),
);
app.enableShutdownHooks();
const config = new DocumentBuilder()
.setTitle('Chromacase')
.setDescription('The chromacase API')
.setVersion('1.0')
.setTitle("Chromacase")
.setDescription("The chromacase API")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config, {
extraModels: [...PrismaModel.extraModels],
});
SwaggerModule.setup('api', app, document);
SwaggerModule.setup("api", app, document);
app.useGlobalPipes(new ValidationPipe());
app.enableCors();
app.useGlobalInterceptors(new AspectLogger());
//app.useGlobalInterceptors(new AspectLogger());
await app.listen(3000);
}

View File

@@ -2,15 +2,29 @@
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
*/
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type, applyDecorators } from "@nestjs/common";
import {
ApiExtraModels,
ApiOkResponse,
ApiProperty,
getSchemaPath,
} from "@nestjs/swagger";
export class PlageMetadata {
@ApiProperty()
this: string;
@ApiProperty({ type: "string", nullable: true, description: "null if there is no next page, couldn't set it in swagger"})
@ApiProperty({
type: "string",
nullable: true,
description: "null if there is no next page, couldn't set it in swagger",
})
next: string | null;
@ApiProperty({ type: "string", nullable: true, description: "null if there is no previous page, couldn't set it in swagger" })
@ApiProperty({
type: "string",
nullable: true,
description:
"null if there is no previous page, couldn't set it in swagger",
})
previous: string | null;
}
@@ -21,9 +35,9 @@ export class Plage<T extends object> {
constructor(data: T[], request: Request | any) {
this.data = data;
let take = Number(request.query['take'] ?? 20).valueOf();
let take = Number(request.query["take"] ?? 20).valueOf();
if (take == 0) take = 20;
let skipped: number = Number(request.query['skip'] ?? 0).valueOf();
let skipped: number = Number(request.query["skip"] ?? 0).valueOf();
if (skipped % take) {
skipped += take - (skipped % take);
}
@@ -55,22 +69,24 @@ export class Plage<T extends object> {
}
}
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(dataDto: DataDto) =>
applyDecorators(
ApiExtraModels(Plage, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Plage) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
})
)
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(
dataDto: DataDto,
) =>
applyDecorators(
ApiExtraModels(Plage, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(Plage) },
{
properties: {
data: {
type: "array",
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
}),
);

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class Setting {
@ApiProperty()

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class User {
@ApiProperty()
@@ -11,4 +11,6 @@ export class User {
isGuest: boolean;
@ApiProperty()
partyPlayed: number;
@ApiProperty()
totalScore: number;
}

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Module({
providers: [PrismaService],

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from './prisma.service';
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaService } from "./prisma.service";
describe('PrismaService', () => {
describe("PrismaService", () => {
let service: PrismaService;
beforeEach(async () => {
@@ -12,7 +12,7 @@ describe('PrismaService', () => {
service = module.get<PrismaService>(PrismaService);
});
it('should be defined', () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {

View File

@@ -0,0 +1,16 @@
import { Controller, Get } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { ScoresService } from "./scores.service";
import { User } from "@prisma/client";
@ApiTags("scores")
@Controller("scores")
export class ScoresController {
constructor(private readonly scoresService: ScoresService) {}
@ApiOkResponse({ description: "Successfully sent the Top 20 players" })
@Get("top/20")
getTopTwenty(): Promise<User[]> {
return this.scoresService.topTwenty();
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ScoresService } from "./scores.service";
import { ScoresController } from "./scores.controller";
import { PrismaModule } from "src/prisma/prisma.module";
@Module({
imports: [PrismaModule],
controllers: [ScoresController],
providers: [ScoresService],
})
export class ScoresModule {}

View File

@@ -0,0 +1,17 @@
import { Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ScoresService {
constructor(private prisma: PrismaService) {}
async topTwenty(): Promise<User[]> {
return this.prisma.user.findMany({
orderBy: {
totalScore: "desc",
},
take: 20,
});
}
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class SearchSongDto {
@ApiProperty()

View File

@@ -0,0 +1,29 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import MeiliSearch, { DocumentOptions, Settings } from "meilisearch";
@Injectable()
export class MeiliService extends MeiliSearch implements OnModuleInit {
constructor() {
super({
host: process.env.MEILI_ADDR || "http://meilisearch:7700",
apiKey: process.env.MEILI_MASTER_KEY,
});
}
async definedIndex(uid: string, opts: Settings) {
let task = await this.createIndex(uid, { primaryKey: "id" });
await this.waitForTask(task.taskUid);
task = await this.index(uid).updateSettings(opts);
await this.waitForTask(task.taskUid);
}
async onModuleInit() {
await this.definedIndex("songs", {
searchableAttributes: ["name", "artist"],
filterableAttributes: ["artistId", "genreId"],
});
await this.definedIndex("artists", {
searchableAttributes: ["name"],
});
}
}

View File

@@ -1,76 +1,78 @@
import {
BadRequestException,
Body,
Controller,
DefaultValuePipe,
Get,
HttpCode,
InternalServerErrorException,
NotFoundException,
Param,
ParseIntPipe,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { Artist, Genre, Song } from '@prisma/client';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { SearchSongDto } from './dto/search-song.dto';
import { SearchService } from './search.service';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
} from "@nestjs/common";
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { Artist, Genre, Song } from "@prisma/client";
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
import { SearchService } from "./search.service";
import { Song as _Song } from "src/_gen/prisma-class/song";
import { Genre as _Genre } from "src/_gen/prisma-class/genre";
import { Artist as _Artist } from "src/_gen/prisma-class/artist";
import { mapInclude } from "src/utils/include";
import { SongController } from "src/song/song.controller";
import { GenreController } from "src/genre/genre.controller";
import { ArtistController } from "src/artist/artist.controller";
@ApiTags('search')
@Controller('search')
@ApiTags("search")
@Controller("search")
@UseGuards(JwtAuthGuard)
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get('songs/:query')
@ApiOkResponse({ type: _Song, isArray: true})
@ApiOperation({ description: "Search a song"})
@ApiUnauthorizedResponse({ description: "Invalid token"})
@UseGuards(JwtAuthGuard)
@Get("songs/:query")
@ApiOkResponse({ type: _Song, isArray: true })
@ApiOperation({ description: "Search a song" })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async searchSong(
@Request() req: any,
@Param('query') query: string,
@Param("query") query: string,
@Query("artistId") artistId: number,
@Query("genreId") genreId: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Song[] | null> {
try {
const ret = await this.searchService.songByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
throw new InternalServerErrorException();
}
return await this.searchService.searchSong(
query,
artistId,
genreId,
mapInclude(include, req, SongController.includableFields),
skip,
take,
);
}
@Get('genres/:query')
@Get("artists/:query")
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: "Invalid token"})
@ApiOkResponse({ type: _Genre, isArray: true})
@ApiOperation({ description: "Search a genre"})
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
try {
const ret = await this.searchService.genreByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
throw new InternalServerErrorException();
}
}
@Get('artists/:query')
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _Artist, isArray: true})
@ApiUnauthorizedResponse({ description: "Invalid token"})
@ApiOperation({ description: "Search an artist"})
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
try {
const ret = await this.searchService.artistByGuess(query, req.user?.id);
if (!ret.length) throw new NotFoundException();
else return ret;
} catch (error) {
throw new InternalServerErrorException();
}
@ApiOkResponse({ type: _Artist, isArray: true })
@ApiUnauthorizedResponse({ description: "Invalid token" })
@ApiOperation({ description: "Search an artist" })
async searchArtists(
@Request() req: any,
@Query("include") include: string,
@Param("query") query: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Artist[] | null> {
return await this.searchService.searchArtists(
query,
mapInclude(include, req, ArtistController.includableFields),
skip,
take,
);
}
}

View File

@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchController } from './search.controller';
import { HistoryModule } from 'src/history/history.module';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SongService } from 'src/song/song.service';
import { Module } from "@nestjs/common";
import { SearchService } from "./search.service";
import { SearchController } from "./search.controller";
import { HistoryModule } from "src/history/history.module";
import { PrismaModule } from "src/prisma/prisma.module";
import { SongService } from "src/song/song.service";
import { MeiliService } from "./meilisearch.service";
@Module({
imports: [PrismaModule, HistoryModule],
controllers: [SearchController],
providers: [SearchService, SongService],
exports: [SearchService],
providers: [SearchService, SongService, MeiliService],
exports: [SearchService, MeiliService],
})
export class SearchModule {}

View File

@@ -1,36 +1,84 @@
import { Injectable } from '@nestjs/common';
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
import { HistoryService } from 'src/history/history.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Artist, Prisma, Song, Genre } from "@prisma/client";
import { HistoryService } from "src/history/history.service";
import { PrismaService } from "src/prisma/prisma.service";
import { MeiliService } from "./meilisearch.service";
@Injectable()
export class SearchService {
constructor(
private prisma: PrismaService,
private history: HistoryService,
private search: MeiliService,
) {}
async songByGuess(query: string, userID: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
});
async searchSong(
query: string,
artistId?: number,
genreId?: number,
include?: Prisma.SongInclude,
skip?: number,
take?: number,
): Promise<Song[]> {
if (query.length === 0) {
return await this.prisma.song.findMany({
where: {
artistId,
genreId,
},
take,
skip,
include,
});
}
const ids = (
await this.search.index("songs").search(query, {
limit: take,
offset: skip,
filter: [
...(artistId ? [`artistId = ${artistId}`] : []),
...(genreId ? [`genreId = ${genreId}`] : []),
].join(" AND "),
})
).hits.map((x) => x.id);
return (
await this.prisma.song.findMany({
where: {
id: { in: ids },
},
include,
})
).sort((x) => ids.indexOf(x.id));
}
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
return this.prisma.genre.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
});
}
async searchArtists(
query: string,
include?: Prisma.ArtistInclude,
skip?: number,
take?: number,
): Promise<Artist[]> {
if (query.length === 0) {
return this.prisma.artist.findMany({
take,
skip,
include,
});
}
const ids = (
await this.search.index("artists").search(query, {
limit: take,
offset: skip,
})
).hits.map((x) => x.id);
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
return this.prisma.artist.findMany({
where: {
name: { contains: query, mode: 'insensitive' },
},
});
return (
await this.prisma.artist.findMany({
where: {
id: { in: ids },
},
include,
})
).sort((x) => ids.indexOf(x.id));
}
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class UpdateSettingDto {
@ApiProperty()

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { Module } from "@nestjs/common";
import { SettingsService } from "./settings.service";
import { PrismaModule } from "src/prisma/prisma.module";
@Module({
imports: [PrismaModule],

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma, UserSettings } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Prisma, UserSettings } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class SettingsService {

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty } from "class-validator";
export class CreateSongDto {
@IsNotEmpty()

View File

@@ -15,71 +15,92 @@ import {
Req,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateSongDto } from './dto/create-song.dto';
import { SongService } from './song.service';
import { Request } from 'express';
import { Prisma, Song } from '@prisma/client';
import { createReadStream, existsSync } from 'fs';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiProperty, ApiResponse, ApiResponseProperty, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { HistoryService } from 'src/history/history.service';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { FilterQuery } from 'src/utils/filter.pipe';
import { Song as _Song } from 'src/_gen/prisma-class/song';
import { SongHistory } from 'src/_gen/prisma-class/song_history';
Header,
} from "@nestjs/common";
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
import { CreateSongDto } from "./dto/create-song.dto";
import { SongService } from "./song.service";
import { Request } from "express";
import { Prisma, Song } from "@prisma/client";
import { createReadStream, existsSync, readFileSync } from "fs";
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { HistoryService } from "src/history/history.service";
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
import { FilterQuery } from "src/utils/filter.pipe";
import { Song as _Song } from "src/_gen/prisma-class/song";
import { SongHistory } from "src/_gen/prisma-class/song_history";
import { IncludeMap, mapInclude } from "src/utils/include";
import { Public } from "src/auth/public";
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
class SongHistoryResult {
@ApiProperty()
best: number;
@ApiProperty({ type: SongHistory, isArray: true})
@ApiProperty({ type: SongHistory, isArray: true })
history: SongHistory[];
}
@Controller('song')
@ApiTags('song')
@Controller("song")
@ApiTags("song")
@UseGuards(ChromaAuthGuard)
export class SongController {
static filterableFields: string[] = [
'+id',
'name',
'+artistId',
'+albumId',
'+genreId',
"+id",
"name",
"+artistId",
"+albumId",
"+genreId",
];
static includableFields: IncludeMap<Prisma.SongInclude> = {
artist: true,
album: true,
genre: true,
SongHistory: ({ user }) => ({ where: { userID: user.id } }),
likedByUsers: ({ user }) => ({ where: { userId: user.id } }),
};
constructor(
private readonly songService: SongService,
private readonly historyService: HistoryService,
) {}
@Get(':id/midi')
@ApiOperation({ description: "Streams the midi file of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the midi file succesfully"})
async getMidi(@Param('id', ParseIntPipe) id: number) {
@Get(":id/midi")
@ApiOperation({ description: "Streams the midi file of the requested song" })
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ description: "Returns the midi file succesfully" })
async getMidi(@Param("id", ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
if (!song) throw new NotFoundException("Song not found");
try {
const file = createReadStream(song.midiPath);
return new StreamableFile(file, { type: 'audio/midi' });
return new StreamableFile(file, { type: "audio/midi" });
} catch {
throw new InternalServerErrorException();
}
}
@Get(':id/illustration')
@ApiOperation({ description: "Streams the illustration of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the illustration succesfully"})
async getIllustration(@Param('id', ParseIntPipe) id: number) {
@Get(":id/illustration")
@ApiOperation({
description: "Streams the illustration of the requested song",
})
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ description: "Returns the illustration succesfully" })
@Header("Cache-Control", "max-age=86400")
@Public()
async getIllustration(@Param("id", ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
if (!song) throw new NotFoundException("Song not found");
if (song.illustrationPath === null) throw new NotFoundException();
if (!existsSync(song.illustrationPath))
throw new NotFoundException('Illustration not found');
throw new NotFoundException("Illustration not found");
try {
const file = createReadStream(song.illustrationPath);
@@ -89,20 +110,78 @@ export class SongController {
}
}
@Get(':id/musicXml')
@ApiOperation({ description: "Streams the musicXML file of the requested song"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ description: "Returns the musicXML file succesfully"})
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
@Get(":id/musicXml")
@ApiOperation({
description: "Streams the musicXML file of the requested song",
})
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ description: "Returns the musicXML file succesfully" })
async getMusicXml(@Param("id", ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException('Song not found');
if (!song) throw new NotFoundException("Song not found");
const file = createReadStream(song.musicXmlPath, { encoding: 'binary' });
const file = createReadStream(song.musicXmlPath, { encoding: "binary" });
return new StreamableFile(file);
}
@Get(":id/assets/partition")
@ApiOperation({
description: "Streams the svg partition of the requested song",
})
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ description: "Returns the svg partition succesfully" })
@Header("Cache-Control", "max-age=86400")
@Header("Content-Type", "image/svg+xml")
@Public()
async getPartition(@Param("id", ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException("Song not found");
// check if /data/cache/songs/id exists
if (!existsSync("/data/cache/songs/" + id + ".svg")) {
// if not, generate assets
await this.songService.createAssets(song.musicXmlPath, id);
}
try {
const file = readFileSync("/data/cache/songs/" + id + ".svg");
return file.toString();
} catch {
throw new InternalServerErrorException();
}
}
@Get(":id/assets/cursors")
@ApiOperation({
description: "Streams the partition cursors of the requested song",
})
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ description: "Returns the partition cursors succesfully" })
@Header("Cache-Control", "max-age=86400")
@Header("Content-Type", "application/json")
async getCursors(@Param("id", ParseIntPipe) id: number) {
const song = await this.songService.song({ id });
if (!song) throw new NotFoundException("Song not found");
// check if /data/cache/songs/id exists
if (!existsSync("/data/cache/songs/" + id + ".json")) {
// if not, generate assets
await this.songService.createAssets(song.musicXmlPath, id);
}
try {
const file = readFileSync("/data/cache/songs/" + id + ".json");
return JSON.parse(file.toString());
} catch {
throw new InternalServerErrorException();
}
}
@Post()
@ApiOperation({description: "register a new song in the database, should not be used by the frontend"})
@ApiOperation({
description:
"register a new song in the database, should not be used by the frontend",
})
async create(@Body() createSongDto: CreateSongDto) {
try {
return await this.songService.createSong({
@@ -118,20 +197,19 @@ export class SongController {
: undefined,
});
} catch {
throw new ConflictException(
await this.songService.song({ name: createSongDto.name }),
);
}
}
@Delete(':id')
@ApiOperation({ description: "delete a song by id"})
async remove(@Param('id', ParseIntPipe) id: number) {
@Delete(":id")
@ApiOperation({ description: "delete a song by id" })
async remove(@Param("id", ParseIntPipe) id: number) {
try {
return await this.songService.deleteSong({ id });
} catch {
throw new NotFoundException('Invalid ID');
throw new NotFoundException("Invalid ID");
}
}
@@ -140,35 +218,51 @@ export class SongController {
async findAll(
@Req() req: Request,
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Song>> {
const ret = await this.songService.songs({
skip,
take,
where,
include: mapInclude(include, req, SongController.includableFields),
});
return new Plage(ret, req);
}
@Get(':id')
@ApiOperation({ description: "Get a specific song data"})
@ApiNotFoundResponse({ description: "Song not found"})
@ApiOkResponse({ type: _Song, description: "Requested song"})
async findOne(@Param('id', ParseIntPipe) id: number) {
const res = await this.songService.song({ id });
@Get(":id")
@ApiOperation({ description: "Get a specific song data" })
@ApiNotFoundResponse({ description: "Song not found" })
@ApiOkResponse({ type: _Song, description: "Requested song" })
async findOne(
@Req() req: Request,
@Param("id", ParseIntPipe) id: number,
@Query("include") include: string,
) {
const res = await this.songService.song(
{
id,
},
mapInclude(include, req, SongController.includableFields),
);
if (res === null) throw new NotFoundException('Song not found');
if (res === null) throw new NotFoundException("Song not found");
return res;
}
@Get(':id/history')
@Get(":id/history")
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@ApiOperation({ description: "get the history of the connected user on a specific song"})
@ApiOkResponse({ type: SongHistoryResult, description: "Records of previous games of the user"})
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
@ApiOperation({
description: "get the history of the connected user on a specific song",
})
@ApiOkResponse({
type: SongHistoryResult,
description: "Records of previous games of the user",
})
@ApiUnauthorizedResponse({ description: "Invalid token" })
async getHistory(@Req() req: any, @Param("id", ParseIntPipe) id: number) {
return this.historyService.getForSong({
playerId: req.user.id,
songId: id,

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { SongService } from './song.service';
import { SongController } from './song.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { HistoryModule } from 'src/history/history.module';
import { Module } from "@nestjs/common";
import { SongService } from "./song.service";
import { SongController } from "./song.controller";
import { PrismaModule } from "src/prisma/prisma.module";
import { HistoryModule } from "src/history/history.module";
import { SearchModule } from "src/search/search.module";
@Module({
imports: [PrismaModule, HistoryModule],
imports: [PrismaModule, HistoryModule, SearchModule],
providers: [SongService],
controllers: [SongController],
})

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SongService } from './song.service';
import { Test, TestingModule } from "@nestjs/testing";
import { SongService } from "./song.service";
describe('SongService', () => {
describe("SongService", () => {
let service: SongService;
beforeEach(async () => {
@@ -12,7 +12,7 @@ describe('SongService', () => {
service = module.get<SongService>(SongService);
});
it('should be defined', () => {
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -1,30 +1,65 @@
import { Injectable } from '@nestjs/common';
import { Prisma, Song } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { Injectable } from "@nestjs/common";
import { Prisma, Song } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { MeiliService } from "src/search/meilisearch.service";
import { generateSongAssets } from "src/assetsgenerator/generateImages_browserless";
@Injectable()
export class SongService {
constructor(private prisma: PrismaService) {}
// number is the song id
private assetCreationTasks: Map<number, Promise<void>>;
constructor(
private prisma: PrismaService,
private search: MeiliService,
) {
this.assetCreationTasks = new Map();
}
async createAssets(mxlPath: string, songId: number): Promise<void> {
if (this.assetCreationTasks.has(songId)) {
await this.assetCreationTasks.get(songId);
this.assetCreationTasks.delete(songId);
return;
}
// mxlPath can the path to an archive to an xml file or the path to the xml file directly
this.assetCreationTasks.set(
songId,
generateSongAssets(songId, mxlPath, "/data/cache/songs", "svg"),
);
return await this.assetCreationTasks.get(songId);
}
async songByArtist(data: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
artistId: {equals: data},
artistId: { equals: data },
},
});
}
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
return this.prisma.song.create({
const song = await this.prisma.song.create({
data,
});
// Inculde the name of the artist in the song document to make search easier.
const artist = song.artistId
? await this.prisma.artist.findFirst({
where: { id: song.artistId },
})
: null;
await this.search
.index("songs")
.addDocuments([{ ...song, artist: artist?.name }]);
return song;
}
async song(
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
include?: Prisma.SongInclude,
): Promise<Song | null> {
return this.prisma.song.findUnique({
where: songWhereUniqueInput,
include,
});
}
@@ -34,20 +69,24 @@ export class SongService {
cursor?: Prisma.SongWhereUniqueInput;
where?: Prisma.SongWhereInput;
orderBy?: Prisma.SongOrderByWithRelationInput;
include?: Prisma.SongInclude;
}): Promise<Song[]> {
const { skip, take, cursor, where, orderBy } = params;
const { skip, take, cursor, where, orderBy, include } = params;
return this.prisma.song.findMany({
skip,
take,
cursor,
where,
orderBy,
include,
});
}
async deleteSong(where: Prisma.SongWhereUniqueInput): Promise<Song> {
return this.prisma.song.delete({
const ret = await this.prisma.song.delete({
where,
});
await this.search.index("songs").deleteDocument(ret.id);
return ret;
}
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty } from "@nestjs/swagger";
export class CreateUserDto {
@ApiProperty()

View File

@@ -1,4 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -1,10 +1,17 @@
import { Controller, Get, Post, Param, NotFoundException, Response } from '@nestjs/common';
import { UsersService } from './users.service';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { User } from 'src/models/user';
import {
Controller,
Get,
Post,
Param,
NotFoundException,
Response,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { User } from "src/models/user";
@ApiTags('users')
@Controller('users')
@ApiTags("users")
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@@ -13,17 +20,19 @@ export class UsersController {
return this.usersService.users({});
}
@Get(':id')
@Get(":id")
@ApiNotFoundResponse()
async findOne(@Param('id') id: number): Promise<User> {
async findOne(@Param("id") id: number): Promise<User> {
const ret = await this.usersService.user({ id: +id });
if (!ret) throw new NotFoundException();
return ret;
}
@Get(':id/picture')
@ApiOkResponse({description: 'Return the profile picture of the requested user'})
async getPicture(@Response() res: any, @Param('id') id: number) {
@Get(":id/picture")
@ApiOkResponse({
description: "Return the profile picture of the requested user",
})
async getPicture(@Response() res: any, @Param("id") id: number) {
return await this.usersService.getProfilePicture(+id, res);
}
}

View File

@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SettingsService } from 'src/settings/settings.service';
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
import { UsersController } from "./users.controller";
import { PrismaModule } from "src/prisma/prisma.module";
import { SettingsService } from "src/settings/settings.service";
@Module({
imports: [PrismaModule],

View File

@@ -2,19 +2,17 @@ import {
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'crypto';
import { createReadStream, existsSync } from 'fs';
import fetch from 'node-fetch';
} from "@nestjs/common";
import { User, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import * as bcrypt from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { createReadStream, existsSync } from "fs";
import fetch from "node-fetch";
@Injectable()
export class UsersService {
constructor(
private prisma: PrismaService,
) {}
constructor(private prisma: PrismaService) {}
async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
@@ -55,7 +53,7 @@ export class UsersService {
isGuest: true,
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
email: null,
password: '',
password: "",
},
});
}
@@ -65,7 +63,7 @@ export class UsersService {
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
if (typeof data.password === 'string')
if (typeof data.password === "string")
data.password = await bcrypt.hash(data.password, 8);
else if (data.password && data.password.set)
data.password = await bcrypt.hash(data.password.set, 8);
@@ -91,9 +89,9 @@ export class UsersService {
const user = await this.user({ id: userId });
if (!user) throw new InternalServerErrorException();
if (!user.email) throw new NotFoundException();
const hash = createHash('md5')
const hash = createHash("md5")
.update(user.email.trim().toLowerCase())
.digest('hex');
.digest("hex");
const resp = await fetch(
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
);
@@ -101,35 +99,33 @@ export class UsersService {
resp.body!.pipe(res);
}
async addLikedSong(
userId: number,
songId: number,
) {
return this.prisma.likedSongs.create(
{
data: { songId: songId, userId: userId }
}
)
async addLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.create({
data: { songId: songId, userId: userId },
});
}
async getLikedSongs(
userId: number,
) {
return this.prisma.likedSongs.findMany(
{
where: { userId: userId },
}
)
async getLikedSongs(userId: number, include?: Prisma.SongInclude) {
return this.prisma.likedSongs.findMany({
where: { userId: userId },
include: { song: include ? { include } : true },
});
}
async removeLikedSong(
userId: number,
songId: number,
) {
return this.prisma.likedSongs.deleteMany(
{
where: { userId: userId, songId: songId },
}
)
async removeLikedSong(userId: number, songId: number) {
return this.prisma.likedSongs.deleteMany({
where: { userId: userId, songId: songId },
});
}
async addScore(where: number, score: number) {
return this.prisma.user.update({
where: { id: where },
data: {
partyPlayed: {
increment: score,
},
},
});
}
}

View File

@@ -3,7 +3,7 @@ import {
Injectable,
PipeTransform,
Query,
} from '@nestjs/common';
} from "@nestjs/common";
@Injectable()
export class FilterPipe implements PipeTransform {
@@ -12,13 +12,13 @@ export class FilterPipe implements PipeTransform {
transform(value: Record<string, string>) {
const filter = {};
for (const fieldIdentifier of this.fields) {
const field = fieldIdentifier.startsWith('+')
const field = fieldIdentifier.startsWith("+")
? fieldIdentifier.slice(1)
: fieldIdentifier;
if (value[field] === undefined) continue;
if (fieldIdentifier.startsWith('+')) {
if (fieldIdentifier.startsWith("+")) {
filter[field] = parseInt(value[field]);
if (isNaN(filter[field]))
throw new BadRequestException(

33
back/src/utils/include.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Request } from "express";
import { BadRequestException } from "@nestjs/common";
export type IncludeMap<IncludeType> = {
[key in keyof IncludeType]:
| boolean
| ((ctx: { user: { id: number; username: string } }) => IncludeType[key]);
};
export function mapInclude<IncludeType>(
include: string | undefined,
req: Request,
fields: IncludeMap<IncludeType>,
): IncludeType | undefined {
if (!include) return undefined;
const ret: IncludeType = {} as IncludeType;
for (const key of include.split(",")) {
const value =
typeof fields[key] === "function"
? fields[key]({ user: req.user })
: fields[key];
if (value !== false && value !== undefined) ret[key] = value;
else {
throw new BadRequestException(
`Invalid include, ${key} is not valid. Valid includes are: ${Object.keys(
fields,
).join(", ")}.`,
);
}
}
return ret;
}

View File

@@ -1,9 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "./../src/app.module";
describe('AppController (e2e)', () => {
describe("AppController (e2e)", () => {
let app: INestApplication;
beforeEach(async () => {
@@ -15,10 +15,10 @@ describe('AppController (e2e)', () => {
await app.init();
});
it('/ (GET)', () => {
it("/ (GET)", () => {
return request(app.getHttpServer())
.get('/')
.get("/")
.expect(200)
.expect('Hello World!');
.expect("Hello World!");
});
});

View File

@@ -3,6 +3,8 @@ Documentation Tests of the /album route.
... Ensures that the album CRUD works corectly.
Resource ../rest.resource
Resource ../auth/auth.resource
Test Setup ApiKey
*** Test Cases ***

View File

@@ -3,6 +3,8 @@ Documentation Tests of the /artist route.
... Ensures that the artist CRUD works corectly.
Resource ../rest.resource
Resource ../auth/auth.resource
Test Setup ApiKey
*** Test Cases ***

View File

@@ -5,6 +5,11 @@ Resource ../rest.resource
*** Keywords ***
ApiKey
[Documentation] Set the API Key
Set Headers {"Authorization": "API Key %{API_KEY_ROBOT}"}
Login
[Documentation] Shortcut to login with the given username for future requests
[Arguments] ${username}

View File

@@ -4,6 +4,8 @@ Documentation Tests of the /auth route.
Resource ../rest.resource
Resource ./auth.resource
Test Setup ApiKey
*** Test Cases ***

View File

@@ -66,7 +66,7 @@ GuestToNormal
Integer response status 200
Boolean response body isGuest true
${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "a@b.c"}
${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "awdaw@b.c"}
Output
Integer response status 200
String response body username "toto"

View File

@@ -3,6 +3,8 @@ Documentation Tests of the /genre route.
... Ensures that the genre CRUD works corectly.
Resource ../rest.resource
Resource ../auth/auth.resource
Test Setup ApiKey
*** Test Cases ***

View File

@@ -4,6 +4,7 @@ Documentation Tests of the /history route.
Resource ../rest.resource
Resource ../auth/auth.resource
Test Setup ApiKey
*** Test Cases ***

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