Compare commits

..
142 Commits
Author SHA1 Message Date
RamazanandArthur Jamet ecc51ac6f0 Added russian lang support 🇷🇺 (#1021)
* Added russian lang support

* Added russian lang support

* Fixed indents

* Fixed indent on 98 line

* Added line ending

* Changed spaces to tabs

* Russian Translation: Fix indentation

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-05-21 20:05:11 +01:00
Arthur Jamet a9bc1b8151 Front: Fix double 'marking song as played' in crossfade handler 2025-05-21 18:56:40 +01:00
Arthur Jamet e3b030d6f2 Scrobbler Support (#1015)
* Front: Mark a song as played according to last fm's scrobbling rules

See 'https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble'

* Prisma: Add Scrobbler Table

* Server: Add Scheduler/Cron job module

* Server: Scrobbler Service + Controller + Implem LastFM

* Front: Queries + Model for Scrobbler Statuses

* Front: Callback handler for LastFM Auth

* Front: Settings: Add Section for Scrobblers

* Doc: Add LastFM Fields in .env

* Server: Scrobbler Controller: Only authed requests can get lastfm auth url

* Server: Scrobbler: Error handling when pushing scrobbles fail

* Front: Scrobbler Settings: Make things a bit more generic

* Server: Scrobbler: Implement ListenBrainz

* Server: Scrobbler: Implement ListenBrainz

* Front: Settings: Add form to connect ListenBrainz

* README: Mention Scrobbling feature

* Server: Scrobbler: Unlink

* Front: Settings: Unlink Scrobbler account

* Server: README: List Lastfm env vars

* Server: ListenBrainz: remove ununsed model

* Server: Scrobbler Service: Avoid crash when deleting scrobbler that is not connected

* Server: Scrobbler: Test CRUD
2025-05-21 12:09:12 +01:00
Arthur Jamet 52e6b6b57d Server: Lint 2025-05-21 11:05:40 +01:00
Arthur Jamet c65b202111 Server: Search: Fix returning every items in the table if no results 2025-05-21 11:05:40 +01:00
Arthur Jamet cc2d69f1f0 Server: Illustration controller: change url in tests
URL didn't seem to work anymore
2025-05-21 11:05:40 +01:00
Arthur Jamet a57aa8c3c0 Front: Avoid adding redundant page size query parameters to api requests 2025-05-19 14:39:19 +01:00
Arthur Jamet 771e8867c6 CI: Dockerisation: Fix publish of ARM images (#1014) 2025-05-17 14:47:59 +01:00
Arthur Jamet 528cd64269 Server: Fix queries to select items from search history 2025-05-17 13:03:51 +01:00
Arthur Jamet ac766d4070 Front: Fix infinite queries' key, to fix invalidation 2025-05-16 19:52:07 +01:00
Arthur Jamet 009b34997a CI: Dockerisation: Split ARM and AMD into 2 steps 2025-05-16 19:14:02 +01:00
Arthur Jamet 3a636e2c3c Server: Fix bad null check in Illustration Controller 2025-05-16 18:17:47 +01:00
Arthur Jamet 0af23c0a45 Server: Fix sorting songs by user play count 2025-05-14 12:05:22 +01:00
Weblate (bot)andArthur Jamet f7acf4b4d4 Integrate Weblate (#1004)
* Translated using Weblate (English)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/en/

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (268 of 268 strings)

Translation: Meelo/front
Translate-URL: https://hosted.weblate.org/projects/meelo/front/fr/

* README: Add link to weblate

* Front: Lint: Ignore JSON from weblate

---------

Co-authored-by: Arthur Jamet <arthur.jamet@gmail.com>
2025-05-10 18:56:50 +01:00
Arthur Jamet c036c8f123 Front: Refactor translation files + update translation keys (#1003)
* Front: Translation: Structure file for english

* Front: i18next: only use english json structure for typing

* Front: Use new translation keys

* Front: i18next: update french translation file structure
2025-05-10 17:44:44 +01:00
Arthur Jamet 983cc3a629 Front: Release page: add 'mixed' in header if all tracks are mixed 2025-05-05 18:53:16 +01:00
Arthur Jamet 8810b04073 Front: Track Item: Add 'mixed' when track is mixed 2025-05-05 18:53:16 +01:00
Arthur Jamet 0017e7fcb4 Server: Add 'mixed' field in track model + parse 2025-05-05 18:53:16 +01:00
Arthur Jamet 2e83324a58 Matcher: Genius: Fix selector on Lyrics and song description (#1001) 2025-05-05 15:07:17 +01:00
Arthur Jamet d368f8a332 Front: Release page: fix alignment of album artist name 2025-05-05 14:49:25 +01:00
Arthur Jamet 6f1f9cc084 Server: Avoid using deepemerge for Prisma queries (#999)
* Server: Prisma queries w/o deepmerge

* Server: Prevent empty deletion list to select all elements

* Server: Remove dependency on deepmerge
2025-05-05 14:43:40 +01:00
Arthur Jamet 72dd8e302c Front: Sort albums by release date when coming from the artist page 2025-05-05 11:50:20 +01:00
Arthur Jamet 6ef9808ca6 Front: Home Page: Add top labels 2025-05-04 15:24:07 +01:00
Arthur Jamet 13635e57b4 API: Allow sorting labels by release count 2025-05-04 15:24:07 +01:00
Arthur Jamet f86025a0d8 Front: Label page 2025-05-04 15:24:07 +01:00
Arthur Jamet 0d29db2df1 Front: Release Page: Add label of the release 2025-05-04 15:24:07 +01:00
Arthur Jamet 7d93263c0c API: Fix including label with releases 2025-05-04 15:24:07 +01:00
Arthur Jamet b525f240a5 Front: Define model and queries for labels 2025-05-04 15:24:07 +01:00
Arthur Jamet 89cd012a7c Scanner: Get and push label from embedded metadata and path 2025-05-04 15:24:07 +01:00
Arthur Jamet 7e92c2b43b API: Get one/many label + filter by artist and album 2025-05-04 15:24:07 +01:00
Arthur Jamet 49664306d3 API: Get artists, albums and releases by label 2025-05-04 15:24:07 +01:00
Arthur Jamet a9764ff197 Server: Label Model, base service and add it to registration dto 2025-05-04 15:24:07 +01:00
Arthur Jamet d185e3c21d Server: Fix Track Service Test 2025-05-03 18:57:59 +01:00
Arthur Jamet 07ec640757 Front: Action to reassign track 2025-05-03 18:57:59 +01:00
Arthur Jamet 22d61732cf Front: Action to merge songs 2025-05-03 18:57:59 +01:00
Arthur Jamet e3d3dd3619 Front: Refresh Metadata Action: Fix broken checkbox 2025-05-03 18:57:59 +01:00
Arthur Jamet 6a080c69a5 API: Allow merging songs 2025-05-03 18:57:59 +01:00
Arthur Jamet c84c6948f3 API: Allow reassigning a track to a song 2025-05-03 18:57:59 +01:00
Arthur Jamet 92df81dd89 Front: Expanded Player: Set dummy ctxt menu as disabled 2025-05-01 16:29:42 +01:00
Arthur Jamet 5f286b1eb0 Front: Player: Remove thumb from slider + avoid shift caused by progress text 2025-05-01 16:29:42 +01:00
Arthur Jamet 10d419dc29 Front: Relation Page Header: Change spacing in row + set subtitle to h5 2025-05-01 16:29:42 +01:00
Arthur Jamet 4e2c097d22 Front: Relation Page Header: Limit secondary title to 3 lines 2025-05-01 16:29:42 +01:00
Arthur Jamet 374688e135 Front: List Item: No hover styling when hovering trailing component 2025-05-01 16:29:42 +01:00
Arthur Jamet ede743b97f Front: Synced Lyrics: Avoid shifts and line breaks caused with font size/Weight change 2025-04-30 18:56:09 +01:00
Arthur Jamet 986a24545d Front: Fix Pagination for 'play' and 'shuffle' actions in infinite song lists 2025-04-29 12:10:33 +01:00
Arthur Jamet 31f5699f17 Front: Fix infinite setstate in search page 2025-04-28 20:48:00 +01:00
Arthur Jamet f6233bdd5c Front: Player queue: fix overflow on long titles 2025-04-28 20:48:00 +01:00
Arthur Jamet 8dd2384801 Front: Release Tracklist: Move 'isVideo' icon, replacing track index 2025-04-28 20:48:00 +01:00
Arthur Jamet a369a2316e Front: Release Tracklist: Fix missing ellipsis on overflow 2025-04-28 20:48:00 +01:00
Arthur Jamet e80fb98ba1 Front: Fix CSR URLs to API (#988) 2025-04-27 20:25:31 +01:00
Arthur Jamet 601aa49789 Server: Fix Playlist migration (#987) 2025-04-27 20:02:05 +01:00
Arthur Jamet b730326130 Server: Fix Playlist migration (#986) 2025-04-27 19:45:49 +01:00
Arthur Jamet 3c58225483 Playlist: Add visibility settings (#985)
* Prisma: add visibility field to playlist table

* Server: Test: add user to dummy repository

* Server: Fix permission of route that accept both anonymous requests and authed ones

* Server: add 'isPublic' and 'allowChanges' to playlist

* Prisma: Make Migration for playlist visibility non breaking

* Front: Playlist Form: add visibility fields

* Front: Playlist Menu: Filter out update action if not owner

* API: allow filter playlist by 'allowChanges'

* Front: Filter out playlist that cannot be edited in 'select playlist' form

* Front: Playlist: Disable edit + delete button depending on user permissions

* Front: Fix uploading illustration, because 'this' wasn't bound

* Server: Test 'changeable' selector in playlist controller

* Front: Remove debug msg

* Server: Playlist Service: Make query make more sense
2025-04-27 17:30:50 +01:00
Arthur Jamet ee0fb392a9 Front: Restructure API Wrapper + 'useQuery' stuff (#984)
* Front: Refactor API + Query

* Front: Use New API + Query hooks

* Front: Fix 'transform' in player state + simplify pagination handling in API

* Front: Prevent undefined error in player after logout

* Front: Fix Pagination in API

* Front: Remove comment

* Front: Fix wrong toas tmessage on user un-admin

* Front: Setting Grids: fix color of header
2025-04-25 10:12:59 +01:00
Arthur Jamet d5f2bdb527 Front: Player has loading state (#981)
* Front: Player State: add loading field

* Front: Minimised Player: Skeleton when queue is loading
2025-04-20 10:20:27 +01:00
Arthur Jamet 230f84245d Front: Release Tracklist: Better colors for 'playing' + video icons 2025-04-19 15:01:24 +01:00
Arthur Jamet bb9925c616 Front: fix null coertion in player when playing audio track after video 2025-04-19 15:01:24 +01:00
Arthur Jamet aea005baad New Icon + Banner 2025-04-19 14:53:19 +01:00
Marie bc301f685a Docker: remove unused profile definitions 2025-04-18 16:09:40 +01:00
Arthur Jamet b96a744336 Server: Bump nest-related deps 2025-04-17 15:10:24 +01:00
Arthur Jamet 2ce5ad7d56 Scanner: Bump net dep 2025-04-17 15:01:26 +01:00
Arthur Jamet f662a7aceb Front: Clicking on a song in an infinite view adds the rest of the song to the queue 2025-04-17 14:49:26 +01:00
Arthur Jamet dfff5a43c4 Front: Fade Between Tracks (#971)
* tmp

* Front: Easy Gapless playback

* Front: Gapless Playback: Only between audio tracks

* Front: Settings: Crossfade
2025-04-17 14:26:27 +01:00
Arthur Jamet 9b4d1de727 Front: Release Page: Disc index/names are clickable 2025-04-14 14:20:14 +01:00
Arthur Jamet 70c959bdeb Front: Clicking on a Song Grid item also adds the other songs in the queue 2025-04-13 12:48:03 +01:00
Arthur Jamet 7bd8010137 Front: Bump to MUI v7 (#967)
* Front: Bump MUI to v6

* Front: Bump MUI DataGrid to v6

* Front: MUI: Use Grid2 for library form + list items

* Front: MUI: Use Grid2 for artist page + page sections component

* Front: MUI: Use Grid2 for playlist page

* Front: MUI: Use Grid2 for UI settings

* Front: MUI: Use Grid2 for Release Page

* Front: MUI: Use Grid2 for Highlight Cards

* Front: MUI: Use Grid2 for empty state

* Front: MUI: Use Grid2 for Library Settings

* Front: MUI: Use Grid2 for Genre tiles

* Front: MUI: Use Grid2 for Player Slider

* Front: MUI: Use Grid2 for Track items

* Front: MUI: Use Grid2 for Relation Page Header

* Front: MUI: Use Grid2 for Actions + Infinite view controls

* Front: MUI: Use Grid2 for Player's minimized controls

* Front: MUI: Use Grid2 for Keyboard Biding Modal

* Front: MUI: Use Grid2 for Player's expanded controls

* Front: MUI: Use Grid2 for Auth form

* Front: Lint

* Front: MUI 7: Use new Grid import

* Front: MUI 7: Re-setup Style in SSR

* Front: Lint

* Front: Bump MUI DataGrid to v7

* Front: Fix MUI Migration
2025-04-06 16:19:18 +00:00
Arthur Jamet cd78bd408a Front: Fix Link in contextual menus 2025-04-01 13:21:47 +01:00
Arthur Jamet 1de13e7354 Front: Genres Tile, Infinite List and radio 2025-03-30 10:54:00 +01:00
Arthur Jamet eda1529a6a Front: Player Controls: add button to force load next queue page 2025-03-29 20:39:08 +00:00
Arthur Jamet ed680064d7 Front: Fix Key Warning on Action Button Group 2025-03-29 20:39:08 +00:00
Arthur Jamet 4d5bf293f5 Front: Player State: Add infinite query to lazyload queue 2025-03-29 20:39:08 +00:00
Arthur Jamet 3f633803f4 Front: Restructure Internal File System (#963)
* Front: Front: Rename Auth Files

* Front: Front: Rename Contextual Menu Files

* Front: Front: Rename Highlight Card Files

* Front: Rename Infinite Scroll Files

* Front: Rename List Item Files

* Front: Rename Contextual Menu Files

* Front: Rename Player Files

* Front: Rename Relation Page Header Files

* Front: Rename Scaffold Files

* Front: Rename Action Files

* Front: Rename Settings Files

* Front: Rename Tile Files

* Front: Rename Tile Files

* Front: Avoid relative imports

* Front: Lint

* Front: Avoid relative imports

* Front: Add tilde to differentiate local imports from dependencies

* Front: Rename API File

* Front: rollback tests
2025-03-29 11:24:46 +00:00
Arthur Jamet a3095b4dfa Front: Rewrite Infinite Views' Controls
* Front: Start new Controls + Actio  Group

* Front: New Controls: Layout Button

* Front: New Controls: Split into subcomponents

* Front: New Controls: Layout Hook

* Front: New Controls: Sort Hook

* Front: New Controls: Filter Hook

* Front: TMP

* Front: New Controls: Fixes for Sorting

* Front: New Controls: Specialize filter for single/multiple choices

* Front: New Controls: Fix typing for song groups

* Front: New Controls: Use in Album View

* Front: New Controls: Use in Video View

* Front: New Controls: Use in Track View

* Front: New Controls: Use in Playlist View

* Front: New Controls: Fix opeing actions' dialog

* Front: New Controls: Use in Artist View

* Front: Remove Old controls

* Front: New Controls: Rename files, shorten paths

* Front: New Controls: Fix initial state of layout when SSR

* Front: New Controls: Filters and Sort have dedicated button groups

* Front: New Controls: Fix 'selected' state when filter has multiple choices

* Front: New Controls: Remove 'defaultSortingKey' from Sort Control
2025-03-28 10:29:19 +00:00
Arthur Jamet 385ca37848 Front: Bump Nextjs 2025-03-24 17:47:16 +00:00
Arthur Jamet ec02d780ea Scanner: LRC-formatted Lyrics (#960)
* Scanner: Defined plain/synced lyrics type

* Scanner: Parse LRC

* Scanner: Look for LRC files

* Scanner: Look for LRC files

* README: Mention LRC support
2025-03-17 11:34:21 +00:00
Arthur Jamet ecdb82782f Front: Remove Dependency to spinner library (#959) 2025-03-16 10:32:45 +00:00
Matthieu LAURENT ace1d78505 Capture "/" key event to prevent quick find from opening (#957) 2025-03-16 10:07:56 +00:00
Matthieu LAURENT 87dc8a3f66 Only skip using the embedded thumbnail as illustrations for videos (#958) 2025-03-16 10:00:44 +00:00
Arthur Jamet e8b6db6ed5 README: Add Synced Lyrics GIF 2025-03-15 10:06:00 +00:00
Matthieu LAURENT 7aa484b1e4 Use the embeded video illustration as thumbnail if one exists (#949)
* Use the embeded video illustration as thumbnail if one exists.
This falls back to the existing implementation of using a frame from the video if no embeded illustration is present.

* Add aspect ratio check

* Revert "Add aspect ratio check"

This reverts commit 73a0c737156feda2c9a1a05e6736cb8d07d4c5d4.

* Add "useEmbeddedThumbnails" option

* Format code
2025-03-15 09:55:19 +00:00
Arthur Jamet 988a2e38f0 Support Disc Names (#954)
* Scanner: Get Disc Name

* Prisma: Define Disc View

* Server: Persist Disc Name

* API: Get Disc from release

* Front: Release Page: Show Disc Names
2025-03-14 15:16:48 +00:00
Arthur Jamet af359358d5 Front: Fix padding around song-type icons (#953) 2025-03-13 16:38:05 +00:00
Matthieu LAURENT fd3e5c9e5f Add an icon with the song type (#948)
* Add song type icons next to the name

* Use translated name for song type tooltip

* Move icons to song info page and track info modal

* Change icons for cut and edit song types

* Remove leftover code edits

* Change demo song type icon

* Improve songType french translation

* Remove unnecessary casts

* Make the track info song type icon fit the font size
This also makes it smaller overall

* Remove song type icon tooltip

* Handle the case where a track has no song during song type display

* Show a skeleton for song type while the data is loading
This also makes the icon a bit smaller

* Fix type check, lint & format
2025-03-13 16:26:06 +00:00
Arthur Jamet bae28fb94a Scanner: Bump Version of Go + Bump dep 2025-03-13 14:37:36 +00:00
Arthur Jamet 8964086a61 Server: Bump Axios 2025-03-13 13:05:21 +00:00
Arthur Jamet e0c2711fac Synced Lyrics (#944) 2025-03-10 19:04:54 +00:00
Arthur Jamet 27146f2bb0 Scanner: Get embedded tags from streams 2025-03-09 20:07:17 +00:00
Arthur Jamet e7e89a4baf Allow library path to be ‘./‘ (#937) 2025-03-06 07:26:37 +00:00
Arthur Jamet 315807aca2 Add BPM (#936)
* Scanner: Parse BPM

* Scanner: Add BPM to Registration DTO

* Server: Persist BPM

* API: Sort Songs by BPM

* Front: Show BPM in Track Info Modal

* Front: Make Info Icon look like an info icon

* Front: Song Contextual Menu: add link to song info page

* Scanner: Fix tests
2025-03-06 06:49:55 +00:00
Arthur Jamet 7817a871bf Front: Fix Validation of nullable release dates (#934) 2025-03-04 18:41:15 +00:00
Arthur Jamet fb22c1cee7 Front: Fix Filtering of videos by albums (#933) 2025-03-04 08:35:21 +00:00
Arthur Jamet 961a9f0036 Front: Smooth Gradient Background Transition (#932)
* Front: Use Atom, Fix Gradient Background Flash

* Front: Smooth Background Gradient Transition
2025-03-04 06:47:26 +00:00
Arthur Jamet 81f4f3aecb Front: Use Atom, Fix Gradient Background Flash 2025-03-03 15:25:03 +00:00
Arthur Jamet f2eabb94c8 Scanner: Add filter to deinterlace thumbnails (#930) 2025-03-02 12:25:08 +00:00
Arthur Jamet 1ba71dbf15 Front: Ensure type-safe dates (#928)
Rehydrated dates are string, not proper Date objects.
We ensure on the type level that dates are either strings or Dates
2025-03-02 11:51:31 +00:00
Arthur Jamet a29cf5f7da Front: Empty States 2025-03-02 09:57:08 +00:00
Arthur Jamet 95bf08c3fb Front: Empty States 2025-03-02 09:57:08 +00:00
Arthur Jamet cde9df4d81 Front: Stop playback on logout 2025-03-02 09:57:08 +00:00
Arthur Jamet 30d3f1600f Server: 'Filter' API (#926) 2025-02-28 20:17:29 +00:00
Arthur Jamet c2d28d1dc8 CI: Use Composite Action for dockerization (#925) 2025-02-25 18:27:50 +00:00
Arthur Jamet 8387805f86 CI: Matcher: Fix 'needs' for dockerisation (#924) 2025-02-25 18:07:17 +00:00
Arthur Jamet c555b0063f CI: Composite Action for Dockerisation + use it for matcher (#923) 2025-02-25 17:52:03 +00:00
Arthur Jamet c741a6122a Front: Normalize color for CSS 2025-02-25 12:20:36 +00:00
Arthur Jamet c1c474e2e8 CI: Cache Dockerisation (#921)
* CI: Server: Cache Dockerisation

* CI: Front, Scanner, Matcher: Cache Dockerisation
2025-02-25 11:54:49 +00:00
Arthur Jamet 9da35dc7ff CI: Fix Cache hit (#920)
* CI: Fix Cache hit for Server

* CI: Fix Cache hit for Front

* CI: Setup cache for Matcher
2025-02-24 19:49:06 +00:00
Arthur Jamet 640653bed3 Front: Release Page shows month of release 2025-02-24 17:47:56 +00:00
Arthur Jamet 93662b802b Server: Bump Nestjs CLI 2025-02-24 08:32:41 +00:00
Arthur Jamet 549eff117b CI: Build docker with multiple platforms (#916) 2025-02-23 19:40:00 +00:00
Arthur Jamet 12dea95ccb Revert "CI: Docker: Build Docker Images for ARM"
This reverts commit 945188928e.
2025-02-23 17:42:47 +00:00
Arthur Jamet 945188928e CI: Docker: Build Docker Images for ARM 2025-02-23 17:25:03 +00:00
Arthur Jamet 82b169718f Front: Save Queue as Playlist 2025-02-22 14:12:41 +00:00
Arthur Jamet becfd85476 Front: Move User Atoms to State folder 2025-02-20 08:28:17 +00:00
Arthur Jamet 05cb71f3e3 Front: Player State is atom 2025-02-20 08:28:17 +00:00
Arthur Jamet 55a929dee8 Front: Remove Redux for user state (#905)
* Front: Remove Redux, use context provider for access token (WIP)

* Front: Set Access Token on SSR and CSR
2025-02-18 18:38:21 +00:00
Arthur Jamet e7d7d20997 Front: Add button to delete track from queue (#900)
* Front: Add 'New' Playlist button to 'Add to playlist' dialog

* Front: Lint

* Front: Add button to delete track from queue
2025-02-11 19:18:15 +00:00
Arthur Jamet 4d09f9e519 Front: Add 'New' Playlist button to 'Add to playlist' dialog (#899)
* Front: Add 'New' Playlist button to 'Add to playlist' dialog

* Front: Lint
2025-02-11 19:13:13 +00:00
Arthur Jamet 103725bc15 CI: Use new Sonarcloud action 2025-02-11 20:06:28 +01:00
Arthur Jamet 340a17165c Change Grid's Items size (#898)
* Front: Infinite Scroll Controlls: add button to change grid item size

* Front: Define Default Item Size

* Front: Lint

* Front: Grid Resize: Avoid rerender

* Front: Use Default Item Size constant instead of magic string

* Front: Add Add/Minus Icon
2025-02-11 14:06:25 +00:00
Arthur Jamet f58c3b1081 Server: Sorting: Define fallback behaviour when sort on same value (#891) 2025-02-09 19:22:53 +00:00
Arthur Jamet a0623fdeff Scanner: Split Embedded genres (#890) 2025-02-09 16:25:58 +00:00
Arthur Jamet ba16ff1c14 Scanner: Case-insensitive tags from ffprobe (#889) 2025-02-09 15:19:06 +00:00
Arthur Jamet 788a86331d Server: Simplify env setup for tests 2025-02-09 10:47:00 +01:00
Arthur Jamet 568e8996d4 Docker Compose: Use Docker hub + env var for tags (#883) 2025-02-08 21:01:15 +01:00
Arthur Jamet 4f232ac245 README: Add link to discussion (#880)
* README: Add link to discussion

* Update README.md
2025-02-08 17:17:55 +00:00
Arthur Jamet 8ce73e9920 Front: Add 'Compilations' page 2025-02-06 08:40:03 +00:00
Arthur Jamet a45d42bb95 Front: Alway use icon as illustration fallback + add skeleton for background 2025-02-05 14:13:23 +00:00
Arthur Jamet 1258de0571 Server: Parser Service: Add 'EPK' keyword for interview videos 2025-02-05 11:53:23 +00:00
Arthur Jamet 6f0a651b53 Front: Infinite Query: Return concatenated list of all items 2025-02-05 11:35:04 +00:00
Arthur Jamet 8c0f341632 Front: Remove expandable button for resource description 2025-02-04 19:38:34 +00:00
Arthur Jamet b122a48cba Matcher: Wikidata: Use v1 API 2025-02-04 13:14:40 +00:00
Arthur Jamet a7eb3514fc Matcher: Genius: Fix selector on Lyrics 2025-02-04 13:14:40 +00:00
Arthur Jamet 54514bb178 Front: Settings: Show Task progress (#867) 2025-02-03 15:12:50 +00:00
Arthur Jamet 693f617957 Scanner: Task Progress (#866)
* Scanner: Add progress value in worker

* Scanner: add progress in /tasks response + set progress for scan task

* Scanner: set progress for refresh task

* Scanner: set progress for cleaning task
2025-02-03 13:29:12 +00:00
Arthur Jamet a3fa306ac1 Docs: Remove mkdoc, use GitHub Wiki 2025-02-02 10:14:46 +00:00
Arthur Jamet a7162352ec Front: Make modal open on library metadata refresh 2025-02-01 19:22:13 +00:00
Arthur Jamet 51de0d1a03 Scanner: Use ID3 Compilation tag 2025-02-01 19:18:26 +00:00
Arthur Jamet 43f5d9f973 Scanner: POST embedded lyrics 2025-02-01 13:27:21 +00:00
Arthur Jamet a1e1060ae3 Front: Format 2025-02-01 10:55:09 +00:00
Arthur Jamet 3063fad0d2 Front: Add translation 2025-02-01 10:55:09 +00:00
Arthur Jamet 4ad434a71c Front: Refresh Metadata Action: add checkbox to force refresh 2025-02-01 10:55:09 +00:00
Arthur Jamet 8be291d9d2 Front: Bump type-fest 2025-02-01 10:55:09 +00:00
Arthur Jamet 9b07d2ecbc Scanner: Add force parameter to force metadata refresh 2025-02-01 10:55:09 +00:00
396 changed files with 17409 additions and 10115 deletions

No files matched your search

+18 -1
View File
@@ -1,3 +1,10 @@
#################### Version
# The version of meelo to use (edge, latest, vX.X.X)
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
# The default value is 'latest'
# Please read the changelog before changing this value
# https://github.com/Arthi-chaud/Meelo/releases
TAG=
#################### Database
# The port on the host where meelo will be accessible
PORT=
@@ -33,7 +40,17 @@ MEILI_MASTER_KEY=
# A coma-separated list of random strings used for internal authentication
# Example: API_KEYS=azertyuip,qwertyuiop
API_KEYS=
#################### Scrobblers
# All values here are optional
# Fill in the values according to what scrobblers you want to enable
## LastFM:
## Values obtained when creating an API account
## https://www.last.fm/api/account/create
LASTFM_API_KEY=
LASTFM_API_SECRET=
## ListenBrainz:
### No configuration needed
#################### Internal
# Do not change this
INTERNAL_DATA_DIR=/data
INTERNAL_CONFIG_DIR=/config
INTERNAL_CONFIG_DIR=/config
+2 -2
View File
@@ -12,5 +12,5 @@ runs:
path: |
./front/node_modules
./front/.next
key: front
restore-keys: front
key: front-${{ hashFiles('front/yarn.lock') }}
restore-keys: front-
+12 -1
View File
@@ -3,7 +3,18 @@ description: 'Cache for Matcher'
runs:
using: "composite"
steps:
- run: apk add --update --no-progress lsb-release python3 py3-pip
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
shell: sh
- run: mkdir ./pip-cache
shell: sh
- name: Cache
uses: actions/cache@v3
with:
path: |
./pip-cache
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
restore-keys: matcher-
- run: pip3 install -r matcher/requirements.txt
shell: sh
env:
PIP_CACHE_DIR: ./pip-cache
+2 -1
View File
@@ -12,4 +12,5 @@ runs:
with:
check-latest: true
go-version-file: 'scanner/go.mod'
cache-dependency-path: scanner/go.sum
cache-dependency-path: scanner/go.sum
go-version: '1.24.1'
+2 -2
View File
@@ -12,5 +12,5 @@ runs:
path: |
./server/node_modules
./server/dist
key: server
restore-keys: server
key: server-${{ hashFiles('server/yarn.lock') }}
restore-keys: server-
+59
View File
@@ -0,0 +1,59 @@
name: 'Dockerisation'
description: 'Build using Docker, and publish if necessary'
inputs:
docker-username:
description: 'Username to publish Docker image under'
required: true
docker-token:
description: 'Token to publish Docker image'
required: true
github-token:
description: 'GitHub Token'
required: true
image-suffix:
description: "Suffix to 'meelo-'"
required: true
publish:
description: 'Publish to registry'
default: 'false'
context:
description: 'Directory of the Dockerfile'
required: true
runs:
using: "composite"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ inputs.docker-username }}
password: ${{ inputs.docker-token }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github-token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-${{ inputs.image-suffix }}
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: ${{ inputs.context }}
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
labels: ${{ steps.meta.outputs.labels }}
-40
View File
@@ -1,40 +0,0 @@
name: Documentation
on:
push:
branches: [ main ]
permissions:
contents: write
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
docs:
- 'docs/**'
- '.github/workflows/documentation.yml'
- 'assets/**'
Build:
needs: changes
name: Deploy
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.docs == 'true' }}
steps:
- name: Checkout main
uses: actions/checkout@v3
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONFIG_FILE: docs/mkdocs.yml
REQUIREMENTS: docs/requirements.txt
+11 -52
View File
@@ -28,6 +28,8 @@ jobs:
front:
- 'front/**'
- '.github/workflows/front.yml'
- '.github/actions/cache-front/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
@@ -92,70 +94,27 @@ jobs:
name: coverage-report-front
path: ./front/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./front
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
Dockerize:
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./front
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-front
ghcr.io/${{ github.repository }}-front
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./front
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: front
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+11 -51
View File
@@ -28,6 +28,8 @@ jobs:
matcher:
- 'matcher/**'
- '.github/workflows/matcher.yml'
- '.github/actions/cache-matcher/**'
- '.github/actions/dockerize/**'
Typecheck:
needs: changes
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
@@ -69,63 +71,21 @@ jobs:
uses: ./.github/actions/cache-matcher
- run: python3 -m tests
Dockerize:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
needs: [ Typecheck ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Typecheck Docker
uses: docker/build-push-action@v3
with:
context: ./matcher
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-matcher
ghcr.io/${{ github.repository }}-matcher
- name: Typecheck and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./matcher
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: matcher
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+12 -52
View File
@@ -29,6 +29,8 @@ jobs:
scanner:
- 'scanner/**'
- '.github/workflows/scanner.yml'
- '.github/actions/cache-scanner/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
@@ -86,70 +88,28 @@ jobs:
name: coverage-report-scanner
path: ./scanner
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./scanner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
Dockerize:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./scanner
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-scanner
ghcr.io/${{ github.repository }}-scanner
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./scanner
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: scanner
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+11 -52
View File
@@ -28,6 +28,8 @@ jobs:
server:
- 'server/**'
- '.github/workflows/server.yml'
- '.github/actions/cache-server/**'
- '.github/actions/dockerize/**'
Build:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
@@ -121,70 +123,27 @@ jobs:
name: coverage-report-server
path: ./server/coverage
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
uses: SonarSource/sonarqube-scan-action@master
with:
projectBaseDir: ./server
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
Dockerize:
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
needs: [ Build ]
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker
uses: docker/build-push-action@v3
with:
context: ./server
push: false
Publish:
needs: changes
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
arthichaud/meelo-server
ghcr.io/${{ github.repository }}-server
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
- uses: ./.github/actions/dockerize
with:
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
context: ./server
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
labels: ${{ steps.meta.outputs.labels }}
image-suffix: server
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+18 -5
View File
@@ -1,4 +1,4 @@
# <img alt="Icon" src="./front/public/banner.png" width="150px">
# <img alt="Icon" src="./front/public/banner1-white.png" width="150px">
Welcome! Meelo is a self-hosted music server and web app. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
@@ -37,7 +37,8 @@ Meelo has all the basic features you might expect from a music player/server (pl
- Use either the embedded metadata or the file's name (or both!) to extract metadata
- Also works with album covers!
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
- Download lyrics
- Push Scrobbles to ListenBrainz and LastFM!
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
@@ -46,7 +47,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
## :desktop_computer: Live Demo
@@ -65,19 +66,31 @@ https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
![Artist Page](./assets/examples/artist-page.png)
![Player Page](./assets/examples/player.png)
![Release Page](./assets/examples/releases.png)
![Synced Lyrics](./assets/examples/synced-lyrics.gif)
More screenshots [here](./assets/examples/)
</details>
## :handshake: Contributing and Bugs reporting
## 🌐 Translation
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
[![Stats](https://hosted.weblate.org/widget/meelo/front/multi-auto.svg)](https://hosted.weblate.org/engage/meelo/)
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
## :handshake: Contributing and Bugs reporting
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
---
Get ready to make the most out of you music collection!
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

-1
View File
@@ -134,7 +134,6 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
depends_on:
db:
condition: service_healthy
+4 -5
View File
@@ -1,7 +1,7 @@
# This compose allows to setup the whole project in the production environment
services:
server:
image: ghcr.io/arthi-chaud/meelo-server:latest
image: arthichaud/meelo-server:${TAG:-latest}
expose:
- 4000
restart: on-failure
@@ -28,7 +28,7 @@ services:
timeout: 5s
retries: 5
scanner:
image: ghcr.io/arthi-chaud/meelo-scanner:latest
image: arthichaud/meelo-scanner:${TAG:-latest}
expose:
- 8133
depends_on:
@@ -48,7 +48,7 @@ services:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
image: ghcr.io/arthi-chaud/meelo-front:latest
image: arthichaud/meelo-front:${TAG:-latest}
expose:
- 3000
depends_on:
@@ -62,7 +62,7 @@ services:
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
matcher:
image: ghcr.io/arthi-chaud/meelo-matcher:latest
image: arthichaud/meelo-matcher:${TAG:-latest}
restart: on-failure
depends_on:
server:
@@ -121,7 +121,6 @@ services:
depends_on:
db:
condition: service_healthy
profiles: ['', 'cpu']
mq:
image: rabbitmq:4.0-alpine
environment:
-1
View File
@@ -99,7 +99,6 @@ services:
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
db:
image: postgres:alpine3.14
healthcheck:
-1
View File
@@ -1 +0,0 @@
site/
-45
View File
@@ -1,45 +0,0 @@
site_name: Meelo - User Documentation
docs_dir: './src'
repo_url: https://github.com/Arthi-chaud/Meelo
nav:
- Setup:
- TLDR: setup/tldr.md
- Requirements: setup/requirements.md
- Prepare Music Library: setup/prepare-music-library.md
- Create Configuration Folder: setup/create-conf-folder.md
- Prepare Your Settings: setup/settings.md
- Configure Providers: setup/providers.md
- Environment Variables: setup/env.md
- Docker: setup/docker.md
- Start: setup/startup.md
- Create First Library: setup/create-library.md
- Album Types: album-types.md
- Song Types: song-types.md
- Featuring Artists: featuring-artists.md
theme:
name: material
language: en
palette:
scheme: default
titles_only: true
icon:
repo: fontawesome/brands/github
font:
text: Noto Sans
logo: ./icon.png
favicon: ./favicon.ico
features:
- navigation.instant
- navigation.tracking
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.indexes
- toc.integrate
- navigation.top
extra_css:
- stylesheets/extra.css
plugins:
- search
markdown_extensions:
- mdx_truly_sane_lists
-2
View File
@@ -1,2 +0,0 @@
mkdocs-material
mdx_truly_sane_lists
-42
View File
@@ -1,42 +0,0 @@
# Meelo's Album Types
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
## Available Types
Meelo supports this categorization, and the following album types:
- Studio Recording
- What we would usually call *Albums*
- Live Recording
- Compilation
- Single
- Soundtrack
- Remix Album
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
- Video Album
- Any kind of collection that would only contain video tracks
## Type recognition
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
### Keywords
| Album Type | Keywords |
|---|---|
| Studio Album | See note below |
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
Notes:
- Keywords are case-insensitive.
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
- Any album that would not fall into the other categories will be considered to be a Studio Recording
-1
View File
@@ -1 +0,0 @@
../../front/public/banner-black.png
-1
View File
@@ -1 +0,0 @@
../../front/public/favicon.ico
-53
View File
@@ -1,53 +0,0 @@
# Meelo's Featuring Artist Management
Meelo is now able to parse and recognise featuring artists on a song, based on:
- Its name
- Its artist field (from the metadata)
It does not leverage the album artist.
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
Therefore, each featuring artist will be added to the database, and treated like any other artist.
Parsing works with `()`, `[]` and `-` as delimiters.
## Examples
In this section, we list examples that would be successfully parsed by Meelo
- 'My Song (feat. B)' by 'A'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song (Remix) [feat. B]' by 'A'
- Name: 'My Song (Remix)'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A & B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
- 'My Song' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C'
- 'My Song - With D' by 'A, B & C'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B', 'C', 'D'
- 'My Song - Version With Drums' by 'A'
- Name: 'My Song - Version With Drums'
- Artist: 'A'
- Featuring: None
- 'My Song' by 'A Vs. B'
- Name: 'My Song'
- Artist: 'A'
- Featuring: 'B'
## Special Case: Artist is already known
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
-1
View File
@@ -1 +0,0 @@
../../front/public/icon.png
-3
View File
@@ -1,3 +0,0 @@
![Meelo](./banner-black.png)
# Welcome to Meelo's User Documentation
-7
View File
@@ -1,7 +0,0 @@
# Create a configuration folder
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
-8
View File
@@ -1,8 +0,0 @@
# Create your first library
After creating the admin account (from Web App),
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
-5
View File
@@ -1,5 +0,0 @@
# Docker and Docker-compose
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
-3
View File
@@ -1,3 +0,0 @@
# Environment variables
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
-18
View File
@@ -1,18 +0,0 @@
# Prepare your music library
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
```tree
Library's Folder
│
Artist 1
│ │
│ └───Album 1
│ │ 01 Track 1.m4a
│ │ 02 Track 2.m4a
| | cover.jpg
│ │ ...
│ ...
```
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
-42
View File
@@ -1,42 +0,0 @@
# Providers
## Enabling Providers
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
```json
// In settings.json
{
//... Other properties
"providers": {
"provider1": {}, // Provider 1 is enabled
// Provider 2 was not specified, it is therefore disabled
"provider3": {
"enabled": true // Provider 3 is enabled
},
"provider4": {
"enabled": false, // Provider 4 is disabled
"apiKey": "" // Other property of provider 4
}
}
}
```
If a provider is disabled, no call to its service will be done.
## Providers' properties and features
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | x | x | | |
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
| AllMusic | `allmusic` | None | | | x | x | | | | |
| MetaCritic | `metacritic` | None | | | x | x | | | | |
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
*Note*: If MusicBrainz is enabled, we might make requests to [AcoustID](https://acoustid.org)'s API to match songs using audio fingerprints (No data will be submitted).
-8
View File
@@ -1,8 +0,0 @@
# Requirements
Meelo is easy to setup! But you'll need the following things before continuing
- Have you music library ready (see [*this step*](../prepare-music-library))
- Know how `Docker` and `docker-compose` work
- Know how to create a `.env` file
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
-35
View File
@@ -1,35 +0,0 @@
# Prepare your settings
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
All fields are **required**
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
- `AlbumArtist`: The artist of the parent album.
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
- `Year`: The Year of the release of the material
- `Disc`: The Index of the disc the track is on.
- `Index`: The index of the track in a playlist/on a disc.
- `Track`: The name of the track
- `Genre`: The genre of the material
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
- *Note*: **If you do not use embedded data**, most regex groups are optional. Only `Track` and (`AlbumArtist` or `Artist`) are required for the file to be accepted by the scanner.
- `metadata`: Configuration of the **metadata extraction** system:
- `source`: The **primary metadata source** to use:
- `embedded`: Using the embedded tags
- `path`: Using the Regular Expression's groups.
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
- `providers`: Please refer to [*Providers*](../providers)
- `compilations`:
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
*Note*: The provided Regular Expression uses the iTunes file architecture.
*Note*: Regular Expression must use the Golang Flavour.
-16
View File
@@ -1,16 +0,0 @@
# Start Meelo
Once these three files are ready, run the following command from your `Meelo` folder:
```bash
docker-compose up
```
## Troubleshooting
Two things can prevent Meelo from starting normally:
- A bad `settings.json` file
- In that case, a message should tell you what went wrong
- Connection to database failed
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
-12
View File
@@ -1,12 +0,0 @@
# TLDR
Don't feel like reading? Here's how to setup Meelo quickly
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
Enjoy!
-31
View File
@@ -1,31 +0,0 @@
# Meelo's Album Types
It is possible to filter songs based on their *type*.
## Available Types
Meelo supports the following song types:
- Original
- Live
- Acoustic
- Remix
- Instrumental
- A Cappella
- Edit
- Of the original version.
- If the song is an edit of a remix, it will be categorised as 'Remix'
- Clean
- Demo
- Medley
- Non-Music (e.g. Interviews)
## Type recognition
Songs' type recognition is solely based on keywords present in their name. For example:
- 'My Song (Remix)' would be typed as 'Remix'
- 'My Song (Live)' would be recognised as a Live rendition
- 'My Song (Rough Mix)' would be defined as 'Demo'
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
-3
View File
@@ -1,3 +0,0 @@
:root {
--md-primary-fg-color: #2F2C2B;
}
-28
View File
@@ -1,28 +0,0 @@
import userReducer, {
setAccessToken,
setUserProfile,
unsetAccessToken,
} from "../../src/state/userSlice";
describe("User Slice", () => {
let state: ReturnType<typeof userReducer> | undefined = undefined;
it("Should set the access token", () => {
state = userReducer({}, setAccessToken("1234"));
expect(state).toStrictEqual({ accessToken: "1234" });
});
it("Should set the user profile", () => {
const user = { id: 1, name: "user", admin: true, enabled: true };
state = userReducer(state, setUserProfile(user));
expect(state).toStrictEqual({ accessToken: "1234", user });
});
it("Should unset the access token, and user profile", () => {
state = userReducer(state, unsetAccessToken());
expect(state.accessToken).toBeUndefined();
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
"lineWidth": 80
},
"files": {
"ignore": ["**/.next/**"]
"ignore": ["**/.next/**", "src/i18n/translations/*.json"]
},
"linter": {
"rules": {
+11 -13
View File
@@ -12,15 +12,15 @@
"test:cov": "jest -i --coverage"
},
"dependencies": {
"@emotion/cache": "^11.10.0",
"@emotion/react": "^11.10.0",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.10.0",
"@mui/material": "^5.10.1",
"@mui/utils": "^5.10.6",
"@mui/x-data-grid": "^5.17.14",
"@mui/x-date-pickers": "^5.0.8",
"@reduxjs/toolkit": "^1.8.5",
"@emotion/styled": "^11.14.0",
"@mui/material": "^7.0.1",
"@mui/material-nextjs": "^7.0.0",
"@mui/utils": "^7.0.1",
"@mui/x-data-grid": "^7.28.3",
"@mui/x-date-pickers": "^7.28.3",
"accept-language-parser": "^1.5.0",
"blurhash": "^2.0.5",
"change-case": "^5.1.2",
@@ -31,9 +31,10 @@
"hls.js": "^1.5.11",
"i18next": "^23.7.16",
"iconsax-react": "^0.0.8",
"jotai": "^2.12.1",
"material-ui-confirm": "^3.0.7",
"mui-react-hook-form-plus": "^1.3.2",
"next": "14.2.23",
"next": "14.2.25",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-blurhash": "^0.3.0",
@@ -43,13 +44,10 @@
"react-hot-toast": "^2.3.0",
"react-i18next": "^12.2.2",
"react-infinite-scroller": "^1.2.6",
"react-loader-spinner": "^5.2.0",
"react-query": "^3.39.2",
"react-redux": "^8.0.2",
"react-use": "^17.5.1",
"redux-persist": "^6.0.0",
"sharp": "^0.33.4",
"type-fest": "^2.19.0",
"type-fest": "^4.33.0",
"usehooks-ts": "^2.9.5",
"uuid": "^10.0.0",
"yup": "^1.0.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

+1 -2
View File
@@ -17,7 +17,6 @@
*/
import type en from "../i18n/translations/en.json";
import type fr from "../i18n/translations/fr.json";
import "i18next";
declare module "i18next" {
@@ -26,7 +25,7 @@ declare module "i18next" {
returnNull: false;
returnObjects: false;
resources: {
translation: typeof en | typeof fr;
translation: typeof en;
};
}
}
-1520
View File
File diff suppressed because it is too large. Load diff
+199
View File
@@ -0,0 +1,199 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
useInfiniteQuery as useReactInfiniteQuery,
useQueries as useReactQueries,
useQuery as useReactQuery,
useQueryClient as useReactQueryClient,
} from "react-query";
import type { PaginationParameters } from "~/models/pagination";
import type Resource from "~/models/resource";
import {
type InfiniteQueryFn,
type QueryFn,
toTanStackInfiniteQuery,
toTanStackQuery,
} from "~/query";
import { store } from "~/state/store";
import { accessTokenAtom } from "~/state/user";
import API from ".";
const apiInstancesAtom = atom({} as Record<string, API>);
export const useAPI = () => {
const [accessToken] = useAtom(accessTokenAtom);
const [apiInstances, setApiInstances] = useAtom(apiInstancesAtom);
if (!accessToken) {
return getAPI_(null);
}
const matchingInstance = apiInstances[accessToken];
if (!matchingInstance) {
const newInstance = getAPI_(accessToken);
setApiInstances((rest) => ({ ...rest, [accessToken]: newInstance }));
return newInstance;
}
return matchingInstance;
};
// Get API instance using atom to resolve access token
export const getAPI = () => {
const accessToken = store.get(accessTokenAtom);
return getAPI_(accessToken ?? null);
};
export const getAPI_ = (accessToken: string | null) => {
const isDev = process.env.NODE_ENV === "development";
const api = new API(
accessToken,
{
api: {
ssr:
process.env.SSR_SERVER_URL ??
process.env.PUBLIC_SERVER_URL!,
csr: isDev ? "/api" : (process.env.PUBLIC_SERVER_URL ?? "/api"),
},
scanner: {
ssr:
process.env.SSR_SCANNER_URL ??
process.env.PUBLIC_SCANNER_URL!,
csr: isDev
? "/scanner"
: (process.env.PUBLIC_SCANNER_URL ?? "/scanner"),
},
},
isDev,
);
return api;
};
/**
* Wrapper for the react-query's *useQuery*
*/
export const useQuery = <
ReturnType,
Params extends any[],
Transformed = ReturnType,
>(
query: QueryFn<ReturnType, Transformed, Params>,
...queryParams: Partial<Params>
) => {
const api = useAPI();
const queryOpts = useMemo(
() => toTanStackQuery(api, query, ...queryParams),
[query, queryParams],
);
const hook = useReactQuery(queryOpts);
useEffect(() => {
if (queryOpts.enabled) {
hook.refetch();
}
}, [api]);
return hook;
};
/**
* Wrapper for the react-query's *useQueries*
*/
export const useQueries = <ReturnType, Params extends any[][]>(
...queries: readonly Parameters<
typeof useQuery<ReturnType, Params[number], ReturnType>
>[]
) => {
const api = useAPI();
const queryOpts = useMemo(
() =>
queries.map(([query, ...params]) =>
toTanStackQuery(api, query, ...(params as Params[number])),
),
[queries],
);
const hook = useReactQueries(queryOpts);
useEffect(() => {
queryOpts.forEach((queryOpt, idx) => {
if (queryOpt.enabled) {
hook[idx].refetch();
}
});
}, [api]);
return hook;
};
/**
* Wrapper for the react-query's *useInfiniteQuery*
*/
export const useInfiniteQuery = <
ReturnType extends Resource,
Params extends any[],
TransformedType = ReturnType,
>(
query: InfiniteQueryFn<ReturnType, TransformedType, Params>,
...queryParams: Partial<Params>
) => {
const api = useAPI();
const queryOpts = useMemo(
() => toTanStackInfiniteQuery(api, query, ...queryParams),
[query, queryParams],
);
const { data, ...rest } = useReactInfiniteQuery({
...queryOpts,
getNextPageParam: (lastPage): PaginationParameters | null => {
if (lastPage.metadata.next === null) {
return null;
}
const lastItemId = lastPage.items.at(-1)?.id;
if (lastItemId) {
return { afterId: lastItemId };
}
return null;
},
});
const [items, setItems] = useState(data?.pages.at(0)?.items);
useEffect(() => {
setItems(data?.pages.flatMap((p) => p.items));
}, [data?.pages]);
// useEffect(() => {
// if (queryOpts.enabled) {
// rest.refetch();
// }
// }, [api]);
return { ...rest, data, items };
};
/**
* Wrapper of the useQueryClient Hook, to wrap `toTanStackQuery`
*/
export const useQueryClient = () => {
const api = useAPI();
const queryClient = useReactQueryClient();
return {
api: api,
client: queryClient,
fetchQuery: <R, R1, Params extends any[]>(
query: ReturnType<QueryFn<R, R1, Params>>,
) => queryClient.fetchQuery(toTanStackQuery(api, () => query)),
};
};
export type QueryClient = ReturnType<typeof useQueryClient>;
+701
View File
@@ -0,0 +1,701 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ResourceNotFound } from "exceptions";
import type { RequireExactlyOne } from "type-fest";
import * as yup from "yup";
import type { AlbumType } from "~/models/album";
import Library from "~/models/library";
import type { PaginationParameters } from "~/models/pagination";
import Playlist, {
type CreatePlaylistDto,
type UpdatePlaylistDto,
} from "~/models/playlist";
import type { Scrobbler } from "~/models/scrobblers";
import type { SaveSearchItem } from "~/models/search";
import type { SongType } from "~/models/song";
import { TaskResponse } from "~/models/task";
import type { TrackType } from "~/models/track";
import User from "~/models/user";
import type { VideoType } from "~/models/video";
import { isSSR } from "~/utils/is-ssr";
import type { SortingParameters } from "~/utils/sorting";
const AuthenticationResponse = yup.object({
access_token: yup.string().required(),
});
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
type QueryParameters<Keys extends readonly string[]> = {
pagination?: PaginationParameters | null;
include?: string[];
sort?: SortingParameters<Keys>;
};
type AuthenticationInput = {
username: string;
password: string;
};
export enum Service {
API = 0,
Scanner = 1,
}
type FetchParameters<Keys extends readonly string[], ReturnType> = {
route: string;
parameters: QueryParameters<Keys>;
otherParameters?: any;
errorMessage?: string;
service?: Service;
data?: Record<string, any>;
method?: "GET" | "PUT" | "POST" | "DELETE";
} & RequireExactlyOne<{
emptyResponse: true;
validator: yup.Schema<ReturnType>;
customValidator: (value: unknown) => Promise<ReturnType>;
}>;
type APIUrls = {
api: Record<"ssr" | "csr", string>;
scanner: Record<"ssr" | "csr", string>;
};
export default class API {
constructor(
private readonly accessToken: string | null,
private readonly urls: APIUrls,
private readonly isDev = false,
public readonly pageSize = API.DefaultPageSize,
) {}
public static readonly DefaultPageSize = 35;
///// Auth + User management
async login(
credentials: AuthenticationInput,
): Promise<AuthenticationResponse> {
return this.fetch({
route: "/auth/login",
data: credentials,
errorMessage: "Username or password is incorrect",
parameters: {},
method: "POST",
validator: AuthenticationResponse,
});
}
/**
* Creates a new user
* @param credentials the credentails of the new user
* @returns The newly created user
*/
async register(credentials: AuthenticationInput): Promise<User> {
return this.fetch({
route: "/users",
data: {
name: credentials.username,
password: credentials.password,
},
parameters: {},
method: "POST",
validator: User,
});
}
async updateUser(
userId: number,
updatedFields: Partial<Pick<User, "admin" | "enabled">>,
): Promise<User> {
return this.fetch({
route: `/users/${userId}`,
errorMessage: "User could not be updated",
data: updatedFields,
parameters: {},
method: "PUT",
validator: User,
});
}
/**
* Delete user
* @param userId the id of the user to delete
*/
async deleteUser(userId: number): Promise<User> {
return this.fetch({
route: `/users/${userId}`,
errorMessage: "User could not be deleted",
parameters: {},
method: "DELETE",
validator: User,
});
}
//// Tasks
async cleanLibraries(): Promise<TaskResponse> {
return this.fetch({
route: "/clean",
errorMessage: "Library clean failed",
parameters: {},
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
async scanLibrary(librarySlugOrId: number | string): Promise<TaskResponse> {
return this.fetch({
route: `/scan/${librarySlugOrId}`,
errorMessage: "Library scan failed",
parameters: {},
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
async cleanLibrary(
librarySlugOrId: number | string,
): Promise<TaskResponse> {
return this.fetch({
route: `/clean/${librarySlugOrId}`,
errorMessage: "Library clean failed",
parameters: {},
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
async scanLibraries(): Promise<TaskResponse> {
return this.fetch<TaskResponse, []>({
route: "/scan",
parameters: {},
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
async refreshMetadata(
parentResourceType: "library" | "album" | "song" | "release" | "track",
resourceSlugOrId: number | string,
force: boolean,
): Promise<void> {
return this.fetch({
method: "POST",
route: "/refresh",
errorMessage: "Metadata Refresh request failed",
otherParameters: { [parentResourceType]: resourceSlugOrId, force },
parameters: {},
service: Service.Scanner,
emptyResponse: true,
});
}
//// Playlists
async createPlaylist(dto: CreatePlaylistDto): Promise<Playlist> {
return this.fetch({
route: "/playlists",
data: dto,
errorMessage: "Playlist Creation Failed",
parameters: {},
method: "POST",
validator: Playlist,
});
}
async updatePlaylist(
playlistSlugOrId: number | string,
dto: UpdatePlaylistDto,
): Promise<Playlist> {
return this.fetch({
route: `/playlists/${playlistSlugOrId}`,
data: dto,
parameters: {},
method: "PUT",
validator: Playlist,
});
}
async reorderPlaylist(
playlistSlugOrId: number | string,
entriesIds: number[],
): Promise<void> {
return this.fetch({
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
data: { entryIds: entriesIds },
parameters: {},
method: "PUT",
emptyResponse: true,
});
}
async addSongToPlaylist(
songId: number,
playlistId: number,
): Promise<unknown> {
return this.fetch({
route: `/playlists/${playlistId}/entries`,
errorMessage: "Failed to add song to playlist",
parameters: {},
data: { songId },
method: "POST",
emptyResponse: true,
});
}
async deletePlaylistEntry(entryId: number): Promise<unknown> {
return this.fetch({
route: `/playlists/entries/${entryId}`,
errorMessage: "Failed to remove song from playlist",
parameters: {},
method: "DELETE",
emptyResponse: true,
});
}
async deletePlaylist(playlistSlugOrId: number | string): Promise<unknown> {
return this.fetch({
route: `/playlists/${playlistSlugOrId}`,
errorMessage: "Failed to remove playlist",
parameters: {},
method: "DELETE",
emptyResponse: true,
});
}
//// Libraries
async createLibrary(
libraryName: string,
libraryPath: string,
): Promise<Library> {
return this.fetch({
route: "/libraries",
data: { name: libraryName, path: libraryPath },
parameters: {},
method: "POST",
validator: Library,
});
}
async updateLibrary(
libraryId: number,
libraryName: string,
libraryPath: string,
): Promise<Library> {
return this.fetch({
route: `/libraries/${libraryId}`,
data: { name: libraryName, path: libraryPath },
errorMessage: "Library Update Failed",
parameters: {},
method: "PUT",
validator: Library,
});
}
async deleteLibrary(librarySlugOrId: number | string): Promise<unknown> {
return this.fetch({
route: `/libraries/${librarySlugOrId}`,
errorMessage: "Library deletion failed",
parameters: {},
method: "DELETE",
validator: yup.mixed(),
});
}
///// Illustrations
async updateArtistIllustration(artistId: number, illustrationUrl: string) {
return this.updateResourceIllustration(
artistId,
illustrationUrl,
"artist",
);
}
async updateReleaseIllustration(
releaseId: number,
illustrationUrl: string,
) {
return this.updateResourceIllustration(
releaseId,
illustrationUrl,
"release",
);
}
async updateTrackIllustration(trackId: number, illustrationUrl: string) {
return this.updateResourceIllustration(
trackId,
illustrationUrl,
"track",
);
}
async updatePlaylistIllustration(
playlistId: number,
illustrationUrl: string,
) {
return this.updateResourceIllustration(
playlistId,
illustrationUrl,
"playlist",
);
}
private async updateResourceIllustration(
resourceId: number,
illustrationUrl: string,
resourceType: "artist" | "release" | "track" | "playlist",
): Promise<unknown> {
return this.fetch({
route: "/illustrations/url",
errorMessage: "Update Illustration Failed",
method: "POST",
parameters: {},
emptyResponse: true,
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
});
}
/// Albums
async updateAlbum(
albumSlugOrId: number | string,
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
): Promise<void> {
return this.fetch({
route: `/albums/${albumSlugOrId}`,
errorMessage: "Update Album Failed",
method: "PUT",
parameters: {},
emptyResponse: true,
data: dto,
});
}
//// Songs
async updateSong(
songSlugOrId: number | string,
dto: Partial<{ type: SongType; masterTrackId: number }>,
): Promise<void> {
return this.fetch({
route: `/songs/${songSlugOrId}`,
errorMessage: "Update Song Failed",
method: "PUT",
parameters: {},
emptyResponse: true,
data: dto,
});
}
async setSongAsPlayed(songSlugOrId: string | number): Promise<unknown> {
return this.fetch({
route: `/songs/${songSlugOrId}/played`,
errorMessage: "Song update failed",
parameters: {},
method: "PUT",
validator: yup.mixed(),
});
}
async mergeSongs(srcSongId: number, destSongId: number): Promise<void> {
return this.fetch({
route: `/songs/${srcSongId}/merge`,
data: { songId: destSongId },
parameters: {},
method: "POST",
emptyResponse: true,
});
}
//// tracks
async updateTrack(trackId: number, destSongId: number): Promise<void> {
return this.fetch({
route: `/tracks/${trackId}`,
data: { songId: destSongId },
parameters: {},
method: "PUT",
emptyResponse: true,
});
}
//// videos
async updateVideo(
videoSlugOrId: number | string,
dto: Partial<{ type: VideoType; masterTrackId: number }>,
): Promise<void> {
return this.fetch({
route: `/videos/${videoSlugOrId}`,
errorMessage: "Update Video Failed",
method: "PUT",
parameters: {},
emptyResponse: true,
data: dto,
});
}
//// search
saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
return this.fetch({
method: "POST",
route: "/search/history",
errorMessage: "Saving Search History failed",
parameters: {},
data: resource,
emptyResponse: true,
});
}
/// LastFM
getLastFMAuthUrl(origin: string) {
return this.fetch({
route: "/scrobblers/lastfm/url",
parameters: {},
otherParameters: {
callback: `${origin}/scrobblers/lastfm/callback_handler`, //redirecting to the settings page
},
validator: yup.object({ url: yup.string().required() }),
});
}
async postLastFMToken(token: string) {
return this.fetch({
method: "POST",
route: "/scrobblers/lastfm",
data: { token },
parameters: {},
emptyResponse: true,
});
}
/// ListenBrainz
async postListenBrainzToken(token: string, instanceUrl: string | null) {
return this.fetch({
method: "POST",
route: "/scrobblers/listenbrainz",
data: { token, instanceUrl },
parameters: {},
emptyResponse: true,
});
}
async disconnectScrobbler(scrobbler: Scrobbler) {
let route = "/scrobblers";
switch (scrobbler) {
case "LastFM":
route = `${route}/lastfm`;
break;
case "ListenBrainz":
route = `${route}/listenbrainz`;
break;
}
return this.fetch({
method: "DELETE",
route,
parameters: {},
emptyResponse: true,
});
}
async fetch<ReturnType, Keys extends readonly string[]>({
route,
parameters,
otherParameters,
errorMessage,
data,
method,
validator,
customValidator,
emptyResponse,
service,
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
const header = {
"Content-Type": "application/json",
};
const response = await fetch(
this.buildURL(route, parameters, otherParameters, service),
{
method: method ?? "GET",
body: data ? JSON.stringify(data) : undefined,
headers: this.accessToken
? {
...header,
Authorization: `Bearer ${this.accessToken}`,
}
: header,
},
);
const jsonResponse = emptyResponse
? undefined
: await response.json().catch(() => {
throw new Error("Error while parsing Server's response");
});
switch (response.status) {
case 401:
throw new Error(jsonResponse.message ?? errorMessage);
case 403:
throw new Error(
errorMessage ?? "Unauthorized: Only for admins",
);
case 404:
throw new ResourceNotFound(
errorMessage ?? jsonResponse.message ?? response.statusText,
);
default:
if (!response.ok) {
throw new Error(
errorMessage ??
jsonResponse.message ??
response.statusText,
);
}
}
if (emptyResponse) {
return {} as ReturnType;
}
try {
if (customValidator) {
return await customValidator(jsonResponse);
}
const validated = await validator.validate(jsonResponse);
return validator.cast(validated);
} catch (err) {
// biome-ignore lint/suspicious/noConsole: OK for debugging
console.error(jsonResponse, err);
throw new Error("Error: Invalid Response Type");
}
}
/// URL builders
/**
* Builds the URL to fetch an illustration, from the browser/client pov
* @param imageURL
* @returns the correct, rerouted URL
*/
getIllustrationURL(imageURL: string): string {
if (this.isDev) {
return `${"/api"}${imageURL}`;
}
return `${this.urls.api.csr ?? "/api"}${imageURL}`;
}
getDirectStreamURL(fileId: number): string {
return this.buildURL(`/stream/${fileId}/direct`, {});
}
getTranscodeStreamURL(fileId: number, type: TrackType): string {
if (type === "Video") {
return this.buildURL(`/stream/${fileId}/master.m3u8`, {});
}
return this.buildURL(`/stream/${fileId}/audio/0/index.m3u8`, {});
}
/**
* Builds the URL to get an archive of a given release
* @param releaseId slug or id of the release
* @returns the correct, rerouted URL
*/
getReleaseArchiveURL(releaseId: number | string): string {
return this.buildURL(`/releases/${releaseId}/archive`, {});
}
private buildURL(
route: string,
parameters: QueryParameters<any>,
otherParameters?: any,
service: Service = Service.API,
): string {
const apiHost = isSSR() ? this.urls.api.ssr : this.urls.api.csr;
const scannerHost = isSSR()
? this.urls.scanner.ssr
: this.urls.scanner.csr;
const host = service === Service.API ? apiHost : scannerHost;
return `${host}${route}${this.formatQueryParameters(parameters, otherParameters)}`;
}
private formatQueryParameters<Keys extends string[]>(
parameters: QueryParameters<Keys>,
otherParameters?: any,
): string {
const formattedQueryParams: string[] = [];
if (parameters.sort) {
formattedQueryParams.push(`sortBy=${parameters.sort.sortBy}`);
formattedQueryParams.push(
`order=${parameters.sort.order ?? "asc"}`,
);
}
if ((parameters.include?.length ?? 0) !== 0) {
formattedQueryParams.push(API.formatInclude(parameters.include!)!);
}
if (
parameters.pagination !== undefined &&
parameters.pagination !== null
) {
formattedQueryParams.push(
this.formatPagination(parameters.pagination),
);
}
for (const otherParams in otherParameters) {
if (otherParameters[otherParams] !== undefined) {
formattedQueryParams.push(
`${encodeURIComponent(otherParams)}=${encodeURIComponent(
otherParameters[otherParams],
)}`,
);
}
}
if (formattedQueryParams.length === 0) {
return "";
}
return `?${formattedQueryParams.join("&")}`;
}
private static formatInclude(include: string[]): string | null {
if (include.length === 0) {
return null;
}
return `with=${include.join(",")}`;
}
private formatPagination(pagination: PaginationParameters): string {
const formattedParameters: string[] = [];
if (pagination.afterId !== undefined) {
formattedParameters.push(`afterId=${pagination.afterId}`);
}
formattedParameters.push(
`take=${pagination.pageSize ?? this.pageSize}`,
);
return formattedParameters.join("&");
}
}
+682
View File
@@ -0,0 +1,682 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { RequireExactlyOne } from "type-fest";
import * as yup from "yup";
import {
type AlbumInclude,
type AlbumSortingKeys,
type AlbumType,
AlbumWithRelations,
} from "~/models/album";
import {
type ArtistInclude,
type ArtistSortingKeys,
ArtistWithRelations,
} from "~/models/artist";
import {
AlbumExternalMetadata,
ArtistExternalMetadata,
SongExternalMetadata,
} from "~/models/external-metadata";
import File from "~/models/file";
import Genre, { type GenreSortingKeys } from "~/models/genre";
import Label, { type LabelSortingKeys } from "~/models/label";
import Library from "~/models/library";
import PaginatedResponse, {
type PaginationParameters,
} from "~/models/pagination";
import {
PlaylistEntryWithRelations,
type PlaylistInclude,
type PlaylistSortingKeys,
PlaylistWithRelations,
} from "~/models/playlist";
import {
type ReleaseInclude,
type ReleaseSortingKeys,
ReleaseWithRelations,
} from "~/models/release";
import { ScrobblersStatus } from "~/models/scrobblers";
import { type SearchResult, SearchResultTransformer } from "~/models/search";
import {
type SongInclude,
type SongSortingKeys,
type SongType,
SongWithRelations,
} from "~/models/song";
import {
type SongGroupSortingKeys,
SongGroupWithRelations,
} from "~/models/song-group";
import {
type TrackInclude,
type TrackSortingKeys,
TrackWithRelations,
} from "~/models/track";
import { TracklistItemWithRelations } from "~/models/tracklist";
import User, { type UserSortingKeys } from "~/models/user";
import {
type VideoInclude,
type VideoSortingKeys,
type VideoType,
VideoWithRelations,
} from "~/models/video";
import type { InfiniteQuery, Query } from "~/query";
import { store } from "~/state/store";
import { accessTokenAtom } from "~/state/user";
import type { SortingParameters } from "~/utils/sorting";
import type API from ".";
import { Service } from ".";
type Identifier = number | string;
/// Artists
export const getArtists = <I extends ArtistInclude | never = never>(
filter: {
library?: Identifier[];
album?: Identifier;
genre?: Identifier;
query?: Identifier;
label?: Identifier;
},
sort?: SortingParameters<typeof ArtistSortingKeys>,
include: I[] = [],
): InfiniteQuery<ArtistWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/artists",
filter: {
albumArtistOnly: filter.album ? undefined : "true",
...filter,
library: formatOr(filter.library),
},
sort,
include,
validator: PaginatedResponse(ArtistWithRelations(include ?? [])),
});
};
export const getArtist = <I extends ArtistInclude | never = never>(
slugOrId: string | number,
include: I[] = [],
): Query<ArtistWithRelations<I>> => {
return _mkSimpleQuery({
route: `/artists/${slugOrId}`,
include,
validator: ArtistWithRelations(include ?? []),
});
};
/// Albums
export const getAlbum = <I extends AlbumInclude | never = never>(
albumSlugOrId: string | number,
include?: I[],
): Query<AlbumWithRelations<I>> => {
return _mkSimpleQuery({
route: `/albums/${albumSlugOrId}`,
include,
validator: AlbumWithRelations(include ?? []),
});
};
export const getAlbums = <I extends AlbumInclude | never = never>(
filter: {
library?: Identifier[];
artist?: Identifier;
genre?: Identifier;
type?: AlbumType[];
related?: Identifier;
appearance?: Identifier;
query?: Identifier;
label?: Identifier;
random?: number;
},
sort?: SortingParameters<typeof AlbumSortingKeys>,
include?: I[],
): InfiniteQuery<AlbumWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/albums",
include,
sort,
filter: {
...filter,
library: formatOr(filter.library),
type: formatOr(filter.type),
},
validator: PaginatedResponse(AlbumWithRelations(include ?? [])),
});
};
/// Songs
export const getSongs = <I extends SongInclude | never = never>(
filter: {
library?: Identifier[];
type?: SongType[];
genre?: Identifier;
artist?: Identifier;
versionsOf?: Identifier;
rare?: Identifier;
query?: string;
bsides?: Identifier;
random?: number;
},
sort?: SortingParameters<typeof SongSortingKeys>,
include?: I[],
): InfiniteQuery<SongWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/songs",
filter: {
...filter,
library: formatOr(filter.library),
type: formatOr(filter.type),
},
sort,
include,
validator: PaginatedResponse(SongWithRelations(include ?? [])),
});
};
export const getSong = <I extends SongInclude | never = never>(
songSlugOrId: string | number,
include?: I[],
): Query<SongWithRelations<I>> => {
return _mkSimpleQuery({
route: `/songs/${songSlugOrId}`,
include,
validator: SongWithRelations(include ?? []),
});
};
export const getSongGroups = <I extends SongInclude | never = never>(
filter: {
library?: Identifier[];
genre?: Identifier;
artist?: Identifier;
query?: string;
type?: SongType;
},
sort?: SortingParameters<typeof SongGroupSortingKeys>,
include?: I[],
): InfiniteQuery<SongGroupWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/song-groups",
include,
sort,
filter: { ...filter, library: formatOr(filter.library) },
validator: PaginatedResponse(SongGroupWithRelations(include ?? [])),
});
};
/// Videos
export const getVideos = <I extends VideoInclude | never = never>(
filter: {
library?: Identifier[];
artist?: Identifier;
album?: Identifier;
song?: Identifier;
group?: Identifier;
random?: number;
type?: VideoType[];
query?: string;
},
sort?: SortingParameters<typeof VideoSortingKeys>,
include?: I[],
): InfiniteQuery<VideoWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/videos",
include,
sort,
filter: {
...filter,
library: formatOr(filter.library),
type: formatOr(filter.type),
},
validator: PaginatedResponse(VideoWithRelations(include ?? [])),
});
};
export const getVideo = <I extends VideoInclude | never = never>(
videoSlugOrId: string | number,
include?: I[],
): Query<VideoWithRelations<I>> => {
return _mkSimpleQuery({
route: `/videos/${videoSlugOrId}`,
include,
validator: VideoWithRelations(include ?? []),
});
};
/// Releases
export const getReleases = <I extends ReleaseInclude | never = never>(
filter: { album?: Identifier },
sort?: SortingParameters<typeof ReleaseSortingKeys>,
include?: I[],
): InfiniteQuery<ReleaseWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/releases",
filter,
sort,
include,
validator: PaginatedResponse(ReleaseWithRelations(include ?? [])),
});
};
export const getMasterRelease = <I extends ReleaseInclude | never = never>(
albumSlugOrId: string | number,
include?: I[],
): Query<ReleaseWithRelations<I>> => {
return _mkSimpleQuery({
route: `/releases/master/${albumSlugOrId}`,
include,
validator: ReleaseWithRelations(include ?? []),
});
};
export const getRelease = <I extends ReleaseInclude | never = never>(
slugOrId: string | number,
include?: I[],
): Query<ReleaseWithRelations<I>> => {
return _mkSimpleQuery({
route: `/releases/${slugOrId}`,
include,
validator: ReleaseWithRelations(include ?? []),
});
};
/// Tracks
export const getTrack = <I extends TrackInclude | never = never>(
trackId: string | number,
include?: I[],
): Query<TrackWithRelations<I>> => {
return _mkSimpleQuery({
route: `/tracks/${trackId}`,
include,
validator: TrackWithRelations(include ?? []),
});
};
export const getTracks = <I extends TrackInclude | never = never>(
filter: { song?: string | number; library?: Identifier[] },
sort?: SortingParameters<typeof TrackSortingKeys>,
include?: I[],
): InfiniteQuery<TrackWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/tracks",
include,
sort,
filter: { ...filter, library: formatOr(filter.library) },
validator: PaginatedResponse(TrackWithRelations(include ?? [])),
});
};
export const getReleaseTracklist = <I extends SongInclude | never = never>(
slugOrId: string | number,
exclusiveOnly = false,
include?: I[],
): InfiniteQuery<TracklistItemWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: `/releases/${slugOrId.toString()}/tracklist`,
include,
filter: { exclusive: exclusiveOnly ? "true" : undefined },
validator: PaginatedResponse(TracklistItemWithRelations(include ?? [])),
});
};
export const getSongMasterTrack = <I extends TrackInclude | never = never>(
songSlugOrId: string | number,
include?: I[],
): Query<TrackWithRelations<I>> => {
return _mkSimpleQuery({
route: `/tracks/master/song/${songSlugOrId}`,
include,
validator: TrackWithRelations(include ?? []),
});
};
/// Libraries
export const getLibraries = (): InfiniteQuery<Library> => {
return _mkSimplePaginatedQuery({
route: "/libraries",
validator: PaginatedResponse(Library),
});
};
/// Playlists
export const getPlaylists = <I extends PlaylistInclude | never = never>(
filter: { album?: Identifier; changeable?: true },
sort?: SortingParameters<typeof PlaylistSortingKeys>,
include?: I[],
): InfiniteQuery<PlaylistWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: "/playlists",
sort,
include,
filter: {
...filter,
changeable: filter.changeable ? "true" : undefined,
},
validator: PaginatedResponse(PlaylistWithRelations(include ?? [])),
});
};
export const getPlaylist = <I extends PlaylistInclude | never = never>(
playlistSlugOrId: string | number,
include?: I[],
): Query<PlaylistWithRelations<I>> => {
return _mkSimpleQuery({
route: `/playlists/${playlistSlugOrId}`,
include,
validator: PlaylistWithRelations(include ?? []),
});
};
export const getPlaylistEntries = <I extends SongInclude | never = never>(
playlistSlugOrId: string | number,
include?: I[],
): InfiniteQuery<PlaylistEntryWithRelations<I>> => {
return _mkSimplePaginatedQuery({
route: `/playlists/${playlistSlugOrId}/entries`,
include,
validator: PaginatedResponse(PlaylistEntryWithRelations(include ?? [])),
});
};
/// Genres
export const getGenres = (
filter: { artist?: Identifier; album?: Identifier; song?: Identifier },
sort?: SortingParameters<typeof GenreSortingKeys>,
): InfiniteQuery<Genre> => {
return _mkSimplePaginatedQuery({
route: "/genres",
sort,
filter,
validator: PaginatedResponse(Genre),
});
};
export const getGenre = (idOrSlug: string | number): Query<Genre> => {
return _mkSimpleQuery({
route: `/genres/${idOrSlug}`,
validator: Genre,
});
};
/// Labels
export const getLabel = (identifier: string | number): Query<Label> => {
return _mkSimpleQuery({
route: `/labels/${identifier}`,
validator: Label,
});
};
export const getLabels = (
filter: { artist?: Identifier; album?: Identifier },
sort?: SortingParameters<typeof LabelSortingKeys>,
): InfiniteQuery<Label> => {
return _mkSimplePaginatedQuery({
route: "/labels",
filter,
sort,
validator: PaginatedResponse(Label),
});
};
/// External Metadata
export const getArtistExternalMetadata = (slugOrId: string | number) =>
getResourceExternalMetadata<ArtistExternalMetadata>(
slugOrId,
"artist",
ArtistExternalMetadata,
);
export const getSongExternalMetadata = (slugOrId: string | number) =>
getResourceExternalMetadata<SongExternalMetadata>(
slugOrId,
"song",
SongExternalMetadata,
);
export const getAlbumExternalMetadata = (slugOrId: string | number) =>
getResourceExternalMetadata<AlbumExternalMetadata>(
slugOrId,
"album",
AlbumExternalMetadata,
);
const getResourceExternalMetadata = <
T,
V extends yup.Schema<T> = yup.Schema<T>,
>(
resourceSlugOrId: string | number,
resourceType: "artist" | "album" | "song",
validator: V,
): Query<T | null> => {
const query = _mkSimpleQuery({
route: "/external-metadata",
params: { [resourceType]: resourceSlugOrId },
validator,
});
return {
...query,
exec: (api) => () =>
query
.exec(api)()
.catch(() => null),
};
};
/// File
export const getSourceFile = (sourceFileId: string | number): Query<File> => {
return _mkSimpleQuery({
route: `/files/${sourceFileId}`,
validator: File,
});
};
/// Search
export const searchAll = (query: string): Query<SearchResult[]> => {
return _mkSimpleQuery({
route: "/search",
params: { query },
customValidator: SearchResultTransformer,
});
};
export const getSearchHistory = (): Query<SearchResult[]> => {
return _mkSimpleQuery({
route: "/search/history",
customValidator: SearchResultTransformer,
});
};
/// Users
export const getCurrentUserStatus = (): Query<User> => {
const accessToken = store.get(accessTokenAtom);
return {
key: ["user", accessToken ?? ""],
exec: (api) => () =>
api.fetch({
route: "/users/me",
parameters: {},
validator: User,
}),
};
};
export const getUsers = (
sort?: SortingParameters<typeof UserSortingKeys>,
): InfiniteQuery<User> => {
return _mkSimplePaginatedQuery({
route: "/users",
sort,
validator: PaginatedResponse(User),
});
};
/// Tasks
export const getTasks = () => {
return _mkSimpleQuery({
route: "/tasks",
validator: yup.object({
current_task: yup.string().required().nullable(),
progress: yup.number().required().nullable(),
pending_tasks: yup.array(yup.string().required()).required(),
}),
service: Service.Scanner,
});
};
/// Scrobblers
export const getScrobblerStatus = (): Query<ScrobblersStatus> => {
return _mkSimpleQuery({
route: "/scrobblers",
validator: ScrobblersStatus,
});
};
export const _mkSimpleQuery = <T>(
arg: {
route: string;
include?: string[];
params?: Record<string, string | number>;
service?: Service;
errorMessage?: string;
} & RequireExactlyOne<{
emptyResponse: true;
validator: yup.Schema<T>;
customValidator: (value: unknown) => Promise<T>;
}>,
) => {
const key = [
...arg.route.split("/").filter((t) => t.length > 0),
...formatIncludeKeys(arg.include),
...formatObject(arg.params),
];
return {
key,
exec: (api: API) => () =>
api.fetch({
route: arg.route,
errorMessage: arg.errorMessage,
parameters: { include: arg.include },
otherParameters: arg.params,
service: arg.service ?? Service.API,
...(arg.validator
? { validator: arg.validator }
: arg.customValidator
? { customValidator: arg.customValidator }
: { emptyResponse: true }),
}),
};
};
export const _mkSimplePaginatedQuery = <T>(
arg: {
route: string;
include?: string[];
filter?: Record<
string,
undefined | string | number | (string | number)[]
>;
sort?: SortingParameters<any>;
service?: Service;
errorMessage?: string;
} & RequireExactlyOne<{
emptyResponse: true;
validator: yup.Schema<T>;
customValidator: (value: unknown) => Promise<T>;
}>,
) => {
const key = [
...arg.route.split("/").filter((t) => t.length > 0),
...formatIncludeKeys(arg.include),
...formatObject(arg.sort),
...formatObject(arg.filter),
];
return {
key,
exec: (api: API) => (pagination?: PaginationParameters) => {
if (pagination === undefined) {
pagination = { pageSize: api.pageSize };
}
return api.fetch({
route: arg.route,
errorMessage: arg.errorMessage,
parameters: {
include: arg.include,
pagination,
sort: arg.sort,
},
otherParameters: arg.filter,
service: arg.service ?? Service.API,
...(arg.validator
? { validator: arg.validator }
: arg.customValidator
? { customValidator: arg.customValidator }
: { emptyResponse: true }),
});
},
};
};
// Format Includes for the query key
const formatIncludeKeys = (includes?: string[]) =>
includes?.map((include) => `include-${include}`) ?? [];
// format any object for the query key
const formatObject = (includes?: object) =>
includes
? Object.entries(includes)
.filter(
([_, value]) =>
value !== null &&
value !== undefined &&
value !== "view" &&
(Array.isArray(value) ? value.length > 0 : true),
)
.map(([key, value]) => `params-${key}-${value}`)
: [];
const formatOr = (
items: (string | number)[] | undefined,
): string | undefined => {
if (!items || items.length === 0) {
return undefined;
}
if (items.length === 1) {
return items[0].toString();
}
return `or:${items.map((i) => i.toString()).join(",")}`;
};
-242
View File
@@ -1,242 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-restricted-imports */
import {
type QueryFunctionContext,
useInfiniteQuery as useReactInfiniteQuery,
useQueries as useReactQueries,
useQuery as useReactQuery,
useQueryClient as useReactQueryClient,
} from "react-query";
import type {
InfiniteFetchFn,
Page,
} from "../components/infinite/infinite-scroll";
import type Resource from "../models/resource";
import API from "./api";
type Key = string | number;
//// Query types
export type Query<Type> = {
key: Key[];
// @warning If you call this method 'by hand', please consider using a query client instead
exec: () => Promise<Type>;
};
export type InfiniteQuery<Type> = {
key: Key[];
exec: InfiniteFetchFn<Type>;
};
//// Query functions
export type MeeloQueryFn<
QueryReturnType = unknown,
Params extends any[] = any[],
> = (...args: Params) => Query<QueryReturnType>;
export type MeeloInfiniteQueryFn<
QueryReturnType = unknown,
Params extends any[] = any[],
> = (...args: Params) => InfiniteQuery<QueryReturnType>;
export const DefaultMeeloQueryOptions = {
useErrorBoundary: true,
retry: 2,
staleTime: 5 * (60 * 1000),
cacheTime: 10 * (60 * 1000),
refetchOnMount: true,
// We want this in dev, not in prod to avoid useless refecthes.
refetchOnWindowFocus: process.env.NODE_ENV !== "production",
refetchOnReconnect: true,
};
/**
* Wrapper for 'react-query''s useQuery's parameters, to manage dependent queries more easily
* @param query the function that prepare the query
* @param queryArgs the arguments to pass the the query function. If one of them is undefined, the query will not be enabled
* @returns
*/
const prepareMeeloQuery = <
QueryReturnType = unknown,
Params extends any[] = unknown[],
>(
query: MeeloQueryFn<QueryReturnType, Params>,
...queryArgs: Partial<Params>
) => {
const enabled = isEnabled(queryArgs);
const queryParams = query(...(queryArgs as Parameters<typeof query>));
return {
queryKey: queryParams.key,
queryFn: queryParams.exec,
enabled: enabled,
...DefaultMeeloQueryOptions,
};
};
/**
* Using the query parameters, defines if a query should be enabled or not
* @param args
* @returns
*/
const isEnabled = (args: any[]) => {
return args.findIndex((elem) => elem === undefined || elem === null) === -1;
};
/**
* Wrapper for 'react-query''s useInfiniteQuery's parameters, to manage dependent queries more easily
* @param query the function that prepare the query
* @param queryArgs the arguments to pass the the query function. If one of them is undefined, the query will not be enabled
* @returns
*/
const prepareMeeloInfiniteQuery = <
QueryReturnType extends Resource = Resource,
Params extends any[] = unknown[],
>(
query: MeeloInfiniteQueryFn<QueryReturnType, Params>,
...queryArgs: Partial<Params>
) => {
const enabled = isEnabled(queryArgs);
const queryParams = query(...(queryArgs as Params));
return {
queryKey: queryParams.key,
queryFn: (context: QueryFunctionContext) =>
queryParams
.exec(
context.pageParam ?? {
index: 0,
pageSize: API.defaultPageSize,
},
)
.then(
(result): Page<QueryReturnType> => ({
pageSize: result.items.length,
items: result.items,
afterId: result.items.at(-1)?.id ?? null,
end: result.metadata.next === null,
}),
),
enabled: enabled,
...DefaultMeeloQueryOptions,
};
};
/**
* Wrapper for the react-query's *useQuery*
*/
const useQuery = <ReturnType, Params extends any[]>(
query: MeeloQueryFn<ReturnType, Params>,
...queryParams: Partial<Params>
) => {
return useReactQuery(prepareMeeloQuery(query, ...queryParams));
};
/**
* Wrapper for the react-query's *useQueries*
*/
const useQueries = <ReturnType, Params extends any[][]>(
...queries: Parameters<typeof useQuery<ReturnType, Params[number]>>[]
) => {
return useReactQueries(
queries.map(([query, ...params]) =>
prepareMeeloQuery(query, ...(params as Params[number])),
),
);
};
/**
* Wrapper for the react-query's *useInfiniteQuery*
*/
const useInfiniteQuery = <ReturnType extends Resource, Params extends any[]>(
query: MeeloInfiniteQueryFn<ReturnType, Params>,
...queryParams: Partial<Params>
) => {
const pageSize = API.defaultPageSize;
return useReactInfiniteQuery({
...prepareMeeloInfiniteQuery(query, ...queryParams),
getNextPageParam: (
lastPage: Page<ReturnType>,
): Page<ReturnType> | undefined => {
if (lastPage.end || lastPage.items.length < pageSize) {
return undefined;
}
return lastPage;
},
});
};
/**
* Wrapper of the useQueryClient Hook, to wrap `prepareMeeloQuery`
*/
const useQueryClient = () => {
const queryClient = useReactQueryClient();
return {
client: queryClient,
fetchQuery: <R, Params extends any[]>(
query: ReturnType<MeeloQueryFn<R, Params>>,
) => queryClient.fetchQuery(prepareMeeloQuery(() => query)),
};
};
export const toInfiniteQuery = <T>(q: Query<T[]>): InfiniteQuery<T> => {
return {
key: q.key,
exec: () =>
q.exec().then((res) => ({
items: res,
metadata: {
next: null,
page: 0,
previous: null,
this: "",
},
})),
};
};
export const transformPage = <To, From>(
q: InfiniteQuery<From>,
transformer: (item: From, index: number) => To,
): InfiniteQuery<To> => {
return {
key: q.key,
exec: (p) =>
q.exec(p).then((res) => ({
items: res.items.map(transformer),
metadata: res.metadata,
})),
};
};
export type QueryClient = ReturnType<typeof useQueryClient>;
export {
useQuery,
useQueries,
useInfiniteQuery,
useQueryClient,
prepareMeeloQuery,
prepareMeeloInfiniteQuery,
};
+6 -6
View File
@@ -16,14 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import store from "../../state/store";
import { unsetAccessToken } from "../../state/userSlice";
import { LogoutIcon } from "../icons";
import type Action from "./action";
import { LogoutIcon } from "~/components/icons";
import { store } from "~/state/store";
import { accessTokenAtom } from "~/state/user";
import type Action from "./";
export const LogoutAction: Action = {
label: "logout",
label: "actions.logout",
icon: <LogoutIcon />,
href: "/",
onClick: () => store.dispatch(unsetAccessToken()),
onClick: () => store.set(accessTokenAtom, undefined),
};
+17 -13
View File
@@ -17,56 +17,60 @@
*/
import type { useConfirm } from "material-ui-confirm";
import API from "../../api/api";
import type { Translator } from "../../i18n/i18n";
import confirmDownloadAction from "../confirm-download-action";
import { ArchiveIcon, DownloadIcon } from "../icons";
import type Action from "./action";
import type API from "~/api";
import confirmDownloadAction from "~/components/confirm-download-action";
import { ArchiveIcon, DownloadIcon } from "~/components/icons";
import type { Translator } from "~/i18n/i18n";
import type Action from "./";
export const DownloadAction = (
api: API,
confirm: ReturnType<typeof useConfirm>,
sourceFileId: number,
t: Translator,
): Action => ({
icon: <DownloadIcon />,
label: "download",
label: "actions.download.label",
onClick: () =>
confirmDownloadAction(confirm, API.getDirectStreamURL(sourceFileId), t),
confirmDownloadAction(confirm, api.getDirectStreamURL(sourceFileId), t),
});
export const DownloadAsyncAction = (
api: API,
confirm: ReturnType<typeof useConfirm>,
sourceFileId: () => PromiseLike<number>,
t: Translator,
): Action => ({
icon: <DownloadIcon />,
label: "download",
label: "actions.download.label",
onClick: () =>
sourceFileId().then((id) =>
confirmDownloadAction(confirm, API.getDirectStreamURL(id), t),
confirmDownloadAction(confirm, api.getDirectStreamURL(id), t),
),
});
export const DownloadReleaseAction = (
api: API,
confirm: ReturnType<typeof useConfirm>,
releaseId: number | string,
t: Translator,
): Action => ({
icon: <ArchiveIcon />,
label: "archive",
label: "actions.archive",
onClick: () =>
confirmDownloadAction(confirm, API.getReleaseArchiveURL(releaseId), t),
confirmDownloadAction(confirm, api.getReleaseArchiveURL(releaseId), t),
});
export const DownloadReleaseAsyncAction = (
api: API,
confirm: ReturnType<typeof useConfirm>,
releaseId: () => PromiseLike<number | string>,
t: Translator,
): Action => ({
icon: <ArchiveIcon />,
label: "archive",
label: "actions.archive",
onClick: () =>
releaseId().then((id) =>
confirmDownloadAction(confirm, API.getReleaseArchiveURL(id), t),
confirmDownloadAction(confirm, api.getReleaseArchiveURL(id), t),
),
});
@@ -17,7 +17,7 @@
*/
import type { RequireExactlyOne } from "type-fest";
import type { TranslationKey } from "../../i18n/i18n";
import type { TranslationKey } from "~/i18n/i18n";
/**
* Props for a generic component to run an action/go to page
+20 -18
View File
@@ -17,10 +17,10 @@
*/
import toast from "react-hot-toast";
import API from "../../api/api";
import type { TaskResponse } from "../../models/task";
import { CleanIcon, ScanIcon } from "../icons";
import type Action from "./action";
import type API from "~/api";
import { CleanIcon, ScanIcon } from "~/components/icons";
import type { TaskResponse } from "~/models/task";
import type Action from "./";
/**
* Using the resolved value of the task porimise, triggers an appropriate toast
@@ -33,30 +33,32 @@ const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
toast.error(s.message ?? s.status ?? "Task request failed"),
);
export const ScanAllLibrariesAction: Action = {
label: "scanLibraries",
export const ScanAllLibrariesAction = (api: API): Action => ({
label: "tasks.scanLibraries",
icon: <ScanIcon />,
onClick: () => handleTask(API.scanLibraries()),
};
onClick: () => handleTask(api.scanLibraries()),
});
export const ScanLibraryAction = (
api: API,
librarySlugOrId: number | string,
): Action => ({
label: "scan",
icon: ScanAllLibrariesAction.icon,
onClick: () => handleTask(API.scanLibrary(librarySlugOrId)),
label: "tasks.scan",
icon: <ScanIcon />,
onClick: () => handleTask(api.scanLibrary(librarySlugOrId)),
});
export const CleanAllLibrariesAction: Action = {
label: "cleanLibraries",
export const CleanAllLibrariesAction = (api: API): Action => ({
label: "tasks.cleanLibraries",
icon: <CleanIcon />,
onClick: () => handleTask(API.cleanLibraries()),
};
onClick: () => handleTask(api.cleanLibraries()),
});
export const CleanLibraryAction = (
api: API,
librarySlugOrId: number | string,
): Action => ({
label: "clean",
icon: CleanAllLibrariesAction.icon,
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
label: "tasks.clean",
icon: <CleanIcon />,
onClick: () => handleTask(api.cleanLibrary(librarySlugOrId)),
});
+22 -13
View File
@@ -21,27 +21,36 @@ import toast from "react-hot-toast";
import {
AlbumIcon,
ArtistIcon,
InfoIcon,
LyricsIcon,
RelatedTracksIcon,
SearchIcon,
SettingsIcon,
SongIcon,
} from "../icons";
import type Action from "./action";
} from "~/components/icons";
import type Action from "./";
export const GoToSongLyricsAction = (
songIdentifier: string | number,
): Action => ({
href: `/songs/${songIdentifier}/lyrics`,
label: "seeLyrics",
label: "actions.song.seeLyrics",
icon: <LyricsIcon />,
});
export const GoToSongInfoAction = (
songIdentifier: string | number,
): Action => ({
href: `/songs/${songIdentifier}/info`,
label: "actions.song.seeSongInfo",
icon: <InfoIcon />,
});
export const GoToArtistAction = (
artistIdentifier: string | number,
): Action => ({
href: `/artists/${artistIdentifier}`,
label: "goToArtist",
label: "actions.goToArtist",
icon: <ArtistIcon />,
});
@@ -49,7 +58,7 @@ export const GoToArtistAlbumsAction = (
artistIdentifier: string | number,
): Action => ({
href: `/artists/${artistIdentifier}/albums`,
label: "seeAlbums",
label: "actions.artist.seeAlbums",
icon: <AlbumIcon />,
});
@@ -57,13 +66,13 @@ export const GoToArtistSongsAction = (
artistIdentifier: string | number,
): Action => ({
href: `/artists/${artistIdentifier}/songs`,
label: "seeSongs",
label: "actions.artist.seeSongs",
icon: <SongIcon />,
});
export const GoToAlbumAction = (albumIdentifier: string | number): Action => ({
href: `/albums/${albumIdentifier}`,
label: "goToAlbum",
label: "actions.album.goToAlbum",
icon: <AlbumIcon />,
});
@@ -71,7 +80,7 @@ export const GoToReleaseAction = (
releaseIdentifier: string | number,
): Action => ({
href: `/releases/${releaseIdentifier}`,
label: "goToAlbum",
label: "actions.album.goToAlbum",
icon: <AlbumIcon />,
});
@@ -87,7 +96,7 @@ export const GoToReleaseAsyncAction = (
router.push(`/releases/${id}`);
}
}),
label: "goToAlbum",
label: "actions.album.goToAlbum",
icon: <AlbumIcon />,
});
@@ -95,7 +104,7 @@ export const GoToSongVersionAction = (
songIdentifier: string | number,
): Action => ({
href: `/songs/${songIdentifier}/versions`,
label: "seeOtherVersions",
label: "actions.song.seeOtherVersions",
icon: <AlbumIcon />,
});
@@ -103,18 +112,18 @@ export const GoToRelatedTracksAction = (
songIdentifier: string | number,
): Action => ({
href: `/songs/${songIdentifier}/tracks`,
label: "seeRelatedTracks",
label: "actions.song.seeRelatedTracks",
icon: <RelatedTracksIcon />,
});
export const GoToSearchAction = {
label: "search",
label: "nav.search",
icon: <SearchIcon />,
href: "/search",
} as const;
export const GoToSettingsAction: Action = {
label: "settings",
label: "actions.goToSettingsPage",
icon: <SettingsIcon />,
href: "/settings",
};
+209
View File
@@ -0,0 +1,209 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import { useRouter } from "next/router";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import type { QueryClient } from "~/api/hook";
import { getSongs } from "~/api/queries";
import type { Song, SongWithRelations } from "~/models/song";
import type { TrackWithRelations } from "~/models/track";
import type { InfiniteQueryFn } from "~/query";
import type Action from ".";
import { MergeIcon, SongIcon } from "../icons";
import Illustration from "../illustration";
import InfiniteList from "../infinite/list";
import ListItem from "../list-item";
export const MergeSongAction = (
song: Song,
queryClient: QueryClient,
): Action => ({
icon: <MergeIcon />,
label: "actions.song.mergeSong.label",
dialog: ({ close }) => {
const [selectedSongId, selectSong] = useState<number>();
const closeModal = () => selectSong(undefined);
const router = useRouter();
const { t } = useTranslation();
const mutation = useMutation(async (destSongId: number) => {
await toast.promise(
queryClient.api.mergeSongs(song.id, destSongId),
{
loading: t("misc.loading"),
success: "Merging successfull",
error: "error",
},
);
router.replace(`/songs/${destSongId}/tracks`);
await queryClient.client.invalidateQueries("songs");
await queryClient.client.invalidateQueries(song.id.toString());
await queryClient.client.invalidateQueries(
song.artistId.toString(),
);
await queryClient.client.invalidateQueries(song.slug);
});
return (
<>
<Dialog
open={selectedSongId !== undefined}
onClose={closeModal}
fullWidth
sx={{ zIndex: 999999 }}
>
<DialogTitle>{t("form.confirm")} ?</DialogTitle>
<DialogContent>
{t("actions.song.mergeSong.warningBeforeMerging")}
</DialogContent>
<DialogActions>
<Button onClick={closeModal} variant="outlined">
{t("form.cancel")}
</Button>
<Button
onClick={() => {
mutation.mutate(selectedSongId!);
close();
}}
>
{t("form.confirm")}
</Button>
</DialogActions>
</Dialog>
<SelectSongForm
onClose={close}
onSelect={(songId) => {
selectSong(songId);
}}
songQuery={() =>
getSongs(
{ artist: song.artistId },
{ sortBy: "name" },
["illustration", "artist"],
)
}
/>
</>
);
},
});
export const ReassignTrackAction = (
track: TrackWithRelations<"song"> & { song: Song },
queryClient: QueryClient,
): Action => ({
icon: <MergeIcon />,
label: "actions.track.reassignTrack",
dialog: ({ close }) => {
const router = useRouter();
const { t } = useTranslation();
const mutation = useMutation(async (destSongId: number) => {
await toast.promise(
queryClient.api.updateTrack(track.id, destSongId),
{
loading: t("misc.loading"),
success: "Merging successfull",
error: "error",
},
);
router.replace(`/songs/${destSongId}/tracks`);
await queryClient.client.invalidateQueries("songs");
await queryClient.client.invalidateQueries("tracks");
await queryClient.client.invalidateQueries(track.id.toString());
await queryClient.client.invalidateQueries(
track.song.id.toString(),
);
await queryClient.client.invalidateQueries(track.song.slug);
});
return (
<SelectSongForm
onClose={close}
onSelect={(songId) => {
mutation.mutate(songId);
close();
}}
songQuery={() =>
getSongs(
{ artist: track.song?.artistId },
{ sortBy: "name" },
["illustration", "artist"],
)
}
/>
);
},
});
// TODO it's a bit too similar to 'SelectPlaylistForm'
type SelectSongFormProps = {
songQuery: InfiniteQueryFn<SongWithRelations<"illustration" | "artist">>;
onSelect: (songId: number) => void;
onClose: () => void;
};
const SelectSongForm = (props: SelectSongFormProps) => {
const { t } = useTranslation();
return (
<>
<DialogTitle>{t("actions.song.mergeSong.modalTitle")}</DialogTitle>
<DialogContent>
<InfiniteList
query={props.songQuery}
render={(item, _, index) => (
<ListItem
key={index}
title={item?.name}
icon={
<Illustration
illustration={item?.illustration}
fallback={<SongIcon />}
quality="low"
/>
}
secondTitle={item?.artist?.name}
onClick={
item &&
(() => {
props.onSelect(item.id);
})
}
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="outlined">
{t("form.cancel")}
</Button>
</DialogActions>
</>
);
};
+131 -45
View File
@@ -18,74 +18,91 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Grid,
} from "@mui/material";
import { Add, Edit } from "iconsax-react";
import type { useConfirm } from "material-ui-confirm";
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
import {
HookCheckBox,
HookTextField,
useHookForm,
} from "mui-react-hook-form-plus";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import API from "../../api/api";
import type { MeeloInfiniteQueryFn, QueryClient } from "../../api/use-query";
import type { PlayerActions, TrackState } from "../../contexts/player";
import type Playlist from "../../models/playlist";
import type { PlaylistWithRelations } from "../../models/playlist";
import { type QueryClient, useQueryClient } from "~/api/hook";
import { getPlaylists } from "~/api/queries";
import {
AddItemToPlaylistIcon,
AddToPlaylistIcon,
DeleteIcon,
PlayAfterIcon,
PlayNextIcon,
} from "../icons";
import Illustration from "../illustration";
import InfiniteList from "../infinite/infinite-list";
import ListItem from "../list-item/item";
import { WideLoadingComponent } from "../loading/loading";
import type Action from "./action";
} from "~/components/icons";
import Illustration from "~/components/illustration";
import InfiniteList from "~/components/infinite/list";
import ListItem from "~/components/list-item";
import type Playlist from "~/models/playlist";
import type {
CreatePlaylistDto,
PlaylistWithRelations,
UpdatePlaylistDto,
} from "~/models/playlist";
import type { InfiniteQueryFn } from "~/query";
import { type TrackState, playAfterAtom, playNextAtom } from "~/state/player";
import { store } from "~/state/store";
import type Action from "./";
export const PlayNextAction = (
getTrack: () => PromiseLike<TrackState>,
playNext: PlayerActions["playNext"],
): Action => ({
onClick: () =>
getTrack().then((track) => {
playNext(track);
store.set(playNextAtom, track);
toast.success(`'${track.track.name}' will play next!`);
}),
label: "playNext",
label: "actions.playback.playNext",
icon: <PlayNextIcon />,
});
export const PlayAfterAction = (
getTrack: () => PromiseLike<TrackState>,
playAfter: PlayerActions["playAfter"],
): Action => ({
onClick: () =>
getTrack().then((track) => {
playAfter(track);
store.set(playAfterAtom, track);
toast.success(`'${track.track.name}' will play after!`);
}),
label: "playAfter",
label: "actions.playback.playAfter",
icon: <PlayAfterIcon />,
});
type CreateOrUpdatePlaylistFormProps = {
onSubmit: (newUrl: string) => void;
onSubmit: (dto: CreatePlaylistDto) => void;
onClose: () => void;
defaultValue?: string;
defaultValue?: CreatePlaylistDto;
};
const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
const defaultValues = { name: props.defaultValue ?? "" };
const { registerState, handleSubmit } = useHookForm({
const defaultValues: CreatePlaylistDto = {
name: props.defaultValue?.name ?? "",
isPublic: props.defaultValue?.isPublic ?? true,
allowChanges: props.defaultValue?.allowChanges ?? false,
};
const { registerState, handleSubmit, control, setValue } = useHookForm({
defaultValues,
});
const isPublic = useWatch({ control, name: "isPublic" });
const { t } = useTranslation();
const onSubmit = (values: typeof defaultValues) => {
props.onSubmit(values.name);
props.onSubmit(values);
props.onClose();
};
@@ -106,7 +123,6 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
fullWidth: true,
label: "Enter name of the playlist",
}}
gridProps={{}}
rules={{
required: {
value: true,
@@ -114,9 +130,47 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
},
}}
/>
<Grid container sx={{ paddingTop: 2 }}>
<HookCheckBox
{...registerState("isPublic")}
checkBoxProps={{
autoFocus: false,
onChange: (_, checked) => {
if (!checked) {
setValue("allowChanges", false);
}
},
}}
formControlLabelProps={{
label: t("form.playlist.playlistIsPublic"),
}}
gridProps={{
size: {
xs: 12,
md: 6,
},
}}
/>
<HookCheckBox
{...registerState("allowChanges")}
checkBoxProps={{
autoFocus: false,
disabled: !isPublic,
}}
formControlLabelProps={{
label: t("form.playlist.allowPlaylistChanges"),
}}
gridProps={{
size: {
xs: 12,
md: 6,
},
}}
/>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose}>{t("cancel")}</Button>
<Button onClick={props.onClose}>{t("form.cancel")}</Button>
<Button type="submit" color="primary" variant="contained">
{props.defaultValue ? "Update" : "Create"}
</Button>
@@ -126,15 +180,20 @@ const CreateOrUpdatePlaylistForm = (props: CreateOrUpdatePlaylistFormProps) => {
);
};
export const CreatePlaylistAction = (queryClient: QueryClient): Action => ({
label: "new",
export const CreatePlaylistAction = (
queryClient: QueryClient,
onCreated?: (playlistId: number) => void,
): Action => ({
label: "actions.new",
icon: <Add />,
dialog: ({ close }) => {
const mutation = useMutation((playlistName: string) => {
return API.createPlaylist(playlistName)
.then(() => {
const mutation = useMutation((formFields: CreatePlaylistDto) => {
return queryClient.api
.createPlaylist(formFields)
.then((playlist) => {
toast.success("Playlist created!");
queryClient.client.invalidateQueries("playlists");
onCreated?.(playlist.id);
})
.catch((error: Error) => toast.error(error.message));
});
@@ -152,11 +211,12 @@ export const UpdatePlaylistAction = (
playlist: Playlist,
queryClient: QueryClient,
): Action => ({
label: "update",
label: "actions.update",
icon: <Edit />,
dialog: ({ close }) => {
const mutation = useMutation((playlistName: string) => {
return API.updatePlaylist(playlistName, playlist.slug)
const mutation = useMutation((dto: UpdatePlaylistDto) => {
return queryClient.api
.updatePlaylist(playlist.slug, dto)
.then(() => {
toast.success("Playlist updated!");
queryClient.client.invalidateQueries("playlists");
@@ -169,7 +229,7 @@ export const UpdatePlaylistAction = (
<CreateOrUpdatePlaylistForm
onClose={close}
onSubmit={(name) => mutation.mutate(name)}
defaultValue={playlist.name}
defaultValue={playlist}
/>
);
},
@@ -181,7 +241,7 @@ export const DeletePlaylistAction = (
librarySlugOrId: number | string,
onDeleted: () => void,
): Action => ({
label: "delete",
label: "actions.delete",
icon: <DeleteIcon />,
onClick: () =>
confirm({
@@ -193,7 +253,8 @@ export const DeletePlaylistAction = (
variant: "outlined",
color: "error",
onClickCapture: () =>
API.deletePlaylist(librarySlugOrId)
queryClient.api
.deletePlaylist(librarySlugOrId)
.then(() => {
onDeleted();
queryClient.client.invalidateQueries("playlist");
@@ -206,22 +267,46 @@ export const DeletePlaylistAction = (
});
type SelectPlaylistFormProps = {
playlistQuery: MeeloInfiniteQueryFn<PlaylistWithRelations<"illustration">>;
playlistQuery: InfiniteQueryFn<PlaylistWithRelations<"illustration">>;
onSubmit: (playlistId: number) => void;
onClose: () => void;
};
const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const createPlaylistAction = CreatePlaylistAction(queryClient);
const [openModal, setOpenModal] = useState(false);
const closeModal = () => setOpenModal(false);
return (
<>
<DialogTitle>Select a playlist</DialogTitle>
<Dialog
open={openModal}
onClose={closeModal}
fullWidth
sx={{ zIndex: 999999 }}
>
{createPlaylistAction.dialog?.({ close: closeModal })}
</Dialog>
<DialogTitle>{t("actions.addToPlaylist.modalTitle")}</DialogTitle>
<DialogContent>
<ListItem
title={t(createPlaylistAction.label!)}
icon={
<Illustration
illustration={null}
fallback={createPlaylistAction.icon}
quality="low"
/>
}
onClick={() => setOpenModal(true)}
secondTitle={null}
/>
<Divider sx={{ paddingTop: 1 }} />
<InfiniteList
loader={() => <WideLoadingComponent />}
query={props.playlistQuery}
render={(item, index) => (
render={(item, _, index) => (
<ListItem
key={index}
title={item?.name}
@@ -246,7 +331,7 @@ const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="outlined">
{t("cancel")}
{t("form.cancel")}
</Button>
</DialogActions>
</>
@@ -258,10 +343,11 @@ export const AddToPlaylistAction = (
queryClient: QueryClient,
): Action => ({
icon: <AddToPlaylistIcon />,
label: "addToPlaylist",
label: "actions.addToPlaylist.label",
dialog: ({ close }) => {
const mutation = useMutation((playlistId: number) => {
return API.addSongToPlaylist(songId, playlistId)
return queryClient.api
.addSongToPlaylist(songId, playlistId)
.then(() => {
toast.success("Song added to Playlist");
queryClient.client.invalidateQueries("playlists");
@@ -275,8 +361,8 @@ export const AddToPlaylistAction = (
onClose={close}
onSubmit={(playlistId) => mutation.mutate(playlistId)}
playlistQuery={() =>
API.getPlaylists(
{},
getPlaylists(
{ changeable: true },
{ sortBy: "creationDate", order: "desc" },
["illustration"],
)
@@ -16,22 +16,72 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Checkbox, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import API from "../../api/api";
import type { Translator } from "../../i18n/i18n";
import { MetadataRefreshIcon } from "../icons";
import type Action from "./action";
import type { ArraySlice } from "type-fest";
import type API from "~/api";
import { useAPI } from "~/api/hook";
import { MetadataRefreshIcon } from "~/components/icons";
import type { Translator } from "~/i18n/i18n";
import type Action from "./";
type APIMethodParams = ArraySlice<Parameters<API["refreshMetadata"]>, 0, -1>;
const RefreshMetadataActionContent = ({
t,
params,
close,
}: { t: Translator; params: APIMethodParams; close: () => void }) => {
const [force, setForce] = useState(false);
const api = useAPI();
return (
<Grid container spacing={2} sx={{ padding: 2 }}>
<Grid
size={{ xs: 12 }}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Checkbox checked={force} onClick={() => setForce((f) => !f)} />
{t("tasks.refreshMetadataForm.forceLabel")}
</Grid>
<Grid size={{ xs: 12 }}>
<Button
fullWidth
variant="contained"
onClick={() => {
api.refreshMetadata(...[...params, force])
.then(() =>
toast.success(
t("toasts.refreshMetadata.started"),
),
)
.catch(() =>
toast.error(t("toasts.refreshMetadata.failed")),
);
close();
}}
>
{t("tasks.refreshMetadata")}
</Button>
</Grid>
</Grid>
);
};
const RefreshMetadataAction = (
t: Translator,
...params: Parameters<typeof API.refreshMetadata>
...params: APIMethodParams
): Action => ({
label: "refreshMetadata",
label: "tasks.refreshMetadata",
icon: <MetadataRefreshIcon />,
onClick: () =>
API.refreshMetadata(...params)
.then(() => toast.success(t("refreshMetadataStarted")))
.catch(() => toast.error(t("refreshMetadataFailed"))),
dialog: ({ close }) => (
<RefreshMetadataActionContent t={t} params={params} close={close} />
),
});
export const RefreshLibraryMetadataAction = (
+31 -24
View File
@@ -21,22 +21,24 @@ import type { useConfirm } from "material-ui-confirm";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import type { QueryClient } from "../../api/use-query";
import type { TranslationKey } from "../../i18n/i18n";
import type Album from "../../models/album";
import { AlbumType } from "../../models/album";
import type Song from "../../models/song";
import { SongType } from "../../models/song";
import type Video from "../../models/video";
import { VideoType } from "../../models/video";
import store from "../../state/store";
import { EditIcon } from "../icons";
import type Action from "./action";
import type { QueryClient } from "~/api/hook";
import { EditIcon } from "~/components/icons";
import type { TranslationKey } from "~/i18n/i18n";
import type Album from "~/models/album";
import { AlbumType } from "~/models/album";
import type Song from "~/models/song";
import { SongType } from "~/models/song";
import type Video from "~/models/video";
import { VideoType } from "~/models/video";
import { store } from "~/state/store";
import { userAtom } from "~/state/user";
import { uncapitalize } from "~/utils/uncapitalize";
import type Action from "./";
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
const ResourceTypeForm = <Enum extends string>(props: {
defaultValue?: Enum;
values: readonly Enum[];
translate: (s: Enum) => TranslationKey;
onSelect: (type: Enum) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
@@ -46,9 +48,9 @@ const ResourceTypeForm = <Enum extends TranslationKey>(props: {
<>
<Grid container spacing={2} justifyContent="center">
{props.values.map((type) => (
<Grid item key={type}>
<Grid key={type}>
<Chip
label={t(type) as string}
label={t(props.translate(type))}
variant={
type === currentType ? "filled" : "outlined"
}
@@ -66,10 +68,11 @@ const ResourceTypeForm = <Enum extends TranslationKey>(props: {
const ChangeResourceType = <
T extends { type: TypeEnum },
TypeEnum extends TranslationKey,
TypeEnum extends string,
>(
resource: T,
types: readonly TypeEnum[],
translateType: (s: TypeEnum) => TranslationKey,
label: TranslationKey,
queryClient: QueryClient,
onSelect: (newType: TypeEnum) => Promise<void>,
@@ -78,7 +81,7 @@ const ChangeResourceType = <
return {
label: label,
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
disabled: store.get(userAtom)?.admin !== true,
onClick: () =>
confirm({
title: <br />,
@@ -86,10 +89,11 @@ const ChangeResourceType = <
<ResourceTypeForm
defaultValue={resource.type}
values={types}
translate={translateType}
onSelect={(type) =>
toast
.promise(onSelect(type), {
loading: null,
loading: "Updating...",
success: "Update successful!",
error: "Update failed...",
})
@@ -127,10 +131,11 @@ const ChangeSongType = (
ChangeResourceType(
s,
SongType.filter((t) => t !== "Unknown"),
"changeSongType",
(type) => `songType.${uncapitalize(type)}`,
"actions.song.changeType",
client,
(newType: SongType) =>
API.updateSong(s.id, { type: newType }).then((res) => {
client.api.updateSong(s.id, { type: newType }).then((res) => {
client.client.invalidateQueries("songs");
client.client.invalidateQueries("release");
client.client.invalidateQueries("tracks");
@@ -149,10 +154,11 @@ const ChangeAlbumType = (
ChangeResourceType(
a,
AlbumType,
"changeAlbumType",
(type) => `albumType.${uncapitalize(type)}`,
"actions.album.changeType",
client,
(newType: AlbumType) =>
API.updateAlbum(a.id, { type: newType }).then((res) => {
client.api.updateAlbum(a.id, { type: newType }).then((res) => {
client.client.invalidateQueries("albums");
return res;
}),
@@ -167,10 +173,11 @@ const ChangeVideoType = (
ChangeResourceType(
v,
VideoType,
"changeVideoType",
(type) => `videoType.${uncapitalize(type)}`,
"actions.video.changeType",
client,
(newType: VideoType) =>
API.updateVideo(v.id, { type: newType }).then((res) => {
client.api.updateVideo(v.id, { type: newType }).then((res) => {
client.client.invalidateQueries("videos");
return res;
}),
+5 -5
View File
@@ -16,14 +16,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { Translator } from "../../i18n/i18n";
import copyLinkToClipboard from "../../utils/copy-link";
import { ShareIcon } from "../icons";
import type Action from "./action";
import { ShareIcon } from "~/components/icons";
import type { Translator } from "~/i18n/i18n";
import copyLinkToClipboard from "~/utils/copy-link";
import type Action from "./";
export const ShareAction = (url: string, t: Translator): Action => ({
onClick: () => copyLinkToClipboard(url, t),
label: "share",
label: "actions.share",
icon: <ShareIcon />,
});
@@ -17,18 +17,18 @@
*/
import type { useConfirm } from "material-ui-confirm";
import API from "../../api/api";
import type { QueryClient } from "../../api/use-query";
import { InfoIcon } from "../icons";
import { openTrackFileInfoModal } from "../track-file-info";
import type Action from "./action";
import type { QueryClient } from "~/api/hook";
import { getSongMasterTrack } from "~/api/queries";
import { InfoIcon } from "~/components/icons";
import { openTrackFileInfoModal } from "~/components/track-file-info";
import type Action from "./";
export const ShowTrackFileInfoAction = (
confirm: ReturnType<typeof useConfirm>,
trackId: number,
): Action => ({
icon: <InfoIcon />,
label: "moreInfo",
label: "actions.track.seeTrackInfo",
onClick: () => openTrackFileInfoModal(confirm, trackId),
});
@@ -38,9 +38,9 @@ export const ShowMasterTrackFileInfoAction = (
songId: number,
): Action => ({
icon: <InfoIcon />,
label: "moreInfo",
label: "actions.track.seeTrackInfo",
onClick: () =>
queryClient
.fetchQuery(API.getSongMasterTrack(songId))
.fetchQuery(getSongMasterTrack(songId))
.then((track) => openTrackFileInfoModal(confirm, track.id)),
});
@@ -26,11 +26,11 @@ import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import API from "../../api/api";
import type { QueryClient } from "../../api/use-query";
import store from "../../state/store";
import { UpdateIllustrationIcon } from "../icons";
import type Action from "./action";
import type { QueryClient } from "~/api/hook";
import { UpdateIllustrationIcon } from "~/components/icons";
import { store } from "~/state/store";
import { userAtom } from "~/state/user";
import type Action from "./";
type IllustrationUpdateFormType = {
onSubmit: (newUrl: string) => void;
@@ -73,8 +73,8 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose}>{t("cancel")}</Button>
<Button type="submit">{t("update")}</Button>
<Button onClick={props.onClose}>{t("form.cancel")}</Button>
<Button type="submit">{t("actions.update")}</Button>
</DialogActions>
</form>
</>
@@ -89,14 +89,15 @@ const UpdateIllustrationAction = (
const mutation = useMutation(async (newUrl: string) => {
const updator =
resourceType === "artist"
? API.updateArtistIllustration
? queryClient.api.updateArtistIllustration
: resourceType === "release"
? API.updateReleaseIllustration
? queryClient.api.updateReleaseIllustration
: resourceType === "playlist"
? API.updatePlaylistIllustration
: API.updateTrackIllustration;
? queryClient.api.updatePlaylistIllustration
: queryClient.api.updateTrackIllustration;
return updator(resourceId, newUrl)
return updator
.call(queryClient.api, resourceId, newUrl)
.then(() => {
toast.success("Illustration updated!");
queryClient.client.invalidateQueries(resourceType);
@@ -106,8 +107,8 @@ const UpdateIllustrationAction = (
});
return {
label: "changeIllutration",
disabled: store.getState().user.user?.admin !== true,
label: "actions.changeIllutration",
disabled: store.get(userAtom)?.admin !== true,
icon: <UpdateIllustrationIcon />,
dialog: (controls) => (
<IllustrationUpdateForm
+14 -17
View File
@@ -18,12 +18,12 @@
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useEffect, useMemo, useState } from "react";
import API from "../api/api";
import { type MeeloInfiniteQueryFn, useInfiniteQuery } from "../api/use-query";
import type Resource from "../models/resource";
import { useAPI, useInfiniteQuery } from "~/api/hook";
import type Resource from "~/models/resource";
import type { InfiniteQueryFn } from "~/query";
type AdminGridProps<DataType extends Resource> = {
infiniteQuery: MeeloInfiniteQueryFn<DataType>;
infiniteQuery: InfiniteQueryFn<DataType>;
columns: GridColDef<DataType>[];
};
@@ -36,19 +36,15 @@ const AdminGrid = <DataType extends Resource>({
}: AdminGridProps<DataType>) => {
const {
data,
items,
hasNextPage,
hasPreviousPage,
fetchNextPage,
fetchPreviousPage,
} = useInfiniteQuery(infiniteQuery);
const api = useAPI();
const [currentPage, setCurrentPage] = useState(0);
const itemsCount = useMemo(
() =>
data?.pages
.map((page) => page.items.length)
.reduce((pageSize, sum) => pageSize + sum, 0) ?? 0,
[data?.pages],
);
const itemsCount = useMemo(() => items?.length, [items]);
useEffect(() => {
hasNextPage && fetchNextPage();
@@ -58,15 +54,16 @@ const AdminGrid = <DataType extends Resource>({
loading={data?.pages.at(currentPage) === undefined}
rows={data?.pages[currentPage]?.items ?? []}
rowCount={itemsCount}
pageSize={API.defaultPageSize}
rowsPerPageOptions={[API.defaultPageSize]}
page={currentPage}
paginationModel={{
pageSize: api.pageSize,
page: currentPage,
}}
pageSizeOptions={[api.pageSize]}
disableColumnSelector
disableRowSelectionOnClick
disableColumnMenu
disableSelectionOnClick
paginationMode="server"
autoHeight
onPageChange={(page) => {
onPaginationModelChange={({ page }) => {
if (page === currentPage + 1 && hasNextPage) {
fetchNextPage();
} else if (page === currentPage - 1 && hasPreviousPage) {
+3 -1
View File
@@ -18,7 +18,8 @@
import { Avatar } from "@mui/material";
import type { ComponentProps } from "react";
import type IllustrationModel from "../models/illustration";
import type IllustrationModel from "~/models/illustration";
import { ArtistIcon } from "./icons";
import Illustration from "./illustration";
const ArtistAvatar = (props: {
@@ -39,6 +40,7 @@ const ArtistAvatar = (props: {
imgProps={{ objectFit: "cover" }}
illustration={props.illustration}
quality={props.quality}
fallback={<ArtistIcon />}
/>
</Avatar>
);
@@ -16,24 +16,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Divider, Grid } from "@mui/material";
import { Box, Button, Divider, Stack } from "@mui/material";
import { useAtom } from "jotai";
import { HookTextField, useHookForm } from "mui-react-hook-form-plus";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import API from "../../api/api";
import { setAccessToken } from "../../state/userSlice";
import { useAPI } from "~/api/hook";
import { accessTokenAtom } from "~/state/user";
/**
* Authentication form
* On successful authentication, update store with access token
*/
const AuthenticationForm = () => {
const dispatch = useDispatch();
const api = useAPI();
const [formType, setFormType] = useState<"login" | "signup">("login");
const defaultValues = { username: "", password: "", confirm: "" };
const [password, setPassword] = useState(defaultValues.password);
const [_, setAccessToken] = useAtom(accessTokenAtom);
const { registerState, handleSubmit } = useHookForm({
defaultValues,
});
@@ -42,15 +43,15 @@ const AuthenticationForm = () => {
const onSubmit = async (values: typeof defaultValues) => {
try {
if (formType === "signup") {
const createdUser = await API.register(values);
const createdUser = await api.register(values);
if (!createdUser.enabled) {
setFormType("login");
toast.success(t("accountCreated"));
toast.success(t("toasts.auth.accountCreated"));
return;
}
}
dispatch(setAccessToken((await API.login(values)).access_token));
setAccessToken((await api.login(values)).access_token);
} catch (error: any) {
toast.error(error.message);
}
@@ -61,9 +62,7 @@ const AuthenticationForm = () => {
onSubmit={handleSubmit(onSubmit)}
style={{ width: "100%", height: "100%" }}
>
<Grid
container
direction="column"
<Stack
spacing={3}
sx={{
display: "flex",
@@ -77,24 +76,24 @@ const AuthenticationForm = () => {
<HookTextField
{...registerState("username")}
textFieldProps={{
label: t("username"),
label: t("form.auth.username"),
}}
gridProps={{}}
rules={{
required: {
value: true,
message: t("usernameIsRequired"),
message: t("form.auth.usernameIsRequired"),
},
minLength: {
value: 4,
message: t("usernameTooShort"),
message: t("form.auth.usernameTooShort"),
},
}}
/>
<HookTextField
{...registerState("password")}
textFieldProps={{
label: t("password"),
label: t("form.auth.password"),
type: "password",
onChange: (event) => setPassword(event.target.value),
}}
@@ -102,11 +101,11 @@ const AuthenticationForm = () => {
rules={{
required: {
value: true,
message: t("passwordIsRequired"),
message: t("form.auth.passwordIsRequired"),
},
minLength: {
value: 6,
message: t("passwordTooShort"),
message: t("form.auth.passwordTooShort"),
},
}}
/>
@@ -114,24 +113,24 @@ const AuthenticationForm = () => {
<HookTextField
{...registerState("confirm")}
textFieldProps={{
label: t("confirmPassword"),
label: t("form.confirm"),
type: "password",
}}
gridProps={{}}
rules={{
required: {
value: true,
message: t("pleaseConfirm"),
message: t("form.auth.pleaseConfirm"),
},
validate: (confirmValue) => {
if (confirmValue !== password) {
return t("passwordsAreDifferent");
return t("form.auth.passwordsAreDifferent");
}
},
}}
/>
)}
<Grid item>
<Box>
<Button
type="submit"
variant="contained"
@@ -139,9 +138,9 @@ const AuthenticationForm = () => {
>
{formType === "login" ? "Login" : "Signup"}
</Button>
</Grid>
<Divider sx={{ width: "100%", paddingY: 1 }} variant="middle" />
<Grid item>
</Box>
<Divider sx={{ width: "100%" }} variant="middle" />
<Box>
<Button
variant="outlined"
onClick={() =>
@@ -152,12 +151,12 @@ const AuthenticationForm = () => {
>
{t(
formType === "login"
? "signupButton"
: "signinButton",
? "auth.signup"
: "auth.signin",
)}
</Button>
</Grid>
</Grid>
</Box>
</Stack>
</form>
);
};
@@ -16,28 +16,26 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Grid } from "@mui/material";
import { useEffect, useState } from "react";
import { Box, Stack } from "@mui/material";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useQuery as useReactQuery } from "react-query";
import { useDispatch, useSelector } from "react-redux";
import API from "../../api/api";
import { prepareMeeloQuery } from "../../api/use-query";
import type { RootState } from "../../state/store";
import { setUserProfile } from "../../state/userSlice";
import ModalPage from "../modal-page";
import ThemedImage from "../themed-image";
import AuthenticationForm from "./authentication-form";
import { getAPI_ } from "~/api/hook";
import { getCurrentUserStatus } from "~/api/queries";
import ModalPage from "~/components/modal-page";
import ThemedImage from "~/components/themed-image";
import { toTanStackQuery } from "~/query";
import { accessTokenAtom, userAtom } from "~/state/user";
import AuthenticationForm from "./form";
const AuthenticationWall = (props: { children: any }) => {
const accessToken = useSelector(
(store: RootState) => store.user.accessToken,
);
const [accessToken] = useAtom(accessTokenAtom);
const [_, setUser] = useAtom(userAtom);
const api = useMemo(() => getAPI_(accessToken ?? null), [accessToken]);
const status = useReactQuery({
...prepareMeeloQuery(API.getCurrentUserStatus),
...toTanStackQuery(api, getCurrentUserStatus),
useErrorBoundary: false,
});
const dispatch = useDispatch();
const [authentified, setAuthenticationStatus] = useState(
status.data?.id !== undefined,
);
@@ -49,33 +47,29 @@ const AuthenticationWall = (props: { children: any }) => {
if (accessToken && status.data?.id && !status.error) {
setAuthenticationStatus(true);
}
if (status.error || accessToken?.valueOf() === undefined) {
if (status.error || accessToken === undefined) {
setAuthenticationStatus(false);
}
}, [accessToken, status, authentified]);
useEffect(() => {
if (accessToken && status.data && !status.error) {
dispatch(setUserProfile(status.data));
setUser(status.data);
}
}, [accessToken, status, dispatch]);
}, [accessToken, status]);
if (!authentified || !status.data?.id) {
return (
<ModalPage open>
<Grid
container
direction="column"
<Stack
sx={{
width: "100%",
height: "100%",
display: "flex",
flexWrap: "nowrap",
justifyContent: "center",
justifyContent: "space-evenly",
alignItems: "center",
}}
>
<Grid
xs={2}
item
<Box
sx={{
position: "relative",
width: "100%",
@@ -84,19 +78,19 @@ const AuthenticationWall = (props: { children: any }) => {
}}
>
<ThemedImage
light={"/banner-black.png"}
dark={"/banner.png"}
light={"/banner1-black.png"}
dark={"/banner1-white.png"}
alt="title"
width={200}
height={150}
priority
style={{ objectFit: "contain" }}
/>
</Grid>
<Grid item xs>
</Box>
<Box flexGrow={1}>
<AuthenticationForm />
</Grid>
</Grid>
</Box>
</Stack>
</ModalPage>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@
import { Box } from "@mui/material";
import { type ComponentProps, useEffect, useMemo, useState } from "react";
import { Blurhash as RBlurhash } from "react-blurhash";
import { blurHashToDataURL } from "../utils/blurhashToDataUrl";
import { blurHashToDataURL } from "~/utils/blurhashToDataUrl";
type BlurhashProps = ComponentProps<typeof Box> & {
blurhash?: string;
@@ -17,7 +17,7 @@
*/
import type { useConfirm } from "material-ui-confirm";
import type { Translator } from "../i18n/i18n";
import type { Translator } from "~/i18n/i18n";
const confirmDownloadAction = (
confirm: ReturnType<typeof useConfirm>,
@@ -25,9 +25,9 @@ const confirmDownloadAction = (
t: Translator,
) => {
confirm({
title: t("warning"),
description: t("downloadWarning"),
confirmationText: t("download"),
title: t("actions.warningModalTitle"),
description: t("actions.download.warning"),
confirmationText: t("actions.download.label"),
confirmationButtonProps: {
color: "error",
variant: "outlined",
@@ -1,72 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Dialog, ListItemIcon, ListItemText, MenuItem } from "@mui/material";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type Action from "../actions/action";
const ContextualMenuItem = (props: Action & { onDialogClose: () => void }) => {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
const onClick = () => {
if (props.disabled === true) {
return;
}
props.onClick?.();
if (props.dialog) {
setModalOpen(true);
}
};
const closeModal = () => {
setModalOpen(false);
props.onDialogClose();
};
const item = (
<MenuItem
disabled={props.disabled}
onClick={onClick}
sx={{ borderRadius: "0" }}
>
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
<ListItemText>{t(props.label)}</ListItemText>
</MenuItem>
);
if (props.href && !props.disabled) {
return <Link href={props.href}>{item}</Link>;
}
return (
<>
{item}
{props.dialog && (
<Dialog
open={modalOpen}
onClose={closeModal}
fullWidth
sx={{ zIndex: 999999 }}
>
{props.dialog({ close: closeModal })}
</Dialog>
)}
</>
);
};
export default ContextualMenuItem;
@@ -16,18 +16,29 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Divider, IconButton, Menu } from "@mui/material";
import {
Box,
Dialog,
Divider,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
} from "@mui/material";
import Link from "next/link";
import { useState } from "react";
import type Action from "../actions/action";
import { ContextualMenuIcon } from "../icons";
import ContextualMenuItem from "./contextual-menu-item";
import { useTranslation } from "react-i18next";
import type Action from "~/components/actions";
import { ContextualMenuIcon } from "~/components/icons";
type ContextualMenuProps = {
export type ContextualMenuProps = {
actions: Action[][];
onSelect?: (action: Action) => void;
buttonIcon?: JSX.Element;
};
const ContextualMenu = (props: ContextualMenuProps) => {
export const ContextualMenu = (props: ContextualMenuProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -92,4 +103,51 @@ const ContextualMenu = (props: ContextualMenuProps) => {
);
};
export default ContextualMenu;
export const ContextualMenuItem = (
props: Action & { onDialogClose: () => void },
) => {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
const onClick = () => {
if (props.disabled === true) {
return;
}
props.onClick?.();
if (props.dialog) {
setModalOpen(true);
}
};
const closeModal = () => {
setModalOpen(false);
props.onDialogClose();
};
const item = (
<MenuItem
disabled={props.disabled}
onClick={onClick}
sx={{ borderRadius: "0" }}
>
{props.icon && <ListItemIcon>{props.icon}</ListItemIcon>}
<ListItemText>{t(props.label)}</ListItemText>
</MenuItem>
);
if (props.href && !props.disabled) {
return <Link href={props.href}>{item}</Link>;
}
return (
<>
{item}
{props.dialog && (
<Dialog
open={modalOpen}
onClose={closeModal}
fullWidth
sx={{ zIndex: 999999 }}
>
{props.dialog({ close: closeModal })}
</Dialog>
)}
</>
);
};
@@ -18,15 +18,15 @@
import { useConfirm } from "material-ui-confirm";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import type { AlbumWithRelations } from "../../models/album";
import { DownloadReleaseAsyncAction } from "../actions/download";
import { GoToArtistAction } from "../actions/link";
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
import { ChangeAlbumType } from "../actions/resource-type";
import { ShareAlbumAction } from "../actions/share";
import ContextualMenu from "./contextual-menu";
import { useQueryClient } from "~/api/hook";
import { getMasterRelease } from "~/api/queries";
import { DownloadReleaseAsyncAction } from "~/components/actions/download";
import { GoToArtistAction } from "~/components/actions/link";
import { RefreshAlbumMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeAlbumType } from "~/components/actions/resource-type";
import { ShareAlbumAction } from "~/components/actions/share";
import type { AlbumWithRelations } from "~/models/album";
import { ContextualMenu } from "..";
type AlbumContextualMenuProps = {
album: AlbumWithRelations<"artist">;
@@ -52,10 +52,11 @@ const AlbumContextualMenu = (props: AlbumContextualMenuProps) => {
],
[
DownloadReleaseAsyncAction(
queryClient.api,
confirm,
() =>
queryClient
.fetchQuery(API.getMasterRelease(albumSlug))
.fetchQuery(getMasterRelease(albumSlug))
.then((release) => release.id),
t,
),
@@ -17,16 +17,16 @@
*/
import { useTranslation } from "react-i18next";
import { useQueryClient } from "../../api/use-query";
import type Artist from "../../models/artist";
import { useQueryClient } from "~/api/hook";
import {
GoToArtistAction,
GoToArtistAlbumsAction,
GoToArtistSongsAction,
} from "../actions/link";
import { ShareArtistAction } from "../actions/share";
import { UpdateArtistIllustrationAction } from "../actions/update-illustration";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/link";
import { ShareArtistAction } from "~/components/actions/share";
import { UpdateArtistIllustrationAction } from "~/components/actions/update-illustration";
import type Artist from "~/models/artist";
import { ContextualMenu } from "..";
type ArtistContextualMenuProps = {
artist: Artist;
@@ -19,15 +19,16 @@
import { useConfirm } from "material-ui-confirm";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "../../api/use-query";
import type Playlist from "../../models/playlist";
import { useQuery, useQueryClient } from "~/api/hook";
import { getCurrentUserStatus } from "~/api/queries";
import {
DeletePlaylistAction,
UpdatePlaylistAction,
} from "../actions/playlist";
import { SharePlaylistAction } from "../actions/share";
import { UpdatePlaylistIllustrationAction } from "../actions/update-illustration";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/playlist";
import { SharePlaylistAction } from "~/components/actions/share";
import { UpdatePlaylistIllustrationAction } from "~/components/actions/update-illustration";
import type Playlist from "~/models/playlist";
import { ContextualMenu } from "..";
type PlaylistContextualMenuProps = {
playlist: Playlist;
@@ -39,27 +40,36 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
const confirm = useConfirm();
const router = useRouter();
const { t } = useTranslation();
const { data: user } = useQuery(getCurrentUserStatus);
return (
<ContextualMenu
onSelect={props.onSelect}
actions={[
[UpdatePlaylistAction(props.playlist, queryClient)],
[
UpdatePlaylistIllustrationAction(
queryClient,
props.playlist.id,
),
],
...(user?.id === props.playlist.ownerId
? [
[UpdatePlaylistAction(props.playlist, queryClient)],
[
UpdatePlaylistIllustrationAction(
queryClient,
props.playlist.id,
),
],
]
: []),
[SharePlaylistAction(props.playlist.slug, t)],
[
DeletePlaylistAction(
confirm,
queryClient,
props.playlist.slug,
() => router.push("/playlists"),
),
],
...(user?.id === props.playlist.ownerId
? [
[
DeletePlaylistAction(
confirm,
queryClient,
props.playlist.slug,
() => router.push("/playlists"),
),
],
]
: []),
]}
/>
);
@@ -18,28 +18,28 @@
import { useConfirm } from "material-ui-confirm";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type Artist from "../../models/artist";
import type { TrackWithRelations } from "../../models/track";
import { DownloadAction } from "../actions/download";
import { useQueryClient } from "~/api/hook";
import { DownloadAction } from "~/components/actions/download";
import {
GoToArtistAction,
GoToRelatedTracksAction,
GoToSongInfoAction,
GoToSongLyricsAction,
GoToSongVersionAction,
} from "../actions/link";
} from "~/components/actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import { ChangeSongType } from "../actions/resource-type";
import { ShareSongAction } from "../actions/share";
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/playlist";
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeSongType } from "~/components/actions/resource-type";
import { ShareSongAction } from "~/components/actions/share";
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
import type Artist from "~/models/artist";
import type { TrackWithRelations } from "~/models/track";
import { ContextualMenu } from "..";
type ReleaseTrackContextualMenuProps = {
track: TrackWithRelations<"song" | "illustration">;
@@ -52,17 +52,21 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
const confirm = useConfirm();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
onSelect={props.onSelect}
actions={[
[GoToArtistAction(props.artist.slug)],
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
songSlug
? [
GoToSongLyricsAction(songSlug),
GoToSongInfoAction(songSlug),
]
: [],
[
PlayNextAction(async () => props, playNext),
PlayAfterAction(async () => props, playAfter),
PlayNextAction(async () => props),
PlayAfterAction(async () => props),
],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
@@ -88,7 +92,12 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
],
[ShowTrackFileInfoAction(confirm, props.track.id)],
[
DownloadAction(confirm, props.track.sourceFileId, t),
DownloadAction(
queryClient.api,
confirm,
props.track.sourceFileId,
t,
),
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
],
]}
@@ -16,51 +16,51 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useAtom } from "jotai";
import { useConfirm } from "material-ui-confirm";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useSelector } from "react-redux";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import type { ReleaseWithRelations } from "../../models/release";
import type { RootState } from "../../state/store";
import { DownloadReleaseAction } from "../actions/download";
import { GoToAlbumAction, GoToArtistAction } from "../actions/link";
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
import { ChangeAlbumType } from "../actions/resource-type";
import { ShareReleaseAction } from "../actions/share";
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
import { MasterIcon, UpgradeIcon } from "../icons";
import ContextualMenu from "./contextual-menu";
import { useQueryClient } from "~/api/hook";
import { getReleaseTracklist } from "~/api/queries";
import { DownloadReleaseAction } from "~/components/actions/download";
import { GoToAlbumAction, GoToArtistAction } from "~/components/actions/link";
import { RefreshReleaseMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeAlbumType } from "~/components/actions/resource-type";
import { ShareReleaseAction } from "~/components/actions/share";
import { UpdateReleaseIllustrationAction } from "~/components/actions/update-illustration";
import { MasterIcon, UpgradeIcon } from "~/components/icons";
import type { ReleaseWithRelations } from "~/models/release";
import { userAtom } from "~/state/user";
import { ContextualMenu } from "..";
type ReleaseContextualMenuProps = {
release: ReleaseWithRelations<"album">;
};
const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
const userIsAdmin = useSelector(
(state: RootState) => state.user.user?.admin === true,
);
const [user] = useAtom(userAtom);
const userIsAdmin = user?.admin === true;
const queryClient = useQueryClient();
const confirm = useConfirm();
const { t } = useTranslation();
const masterMutation = useMutation(async () => {
return API.updateAlbum(props.release.albumId, {
masterReleaseId: props.release.id,
})
return queryClient.api
.updateAlbum(props.release.albumId, {
masterReleaseId: props.release.id,
})
.then(() => {
toast.success(t("releaseSetAsMaster"));
toast.success(t("toasts.releaseSetAsMaster"));
queryClient.client.invalidateQueries();
})
.catch((error: Error) => toast.error(error.message));
});
const tracksMasterMutation = useMutation(async () => {
const query = API.getReleaseTracklist(props.release.id);
const query = getReleaseTracklist(props.release.id);
return queryClient
.fetchQuery({
key: query.key,
exec: () => query.exec({ pageSize: 1000 }),
exec: (api_) => () => query.exec(api_)({ pageSize: 1000 }),
})
.then(({ items: tracks }) => {
Promise.allSettled(
@@ -68,13 +68,13 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
.reverse()
.filter((track) => track.songId != null)
.map((track) =>
API.updateSong(track.songId!, {
queryClient.api.updateSong(track.songId!, {
masterTrackId: track.id,
}),
),
)
.then(() => {
toast.success(t("tracksUpdated"));
toast.success(t("toasts.tracksUpdated"));
queryClient.client.invalidateQueries();
})
.catch((error) => toast.error(error.message));
@@ -90,7 +90,7 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
: []),
GoToAlbumAction(props.release.album.id),
{
label: "setAsMaster",
label: "actions.setAsMaster",
disabled:
props.release.id === props.release.album.masterId ||
!userIsAdmin,
@@ -98,7 +98,7 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
onClick: () => masterMutation.mutate(),
},
{
label: "setAllTracksAsMaster",
label: "actions.release.setAllTracksAsMaster",
icon: <UpgradeIcon />,
disabled: !userIsAdmin,
onClick: () => tracksMasterMutation.mutate(),
@@ -112,7 +112,14 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
),
RefreshReleaseMetadataAction(props.release.id, t),
],
[DownloadReleaseAction(confirm, props.release.id, t)],
[
DownloadReleaseAction(
queryClient.api,
confirm,
props.release.id,
t,
),
],
[ShareReleaseAction(props.release.id, t)],
]}
/>
@@ -20,29 +20,30 @@ import { useConfirm } from "material-ui-confirm";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { SongWithRelations } from "../../models/song";
import { DownloadAsyncAction } from "../actions/download";
import { useQueryClient } from "~/api/hook";
import { getSongMasterTrack } from "~/api/queries";
import { DownloadAsyncAction } from "~/components/actions/download";
import {
GoToArtistAction,
GoToRelatedTracksAction,
GoToReleaseAsyncAction,
GoToSongInfoAction,
GoToSongLyricsAction,
GoToSongVersionAction,
} from "../actions/link";
} from "~/components/actions/link";
import { MergeSongAction } from "~/components/actions/merge";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
import { ChangeSongType } from "../actions/resource-type";
import { ShareSongAction } from "../actions/share";
import { ShowMasterTrackFileInfoAction } from "../actions/show-track-info";
import { DeleteIcon } from "../icons";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/playlist";
import { RefreshSongMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeSongType } from "~/components/actions/resource-type";
import { ShareSongAction } from "~/components/actions/share";
import { ShowMasterTrackFileInfoAction } from "~/components/actions/show-track-info";
import { DeleteIcon } from "~/components/icons";
import type { SongWithRelations } from "~/models/song";
import { ContextualMenu } from "..";
type SongContextualMenuProps = {
song: SongWithRelations<"artist">;
@@ -55,10 +56,9 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
const songSlug = props.song.slug;
const queryClient = useQueryClient();
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const getMasterTrack = () =>
queryClient.fetchQuery(
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
getSongMasterTrack(songSlug, ["release", "illustration"]),
);
const router = useRouter();
const confirm = useConfirm();
@@ -80,10 +80,10 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
(await getMasterTrack()).release?.slug ?? null,
),
],
[GoToSongLyricsAction(songSlug)],
[GoToSongLyricsAction(songSlug), GoToSongInfoAction(songSlug)],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
[AddToPlaylistAction(props.song.id, queryClient)],
[
@@ -93,6 +93,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
[
ChangeSongType(props.song, queryClient, confirm),
RefreshSongMetadataAction(props.song.id, t),
MergeSongAction(props.song, queryClient),
],
[
ShowMasterTrackFileInfoAction(
@@ -103,6 +104,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
],
[
DownloadAsyncAction(
queryClient.api,
confirm,
() =>
getMasterTrack().then(
@@ -117,14 +119,15 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
? [
[
{
label: "deleteFromPlaylist",
label: "actions.deleteFromPlaylist",
icon: <DeleteIcon />,
onClick: () =>
API.deletePlaylistEntry(props.entryId!)
queryClient.api
.deletePlaylistEntry(props.entryId!)
.then(() => {
toast.success(
t(
"playlistItemDeletionSuccess",
"toasts.playlist.itemDeletionSuccess",
),
);
queryClient.client.invalidateQueries(
@@ -16,30 +16,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useAtom } from "jotai";
import { useConfirm } from "material-ui-confirm";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useMutation } from "react-query";
import { useSelector } from "react-redux";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { TrackWithRelations } from "../../models/track";
import type { RootState } from "../../state/store";
import type Action from "../actions/action";
import { DownloadAction } from "../actions/download";
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
import { useQueryClient } from "~/api/hook";
import { getArtist, getRelease } from "~/api/queries";
import type Action from "~/components/actions";
import { DownloadAction } from "~/components/actions/download";
import {
GoToReleaseAction,
GoToSongInfoAction,
GoToSongLyricsAction,
} from "~/components/actions/link";
import { ReassignTrackAction } from "~/components/actions/merge";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import { ChangeSongType } from "../actions/resource-type";
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { MasterIcon } from "../icons";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/playlist";
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeSongType } from "~/components/actions/resource-type";
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
import { MasterIcon } from "~/components/icons";
import type { TrackWithRelations } from "~/models/track";
import { userAtom } from "~/state/user";
import { ContextualMenu } from "..";
type TrackContextualMenuProps = {
track: TrackWithRelations<"video" | "song" | "illustration">;
@@ -47,34 +51,31 @@ type TrackContextualMenuProps = {
};
const TrackContextualMenu = (props: TrackContextualMenuProps) => {
const userIsAdmin = useSelector(
(state: RootState) => state.user.user?.admin === true,
);
const [user] = useAtom(userAtom);
const userIsAdmin = user?.admin === true;
const queryClient = useQueryClient();
const confirm = useConfirm();
const isMaster = props.track.song?.masterId === props.track.id;
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(
API.getArtist(
(props.track.song ?? props.track.video)!.artistId,
),
getArtist((props.track.song ?? props.track.video)!.artistId),
),
props.track.releaseId &&
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
queryClient.fetchQuery(getRelease(props.track.releaseId)),
]).then(([artist, release]) => ({
track: props.track,
artist,
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const masterMutation = useMutation(async () => {
return API.updateSong(props.track.songId!, {
masterTrackId: props.track.id,
})
return queryClient.api
.updateSong(props.track.songId!, {
masterTrackId: props.track.id,
})
.then(() => {
toast.success(t("trackSetAsMaster"));
toast.success(t("toasts.trackSetAsMaster"));
queryClient.client.invalidateQueries();
})
.catch((error: Error) => toast.error(error.message));
@@ -90,11 +91,14 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
: undefined,
].filter((a): a is Action => a !== undefined),
props.track.song
? [GoToSongLyricsAction(props.track.song.slug)]
? [
GoToSongLyricsAction(props.track.song.slug),
GoToSongInfoAction(props.track.song.slug),
]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
@@ -102,7 +106,7 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
props.track.songId
? [
{
label: "setAsMaster",
label: "actions.setAsMaster",
disabled: isMaster || !userIsAdmin,
icon: <MasterIcon />,
onClick: () => masterMutation.mutate(),
@@ -121,9 +125,19 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
: []),
UpdateTrackIllustrationAction(queryClient, props.track.id),
RefreshTrackMetadataAction(props.track.id, t),
...(props.track.song !== null && props.track.songId !== null
? [ReassignTrackAction(props.track as any, queryClient)]
: []),
],
[ShowTrackFileInfoAction(confirm, props.track.id)],
[DownloadAction(confirm, props.track.sourceFileId, t)],
[
DownloadAction(
queryClient.api,
confirm,
props.track.sourceFileId,
t,
),
],
]}
/>
);
@@ -18,27 +18,27 @@
import { useConfirm } from "material-ui-confirm";
import { useTranslation } from "react-i18next";
import API from "../../api/api";
import { useQueryClient } from "../../api/use-query";
import { usePlayerContext } from "../../contexts/player";
import type { VideoWithRelations } from "../../models/video";
import type Action from "../actions/action";
import { DownloadAction } from "../actions/download";
import { useQueryClient } from "~/api/hook";
import { getArtist, getRelease } from "~/api/queries";
import type Action from "~/components/actions";
import { DownloadAction } from "~/components/actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongInfoAction,
GoToSongLyricsAction,
} from "../actions/link";
} from "~/components/actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import { ChangeVideoType } from "../actions/resource-type";
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import ContextualMenu from "./contextual-menu";
} from "~/components/actions/playlist";
import { RefreshTrackMetadataAction } from "~/components/actions/refresh-metadata";
import { ChangeVideoType } from "~/components/actions/resource-type";
import { ShowTrackFileInfoAction } from "~/components/actions/show-track-info";
import { UpdateTrackIllustrationAction } from "~/components/actions/update-illustration";
import type { VideoWithRelations } from "~/models/video";
import { ContextualMenu } from "..";
type VideoContextualMenuProps = {
video: VideoWithRelations<"master" | "illustration">;
@@ -50,10 +50,10 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
const confirm = useConfirm();
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
queryClient.fetchQuery(getArtist(props.video.artistId)),
props.video.master.releaseId &&
queryClient.fetchQuery(
API.getRelease(props.video.master.releaseId),
getRelease(props.video.master.releaseId),
),
]).then(([artist, release]) => ({
track: {
@@ -64,7 +64,6 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
@@ -77,11 +76,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
: undefined,
].filter((a): a is Action => a !== undefined),
props.video.songId
? [GoToSongLyricsAction(props.video.songId)]
? [
GoToSongLyricsAction(props.video.songId),
GoToSongInfoAction(props.video.songId),
]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
PlayNextAction(getPlayNextProps),
PlayAfterAction(getPlayNextProps),
],
props.video.songId
? [AddToPlaylistAction(props.video.songId, queryClient)]
@@ -95,7 +97,14 @@ const VideoContextualMenu = (props: VideoContextualMenuProps) => {
RefreshTrackMetadataAction(props.video.master.id, t),
],
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
[
DownloadAction(
queryClient.api,
confirm,
props.video.master.sourceFileId,
t,
),
],
]}
/>
);
-375
View File
@@ -1,375 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Button,
ButtonGroup,
Dialog,
Grid,
Tooltip,
useTheme,
} from "@mui/material";
import type { NextRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
import API from "../../api/api";
import { prepareMeeloInfiniteQuery } from "../../api/use-query";
import type { TranslationKey } from "../../i18n/i18n";
import globalLibrary from "../../utils/global-library";
import { type LayoutOption, getLayoutParams } from "../../utils/layout";
import parseQueryParam from "../../utils/parse-query-param";
import { type Order, getOrderParams } from "../../utils/sorting";
import type Action from "../actions/action";
import Fade from "../fade";
import { AscIcon, DescIcon, GridIcon, ListIcon } from "../icons";
import type Option from "./option";
import OptionButton from "./option-button";
export type Toggle = {
name: string;
label: TranslationKey;
defaultValue?: boolean;
};
export type OptionState<
SortingKeys extends readonly string[],
AdditionalProps extends object = object,
> = {
view: LayoutOption;
order: Order;
sortBy: SortingKeys[number];
library: string | null;
} & AdditionalProps;
type ControllerProps<
SortingKeys extends readonly string[],
Options extends Option<Values[number]>[],
Values extends string[][],
> = {
options?: Options;
actions?: Action[];
toggles?: Toggle[];
disableSorting?: true;
sortingKeys: SortingKeys;
defaultSortingKey?: SortingKeys[number];
defaultSortingOrder?: Order;
defaultLayout: LayoutOption;
disableLayoutToggle?: boolean;
onChange: (state: OptionState<SortingKeys>) => void;
/**
* If defined, will push changes as query parameters in router
*/
router?: NextRouter;
disableLibrarySelector?: true;
};
const Controls = <
SortingKeys extends readonly string[],
Options extends Option<Values[number]>[],
Values extends string[][],
>(
props: ControllerProps<SortingKeys, Options, Values>,
) => {
const _theme = useTheme();
const { t } = useTranslation();
const librariesQuery = useReactInfiniteQuery({
...prepareMeeloInfiniteQuery(API.getLibraries),
useErrorBoundary: false,
onError: () => {
toast.error(t("librariesLoadFail"));
},
});
const libraries = useMemo(
() => librariesQuery.data?.pages.at(0)?.items ?? null,
[librariesQuery.data],
);
const [optionsState, setOptionState] = useState<OptionState<SortingKeys>>(
() => {
const libraryQuery = Array.isArray(props.router?.query.library)
? props.router?.query.library?.at(0)
: props.router?.query.library;
const baseOptions: OptionState<SortingKeys> = {
view:
((props.disableLayoutToggle !== true &&
getLayoutParams(props.router?.query.view)) ||
undefined) ??
props.defaultLayout,
sortBy:
parseQueryParam(
props.router?.query.sortBy,
props.sortingKeys,
) ??
props.defaultSortingKey ??
props.sortingKeys[0],
order:
getOrderParams(props.router?.query.order) ??
props.defaultSortingOrder ??
"asc",
library: props.disableLibrarySelector
? null
: (libraryQuery ?? null),
};
for (const option of props.options ?? []) {
// @ts-ignore
baseOptions[option.name] =
parseQueryParam(
props.router?.query[option.name],
option.values,
) ?? option.values[0];
}
for (const option of props.toggles ?? []) {
// @ts-ignore
baseOptions[option.name] =
(parseQueryParam(props.router?.query[option.name], [
"false",
"true",
]) ?? "false") === "true";
}
return baseOptions;
},
);
const [openActionModal, setOpenActionModal] = useState<string | null>(null);
const closeModal = () => setOpenActionModal(null);
/**
* To pass the options to parent after initialization
*/
useEffect(() => {
props.onChange(optionsState);
}, [optionsState]);
const updateOptionState = ({
name,
value,
}: {
name: string;
value: boolean | string | null;
}) => {
setOptionState({ ...optionsState, [name]: value });
value = value?.toString() ?? null;
if (props.router) {
const path = props.router.asPath.split("?")[0];
const params = new URLSearchParams(
props.router.asPath.split("?").at(1) ?? "",
);
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
props.router.push(`${path}?${params.toString()}`, undefined, {
shallow: true,
});
}
};
if (libraries === null) {
return <></>;
}
return (
<Fade in>
<Grid
container
gap={1}
sx={{
zIndex: 1000,
width: "100%",
display: "flex",
justifyContent: "center",
marginBottom: 2,
position: "sticky",
top: 16,
left: 0,
}}
>
<Grid item>
<ButtonGroup variant="contained">
{props.disableLibrarySelector !== true && (
<OptionButton
optionGroup={{
name:
optionsState.library ??
t("allLibraries"),
options: [
{
name: "library",
values: [
globalLibrary,
...libraries,
].map((library) => library.name),
currentValue:
[
globalLibrary,
...libraries,
].find(
({ slug }) =>
slug ===
optionsState.library,
)?.name ?? globalLibrary.name,
},
],
}}
onSelect={({ name, value }) => {
if (value === globalLibrary.name) {
updateOptionState({
name,
value: null,
});
} else {
updateOptionState({
name,
value:
libraries.find(
(lib) => lib.name === value,
)?.slug ?? null,
});
}
}}
/>
)}
{!props.disableSorting && (
<OptionButton
optionGroup={{
name: `${t("sortBy")} ${t(
optionsState.sortBy as TranslationKey,
)}`,
icon:
optionsState.order === "desc" ? (
<DescIcon />
) : (
<AscIcon />
),
options: [
{
name: "sortBy",
values: props.sortingKeys,
currentValue: optionsState.sortBy,
},
{
name: "order",
values: ["asc", "desc"],
currentValue: optionsState.order,
},
],
}}
onSelect={updateOptionState}
/>
)}
{props.options?.map((option) => {
return (
<OptionButton
key={option.name}
optionGroup={{
name: option.label ?? option.name,
icon: option.icon,
options: [
{
...option,
currentValue:
optionsState[
option.name as keyof typeof optionsState
] ?? undefined,
},
],
}}
onSelect={updateOptionState}
/>
);
})}
{props.toggles?.map((toggle) => (
<Button
key={toggle.name}
onClick={() => {
updateOptionState({
name: toggle.name,
value:
!optionsState[
toggle.name as keyof OptionState<SortingKeys>
],
});
}}
>
{t(toggle.label)}
</Button>
))}
{props.disableLayoutToggle !== true && (
<Tooltip title={t("changeLayout")}>
<Button
onClick={() =>
updateOptionState({
name: "view",
value:
optionsState.view === "grid"
? "list"
: "grid",
})
}
>
{optionsState.view === "grid" ? (
<ListIcon />
) : (
<GridIcon />
)}
</Button>
</Tooltip>
)}
</ButtonGroup>
</Grid>
<Grid item>
<ButtonGroup variant="contained">
{props.actions?.map((action) => (
<Button
key={`action-${action.label}`}
startIcon={action.icon}
variant="contained"
onClickCapture={() => {
if (action.disabled === true) {
return;
}
action.onClick?.();
action.dialog &&
setOpenActionModal(action.label);
}}
>
{t(action.label)}
{action.dialog && (
<Dialog
open={openActionModal === action.label}
onClose={closeModal}
fullWidth
>
{action.dialog({ close: closeModal })}
</Dialog>
)}
</Button>
)) ?? []}
</ButtonGroup>
</Grid>
</Grid>
</Fade>
);
};
export default Controls;
@@ -1,99 +0,0 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Divider, ListItemIcon, Menu, MenuItem } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { TranslationKey } from "../../i18n/i18n";
import { CheckIcon } from "../icons";
import type Option from "./option";
import type { OptionGroup } from "./option";
type OptionButtonProps<
Options extends Option<OptionsKeys[number]>[],
OptionsKeys extends (readonly string[])[],
> = {
optionGroup: OptionGroup<Options, OptionsKeys>;
onSelect?: (selected: {
name: Options[number]["name"];
value: OptionsKeys[number][number];
}) => void;
};
/**
* @param props an option group
* @returns Dropdown Button with selector.
*/
const OptionButton = <
Options extends Option<OptionsKeys[number]>[],
OptionsKeys extends (readonly string[])[],
>(
props: OptionButtonProps<Options, OptionsKeys>,
) => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(anchorEl);
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
setAnchorEl(event.currentTarget);
const handleMenuClose = () => setAnchorEl(null);
return (
<>
<Button onClick={handleMenuOpen} endIcon={props.optionGroup.icon}>
{t(props.optionGroup.name as TranslationKey)}
</Button>
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
{props.optionGroup.options.map((option, index, array) =>
option.values
.map((value: OptionsKeys[number][number]) => (
<MenuItem
key={value}
sx={{ borderRadius: "0" }}
selected={option.currentValue === value}
onClick={() => {
if (props.onSelect) {
props.onSelect({
name: option.name,
value: value,
});
}
handleMenuClose();
}}
>
<ListItemIcon>
{option.currentValue === value && (
<CheckIcon />
)}
</ListItemIcon>
{/* If value is not a Translation Key, will fall through */}
{t(value as TranslationKey)}
</MenuItem>
))
.concat(
index + 1 !== array.length
? [<Divider key={index} />]
: [],
),
)}
</Menu>
</>
);
};
export default OptionButton;
+63
View File
@@ -0,0 +1,63 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Button, Grid, Stack, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { TranslationKey } from "~/i18n/i18n";
import type Action from "./actions";
export type EmptyStateProps = {
icon: React.ReactNode;
text: TranslationKey;
actions: Action[];
};
export const EmptyState = (props: EmptyStateProps) => {
const { t } = useTranslation();
return (
<Stack
direction="column"
spacing={3}
sx={{
paddingTop: 3,
width: "100%",
height: "100%",
justifyContent: "center",
display: "flex",
alignItems: "center",
color: "text.disabled",
}}
>
<Box>{props.icon}</Box>
<Typography>{t(props.text)}</Typography>
<Grid container sx={{ display: "flex", justifyContent: "center" }}>
{props.actions?.map((a, i) => (
<Button
variant="outlined"
key={i}
href={a.href}
onClick={a.onClick}
>
{t(a.label)}
</Button>
))}
</Grid>
</Stack>
);
};
+2 -2
View File
@@ -42,7 +42,7 @@ const ErrorPage = ({ heading }: ErrorPageProps) => {
minHeight="100vh"
flexDirection="column"
>
<Head title={t("oops")} />
<Head title={t("errors.oops")} />
<Typography
variant="h1"
sx={{
@@ -56,7 +56,7 @@ const ErrorPage = ({ heading }: ErrorPageProps) => {
</Typography>
<Link href="/" onClick={() => resetBoundary()}>
<Button color="inherit" variant="outlined">
{t("goBackHome")}
{t("actions.goBackHome")}
</Button>
</Link>
</Box>
@@ -18,7 +18,8 @@
import { Box, Button, Skeleton } from "@mui/material";
import Link from "next/link";
import type { ExternalMetadataSource } from "../models/external-metadata";
import type { ExternalMetadataSource } from "~/models/external-metadata";
import { ProviderIcon } from "./icons";
import Illustration from "./illustration";
type ExternalMetadataBadgeProps = {
@@ -34,6 +35,7 @@ const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
<Illustration
url={source?.providerIcon ?? undefined}
quality="original"
fallback={<ProviderIcon />}
/>
</Box>
}
+1 -1
View File
@@ -18,7 +18,7 @@
import { Fade as MUIFade } from "@mui/material";
import type { ComponentProps } from "react";
import { isClientSideRendering } from "../utils/is-ssr";
import { isClientSideRendering } from "~/utils/is-ssr";
/**
* Wrapper around MUI's Fade.
* Fade is the main animation used in the app.
+1 -1
View File
@@ -19,7 +19,7 @@
import { Chip, Skeleton } from "@mui/material";
import Link from "next/link";
import type { ComponentProps } from "react";
import type Genre from "../models/genre";
import type Genre from "~/models/genre";
type GenreButtonProps = {
genre: Pick<Genre, "name" | "slug"> | undefined;
+1 -1
View File
@@ -17,7 +17,7 @@
*/
import NextHead from "next/head";
import { DefaultWindowTitle } from "../utils/constants";
import { DefaultWindowTitle } from "~/utils/constants";
export const Head = ({
title,
@@ -16,12 +16,20 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Chip, Grid, Skeleton, Typography, useTheme } from "@mui/material";
import {
Box,
Chip,
Grid,
Skeleton,
Stack,
Typography,
useTheme,
} from "@mui/material";
import Link from "next/link";
import { useMemo } from "react";
import type IllustrationModel from "../../models/illustration";
import { useAccentColor } from "../../utils/accent-color";
import Illustration from "../illustration";
import Illustration from "~/components/illustration";
import type IllustrationModel from "~/models/illustration";
import { useAccentColor } from "~/utils/accent-color";
type HighlightCardProps = {
title: string | undefined;
@@ -87,74 +95,74 @@ const HighlightCard = (props: HighlightCardProps) => {
}}
style={{ borderRadius: theme.shape.borderRadius }}
>
<Grid item sx={{ aspectRatio: "1", height: "100%" }}>
<Grid sx={{ aspectRatio: "1", height: "100%" }}>
<Illustration
illustration={props.illustration}
imgProps={{ borderRadius: 0 }}
quality="medium"
/>
</Grid>
<Grid
item
container
sx={{ width: "100%", overflow: "hidden" }}
direction="column"
padding={2}
>
<Grid item sx={{ width: "100%" }}>
<Typography
variant="h6"
noWrap
style={{
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
paddingRight: 1,
}}
>
{props.headline ?? <Skeleton variant="text" />}
</Typography>
</Grid>
<Grid
item
xs
<Grid container size="grow" sx={{ height: "100%" }}>
<Stack
sx={{
overflowY: "scroll",
overflowX: "clip",
marginRight: -2,
paddingY: 1,
paddingRight: 2,
...scrollbarColorStyle,
width: "100%",
height: "100%",
padding: 2,
}}
>
<Typography
variant="body1"
color="text.disabled"
lineHeight={1.5}
<Box sx={{ width: "100%" }}>
<Typography
variant="h6"
noWrap
style={{
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
paddingRight: 1,
}}
>
{props.headline ?? <Skeleton variant="text" />}
</Typography>
</Box>
<Box
sx={{
overflowY: "scroll",
overflowX: "clip",
flexGrow: 1,
marginRight: -2,
paddingY: 1,
paddingRight: 2,
...scrollbarColorStyle,
}}
>
{props.body ?? <Skeleton variant="text" />}
</Typography>
</Grid>
{props.tags.length > 0 && (
<Grid
item
container
columnSpacing={1}
sx={{ height: 32, overflow: "hidden" }}
>
{props.tags.map((tag, index) => (
<Grid key={index} item>
<Link href={tag.href} key={index}>
<Chip
variant="filled"
clickable
label={tag.label}
/>
</Link>
</Grid>
))}
</Grid>
)}
<Typography
variant="body1"
color="text.disabled"
lineHeight={1.5}
>
{props.body ?? <Skeleton variant="text" />}
</Typography>
</Box>
{props.tags.length > 0 && (
<Grid
container
spacing={1}
sx={{ marginTop: 1, height: 24 }}
>
{props.tags.map((tag, index) => (
<Grid key={index}>
<Link href={tag.href} key={index}>
<Chip
variant="filled"
clickable
label={tag.label}
/>
</Link>
</Grid>
))}
</Grid>
)}
</Stack>
</Grid>
</Grid>
</Link>
@@ -17,10 +17,10 @@
*/
import { useTranslation } from "react-i18next";
import type { AlbumWithRelations } from "../../models/album";
import type { AlbumExternalMetadata } from "../../models/external-metadata";
import getYear from "../../utils/getYear";
import HighlightCard from "./highlight-card";
import HighlightCard from "~/components/highlight-card";
import type { AlbumWithRelations } from "~/models/album";
import type { AlbumExternalMetadata } from "~/models/external-metadata";
import { getYear } from "~/utils/date";
type AlbumHighlightCardProps = {
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
@@ -39,7 +39,7 @@ const AlbumHighlightCard = ({
externalMetadata?.description ||
[
album
? (album.artist?.name ?? t("compilation"))
? (album.artist?.name ?? t("compilationArtistLabel"))
: undefined,
album ? getYear(album.releaseDate) : undefined,
]
+28 -3
View File
@@ -18,7 +18,7 @@
import * as Iconsax from "iconsax-react";
type IconProps = Iconsax.IconProps;
export type IconProps = Iconsax.IconProps;
export const TranscodeIcon = Iconsax.Polyswarm;
export const PlaylistIcon = Iconsax.MusicFilter;
@@ -27,7 +27,10 @@ export const ReleaseIcon = Iconsax.Cd;
export const AlbumIcon = Iconsax.MusicDashboard;
export const ArtistIcon = Iconsax.Profile2User;
export const SongIcon = Iconsax.Musicnote;
export const GenreIcon = Iconsax.Speaker;
export const RadioIcon = Iconsax.Radio;
export const PlayerIcon = Iconsax.PlayCircle;
export const CompilationIcon = Iconsax.Category;
export const TrackIcon = Iconsax.Musicnote;
export const LyricsIcon = Iconsax.MessageText;
export const VideoIcon = Iconsax.VideoSquare;
@@ -36,6 +39,7 @@ export const CloseIcon = Iconsax.CloseCircle;
export const EditIcon = Iconsax.Edit2;
export const LogoutIcon = Iconsax.Logout;
export const DarkMode = Iconsax.Sun1;
export const ProviderIcon = Iconsax.Global;
export const LightMode = (props: IconProps) => (
<Iconsax.Sun1 variant="Bold" {...props} />
);
@@ -48,7 +52,9 @@ export const BurgerIcon = Iconsax.HambergerMenu;
export const AddIcon = Iconsax.AddCircle;
export const DeleteIcon = Iconsax.Trash;
export const ShuffleIcon = Iconsax.Shuffle;
export const InfoIcon = Iconsax.InfoCircle;
export const InfoIcon = (props: IconProps) => (
<Iconsax.InfoCircle style={{ rotate: "180deg" }} {...props} />
);
export const AddToPlaylistIcon = Iconsax.MusicSquareAdd;
export const AddItemToPlaylistIcon = Iconsax.MusicSquareAdd;
export const PlayNextIcon = Iconsax.MusicSquareAdd;
@@ -81,5 +87,24 @@ export const PauseIcon = Iconsax.Pause;
export const RewindIcon = Iconsax.Previous;
export const ForwardIcon = Iconsax.Next;
export const FullscreenIcon = Iconsax.Maximize4;
export const PlusIcon = Iconsax.Add;
export const MinusIcon = Iconsax.Minus;
export const DoneIcon = Iconsax.TickSquare;
export const EmptyStateIcon = Iconsax.SearchStatus1;
export const MergeIcon = Iconsax.Hierarchy;
export const OpenExternalIcon = Iconsax.ExportCircle;
// Icons for song type
export const SongTypeOriginalIcon = Iconsax.Music;
export const SongTypeRemixIcon = Iconsax.Repeat;
export const SongTypeLiveIcon = Iconsax.Microphone2;
export const SongTypeAcousticIcon = Iconsax.VoiceSquare;
export const SongTypeInstrumentalIcon = Iconsax.Musicnote;
export const SongTypeEditIcon = Iconsax.Cut;
export const SongTypeCleanIcon = Iconsax.TickCircle;
export const SongTypeDemoIcon = Iconsax.ReceiptEdit;
export const SongTypeAcapellaIcon = Iconsax.VoiceCricle;
export const SongTypeMedleyIcon = Iconsax.Layer;
export const SongTypeNonMusicIcon = Iconsax.VolumeSlash;
+43 -32
View File
@@ -20,14 +20,11 @@ import { Box, IconButton, Skeleton, useTheme } from "@mui/material";
import Image, { type ImageProps } from "next/image";
import { useState } from "react";
import type { RequireExactlyOne } from "type-fest";
import blackIllustrationFallback from "../../public/icon-black.png";
import whiteIllustrationFallback from "../../public/icon.png";
import API from "../api/api";
import type IllustrationModel from "../models/illustration";
import { isSSR } from "../utils/is-ssr";
import { useAPI } from "~/api/hook";
import type IllustrationModel from "~/models/illustration";
import { isSSR } from "~/utils/is-ssr";
import Blurhash from "./blurhash";
import Fade from "./fade";
import ThemedImage from "./themed-image";
type ImageQuality = "low" | "medium" | "high" | "original";
@@ -61,6 +58,7 @@ type IllustrationProps = {
const Illustration = (props: IllustrationProps) => {
const theme = useTheme();
const api = useAPI();
const [loadingState, setLoadingState] = useState<
"loading" | "errored" | "finished"
>(isSSR() ? "finished" : "loading");
@@ -152,36 +150,49 @@ const Illustration = (props: IllustrationProps) => {
}}
>
{loadingState === "errored" || url === null ? (
props.fallback ? (
<Box
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Skeleton
variant="rounded"
animation={false}
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
disabled
sx={{ fontSize: "large" }}
component="div"
>
{props.fallback}
</IconButton>
</Box>
) : (
<ThemedImage
dark={whiteIllustrationFallback}
light={blackIllustrationFallback}
fill
alt="missing-illustration"
loading="eager"
style={{
padding: "15%",
aspectRatio:
props.aspectRatio?.toString() ?? "1",
}}
/>
)
{props.fallback && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<IconButton
disabled
sx={{ fontSize: "large" }}
component="div"
>
{props.fallback}
</IconButton>
</Box>
)}
</Box>
) : (
<Image
onError={() => setLoadingState("errored")}
@@ -205,7 +216,7 @@ const Illustration = (props: IllustrationProps) => {
...props.imgProps,
}}
src={
API.getIllustrationURL(url) +
api.getIllustrationURL(url) +
(props.quality === "original"
? ""
: `${
@@ -0,0 +1,325 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
Button,
ButtonGroup,
Dialog,
Grid,
ListItemIcon,
Menu,
MenuItem,
ThemeProvider,
Tooltip,
useTheme,
} from "@mui/material";
import { deepmerge } from "@mui/utils";
import { Fragment, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type Action from "~/components/actions";
import {
AscIcon,
CheckIcon,
DescIcon,
GridIcon,
ListIcon,
MinusIcon,
PlusIcon,
} from "~/components/icons";
import type { TranslationKey } from "~/i18n/i18n";
import { ItemSize } from "~/utils/layout";
import type { FilterControl } from "./filters/control";
import type { LayoutControl } from "./layout";
import type { SortControl } from "./sort";
// Controls should not maintain state regarding options
// It should rely on the onUpdate function to update external state
export const Controls = <SortingKey extends string>(props: {
layout?: LayoutControl;
sort?: SortControl<SortingKey>;
actions?: Action[][];
filters?: FilterControl<any>[]; // Putting string prevent from typechecking if filter is library + song type
}) => {
return (
<Grid
container
gap={1}
sx={{
zIndex: 1000,
width: "100%",
display: "flex",
justifyContent: "center",
marginBottom: 2,
position: "sticky",
top: 16,
left: 0,
}}
>
{props.filters && (
<Grid>
<ButtonGroup variant="contained">
{props.filters?.map((filter, idx) => (
<FilterMenuButton key={idx} filter={filter} />
))}
</ButtonGroup>
</Grid>
)}
{props.sort && (
<Grid>
<ButtonGroup variant="contained">
<SortMenuButton sort={props.sort} />
</ButtonGroup>
</Grid>
)}
{props.layout && (
<Grid>
<LayoutButtonGroup layout={props.layout} />
</Grid>
)}
{props.actions?.map((actions, idx) => (
<Grid key={idx}>
<ActionButtonGroup actions={actions} />
</Grid>
))}
</Grid>
);
};
const FilterMenuButton = <FilterKeys extends string>({
filter,
}: { filter: FilterControl<FilterKeys> }) => {
const { t } = useTranslation();
return (
<MenuButton
label={
filter.values === undefined
? "misc.loading"
: filter.buttonLabel
}
icon={filter.values && filter.buttonIcon}
items={(closeMenu) =>
filter.values?.map((key) => (
<MenuItem
key={key}
selected={
filter.multipleChoice
? filter.selected?.includes(key)
: filter.selected === key
}
onClick={() => {
if (filter.multipleChoice) {
const selectedKeys = filter.selected.includes(
key,
)
? filter.selected.filter((k) => k !== key)
: [key, ...filter.selected];
filter.onUpdate(selectedKeys);
} else {
filter.onUpdate(
filter.selected === key ? null : key,
);
}
// TODO Not close at every select
closeMenu();
}}
>
<ListItemIcon>
{(filter.multipleChoice &&
filter.selected.includes(key)) ||
filter.selected === key ? (
<CheckIcon />
) : undefined}
</ListItemIcon>
{t(filter.formatItem(key))}
</MenuItem>
)) ?? []
}
/>
);
};
const SortMenuButton = <SortingKey extends string>({
sort,
}: { sort: SortControl<SortingKey> }) => {
const { t } = useTranslation();
return (
<MenuButton
label={sort.buttonLabel}
icon={sort.selected.order === "asc" ? <AscIcon /> : <DescIcon />}
items={(closeMenu) =>
sort.sortingKeys.map((key) => (
<MenuItem
key={key}
selected={sort.selected.sort === key}
onClick={() => {
sort?.onUpdate({
sort: key,
order:
sort.selected.sort !== key
? "asc"
: sort.selected.order === "asc"
? "desc"
: "asc",
});
closeMenu();
}}
>
<ListItemIcon>
{sort?.selected.sort === key ? (
sort.selected.order === "asc" ? (
<AscIcon />
) : (
<DescIcon />
)
) : undefined}
</ListItemIcon>
{t(sort!.formatItem(key))}
</MenuItem>
)) ?? []
}
/>
);
};
const LayoutButtonGroup = ({ layout }: { layout: LayoutControl }) => {
const { t } = useTranslation();
return (
<ButtonGroup variant="contained">
{layout.layout === "grid" &&
[
[
"xs",
(currentIndex: number) => currentIndex - 1,
MinusIcon,
] as const,
[
"xl",
(currentIndex: number) => currentIndex + 1,
PlusIcon,
] as const,
].map(([size, f, Icon]) => (
<Button
key={`size-button-${size}`}
disabled={layout.itemSize === size}
onClick={() =>
layout.onUpdate({
layout: layout.layout,
itemSize:
ItemSize[
f(ItemSize.indexOf(layout.itemSize))
],
})
}
>
<Icon />
</Button>
))}
{layout.enableToggle && (
<Tooltip title={t("browsing.controls.changeLayout")}>
<Button
onClick={() =>
layout.onUpdate({
itemSize: layout.itemSize,
layout:
layout.layout === "grid" ? "list" : "grid",
})
}
>
{layout.layout === "grid" ? <ListIcon /> : <GridIcon />}
</Button>
</Tooltip>
)}
</ButtonGroup>
);
};
const ActionButtonGroup = ({ actions }: { actions: Action[] }) => {
const { t } = useTranslation();
const [openedDialogIndex, setOpenedDialogIndex] = useState<number | null>(
null,
);
const closeModal = () => setOpenedDialogIndex(null);
return (
<ButtonGroup variant="contained">
{actions.map((action, idx) => (
<Fragment key={`action-${action.label}`}>
<Button
key={`action-${action.label}`}
startIcon={action.icon}
variant="contained"
onClickCapture={() => {
if (action.disabled === true) {
return;
}
action.onClick?.();
action.dialog && setOpenedDialogIndex(idx);
}}
>
{t(action.label)}
</Button>
{action.dialog && (
<Dialog
key={idx}
open={openedDialogIndex === idx}
onClose={closeModal}
fullWidth
>
{action.dialog?.({ close: closeModal })}
</Dialog>
)}
</Fragment>
))}
</ButtonGroup>
);
};
const MenuButton = (props: {
label: TranslationKey;
icon: JSX.Element | undefined;
items: (closeMenu: () => void) => JSX.Element | JSX.Element[];
}) => {
const { t } = useTranslation();
const theme = useTheme();
const extendedTheme = useMemo(
() =>
deepmerge(theme, {
components: {
MuiMenuItem: { defaultProps: { sx: { borderRadius: 0 } } },
},
}),
[theme],
);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(anchorEl);
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
setAnchorEl(event.currentTarget);
const handleMenuClose = () => setAnchorEl(null);
// TODO Remove border radius or menu items
return (
<ThemeProvider theme={extendedTheme}>
<Button onClick={handleMenuOpen} endIcon={props.icon}>
{t(props.label as TranslationKey)}
</Button>
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
{props.items(handleMenuClose)}
</Menu>
</ThemeProvider>
);
};
@@ -0,0 +1,130 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useRouter } from "next/router";
import { useState } from "react";
import type { TranslationKey } from "~/i18n/i18n";
import { parseQueryParam, setQueryParam } from "~/utils/query-param";
export type FilterControl<Key extends string> = {
// Gives the translation key from an item to choose from
formatItem: (k: Key) => TranslationKey;
values: readonly Key[] | undefined;
buttonLabel: TranslationKey;
buttonIcon: JSX.Element | undefined;
} & (
| {
multipleChoice: true;
selected: Key[];
onUpdate: (keys: Key[]) => void;
}
| {
multipleChoice: false;
selected: Key | null;
onUpdate: (key: Key | null) => void;
}
);
// Control for filter that accept multiple values
export const useFiltersControl = <FilterKey extends string>({
filterKeys,
filterId,
buttonLabel,
buttonIcon,
formatItem,
}: {
buttonLabel: (
selected: FilterKey[],
) => FilterControl<FilterKey>["buttonLabel"];
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
formatItem: FilterControl<FilterKey>["formatItem"];
filterKeys: readonly FilterKey[] | undefined;
// Used to persist Filter in query param, must be unique
filterId: string;
}) => {
const router = useRouter();
const [selectedFilters, setFilterState] = useState(() => {
let query = router.query[filterId];
if (!filterKeys || query === undefined) {
return [];
}
if (typeof query === "string") {
query = query.trim().split(",");
}
return query
.map((f) => parseQueryParam(f, filterKeys))
.filter((f): f is FilterKey => f !== null);
});
const control: FilterControl<FilterKey> = {
values: filterKeys,
buttonIcon,
buttonLabel: buttonLabel(selectedFilters),
multipleChoice: true,
selected: selectedFilters,
formatItem: (t: FilterKey) => formatItem(t),
onUpdate: (selected) => {
setQueryParam(
[[filterId, selected.length ? selected.join(",") : null]],
router,
);
setFilterState(selected);
},
};
return [selectedFilters, control] as const;
};
export const useFilterControl = <FilterKey extends string>({
filterKeys,
filterId,
buttonLabel,
buttonIcon,
formatItem,
}: {
buttonLabel: (
selected: FilterKey | null,
) => FilterControl<FilterKey>["buttonLabel"];
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
formatItem: FilterControl<FilterKey>["formatItem"];
filterKeys: readonly FilterKey[] | undefined;
filterId: string;
}) => {
const router = useRouter();
const [selectedFilter, setSelectedFilter] = useState(() => {
let query = router.query[filterId];
if (!filterKeys || query === undefined) {
return null;
}
if (typeof query !== "string") {
query = query[0];
}
return parseQueryParam(query, filterKeys);
});
const control: FilterControl<FilterKey> = {
values: filterKeys,
buttonIcon,
buttonLabel: buttonLabel(selectedFilter),
multipleChoice: false,
selected: selectedFilter,
formatItem: (t: FilterKey) => formatItem(t),
onUpdate: (selected) => {
setQueryParam([[filterId, selected ?? null]], router);
setSelectedFilter(selected);
},
};
return [selectedFilter, control] as const;
};
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff. Show more