Compare commits

...
296 Commits
Author SHA1 Message Date
Arthur Jamet 9d12a9ae2c Merge pull request #753 from Arthi-chaud/v3
v3.0.0
2025-01-10 20:07:18 +00:00
Arthur Jamet 2596726088 Server: Track Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 79d5aaf5dd Server: Album Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet c757027286 Server: Artist Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 85ce3fbbf4 Server: Genre Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 7c65d8f113 Server: Release Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet a2a283e146 Server: Song Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 24c5c3aead Server: Video Service: allow getting many by slug or id + allow deleting many 2025-01-10 17:57:48 +00:00
Arthur Jamet 3dddcf4c36 Matcher: Musicbrainz: Search Album: Better Handling for compilation albums 2025-01-10 15:44:19 +00:00
Arthur Jamet ec64e16553 Matcher: Musicbrainz: Search Album: handle 'Remixes' suffix 2025-01-10 15:44:19 +00:00
Arthur Jamet ff31958623 Matcher: Musicbrainz: Search Album: Sanitize acronyms 2025-01-10 15:44:19 +00:00
Arthur Jamet e31c7f49b6 Server: Search Module: Define function to handle search-related union type 2025-01-08 18:36:20 +00:00
Arthur Jamet 16cc16830c Server: Registration: Prevent Race condition when featuring artist is mentioned multiple times 2025-01-08 18:25:19 +00:00
Arthur Jamet fbbc2fbae5 API Cleanup (#821) 2025-01-08 17:26:05 +00:00
Arthur Jamet 3d6f60e850 Front: Player Control: Handle case when track is standalon + not related to a song 2025-01-08 14:44:42 +00:00
Arthur Jamet d788a5f704 Matcher: Artist is optional in Album domain model 2025-01-08 12:01:53 +00:00
Arthur Jamet affd84272c Matcher: Musicbrainz: Identify Live Albums 2025-01-08 11:48:36 +00:00
Arthur Jamet 634b0f2de4 Rewrite Videos (#818)
* Prisma: Define Video Model

* Server: Artist + Song Housekeeping: Consider Videos before deleting

* Server: Parse Video Type

* Server: Parse Video Extensions

* Server: B-Roll videos are behind-the-scene videos

* Prisma: Add registration date to video model

* Server: Video Service: Get Many

* Server: Video Controller: Selector

* Server: Video Response Builder

* Front: New Video Model

* Server: Video API: Include Featurings

* Server: Fix houseleeping order between song and videos

* Server: Fix linking song to track depending on video type

* Server: Allow including video for tracks

* Front: Use New Video Model

* Server: If Video, Set Track title as video name, not parsed song

* Prisma: Rename video table + add master track field to video

* Server: Allow setting a video track as master

* Server: API: Get Video Master Track

* Server: Make API for Video more standard

* Front: Use New, Updated Video Model

* Server: Fix bug where track could have no song nor track

* Front: Release-Tracklist: Simplify typing

* Front: Song Page: Add Video tab

* API: Get Tracks by Video

* Server: If Video becomes extra, unlink from song

* README: Update feature list about music videos

* API: Videos Controller: Search

* Front: Infinite Video View can be list

* Front: Search Videos

* Server: Videos in 'search all'

* Server: Get Album's Videos: Consider videos from singles

* Front: Release Page: Order Video, moving secondary videos at the end

* Server: Fix detection of live videos

* Front: Release Page: Fix ordering of videos when song not on tracklist
2025-01-05 20:15:22 +00:00
Arthur Jamet 32a52ec102 Front: Player: Add link for standalone videos 2024-12-30 18:40:31 +01:00
Arthur Jamet 118484b766 Matcher: Do not fetch album genres if settings say so 2024-12-29 15:52:30 +01:00
Arthur Jamet 50647a3e86 Server: Parser Test: Refactor tests for splitgroups 2024-12-29 11:40:08 +01:00
Arthur Jamet 655728b657 Server: Parser Test: Refactor tests for extraction of track names 2024-12-29 11:40:08 +01:00
Arthur Jamet 79aee767cf Server: Parser Test: Refactor tests for extraction of release names 2024-12-29 11:40:08 +01:00
Arthur Jamet 2cd96e10dc Server: Parser Test: Refactor tests for song type detection 2024-12-29 11:40:08 +01:00
Arthur Jamet f05b51300c Server: Parser Test: Refactor tests for artist extraction in song names 2024-12-29 11:40:08 +01:00
Arthur Jamet 2fa3946af7 Server: Parser Test: Refactor tests for album type detection 2024-12-29 11:40:08 +01:00
Arthur Jamet b00badc0ca Server: Add 'Original Score' keyword to identify soundtrack albums 2024-12-28 10:51:27 +01:00
Arthur Jamet 95dc125712 Scanner: Remove black bars on thumbnails (#810)
* Scanner: Crop black bars on thumbnails

* Docker: Scanner: Move to alpine to get a newer ffmpeg version
2024-12-25 18:00:24 +01:00
Arthur Jamet 7fe4de658e Matcher: Musicbrainz: Use genres instead of tags for songs 2024-12-25 14:54:15 +01:00
Arthur Jamet b254796fc7 Scanner: Embedded Metadata: Use 'year' key to get release date 2024-12-22 12:15:42 +00:00
Arthur Jamet 556df14a87 Server: Fix detection of BSides when video from related Video Album 2024-12-22 11:51:19 +00:00
Arthur Jamet de2cfc18f7 Server: Remove Dead code about settings 2024-12-22 11:28:11 +00:00
Arthur Jamet 68229e5e0c Doc: Update Provider features 2024-12-22 11:04:12 +00:00
Arthur Jamet f02d3e3b6a Matcher: Use pika consumer for MQ events + set correct prefetch count for priority 2024-12-22 10:49:59 +00:00
Arthur Jamet e9b9577673 Server: Set Track as master on registration if previous master is video 2024-12-22 10:35:40 +00:00
Arthur Jamet 28f220c4cb Matcher: Musicbrainz: Support more formats for release dates 2024-12-22 10:28:03 +00:00
Arthur Jamet 3f200dcf3a Front: Remove 'Fetch Metadata' button in settings 2024-12-22 10:27:50 +00:00
Arthur Jamet db9150af9f Matcher: Push Genres only if settings is true 2024-12-22 10:12:47 +00:00
Arthur Jamet 5aaaa63a63 v3: Merge 2024-12-21 18:10:58 +00:00
Arthur Jamet 0f50129c21 Doc: settings.json: add regex for standalone tracks 2024-12-21 17:58:13 +00:00
Arthur Jamet a913482e18 Front: Fix loading state for artist external description 2024-12-21 17:43:15 +00:00
Arthur Jamet c86b8f0fb7 Scanner: Accept multiple format for dates from ffprobe 2024-12-21 17:26:50 +00:00
Arthur Jamet f95127a105 Docker Compose: Attach config volume to matcher container 2024-12-21 17:12:18 +00:00
Arthur Jamet 6f485b3099 Doc: Fix default regex in settings.json 2024-12-21 17:11:56 +00:00
Arthur Jamet a07b78e697 Scanner Dockerfile: Fix install of fpcalc 2024-12-21 17:11:40 +00:00
Arthur Jamet d11dc7308b Standalone Tracks (#808)
* Server: Thumbnails are independent from release illustrations

* Server: Tracks can have illustration without release

* Server: Track can have no release

* API: Registration: Allow Album/Release to be both null

* Scanner: Accept Album/Release to be null

* Migration: Fix join for standalon track illustrations

* Front: Update Models to support standalone tracks

* Server: Fix tests
2024-12-21 16:48:22 +00:00
Arthur Jamet 42ecd2d930 Server: Slug: Consider Ellipsis as word separator 2024-12-20 15:11:52 +00:00
Arthur Jamet 225babc1d0 Server: Fix Lint 2024-12-20 14:59:29 +00:00
Arthur Jamet be1503633a CI: Trigger on PR/Push on v3 branch 2024-12-20 14:59:29 +00:00
Arthur Jamet fc3fdcf19c Server: Rare Songs and BSides: Look into Video albums for audio tracks 2024-12-20 14:44:49 +00:00
Arthur Jamet 390a7fe2f4 Server: B-Sides: Avoid getting songs from older albums 2024-12-20 13:46:07 +00:00
Arthur Jamet dc4a5ce0da Scanner: Fix nullable fingerpint in DTO 2024-12-20 12:40:09 +00:00
Arthur Jamet d915a4dfdb Server: Define Enum for Event Priority 2024-12-20 11:41:28 +00:00
Arthur Jamet 7cbdca9439 Front: Release Page: Medley BSides in the extra section 2024-12-20 10:39:28 +00:00
Arthur Jamet d7b6feec2b Server: Getting BSides for a release includes Medleys 2024-12-20 10:39:28 +00:00
Arthur Jamet 0c05dfee29 Matcher: Match Songs with acoustid fingerprint if exists 2024-12-20 10:16:54 +00:00
Arthur Jamet 026eb05cd3 Server: Allow microservice to GET files 2024-12-20 10:16:54 +00:00
Arthur Jamet 02d5bc95e8 Server: Registration: Save AcoustId 2024-12-20 10:16:54 +00:00
Arthur Jamet ce4153a954 Scanner: Compute Fingerprint for AcoustID 2024-12-20 10:16:54 +00:00
Arthur Jamet 3aa66792c9 Matcher: Do not use Discogs for Artist description 2024-12-19 19:27:06 +00:00
Arthur Jamet 24222e3623 Docker: MQ: Fix persistence of messages by setting a nodename 2024-12-19 17:01:35 +00:00
Arthur Jamet a908d6756c Matcher: Fetch messages by hand to ensure priority is respected 2024-12-19 17:01:35 +00:00
Arthur Jamet 3479713970 Server: DELETE Library: await 2024-12-19 15:26:35 +00:00
Arthur Jamet 40b2a3b010 Merge pull request #791 from Arthi-chaud/external-metadata
Matcher: Fetch External Metadata
2024-12-19 15:03:27 +00:00
Arthur Jamet 1ef9026776 Server: Set Priority to MQ events 2024-12-19 14:55:01 +00:00
Arthur Jamet 19549ba923 Server: Reject External Metadata DTO when 2 sources from the same provider 2024-12-19 13:40:54 +00:00
Arthur Jamet 878579a0b8 Matcher: Catch Ctrl+C 2024-12-19 13:24:35 +00:00
Arthur Jamet d0603e0141 Matcher: Match Songs (#796)
* Matcher: Define Features for Songs

* Matcher: Setup relation between providers and wikidata for songs

* Matcher: Search Song with MB

* Matcher: Get Song Genres from MB

* Matcher: Get Song Lyrics and Description from Genius

* Matcher: Search Songs with Genius

* Matcher: Genius: Refactor tests using subtests

* Matcher: Discogs: Refactor tests using subtests

* Matcher: Musicbrainz: Refactor tests using subtests

* Matcher: Test Match Song

* Matcher: Define Song Model from API + POST Lyrics

* Server: POST Song Genres

* Matcher: POST Song Genres

* Matcher: Fix test on CI
2024-12-18 16:34:10 +00:00
Arthur Jamet 5089aa0c3f Matcher: Get Album type (#794)
* Matcher: Define feature to get album types

* Matcher: MusicBrainz: Get Album Type

* Matcher: Fix serialisation of enum
2024-12-15 16:25:04 +00:00
Arthur Jamet ab29710e99 Matcher: Lint 2024-12-15 14:23:12 +00:00
Arthur Jamet 0a05e20497 Server: Fix updating album date when its given by the matcher 2024-12-15 14:23:12 +00:00
Arthur Jamet 4bc542e360 Matcher: Do not POST release date if too far from the release date from API 2024-12-15 14:23:12 +00:00
Arthur Jamet b65590c1eb Matcher: Musicbrainz: Avoid None deref when album does not have a primary type 2024-12-15 14:23:12 +00:00
Arthur Jamet 3f176700bb Matcher: Sanitize search features when 'and' or '&' in artist name 2024-12-15 14:23:12 +00:00
Arthur Jamet dc9cbbd442 Matcher: Musicbrainz Provider: Silence Warning about JSON 2024-12-15 14:23:12 +00:00
Arthur Jamet 5dc0ed1623 Matcher: Discogs Provider: Try getting the most square image 2024-12-15 14:23:12 +00:00
Arthur Jamet a9ab63591a Matcher: Use new route to POST Artist Illustration 2024-12-14 20:06:46 +00:00
Arthur Jamet 135bb7d40d V3: Merge 2024-12-14 19:56:08 +00:00
Arthur Jamet 72559e7a8f API: Unique route to POST illustrations (#790)
* API: Rename endpoint to POST embedded illustration

* Server: Factor all routes to POST illustration using url

* Front: Use new route to POST illustrations
2024-12-14 19:46:48 +00:00
Arthur Jamet b042bdf9cb Front: Release Page: Order videos in order of tracklist 2024-12-13 20:43:58 +00:00
Arthur Jamet 0226ca166c Front: Add group id field in song model 2024-12-13 20:43:58 +00:00
Arthur Jamet 77abd9f3e6 Matcher: Provider 'features' (#786)
* Matcher: Define Feature types

* Matcher: Define Provider Boilerplate + Set features for metacritic

* Matcher: Set features for genius

* Matcher: Set features for allmusic and wikipedia

* Matcher: Set features for musicbrainz

* Matcher: Set features for discogs

* Matcher: Update types in Context

* Matcher: Use Features to know if provider is useful

* CI: Matcher: Bump Python version

* Matcher: Fix Lint
2024-12-13 19:38:16 +00:00
Arthur Jamet 26772e2b76 Server: Fix Spelling of 'Acappella' (#785)
* Server: Parser Service: Accept various spellings of acappella

* Server: Migration for new spelling of acappella

* Front: Use new spelling of acappella
2024-12-12 09:36:36 +00:00
Arthur Jamet 321f2b3d88 Matcher: Match albums (#784)
* Matcher: Bootstrap for matching albums

* Matcher: Common module for resource matchers

* Matcher: Flow for Matching albums

* Matcher: Match albums for Musicbrainz

* Matcher: Get Album Description with Wikidata

* Matcher: Search Album and release date With Genius

* Matcher: Get Album Rating with Allmusic

* Matcher: Use library for slugs

* Matcher: Get rating from metacritic

* Matcher: Unit Tests for Metacritic, Wikipedia and Allmusic

* Matcher: Unit Tests for Musicbrainz

* Matcher: Unit Tests for Genius

* Matcher: Unit Tests for Discogs

* Matcher: Fix Typing for External Metadata's description

* Matcher: Allow Microservices to GET albums

* Matcher: Fix Unserilisation of Albums from API

* Matcher: Fix search for Musicbrainz for non-singles

* Matcher: Fix Lint

* Matcher: Fix Tests for CI

* Matcher: Size down provider icons

* Server: POST Album Release Date + add permission to microservice

* Matcher: POST Album Release Date

* Matcher: Workaroung Musicbrainz library to get album's genres

* Matcher: Get Album genres from musicbrainz + use JSON API of MB, instead of XML

* Server: POST Album Genres

* Server: Do not remove illustration file on stream failure

* Matcher: More strict when searching album, manually sort by release date and filter by artist
2024-12-10 14:45:37 +00:00
Arthur Jamet 37c34f6e3e Front: Highlight card: set scrollbar color to accent color 2024-12-06 16:00:24 +00:00
Arthur Jamet 4bbe1a4514 Front: Search All: Show featuring artists for songs 2024-12-06 16:00:24 +00:00
Arthur Jamet d521415458 Matcher: match artists (#782)
* Matcher: Move Context Bootstrap to its own file

* Matcher: Matching artists: use MB to resolve other providers

* Matcher: Matching artists: Get Description and Image URL and POST

* Server: Fix permissions to POST Illustrations

* Matcher: Matching artists: Implem search for discogs and Genius

* Matcher: Matching artists: Link using Wikidata

* Matcher: Matching artists: Link Wikipedia using Wikidata

* Matcher: Matching artists: Get description when possible

* Matcher: Do not use library for Genius

* Matcher: Use MQ event for artists + fix logs from musicbrainz

* Matcher: Fix artist url being a tuple

* Matcher: Lint

* Matcher: Use loadenv for tests

* Matcher: Genius: Ignore placeholder images

* Matcher: test matching artists

* Matcher: Fix test on CI
2024-12-04 11:43:50 +00:00
Arthur Jamet fcc2407d26 Server: External Metadata: Reponse contains parent resource id 2024-11-26 14:19:18 +00:00
Arthur Jamet 76bc2410ec Front: Use New External Metadata Model 2024-11-26 14:19:18 +00:00
Arthur Jamet 2c91936682 Front: Fix SSR for search page 2024-11-03 08:56:58 +00:00
Arthur Jamet c411bc7b37 Front: Search: Show history when no query 2024-11-03 08:56:58 +00:00
Arthur Jamet 1cb6820c2b Search history: Add endpoint to GET entries 2024-11-03 08:56:58 +00:00
Arthur Jamet 3a26a696dc Search Controller: POST Search History 2024-11-03 08:56:58 +00:00
Arthur Jamet a3e6bdefff Front: Filter Videos by type 2024-11-02 17:13:24 +00:00
Arthur Jamet 09777dcfc1 Video Service: Filter Videos by type 2024-11-02 17:13:23 +00:00
Arthur Jamet b10a1b0cba Front: Infinite Video View: Add 'play all' and 'shuffle' buttons 2024-11-02 17:13:23 +00:00
Arthur Jamet 5fb615013d Video Service: Uses random query param to shuffler videos 2024-11-02 17:13:23 +00:00
Arthur Jamet 7607f09396 Server: Remove SongGroupcontroller form AppModule 2024-11-02 17:13:21 +00:00
Arthur Jamet 99805cf9d6 README: Mention song groups 2024-11-02 17:13:09 +00:00
Arthur Jamet faa837fff7 Front: Infinite Song Groups 2024-11-02 17:13:09 +00:00
Arthur Jamet fb0756aa23 Server: Fix Pagination for song groups 2024-11-02 17:13:08 +00:00
Arthur Jamet 1d8f4963d4 Front: Model for song group 2024-11-02 17:13:08 +00:00
Arthur Jamet 98e1fdff2e Server: Song Group: Test API 2024-11-02 17:13:08 +00:00
Arthur Jamet 6e36c4988b Server: Song Service: Add method to get many song groups 2024-11-02 17:13:06 +00:00
Arthur Jamet 6c335874e1 Server: Rework getting random songs/albums by shuffling outside database 2024-11-02 17:12:40 +00:00
Arthur Jamet 34ca446bc9 Front: Hide 'show exclusive tracks' button if album does not have an artist 2024-11-02 17:12:40 +00:00
Arthur Jamet b415eba73a Matcher: Provider Classes + Factory 2024-11-02 10:24:06 +00:00
Arthur Jamet 8fe7db2ea6 Matcher: Setup Singleton Context 2024-11-02 10:24:06 +00:00
Arthur Jamet 751997d6cb Matcher: POST Provider icons 2024-11-02 10:24:06 +00:00
Arthur Jamet cb1013fde1 Matcher: POST Providers at startup 2024-11-02 10:24:06 +00:00
Arthur Jamet 33eb777d4f Matcher: Models for Providers, Page + GET providers 2024-11-02 10:24:06 +00:00
Arthur Jamet 9c0c039a0f Matcher: Assets for providers 2024-11-02 10:24:06 +00:00
Arthur Jamet ebf9c57318 Server: Route to GET external providers 2024-11-02 10:24:06 +00:00
Arthur Jamet e3e5abb1b4 Front: Filter Videos by type 2024-10-30 14:00:51 +00:00
Arthur Jamet c3bd9b44fd Video Service: Filter Videos by type 2024-10-30 14:00:51 +00:00
Arthur Jamet 21bb5586b7 Front: Infinite Video View: Add 'play all' and 'shuffle' buttons 2024-10-30 13:52:31 +00:00
Arthur Jamet 06e24586db Video Service: Uses random query param to shuffler videos 2024-10-30 13:52:31 +00:00
Arthur Jamet 84f6868390 Workflows: Matcher: Run tests 2024-10-26 16:24:16 +01:00
Arthur Jamet b44fba44a3 Matcher: Test Settings Parsing 2024-10-26 16:24:16 +01:00
Arthur Jamet b1a585cec4 Workflows: Matcher: Lint job ignore tests 2024-10-26 16:24:16 +01:00
Arthur Jamet 16a1ad0810 Matcher: Test API Constructor 2024-10-26 16:24:16 +01:00
Arthur Jamet 160513e82b Matcher: Settings: Parse 2024-10-26 16:24:16 +01:00
Arthur Jamet 6f6e03bda5 Matcher: Settings: Open file in constructor 2024-10-26 16:24:16 +01:00
Arthur Jamet 19ef1cc6b8 Illustration Service: Resize down the image before computing the blurhash. Better speed 2024-10-21 17:58:28 +01:00
Arthur Jamet a609f1058e Workflow: Basic CI for Matcher 2024-10-17 18:34:11 +01:00
Arthur Jamet b0e6a361bb Matcher: Configure API Client + Ping API at Startup 2024-10-17 18:34:11 +01:00
Arthur Jamet 9f05114be2 Matcher: Parse MQ event 2024-10-17 18:34:11 +01:00
Arthur Jamet bbbe62f880 Matcher: Connect to MQ + Print received events 2024-10-17 18:34:11 +01:00
Arthur Jamet 023c74d1c2 Server: Use nestjs native Event Publication system 2024-10-17 18:34:11 +01:00
Arthur Jamet 07da1f5428 Matcher: Hello World + Setup Docker 2024-10-17 18:34:11 +01:00
Arthur Jamet e3255cb428 Server: Remove SongGroupcontroller form AppModule 2024-10-14 12:09:33 +01:00
Arthur Jamet c81ed9dd2e README: Mention song groups 2024-10-14 12:09:33 +01:00
Arthur Jamet 45afd3b36b Front: Infinite Song Groups 2024-10-14 12:09:33 +01:00
Arthur Jamet 2b5875b146 Server: Fix Pagination for song groups 2024-10-14 12:09:33 +01:00
Arthur Jamet 3176bc5c36 Front: Model for song group 2024-10-14 12:09:33 +01:00
Arthur Jamet 87efc74920 Server: Song Group: Test API 2024-10-14 12:09:33 +01:00
Arthur Jamet e5fc7a8428 Server: Song Service: Add method to get many song groups 2024-10-14 12:09:33 +01:00
Arthur Jamet aced630093 Server: Rework getting random songs/albums by shuffling outside database 2024-09-30 10:01:01 +01:00
Arthur Jamet bf28a74155 Front: Hide 'show exclusive tracks' button if album does not have an artist 2024-09-30 09:24:21 +01:00
Arthur Jamet dbf75b8d20 Server: Send Event on Album and Song Creation 2024-09-29 11:38:44 +01:00
Arthur Jamet a2e351c670 Server: Send Event on Artist Creation 2024-09-29 11:38:44 +01:00
Arthur Jamet bb147d567f Server: Fix setup of rabbitmq for tests 2024-09-29 11:38:44 +01:00
Arthur Jamet 4e82669f4b Server: README with expected environment variables 2024-09-28 12:50:50 +01:00
Arthur Jamet 31a4ae5b9a Docker: Add MQ Service 2024-09-22 10:19:28 +01:00
Arthur Jamet 606ad93395 Server: Setup Connection to MQ + Mock for test env 2024-09-22 10:19:28 +01:00
Arthur Jamet b078ba20a4 API: External Metadata Model (#760)
* Server: Prisma Rewrite External Metadata and provider models

* Server: External Metadata: CRUD for new provider

* Server: External Metadata: CRUD for new external metadata model

* Server: Remove Previous Provider Models and services

* Server: test Provider Controller

* Server: External Metadata: Service, Controller and tests
2024-09-20 16:52:17 +01:00
Arthur Jamet 26dd43c2ee Merge branch 'main' of github.com:Arthi-chaud/Meelo into v3 2024-09-14 15:08:08 +01:00
Arthur Jamet 87d82b3928 Exclusive songs: do not consider videos 2024-09-14 14:46:06 +01:00
Arthur Jamet 93771c4551 Front: Fix Keybinding Trigger when focus on input (#758) 2024-09-14 14:46:05 +01:00
Arthur Jamet a5d151470e Exclusive songs (#757)
* Server: Get Tracklist: Add query parameter to get exclusive tracks only

* Front: If release is a compilation, add button to filter exclusive tracks

* README: Add text about exclusive songs filtering
2024-09-14 14:46:05 +01:00
Arthur Jamet 7d65700a70 Front: Basic keyboard bindings (#756)
* Front: Bindings: Setup Context + Hook

* Front: Binding to open keyboard binding list

* Front: Keyboard Binding Modals: Style for binding + Close button

* Front: Settings: Add section for keyboard bindings

* Front: Unfocus search text field on escape key press

* Front: Binding: Fix usestate loop + toggle playback on space press
2024-09-14 14:46:05 +01:00
Arthur Jamet 5f58e26314 Server: fix discogs tests (#755)
* Server: Do not use Discogs' notes as description, as plaintext is not available

* Docs: Discogs does not provide album description anymore

* Server: Format
2024-09-14 14:46:04 +01:00
Arthur Jamet f2dfd6903c Exclusive songs: do not consider videos 2024-09-11 19:16:21 +00:00
Arthur Jamet e00d551389 Front: Fix Keybinding Trigger when focus on input (#758) 2024-09-11 06:04:21 +00:00
Arthur Jamet 55e0831e67 Exclusive songs (#757)
* Server: Get Tracklist: Add query parameter to get exclusive tracks only

* Front: If release is a compilation, add button to filter exclusive tracks

* README: Add text about exclusive songs filtering
2024-09-09 21:40:37 +02:00
Arthur Jamet c6cf808bea Front: Basic keyboard bindings (#756)
* Front: Bindings: Setup Context + Hook

* Front: Binding to open keyboard binding list

* Front: Keyboard Binding Modals: Style for binding + Close button

* Front: Settings: Add section for keyboard bindings

* Front: Unfocus search text field on escape key press

* Front: Binding: Fix usestate loop + toggle playback on space press
2024-09-09 18:45:44 +02:00
Arthur Jamet 0f25f7f282 Server: fix discogs tests (#755)
* Server: Do not use Discogs' notes as description, as plaintext is not available

* Docs: Discogs does not provide album description anymore

* Server: Format
2024-09-08 10:45:54 +02:00
Arthur Jamet 4968ea0a40 Server: Compute Illustration Blurhash using aspect ratio (#754) 2024-09-08 09:47:32 +02:00
Arthur Jamet 3560edb27b Merge pull request #752 from Arthi-chaud/scanner-microservice
Scanner Microservice
2024-09-07 22:14:32 +02:00
Arthur Jamet 67c7e2b80b Merge branch 'main' of github.com:Arthi-chaud/Meelo into scanner-microservice 2024-09-07 17:50:49 +02:00
Arthur Jamet 1dc7819162 Readme: Change First screenshot 2024-09-07 17:50:08 +02:00
Arthur Jamet 169383519b Merge pull request #751 from Arthi-chaud/front/use-scanner-microservice
Front: Use scanner microservice
2024-09-07 17:43:43 +02:00
Arthur Jamet e2a9ea96fd Docker Compose: Use PUBLIC_URL env var + connect front to scanner 2024-09-07 17:39:27 +02:00
Arthur Jamet 20d7f69ef2 Server: Remove deps to glob 2024-09-07 17:32:26 +02:00
Arthur Jamet f68dc3a394 Scanner: Dockerfile: add wget for healthcheck 2024-09-07 17:23:00 +02:00
Arthur Jamet 502f01f8b2 Front: Use Scanner's Endpoints 2024-09-07 16:50:48 +02:00
Arthur Jamet 4d47ba66a2 Server: Change Response on task start to match scanner's response 2024-09-07 16:46:59 +02:00
Arthur Jamet 25d5e194dc Server: Format + remove redis container in test setup 2024-09-07 14:17:38 +02:00
Arthur Jamet 4bdbf6340b Docker Compose: Cache front + server deps in dev + Add scanner to prod 2024-09-07 14:17:38 +02:00
Arthur Jamet 5e5cfd8722 Server: Remove use of Redis 2024-09-07 14:17:38 +02:00
Arthur Jamet 2e1337d5d7 Server: Move Illustration Registration to Registration Service + remove ununsed deps and ffmpeg 2024-09-07 14:17:38 +02:00
Arthur Jamet 3ad0cc92b6 Service: Parser Module, Registration Module + start removal of Task service 2024-09-07 14:17:38 +02:00
Arthur Jamet db52907042 Server: Housekeeping service 2024-09-07 14:17:38 +02:00
Arthur Jamet 7c41127075 Rename checksum (#749)
* Server: Rename md5Checkum to checksum

* Server: Rename md5Checkum to checksum

* Front: Remove unused field from File API Model

* Scanner: Rename checksum field from API model
2024-09-06 14:15:15 +02:00
Arthur Jamet 777e68c869 Merge pull request #747 from Arthi-chaud/scanner/refresh-metadata
Scanner: Refresh metadata
2024-09-06 10:43:29 +02:00
Arthur Jamet 62087ae13b Server: Discogs does not provide description anymore 2024-09-06 10:39:46 +02:00
Arthur Jamet e86115740e Scanner: Refresh Metadata when file changes 2024-09-06 10:32:32 +02:00
Arthur Jamet c4b79f37f7 Scanner: API Request to get files w/ selector 2024-09-06 10:32:32 +02:00
Arthur Jamet 18e0b0d00c Scanner: Setup for Metadata Refresh Task 2024-09-06 10:32:32 +02:00
Arthur Jamet 52e3130fea Server: Metadata Controller: Update Metadata 2024-09-06 10:32:31 +02:00
Arthur Jamet 59d49e1617 Server: Remove ununsed dep in metadata module 2024-09-06 10:32:31 +02:00
Arthur Jamet 85ee9baf51 Server: File Controller: Select files from album, release, song and track 2024-09-06 10:32:31 +02:00
Arthur Jamet 5042cca9f0 Docs: Mention that we use Golang-flavored regular expressions 2024-09-06 10:31:38 +02:00
Arthur Jamet 06247901a7 Readme: Change First screenshot 2024-09-06 10:29:19 +02:00
Arthur Jamet 866041bfb6 Merge pull request #746 from Arthi-chaud/scanner/parallel-illustration-scanning 2024-09-05 07:54:01 +02:00
Arthur Jamet 0723e0e9a1 Scanner: Use new route to post cover/thumbnail 2024-09-04 20:58:59 +02:00
Arthur Jamet d564252623 Server: Rework route for Illustration Registration 2024-09-04 19:17:24 +02:00
Arthur Jamet 2124d23cb9 Scanner: Illustration submodule 2024-09-04 18:16:14 +02:00
Arthur Jamet 7faa482135 Merge pull request #745 from Arthi-chaud/scanner/clean
Scanner: Clean Libraries
2024-09-04 13:08:58 +02:00
Arthur Jamet 992d022ba2 Server: Format 2024-09-04 11:24:09 +02:00
Arthur Jamet f22e397de3 Scanner: Clean Files 2024-09-04 11:18:15 +02:00
Arthur Jamet 6d785d4459 Server: Endpoint to delete files 2024-09-04 11:17:55 +02:00
Arthur Jamet e96c86b85b Merge pull request #744 from Arthi-chaud/scanner/push-metadata
Scanner: Push metadata
2024-09-04 10:25:17 +02:00
Arthur Jamet a530fd04d6 Server: Illustration Registration: Prevent order of registration from using the wrong disc illustration for release 2024-09-04 10:20:41 +02:00
Arthur Jamet 220959124a Server: Metadata: Fix validation of compilation tag 2024-09-03 21:29:24 +02:00
Arthur Jamet 1b842094f7 Server: Metadata: Fix file path parsing 2024-09-03 19:02:50 +02:00
Arthur Jamet 96c7e6a99c Scanner: Extract Thumbnail + Fix type detection for OGG 2024-09-03 17:26:48 +02:00
Arthur Jamet 00ca276859 Server: Library Controller: Add permissions for microservice 2024-09-03 16:53:22 +02:00
Arthur Jamet 0c765703e0 Scanner: Add API endpoint to scan a single library 2024-09-03 16:42:14 +02:00
Arthur Jamet 7f65834614 Scanner: Format 2024-09-02 20:30:02 +02:00
Arthur Jamet 88a79348c5 Server: Avoid crash on clean 2024-09-02 20:29:44 +02:00
Arthur Jamet 90882b8e60 Server: Endpoint to POST Thumbnail 2024-09-02 17:28:51 +02:00
Arthur Jamet 4ae9de61d7 Server: Metadata DTO: Fix parsing of dates and booleans 2024-09-02 16:18:58 +02:00
Arthur Jamet 5f7ae2eb30 Scanner: Hide ffmpeg log + remove json key from metadata object 2024-09-02 16:18:58 +02:00
Arthur Jamet 8bcc0fb29d Scanner: checksum: fix 2024-09-02 16:18:58 +02:00
Arthur Jamet cd8a1185f3 Scanner: Parser: Fix Compilation/Album Artist Handling 2024-09-02 16:18:58 +02:00
Arthur Jamet e4cd30a3e4 Scanner: API: Push Metadata 2024-09-02 16:18:58 +02:00
Arthur Jamet 11c7be61cd Server: Metadata Controller: Save Illustration 2024-09-02 16:18:55 +02:00
Arthur Jamet 17089b20c7 Server: Metadata: Save Illustration 2024-09-01 15:58:51 +02:00
Arthur Jamet 76dffbd280 Server: Test Metadata Creation result 2024-09-01 15:15:21 +02:00
Arthur Jamet 005006b02a Server: Metadata Controller: Test error handling 2024-09-01 11:08:39 +02:00
Arthur Jamet de5bca03d9 Scanner: Metadata Parsing: Add Path, checksum and registration date to model 2024-09-01 10:17:26 +02:00
Arthur Jamet c0a2527284 Server: Setup DTO for pushing Metadata 2024-08-31 18:05:42 +02:00
Arthur Jamet 4787a204ab Scanner: Scan: Parse metadata by chunks 2024-08-31 16:44:28 +02:00
Arthur Jamet 9cf58eb3f9 Scanner: Scan Task: Filter unregistered paths 2024-08-31 13:08:16 +02:00
Arthur Jamet 6886c9d757 Server: File API: Add route to get many files 2024-08-31 10:09:56 +02:00
Arthur Jamet 4ab088fd34 Server: Fix JWT Guard 2024-08-28 16:54:46 +02:00
Arthur Jamet fed101e8ae Scanner: Setup Task Queue 2024-08-28 16:40:44 +02:00
Arthur Jamet ea5b8d440e Scanner: Merge 2024-08-28 16:05:11 +02:00
Arthur Jamet b3f7f2206f Scanner: Endpoints: 401 if user is not admin 2024-08-28 14:08:07 +02:00
Arthur Jamet 4d58545b3b Scanner: Check if requesting user is admin 2024-08-28 14:08:07 +02:00
Arthur Jamet 4f3d542159 Server: Api Key Service + Guard 2024-08-28 14:08:03 +02:00
Arthur Jamet e5b9c2e3b2 Scanner: Add API Key to config 2024-08-28 14:07:08 +02:00
Arthur Jamet f3e56f32c0 Scanner: Know where to look for an illustration 2024-08-28 14:07:08 +02:00
Arthur Jamet 2aa6eb8499 Scanner: Test Metadata Merging + sanitization 2024-08-28 14:07:07 +02:00
Arthur Jamet ed03dca79e Scanner: Test Metadata Parser 2024-08-28 14:07:07 +02:00
Arthur Jamet 2e438f12f4 Scanner: Parse embedded data, core parsing function + tests 2024-08-28 14:07:07 +02:00
Arthur Jamet 28fdc0154d Scanner: Merge Metadata 2024-08-28 14:07:07 +02:00
Arthur Jamet 60eb19e29f Scanner: Use Golang Regex + Test path metadata 2024-08-28 14:07:06 +02:00
Arthur Jamet f69ba8a7f1 Scanner: Parse Metadata from path 2024-08-28 14:07:06 +02:00
Arthur Jamet 0fac03a214 Scanner: Path Metadata Parsing: Setup for regexes 2024-08-28 14:07:06 +02:00
Arthur Jamet dca04b202b Scanner: Use error type instead of plain strings 2024-08-28 14:07:06 +02:00
Arthur Jamet 155cb9e039 Scanner: Metadata model + validator 2024-08-28 14:07:06 +02:00
Arthur Jamet 858b9e0464 CI: Scanner: Fix coverage integration on sonarcloud 2024-08-28 14:07:05 +02:00
Arthur Jamet b04cbfad92 Scanner: Cohesive variable namings 2024-08-28 14:07:05 +02:00
Arthur Jamet 762b31108e Scanner: Better validation error messages for settings 2024-08-28 14:07:05 +02:00
Arthur Jamet bb430aea37 Scanner: Test Parsing of User Settings 2024-08-28 14:07:05 +02:00
Arthur Jamet 2a541cb643 Scanner: Parse settings.json 2024-08-28 14:07:04 +02:00
Arthur Jamet ad3fbd7388 Scanner: get data dir env var 2024-08-28 14:07:04 +02:00
Arthur Jamet ee40992008 Scanner: get config dir env var 2024-08-28 14:07:04 +02:00
Arthur Jamet 1beeeb1836 Scanner: Dockerfile: avoid using root 2024-08-28 14:07:04 +02:00
Arthur Jamet e97e811998 CI: Scanner: Install ffmpeg only for tests 2024-08-28 14:07:04 +02:00
Arthur Jamet ffe576d601 CI: Add Static analysis to Scanner 2024-08-28 14:07:03 +02:00
Arthur Jamet 30def99958 Scanner: Connect to API 2024-08-28 14:07:03 +02:00
Arthur Jamet 76c1ecd71c Scanner: func to get video thumbnail + format 2024-08-28 14:07:03 +02:00
Arthur Jamet 16d62a7e19 Scanner: CI + coverage Report 2024-08-28 14:07:03 +02:00
Arthur Jamet 522330d14d Scanner: Add to docker-compose + nginx reverse proxy 2024-08-28 14:07:03 +02:00
Arthur Jamet 7c9d78e875 Scanner: Setup Project + Echo server 2024-08-28 14:07:02 +02:00
Arthur Jamet 2c6f0cbc8b Transcoder: Update Docker-compose with new env var 2024-08-26 17:26:16 +02:00
Arthur Jamet ef235fc264 Scanner Service: Parse 'Vs' Artist separator 2024-08-25 16:51:50 +02:00
Arthur Jamet aae10672dd Server: Rework handling of access control when multiple methods possible + tests 2024-08-24 10:01:47 +02:00
Arthur Jamet 3ebac555fa Server: Add Microservice to Role guards 2024-08-23 20:42:07 +02:00
Arthur Jamet 75788e478b Server: Merge 2024-08-23 20:25:32 +02:00
Arthur Jamet 9a70d27793 Server: Rework Authentication flow. Allow Anonymous Request in settings 2024-08-23 18:30:10 +02:00
Arthur Jamet 7532e199b5 Server: Remove LocalAuthGuard, move logic into controller method 2024-08-23 18:30:10 +02:00
Arthur Jamet 5581c69cfa example env: Add ALLOW_ANONYMOUS key 2024-08-23 18:30:10 +02:00
Arthur Jamet 155e3ca9cb Server: settings for anonymous access 2024-08-23 18:30:10 +02:00
Arthur Jamet 73ba1ca398 wip 2024-08-23 11:32:44 +02:00
Arthur Jamet 527ca4df5d Scanner: Add Tasks to context 2024-08-21 15:22:22 +02:00
Arthur Jamet 27b2b9d813 Scanner: Endpoints: 401 if user is not admin 2024-08-20 14:48:22 +02:00
Arthur Jamet ae44cd5574 Scanner: Check if requesting user is admin 2024-08-20 14:48:22 +02:00
Arthur Jamet 31831e25ce Server: Api Key Service + Guard 2024-08-20 14:48:22 +02:00
Arthur Jamet ba9fc80f82 Scanner: Add API Key to config 2024-08-20 14:48:22 +02:00
Arthur Jamet 061b890a6a Front: Artist Page: Split live from regular videos 2024-08-18 21:14:59 +02:00
Arthur Jamet 78991fb145 Front: Release Page: Split live from regular videos 2024-08-18 21:14:59 +02:00
Arthur Jamet f45d4df3fe Scanner: Know where to look for an illustration 2024-08-18 19:58:15 +02:00
Arthur Jamet 5ff9ad6953 Scanner: Test Metadata Merging + sanitization 2024-08-17 09:03:32 +02:00
Arthur Jamet 6eb0055ca5 Scanner: Test Metadata Parser 2024-08-17 09:03:32 +02:00
Arthur Jamet aa009b4352 Scanner: Parse embedded data, core parsing function + tests 2024-08-17 09:03:32 +02:00
Arthur Jamet 05b69097e9 Scanner: Merge Metadata 2024-08-16 16:35:30 +02:00
Arthur Jamet ab204507e7 Scanner: Use Golang Regex + Test path metadata 2024-08-16 16:35:30 +02:00
Arthur Jamet 879f3a8805 Scanner: Parse Metadata from path 2024-08-16 16:35:30 +02:00
Arthur Jamet 571cde7010 Scanner: Path Metadata Parsing: Setup for regexes 2024-08-16 16:35:30 +02:00
Arthur Jamet 6c643b6f95 Scanner: Use error type instead of plain strings 2024-08-16 16:35:30 +02:00
Arthur Jamet 5d686259c7 Scanner: Metadata model + validator 2024-08-16 16:35:30 +02:00
Arthur Jamet f0ced36c54 CI: Scanner: Fix coverage integration on sonarcloud 2024-08-15 16:20:40 +02:00
Arthur Jamet 65341ebc2f Scanner: Cohesive variable namings 2024-08-15 13:51:34 +02:00
Arthur Jamet b873869559 Scanner: Better validation error messages for settings 2024-08-15 13:51:34 +02:00
Arthur Jamet 967328f494 Scanner: Test Parsing of User Settings 2024-08-15 13:51:34 +02:00
Arthur Jamet 88e8359d2c Scanner: Parse settings.json 2024-08-15 13:51:34 +02:00
Arthur Jamet 4f76203dcc Scanner: get data dir env var 2024-08-15 13:51:34 +02:00
Arthur Jamet b2002336e1 Scanner: get config dir env var 2024-08-15 13:51:34 +02:00
Arthur Jamet b39dc06298 Scanner: Dockerfile: avoid using root 2024-08-14 20:57:24 +02:00
Arthur Jamet 9db513b786 CI: Scanner: Install ffmpeg only for tests 2024-08-14 20:57:24 +02:00
Arthur Jamet 4091703afe CI: Add Static analysis to Scanner 2024-08-14 20:57:24 +02:00
Arthur Jamet aa17aba53c Scanner: Connect to API 2024-08-14 20:35:49 +02:00
Arthur Jamet 7e7aacfead Scanner: func to get video thumbnail + format 2024-08-14 20:26:55 +02:00
Arthur Jamet e2f3b6c07d Scanner: CI + coverage Report 2024-08-14 20:26:55 +02:00
Arthur Jamet 0b93b767f2 Scanner: Add to docker-compose + nginx reverse proxy 2024-08-14 20:26:55 +02:00
Arthur Jamet d4286349e9 Scanner: Setup Project + Echo server 2024-08-14 20:26:55 +02:00
Arthur Jamet 8f77c7e7ed Front: alert before leaving when playing (#723) 2024-08-14 13:10:27 +00:00
Arthur Jamet db6d04817d Front: Set better grid cols count on xl viewports (#722) 2024-08-14 15:04:24 +02:00
Arthur Jamet 1cd4024f63 Server: Bump to NestJs 10 (#721)
* Server: bump nestjs

* Server: Bump genius lyrics api

* Server: Fix Discogs tests

* Server: Fix Allmusic tests
2024-08-13 15:36:40 +02:00
Arthur Jamet da6fdc7f91 Front: Fix dev-only warning about showlabels (#720) 2024-08-13 13:33:40 +02:00
Arthur Jamet cd5f262435 Update Release Notes Generation (#719)
* Update Release Notes Generation

* Update release.yml
2024-08-13 11:28:44 +02:00
Arthur Jamet b9917027c6 Rewrite README (#718) 2024-08-12 16:58:41 +02:00
438 changed files with 19212 additions and 11738 deletions

No files matched your search

+14 -3
View File
@@ -7,21 +7,32 @@ POSTGRES_USER=
POSTGRES_PASSWORD=
# Name of Meelo's database
POSTGRES_DB=
# Username of Meelo's Message Queue
RABBITMQ_USER=
# Password of Meelo's Message Queue
RABBITMQ_PASSWORD=
#################### Config
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
CONFIG_DIR=
# The root path of your libraries (on the host machine)
DATA_DIR=
#################### Anonymous Access
# Set to 1 if you want to allow anonymous request
# This will not affect front-end behaviour
ALLOW_ANONYMOUS=
#################### Web app
# URL of the server/api that would be accessible to the front app's server or any client
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
PUBLIC_SERVER_URL=
# Public URL to access Meelo, without trailing slash
# Example: http://localhost:5000
PUBLIC_URL=
#################### Security
# Random String used to sign JWT Tokens
JWT_SIGNATURE=
# Key used to authenticate the Meilisearch Instance
# Should be a random string, must be at least 16 bytes
MEILI_MASTER_KEY=
# A coma-separated list of random strings used for internal authentication
# Example: API_KEYS=azertyuip,qwertyuiop
API_KEYS=
#################### Internal
# Do not change this
INTERNAL_DATA_DIR=/data
+9
View File
@@ -0,0 +1,9 @@
name: 'Cache (Matcher)'
description: 'Cache for Matcher'
runs:
using: "composite"
steps:
- run: apk add --update --no-progress lsb-release python3 py3-pip
shell: sh
- run: pip3 install -r matcher/requirements.txt
shell: sh
+15
View File
@@ -0,0 +1,15 @@
name: 'Cache (Scanner)'
description: 'Cache for Scanner'
runs:
using: "composite"
steps:
- run: |
cd scanner
go install github.com/swaggo/swag/cmd/swag@latest
~/go/bin/swag init -d app -o ./app/docs
shell: bash
- uses: actions/setup-go@v5
with:
check-latest: true
go-version-file: 'scanner/go.mod'
cache-dependency-path: scanner/go.sum
+1 -1
View File
@@ -13,4 +13,4 @@ runs:
./server/node_modules
./server/dist
key: server
restore-keys: server
restore-keys: server
+8 -23
View File
@@ -1,43 +1,28 @@
# Source: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
changelog:
exclude:
labels:
- dependencies
categories:
- title: Web App
labels:
- Front
- Layout
- title: Server
labels:
- Back
- title: Exciting New Features 🎉
labels:
- Feature
- Basic
- title: Improvement, Optmizations & Enhancements 👍
- title: Improvements, Optmizations & Enhancements 👍
labels:
- Enhancement/Optimization
- Layout
- title: Fixes & Patches 🩹
labels:
- Bug
- Quick Fix
- title: Security 🛡
labels:
- Security
- Logs
- Fixes
- title: Documentation 📖
labels:
- Documentation
- title: CI/CD ⚙
- title: Security 🛡
labels:
- CD/CD
- title: Codebase-related 📑
- Security
- title: Deployment ⚙
labels:
- Coding Style
- Testing
- Architecture
- CI/CD
- title: Other Changes 🔀
labels:
- "*"
- "*"
+3 -3
View File
@@ -1,9 +1,9 @@
name: "Front"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -169,4 +169,4 @@ jobs:
context: ./front
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+131
View File
@@ -0,0 +1,131 @@
name: "Matcher"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
working-directory: ./matcher
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
matcher: ${{ steps.filter.outputs.matcher }}
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
matcher:
- 'matcher/**'
- '.github/workflows/matcher.yml'
Typecheck:
needs: changes
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- uses: jakebailey/pyright-action@v2
with:
working-directory: './matcher/matcher'
Lint:
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
container: python:3.14.0a2-alpine3.21
runs-on: ubuntu-latest
needs: [ Typecheck ]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
uses: ./.github/actions/cache-matcher
- run: pip install ruff
- run: ruff check matcher
Tests:
runs-on: ubuntu-latest
needs: [ Typecheck ]
container: python:3.14.0a2-alpine3.21
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
env:
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
CI: "1"
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Restore Dependencies
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') }}
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:
context: ./matcher
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-matcher:edge' }}
labels: ${{ steps.meta.outputs.labels }}
+155
View File
@@ -0,0 +1,155 @@
name: "Scanner"
on:
pull_request:
branches: [ main, v3 ]
push:
branches: [ main, v3 ]
release:
types: [ published ]
defaults:
run:
shell: bash
working-directory: ./scanner
jobs:
Changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
scanner: ${{ steps.filter.outputs.scanner }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
scanner:
- 'scanner/**'
- '.github/workflows/scanner.yml'
Build:
needs: changes
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- name: Install Dependencies and Build
run: go build -o scanner ./app
Format:
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs: [ Build ]
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- uses: Jerome1337/gofmt-action@v1.0.5
with:
gofmt-path: './scanner'
gofmt-flags: '-l'
Tests:
runs-on: ubuntu-latest
needs: [ Build ]
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/cache-scanner
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
- name: Install Coverage Report
run: go install github.com/jandelgado/gcov2lcov@latest
- name: Run tests
run: go test ./... -coverprofile=coverage.out
- name: Upload Unit Tests artifact
if: ${{ always() }}
uses: actions/upload-artifact@v3
with:
name: coverage-report-scanner
path: ./scanner/coverage.out
Analysis:
needs: [ Tests ]
name: "Static Analysis"
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
steps:
- uses: actions/checkout@v3
- name: Downlad Coverage artifact
uses: actions/download-artifact@v3
with:
name: coverage-report-scanner
path: ./scanner
- name: Run SonarCloud Scan
uses: SonarSource/sonarcloud-github-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@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: ./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') }}
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:
context: ./scanner
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-scanner:edge' }}
labels: ${{ steps.meta.outputs.labels }}
+3 -13
View File
@@ -1,9 +1,9 @@
name: "Server"
on:
pull_request:
branches: [ main, v2 ]
branches: [ main, v3 ]
push:
branches: [ main, v2 ]
branches: [ main, v3 ]
release:
types: [ published ]
@@ -77,7 +77,6 @@ jobs:
if: ${{ needs.changes.outputs.server == 'true' && always() }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
REDIS_HOST: "redis"
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
JWT_SIGNATURE: "12345"
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
@@ -97,15 +96,6 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7.0-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:v1.5
ports:
@@ -208,4 +198,4 @@ jobs:
context: ./server
push: true
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+78 -61
View File
@@ -1,65 +1,82 @@
![Meelo](./front/public/banner.png)
# <img alt="Icon" src="./front/public/banner.png" width="150px">
## Your music, your files, your experience
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:
While the software wants to be a flexible as possible, it requires a little bit of setup. However, once everything is ready, you will just have to sit back, relax, and enjoy your music. :sunglasses:
![Album Page (Top)](./assets/examples/album-top.png)
## :star: Features
Meelo has all the basic features you might expect from a music player/server (playback, browsing, search, playlists, libraries ...). But it comes with a handful of features that make it unique :star_struck:
- Music Videos are first-class citizens
- Access them directly from an album's, artist's or song's page
- We also differentiate music video from interviews, behind-the-scenes, trailers...
- Identifies B-Sides
- Meelo shows you any tracks that could be related to an album directly on the album's page!
- ... and Rare tracks
- And show them on the artist's page, so that these rare gems do not get lost and forgotten!
- Automatic 'featuring' and duet detection
- Albums have _releases_
- Meaning that you can have multiple versions of a single album
- But only its _main_ version would appear on browsing pages
- Of course, you can still access its other versions, directly from the album's page
- Songs have _tracks_
- It is pretty much the same as for albums and releases
- No duplicated songs when browsing your library!
- Song Versions
- Browse Songs [grouped by version](./assets/examples/song-groups.png)
- Album and Song types
- Finding instrumental songs or live recordings has never been this simple!
- Filter Songs that are exclusive to an album (compilations only)
- Supports all formats!
- Thanks to transcoding and the way we parse files, any audio and video format is virtually supported
- (Note: Transcoding is only used when the file format is not supported in the browser)
- Flexible metadata parsing
- 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
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
## :book: How to get started
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.
## :desktop_computer: Live Demo
We are actively working on a public demo, so that you can try out Meelo. Stay tuned...
## :camera: Screenshots
<details>
<summary>Unfold to see what the web application looks like!</summary>
![Album View](./assets/examples/album-page.png)
![Artist Page](./assets/examples/artist-page.png)
![Player Page](./assets/examples/player.png)
![Release Page](./assets/examples/releases.png)
More screenshots [here](./assets/examples/)
</details>
## :handshake: Contributing and Bugs reporting
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
: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!
: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).
---
Get ready to make the most out of you music collection!
[![Coverage (Back-end)](https://sonarcloud.io/api/project_badges/measure?project=arthi-chaud_Meelo-back&metric=coverage)](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=arthi-chaud_Meelo-back&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
This project is made by and for music collectors. Its goal is to have a clean, organized, easy and accessible way to store and serve files.
Get started! Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions
## Features
- Files organized in *Libraries*
- Scan for new files, and remove deleted files
- Music Organization close to a Music Collector's collection
- Artist
- Album
- Releases
- Songs
- Primary and secondary artists
- Tracks
- Versions
- Access, stream, and download songs from your server
- Music Videos
- B-Sides
- Identifies 'Rare' Tracks in your collection
- Playlists
- Metadata scan using:
- Embedded tags (including bitrate)
- Path of file, using customizable Regular Expressions
- Either using one of the two, or the two combined
- Album Typing
- Studio Recording, Single, Live Album, Video Album, Soundtrack Album, ...
- Song Typing
- Original Version, Demo, Instrumental, Remix, Acoustic, Edit, Clean, ...
- ... And allow filtering based on items' types
- Embedded and inline artwork file handling
- External Metadata Providers (Optional)
- Genius, MusicBrainz, Wikipedia, Discogs, Metacritic, Allmusic
- Automated Lyrics download
- User management
- Multi-language support
- English
- French
Keep track of the upcoming features with the [project's issues](https://github.com/Arthi-chaud/Meelo/issues)
## Why Meelo could be what you are looking for
[Plex](https://www.plex.tv/fr/), [Beets](https://github.com/beetbox/beets), [Koel](https://github.com/koel/koel), as well as iTunes are awesome solutions if you have a 'simple' music collection, but they are not tailored for music collectors, who usually have multiple versions of a single album, multiple files for one single song (from multiple album releases). Therefore, their library ends up clustered and unpleasant to browse and use.
## The philosophy behind Meelo
The idea behind Meelo is to have a pleasant way to browse and enjoy your collection and making it the *best* one possible.
The *best* music collection is a collection that is fully useable in any music server (like one mentioned above). The only way to reach this goal is to make the music collection independent metadata-wise, i.e. having all the metadata embedded in the files themselves.
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.
## Screenshots
![Album View](./assets/examples/album-page.png)
More screenshots [here](./assets/examples/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+84 -12
View File
@@ -10,12 +10,20 @@ services:
depends_on:
db:
condition: service_healthy
mq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- ./server/src:/app/src
- ./server/prisma:/app/prisma
- ./server/test:/app/test
- ./server/package.json:/app/package.json
- ./server/.yarn:/app/.yarn
- server_deps:/app/node_modules
- ./server/.yarnrc.yml:/app/.yarnrc.yml
- ./server/tsconfig.json:/app/tsconfig.json
- ./server/tsconfig.build.json:/app/tsconfig.build.json
@@ -26,16 +34,44 @@ services:
- .env
environment:
- TRANSCODER_URL=http://transcoder:7666
- POSTGRES_HOST=db
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
scanner:
build:
context: ./scanner
dockerfile: Dockerfile.dev
restart: on-failure
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "8133:8133"
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ./scanner:/app
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
build:
context: ./front
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
volumes:
- ./front/src:/app/src
- ./front/public:/app/public
@@ -43,17 +79,30 @@ services:
- ./front/package.json:/app/package.json
- ./front/tsconfig.json:/app/tsconfig.json
- ./front/yarn.lock:/app/yarn.lock
- front_deps:/app/node_modules
environment:
# This can be commented out. see front/next.config.js
- PUBLIC_SERVER_URL=http://localhost:4000
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=http://localhost:8133
- SSR_SCANNER_URL=http://scanner:8133
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ./matcher:/app
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
meilisearch:
image: getmeili/meilisearch:v1.5
restart: on-failure
@@ -76,10 +125,18 @@ services:
cpus: 1
environment:
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_SCHEMA=gocoder
- POSTGRES_SERVER=db
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
profiles: ['', 'cpu']
depends_on:
db:
condition: service_healthy
db:
image: postgres:alpine3.16
healthcheck:
@@ -89,7 +146,22 @@ services:
retries: 5
env_file:
- .env
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
volumes:
search:
transcoder_cache:
transcoder_cache:
front_deps:
server_deps:
rabbitmq_data:
+66 -11
View File
@@ -10,6 +10,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -18,14 +20,33 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
timeout: 5s
retries: 5
scanner:
image: ghcr.io/arthi-chaud/meelo-scanner:latest
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
front:
image: ghcr.io/arthi-chaud/meelo-front:latest
expose:
@@ -33,16 +54,28 @@ services:
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
healthcheck:
test: ["CMD", "redis-cli","ping"]
interval: 5s
timeout: 5s
retries: 5
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
matcher:
image: ghcr.io/arthi-chaud/meelo-matcher:latest
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
db:
image: postgres:alpine3.14
healthcheck:
@@ -76,10 +109,31 @@ services:
- 7666
environment:
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_SCHEMA=gocoder
- POSTGRES_SERVER=db
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
depends_on:
db:
condition: service_healthy
profiles: ['', 'cpu']
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -102,3 +156,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
+66 -8
View File
@@ -11,6 +11,8 @@ services:
condition: service_healthy
meilisearch:
condition: service_healthy
mq:
condition: service_healthy
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
@@ -19,9 +21,8 @@ services:
environment:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
interval: 5s
@@ -35,24 +36,66 @@ services:
depends_on:
server:
condition: service_healthy
scanner:
condition: service_healthy
environment:
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
- SSR_SERVER_URL=http://server:4000
redis:
image: redis:7.0-alpine
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
- SSR_SCANNER_URL=http://scanner:8133
scanner:
build:
context: ./scanner
expose:
- 8133
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD", "redis-cli","ping"]
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
interval: 5s
timeout: 5s
retries: 5
retries: 10
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
- API_KEYS=${API_KEYS}
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
matcher:
build:
context: ./matcher
restart: on-failure
depends_on:
server:
condition: service_healthy
mq:
condition: service_healthy
environment:
- API_URL=http://server:4000
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
- API_KEYS=${API_KEYS}
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
volumes:
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
transcoder:
image: ghcr.io/zoriya/kyoo_transcoder:master
restart: on-failure
cpus: 1
expose:
- 7666
depends_on:
db:
condition: service_healthy
environment:
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_SCHEMA=gocoder
- POSTGRES_SERVER=db
volumes:
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
- transcoder_cache:/cache
@@ -82,6 +125,19 @@ services:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
timeout: 5s
retries: 5
mq:
image: rabbitmq:4.0-alpine
environment:
- RABBITMQ_NODENAME=meelo
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 5s
timeout: 5s
retries: 10
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
restart: on-failure
image: nginx:1.24.0-alpine
@@ -96,6 +152,7 @@ services:
- PORT=5000
- FRONT_URL=http://front:3000
- SERVER_URL=http://server:4000
- SCANNER_URL=http://scanner:8133
volumes:
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
volumes:
@@ -104,3 +161,4 @@ volumes:
data:
search:
transcoder_cache:
rabbitmq_data:
+4
View File
@@ -41,6 +41,10 @@ In this section, we list examples that would be successfully parsed by Meelo
- 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
+5 -3
View File
@@ -30,11 +30,13 @@ If a provider is disabled, no call to its service will be done.
| 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 | | |
| 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 | 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.
**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).
+1 -1
View File
@@ -5,4 +5,4 @@ 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 ECMAScript flavor)
- Know how to manipulate Regular Expressions (Especially the Golang flavor)
+3
View File
@@ -16,6 +16,7 @@ All fields are **required**
- `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
@@ -30,3 +31,5 @@ All fields are **required**
- `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.
+3 -1
View File
@@ -11,12 +11,14 @@ Meelo supports the following song types:
- Acoustic
- Remix
- Instrumental
- Acapella
- 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
+6
View File
@@ -27,6 +27,12 @@ if (process.env.NODE_ENV !== "production") {
? `${process.env.SSR_SERVER_URL}/:path*`
: "/api/:path*",
},
{
source: "/scanner/:path*",
destination: process.env.SSR_SCANNER_URL
? `${process.env.SSR_SCANNER_URL}/:path*`
: "/scanner/:path*",
},
];
}
+1
View File
@@ -46,6 +46,7 @@
"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",
+248 -112
View File
@@ -41,7 +41,12 @@ import {
SongType,
SongWithRelations,
} from "../models/song";
import { VideoWithRelations } from "../models/video";
import {
VideoInclude,
VideoSortingKeys,
VideoType,
VideoWithRelations,
} from "../models/video";
import {
TrackInclude,
TrackSortingKeys,
@@ -50,7 +55,6 @@ import {
} from "../models/track";
import { TracklistItemWithRelations } from "../models/tracklist";
import { SortingParameters } from "../utils/sorting";
import LibraryTaskResponse from "../models/library-task-response";
import { ResourceNotFound } from "../exceptions";
import User, { UserSortingKeys } from "../models/user";
import store from "../state/store";
@@ -65,8 +69,21 @@ import Playlist, {
PlaylistWithRelations,
} from "../models/playlist";
import { isSSR } from "../utils/is-ssr";
import { ActiveTask, Task } from "../models/task";
import { SearchResult, SearchResultTransformer } from "../models/search";
import { TaskResponse } from "../models/task";
import {
AlbumExternalMetadata,
ArtistExternalMetadata,
SongExternalMetadata,
} from "../models/external-metadata";
import {
SaveSearchItem,
SearchResult,
SearchResultTransformer,
} from "../models/search";
import {
SongGroupSortingKeys,
SongGroupWithRelations,
} from "../models/song-group";
const AuthenticationResponse = yup.object({
access_token: yup.string().required(),
@@ -74,8 +91,6 @@ const AuthenticationResponse = yup.object({
type Identifier = number | string;
type NullableIdentifier = Identifier | null;
type AuthenticationResponse = yup.InferType<typeof AuthenticationResponse>;
type QueryParameters<Keys extends readonly string[]> = {
@@ -89,11 +104,18 @@ type AuthenticationInput = {
password: string;
};
// eslint-disable-next-line no-shadow
enum Service {
API,
Scanner,
}
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<{
@@ -123,6 +145,8 @@ export default class API {
private static isDev = () => process.env.NODE_ENV === "development";
private static SSR_API_URL =
process.env.SSR_SERVER_URL ?? process.env.PUBLIC_SERVER_URL!;
private static SSR_SCANNER_URL =
process.env.SSR_SCANNER_URL ?? process.env.PUBLIC_SCANNER_URL!;
static defaultPageSize = 35;
/**
@@ -149,7 +173,7 @@ export default class API {
*/
static async register(credentials: AuthenticationInput): Promise<User> {
return API.fetch({
route: "/users/new",
route: "/users",
data: {
name: credentials.username,
password: credentials.password,
@@ -172,9 +196,12 @@ export default class API {
route: `/tasks`,
errorMessage: "Tasks could not be loaded",
parameters: {},
service: Service.Scanner,
validator: yup.object({
active: ActiveTask.required().nullable(),
pending: yup.array(Task).required(),
current_task: yup.string().required().nullable(),
pending_tasks: yup
.array(yup.string().required())
.required(),
}),
}),
};
@@ -228,7 +255,7 @@ export default class API {
static async createPlaylist(playlistName: string): Promise<Playlist> {
return API.fetch({
route: "/playlists/new",
route: "/playlists",
data: { name: playlistName },
errorMessage: "Playlist Creation Failed",
parameters: {},
@@ -255,7 +282,7 @@ export default class API {
entriesIds: number[],
): Promise<void> {
return API.fetch({
route: `/playlists/${playlistSlugOrId}/reorder`,
route: `/playlists/${playlistSlugOrId}/entries/reorder`,
data: { entryIds: entriesIds },
parameters: {},
method: "PUT",
@@ -318,25 +345,14 @@ export default class API {
* Calls for a task to clean all libraries
* @returns A object with the status of the task
*/
static async cleanLibraries(): Promise<LibraryTaskResponse> {
static async cleanLibraries(): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/clean`,
route: `/clean`,
errorMessage: "Library clean failed",
parameters: {},
validator: LibraryTaskResponse,
});
}
/**
* Calls for a task to fetch external metadata
* @returns A object with the status of the task
*/
static async fetchExternalMetadata(): Promise<LibraryTaskResponse> {
return API.fetch({
route: `/tasks/fetch-external-metadata`,
errorMessage: "Fetch failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -346,12 +362,14 @@ export default class API {
*/
static async scanLibrary(
librarySlugOrId: number | string,
): Promise<LibraryTaskResponse> {
): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/scan/${librarySlugOrId}`,
route: `/scan/${librarySlugOrId}`,
errorMessage: "Library scan failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -361,12 +379,14 @@ export default class API {
*/
static async cleanLibrary(
librarySlugOrId: number | string,
): Promise<LibraryTaskResponse> {
): Promise<TaskResponse> {
return API.fetch({
route: `/tasks/clean/${librarySlugOrId}`,
route: `/clean/${librarySlugOrId}`,
errorMessage: "Library clean failed",
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -374,11 +394,13 @@ export default class API {
* Calls for a task to scan all libraries
* @returns the status of the task
*/
static scanLibraries(): Promise<LibraryTaskResponse> {
return API.fetch<LibraryTaskResponse, []>({
route: `/tasks/scan`,
static scanLibraries(): Promise<TaskResponse> {
return API.fetch<TaskResponse, []>({
route: `/scan`,
parameters: {},
validator: LibraryTaskResponse,
service: Service.Scanner,
method: "POST",
validator: TaskResponse,
});
}
@@ -391,10 +413,12 @@ export default class API {
resourceSlugOrId: number | string,
): Promise<void> {
return API.fetch({
route: `/tasks/refresh-metadata`,
errorMessage: "Library refresh failed",
method: "POST",
route: `/refresh`,
errorMessage: "Metadata Refresh request failed",
otherParameters: { [parentResourceType]: resourceSlugOrId },
parameters: {},
service: Service.Scanner,
emptyResponse: true,
});
}
@@ -404,7 +428,7 @@ export default class API {
libraryPath: string,
): Promise<Library> {
return API.fetch({
route: "/libraries/new",
route: "/libraries",
data: { name: libraryName, path: libraryPath },
errorMessage: "Library Creation Failed",
parameters: {},
@@ -448,13 +472,13 @@ export default class API {
* Update Artist Illustration
*/
static async updateArtistIllustration(
artistSlugOrId: number | string,
artistId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
artistSlugOrId,
artistId,
illustrationUrl,
"artists",
"artist",
);
}
@@ -462,13 +486,13 @@ export default class API {
* Update Release Illustration
*/
static async updateReleaseIllustration(
releaseSlugOrId: number | string,
releaseId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
releaseSlugOrId,
releaseId,
illustrationUrl,
"releases",
"release",
);
}
@@ -476,13 +500,13 @@ export default class API {
* Update Track Illustration
*/
static async updateTrackIllustration(
trackSlugOrId: number | string,
trackId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
trackSlugOrId,
trackId,
illustrationUrl,
"tracks",
"track",
);
}
@@ -490,13 +514,13 @@ export default class API {
* Update Track Illustration
*/
static async updatePlaylistIllustration(
playlistSlugOrId: number | string,
playlistId: number,
illustrationUrl: string,
): Promise<unknown> {
return API.updateResourceIllustration(
playlistSlugOrId,
playlistId,
illustrationUrl,
"playlists",
"playlist",
);
}
@@ -504,17 +528,17 @@ export default class API {
* Update Resourse Illustration
*/
private static async updateResourceIllustration(
resourceSlugOrId: number | string,
resourceId: number,
illustrationUrl: string,
resourceType: "artists" | "releases" | "tracks" | "playlists",
resourceType: "artist" | "release" | "track" | "playlist",
): Promise<unknown> {
return API.fetch({
route: `/${resourceType}/${resourceSlugOrId}/illustration`,
route: `/illustrations/url`,
errorMessage: "Update Illustration Failed",
method: "POST",
parameters: {},
emptyResponse: true,
data: { url: illustrationUrl },
data: { url: illustrationUrl, [`${resourceType}Id`]: resourceId },
});
}
@@ -523,15 +547,15 @@ export default class API {
*/
static async updateAlbum(
albumSlugOrId: number | string,
newType: AlbumType,
): Promise<unknown> {
dto: Partial<{ type: AlbumType; masterReleaseId: number }>,
): Promise<void> {
return API.fetch({
route: `/albums/${albumSlugOrId}`,
errorMessage: "Update Album Failed",
method: "POST",
method: "PUT",
parameters: {},
emptyResponse: true,
data: { type: newType },
data: dto,
});
}
@@ -540,15 +564,29 @@ export default class API {
*/
static async updateSong(
songSlugOrId: number | string,
newType: SongType,
dto: Partial<{ type: SongType; masterTrackId: number }>,
): Promise<void> {
return API.fetch({
route: `/songs/${songSlugOrId}`,
errorMessage: "Update Song Failed",
method: "POST",
method: "PUT",
parameters: {},
emptyResponse: true,
data: { type: newType },
data: dto,
});
}
static async updateVideo(
videoSlugOrId: number | string,
dto: Partial<{ type: VideoType; masterTrackId: number }>,
): Promise<void> {
return API.fetch({
route: `/videos/${videoSlugOrId}`,
errorMessage: "Update Video Failed",
method: "PUT",
parameters: {},
emptyResponse: true,
data: dto,
});
}
@@ -695,18 +733,52 @@ export default class API {
};
}
static 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 {
key: [
"song-groups",
...API.formatObject(filter),
...API.formatObject(sort),
...API.formatIncludeKeys(include),
],
exec: (pagination) =>
API.fetch({
route: `/song-groups`,
errorMessage: "Songs could not be loaded",
parameters: { pagination: pagination, include, sort },
otherParameters: { ...filter },
validator: PaginatedResponse(
SongGroupWithRelations(include ?? []),
),
}),
};
}
/**
* Fetch all songs
* @returns An InfiniteQuery of Songs
* @returns An InfiniteQuery of Videos
*/
static getVideos<I extends SongInclude | never = never>(
static 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 SongSortingKeys>,
sort?: SortingParameters<typeof VideoSortingKeys>,
include?: I[],
): InfiniteQuery<VideoWithRelations<I>> {
return {
@@ -720,7 +792,11 @@ export default class API {
API.fetch({
route: `/videos`,
errorMessage: "Videos could not be loaded",
parameters: { pagination: pagination, include, sort },
parameters: {
pagination: pagination,
include,
sort,
},
otherParameters: filter,
validator: PaginatedResponse(
VideoWithRelations(include ?? []),
@@ -749,13 +825,28 @@ export default class API {
};
}
static getVideo<I extends VideoInclude | never = never>(
videoSlugOrId: string | number,
include?: I[],
): Query<VideoWithRelations<I>> {
return {
key: ["video", videoSlugOrId, ...API.formatIncludeKeys(include)],
exec: () =>
API.fetch({
route: `/videos/${videoSlugOrId}`,
parameters: { include },
validator: VideoWithRelations(include ?? []),
}),
};
}
/**
* Get the master track of a song
* @param songSlugOrId the identifier of a song
* @param include the fields to include in the fetched item
* @returns a Query for a Track
*/
static getMasterTrack<I extends TrackInclude | never = never>(
static getSongMasterTrack<I extends TrackInclude | never = never>(
songSlugOrId: string | number,
include?: I[],
): Query<TrackWithRelations<I>> {
@@ -768,7 +859,7 @@ export default class API {
],
exec: () =>
API.fetch({
route: `/tracks/master/${songSlugOrId}`,
route: `/tracks/master/song/${songSlugOrId}`,
parameters: { include },
validator: TrackWithRelations(include ?? []),
}),
@@ -844,14 +935,42 @@ export default class API {
key: ["search", query],
exec: () =>
API.fetch({
route: `/search/${query}`,
route: `/search`,
errorMessage: "Search failed",
parameters: {},
otherParameters: { query },
customValidator: SearchResultTransformer,
}),
};
}
static getSearchHistory(): Query<SearchResult[]> {
return {
key: ["search-history-items"],
exec: () =>
API.fetch({
route: `/search/history`,
errorMessage: "Getting Search History failed",
parameters: {},
customValidator: SearchResultTransformer,
}),
};
}
/**
* Search artists, albums and songs, all at once
*/
static saveSearchHistoryEntry(resource: SaveSearchItem): Promise<void> {
return API.fetch({
method: "POST",
route: `/search/history`,
errorMessage: "Saving Search History failed",
parameters: {},
data: resource,
emptyResponse: true,
});
}
/**
* Get the User object of the authentified user
* @returns A query to a User object
@@ -1007,6 +1126,7 @@ export default class API {
*/
static getReleaseTracklist<I extends SongInclude | never = never>(
slugOrId: string | number,
exclusiveOnly: boolean = false,
include?: I[],
): InfiniteQuery<TracklistItemWithRelations<I>> {
return {
@@ -1014,12 +1134,14 @@ export default class API {
"release",
slugOrId,
"tracklist",
exclusiveOnly ? "exclusives" : "",
...API.formatIncludeKeys(include),
],
exec: (pagination) =>
API.fetch({
route: `/releases/${slugOrId.toString()}/tracklist`,
parameters: { include, pagination },
otherParameters: { exclusive: exclusiveOnly },
validator: PaginatedResponse(
TracklistItemWithRelations(include ?? []),
),
@@ -1110,6 +1232,52 @@ export default class API {
};
}
static getArtistExternalMetadata(
slugOrId: string | number,
): Query<ArtistExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"artist",
ArtistExternalMetadata,
);
}
static getSongExternalMetadata(
slugOrId: string | number,
): Query<SongExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"song",
SongExternalMetadata,
);
}
static getAlbumExternalMetadata(
slugOrId: string | number,
): Query<AlbumExternalMetadata | null> {
return API.getResourceExternalMetadata(
slugOrId,
"album",
AlbumExternalMetadata,
);
}
static getResourceExternalMetadata<T, V extends yup.Schema<T>>(
resourceSlugOrId: string | number,
resourceType: "artist" | "album" | "song",
validator: V,
): Query<T | null> {
return {
key: [resourceType, resourceSlugOrId, "external-metadata"],
exec: () =>
API.fetch({
route: `/external-metadata?${resourceType}=${resourceSlugOrId}`,
errorMessage: "Metadata could not be loaded",
parameters: {},
validator: validator,
}).catch(() => null),
};
}
private static async fetch<ReturnType, Keys extends readonly string[]>({
route,
parameters,
@@ -1120,6 +1288,7 @@ export default class API {
validator,
customValidator,
emptyResponse,
service,
}: FetchParameters<Keys, ReturnType>): Promise<ReturnType> {
const accessToken = store.getState().user.accessToken;
const header = {
@@ -1127,7 +1296,7 @@ export default class API {
};
const response = await fetch(
this.buildURL(route, parameters, otherParameters),
this.buildURL(route, parameters, otherParameters, service),
{
method: method ?? "GET",
body: data ? JSON.stringify(data) : undefined,
@@ -1223,10 +1392,10 @@ export default class API {
playlistId: number,
): Promise<unknown> {
return API.fetch({
route: `/playlists/entries/new`,
route: `/playlists/${playlistId}/entries`,
errorMessage: "Failed to add song to playlist",
parameters: {},
data: { songId, playlistId },
data: { songId },
method: "POST",
emptyResponse: true,
});
@@ -1280,51 +1449,18 @@ export default class API {
});
}
/**
* Mark a release as master
* @param releaseSlugOrId
* @returns
*/
static async setReleaseAsMaster(
releaseSlugOrId: string | number,
): Promise<unknown> {
return API.fetch({
route: `/releases/${releaseSlugOrId}/master`,
errorMessage: "Release update failed",
parameters: {},
method: "PUT",
validator: yup.mixed(),
});
}
/**
* Mark a track as master
* @param trackSlugOrId
* @returns
*/
static async setTrackAsMaster(
trackSlugOrId: string | number,
): Promise<unknown> {
return API.fetch({
route: `/tracks/${trackSlugOrId}/master`,
errorMessage: "Track update failed",
parameters: {},
method: "PUT",
validator: yup.mixed(),
});
}
private static buildURL(
route: string,
parameters: QueryParameters<any>,
otherParameters?: any,
service: Service = Service.API,
): string {
const apiHost = isSSR() ? this.SSR_API_URL : "/api";
const scannerHost = isSSR() ? this.SSR_SCANNER_URL : "/scanner";
return `${apiHost}${route}${this.formatQueryParameters(
parameters,
otherParameters,
)}`;
return `${
service == Service.API ? apiHost : scannerHost
}${route}${this.formatQueryParameters(parameters, otherParameters)}`;
}
private static formatQueryParameters<Keys extends string[]>(
@@ -1,94 +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 { QueryClient } from "../../api/use-query";
import Action from "../actions/action";
import { useConfirm } from "material-ui-confirm";
import Album, { AlbumType } from "../../models/album";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import API from "../../api/api";
import { useMutation } from "react-query";
import { EditIcon } from "../icons";
import { useTranslation } from "react-i18next";
const AlbumTypeForm = (props: {
defaultValue: AlbumType;
onSelect: (type: AlbumType) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{AlbumType.map((type) => (
<Grid item key={type}>
<Chip
label={t(type)}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeAlbumType = (
album: Album,
queryClient: QueryClient,
confirm: ReturnType<typeof useConfirm>,
): Action => {
const { t } = useTranslation();
const mutation = useMutation((newType: AlbumType) => {
return API.updateAlbum(album.id, newType)
.then(() => {
queryClient.client.invalidateQueries("albums");
})
.catch((error: Error) => toast.error(error.message));
});
return {
label: "changeAlbumType",
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: t("changeAlbumType"),
description: (
<AlbumTypeForm
defaultValue={album.type}
onSelect={(type) => mutation.mutate(type)}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: t("done"),
}),
};
};
export default ChangeAlbumType;
+7 -11
View File
@@ -18,18 +18,20 @@
import toast from "react-hot-toast";
import API from "../../api/api";
import LibraryTaskResponse from "../../models/library-task-response";
import { TaskResponse } from "../../models/task";
import Action from "./action";
import { CleanIcon, MetadataRefreshIcon, ScanIcon } from "../icons";
import { CleanIcon, ScanIcon } from "../icons";
/**
* Using the resolved value of the task porimise, triggers an appropriate toast
* @param task the returned promised from an API call to run a task
*/
const handleTask = <T extends LibraryTaskResponse>(task: Promise<T>) =>
const handleTask = <T extends TaskResponse>(task: Promise<T>) =>
task
.then(({ status }) => toast.success(status))
.catch(({ status }) => toast.error(status));
.then((s) => toast.success(s.message))
.catch((s) =>
toast.error(s.message ?? s.status ?? "Task request failed"),
);
export const ScanAllLibrariesAction: Action = {
label: "scanLibraries",
@@ -58,9 +60,3 @@ export const CleanLibraryAction = (
icon: CleanAllLibrariesAction.icon,
onClick: () => handleTask(API.cleanLibrary(librarySlugOrId)),
});
export const FetchExternalMetadata: Action = {
label: "fetchMetadata",
icon: <MetadataRefreshIcon />,
onClick: () => handleTask(API.fetchExternalMetadata()),
};
+9 -2
View File
@@ -27,6 +27,7 @@ import {
} from "../icons";
import { NextRouter } from "next/router";
import Action from "./action";
import toast from "react-hot-toast";
export const GoToSongLyricsAction = (
songIdentifier: string | number,
@@ -76,10 +77,16 @@ export const GoToReleaseAction = (
export const GoToReleaseAsyncAction = (
router: NextRouter,
albumIdentifier: () => PromiseLike<number | string>,
albumIdentifier: () => PromiseLike<number | string | null>,
): Action => ({
onClick: () =>
albumIdentifier().then((id) => router.push(`/releases/${id}`)),
albumIdentifier().then((id) => {
if (id === null) {
toast.error("This resource is not attached to any album.");
} else {
router.push(`/releases/${id}`);
}
}),
label: "goToAlbum",
icon: <AlbumIcon />,
});
@@ -0,0 +1,177 @@
/*
* 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 { QueryClient } from "../../api/use-query";
import Action from "./action";
import { useConfirm } from "material-ui-confirm";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import { EditIcon } from "../icons";
import { useTranslation } from "react-i18next";
import { TranslationKey } from "../../i18n/i18n";
import Song, { SongType } from "../../models/song";
import API from "../../api/api";
import Album, { AlbumType } from "../../models/album";
import Video, { VideoType } from "../../models/video";
const ResourceTypeForm = <Enum extends TranslationKey>(props: {
defaultValue?: Enum;
values: readonly Enum[];
onSelect: (type: Enum) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{props.values.map((type) => (
<Grid item key={type}>
<Chip
label={t(type) as string}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeResourceType = <
T extends { type: TypeEnum },
TypeEnum extends TranslationKey,
>(
resource: T,
types: readonly TypeEnum[],
label: TranslationKey,
queryClient: QueryClient,
onSelect: (newType: TypeEnum) => Promise<void>,
confirm: ReturnType<typeof useConfirm>,
): Action => {
return {
label: label,
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: <br />,
description: (
<ResourceTypeForm
defaultValue={resource.type}
values={types}
onSelect={(type) =>
toast
.promise(onSelect(type), {
loading: null,
success: "Update successful!",
error: "Update failed...",
})
.then(() => {
queryClient.client.invalidateQueries(
"songs",
);
queryClient.client.invalidateQueries(
"release",
);
queryClient.client.invalidateQueries(
"tracks",
);
queryClient.client.invalidateQueries(
"videos",
);
queryClient.client.invalidateQueries(
"bsides",
);
})
}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: "OK",
}),
};
};
const ChangeSongType = (
s: Song,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
s,
SongType.filter((t) => t != "Unknown"),
"changeSongType",
client,
(newType: SongType) =>
API.updateSong(s.id, { type: newType }).then((res) => {
client.client.invalidateQueries("songs");
client.client.invalidateQueries("release");
client.client.invalidateQueries("tracks");
client.client.invalidateQueries("videos");
client.client.invalidateQueries("bsides");
return res;
}),
confirm,
);
const ChangeAlbumType = (
a: Album,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
a,
AlbumType,
"changeAlbumType",
client,
(newType: AlbumType) =>
API.updateAlbum(a.id, { type: newType }).then((res) => {
client.client.invalidateQueries("albums");
return res;
}),
confirm,
);
const ChangeVideoType = (
v: Video,
client: QueryClient,
confirm: ReturnType<typeof useConfirm>,
) =>
ChangeResourceType(
v,
VideoType,
"changeVideoType",
client,
(newType: VideoType) =>
API.updateVideo(v.id, { type: newType }).then((res) => {
client.client.invalidateQueries("videos");
return res;
}),
confirm,
);
export { ChangeSongType, ChangeAlbumType, ChangeVideoType };
@@ -41,6 +41,6 @@ export const ShowMasterTrackFileInfoAction = (
label: "moreInfo",
onClick: () =>
queryClient
.fetchQuery(API.getMasterTrack(songId))
.fetchQuery(API.getSongMasterTrack(songId))
.then((track) => openTrackFileInfoModal(confirm, track.id)),
});
@@ -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 { QueryClient } from "../../api/use-query";
import Action from "./action";
import { useConfirm } from "material-ui-confirm";
import store from "../../state/store";
import { Chip, Grid } from "@mui/material";
import { useState } from "react";
import toast from "react-hot-toast";
import API from "../../api/api";
import { useMutation } from "react-query";
import { EditIcon } from "../icons";
import Song, { SongType } from "../../models/song";
import { useTranslation } from "react-i18next";
const SongTypeForm = (props: {
defaultValue: SongType;
onSelect: (type: SongType) => void;
}) => {
const [currentType, setType] = useState(props.defaultValue);
const { t } = useTranslation();
return (
<>
<Grid container spacing={2} justifyContent="center">
{SongType.filter((type) => type != "Unknown").map((type) => (
<Grid item key={type}>
<Chip
label={t(type)}
variant={
type == currentType ? "filled" : "outlined"
}
onClick={() => {
setType(type);
props.onSelect(type);
}}
/>
</Grid>
))}
</Grid>
</>
);
};
const ChangeSongType = (
song: Song,
queryClient: QueryClient,
confirm: ReturnType<typeof useConfirm>,
): Action => {
const { t } = useTranslation();
const mutation = useMutation((newType: SongType) => {
return API.updateSong(song.id, newType)
.then(() => {
toast.success("Update successful!");
queryClient.client.invalidateQueries("songs");
queryClient.client.invalidateQueries("release");
queryClient.client.invalidateQueries("tracks");
queryClient.client.invalidateQueries("videos");
queryClient.client.invalidateQueries("bsides");
})
.catch((error: Error) => toast.error(error.message));
});
return {
label: "changeSongType",
icon: <EditIcon />,
disabled: store.getState().user.user?.admin !== true,
onClick: () =>
confirm({
title: t("changeSongType"),
description: (
<SongTypeForm
defaultValue={song.type}
onSelect={(type) => mutation.mutate(type)}
/>
),
cancellationButtonProps: { sx: { display: "none" } },
confirmationText: t("done"),
}),
};
};
export default ChangeSongType;
@@ -83,7 +83,7 @@ const IllustrationUpdateForm = (props: IllustrationUpdateFormType) => {
const UpdateIllustrationAction = (
queryClient: QueryClient,
resourceId: number | string,
resourceId: number,
resourceType: "artist" | "track" | "release" | "playlist",
): Action => {
const textFieldId = `update-illustration-${resourceType}-${resourceId}`;
@@ -121,23 +121,23 @@ const UpdateIllustrationAction = (
const UpdateArtistIllustrationAction = (
queryClient: QueryClient,
artistSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, artistSlugOrId, "artist");
artistId: number,
) => UpdateIllustrationAction(queryClient, artistId, "artist");
const UpdateReleaseIllustrationAction = (
queryClient: QueryClient,
releaseSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, releaseSlugOrId, "release");
releaseId: number,
) => UpdateIllustrationAction(queryClient, releaseId, "release");
const UpdateTrackIllustrationAction = (
queryClient: QueryClient,
trackSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, trackSlugOrId, "track");
trackId: number,
) => UpdateIllustrationAction(queryClient, trackId, "track");
const UpdatePlaylistIllustrationAction = (
queryClient: QueryClient,
playlistSlugOrId: number | string,
) => UpdateIllustrationAction(queryClient, playlistSlugOrId, "playlist");
playlistId: number,
) => UpdateIllustrationAction(queryClient, playlistId, "playlist");
export {
UpdateArtistIllustrationAction,
@@ -24,7 +24,7 @@ import { ShareAlbumAction } from "../actions/share";
import ContextualMenu from "./contextual-menu";
import { AlbumWithRelations } from "../../models/album";
import { useQueryClient } from "../../api/use-query";
import ChangeAlbumType from "../actions/album-type";
import { ChangeAlbumType } from "../actions/resource-type";
import { RefreshAlbumMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
@@ -53,38 +53,41 @@ const ContextualMenu = (props: ContextualMenuProps) => {
onClose={handleClose}
style={{ zIndex: 99999 }}
>
{props.actions.map((actions, actionGroupIndex, allActions) =>
[
actions.map((action, actionIndex) => (
<Box
key={`${actionGroupIndex}/${actionIndex}`}
onClick={() => {
// If the action is NOT a dialog, close and unmount the menu.
// If it is a dialog, we need to keep it mounted, so we have to keep it open
if (!action.dialog) {
handleClose();
}
props.onSelect && props.onSelect(action);
}}
>
<ContextualMenuItem
{...action}
onDialogClose={handleClose}
/>
</Box>
)),
].concat(
actionGroupIndex < allActions.length - 1
? [
<Divider
key={actionGroupIndex}
sx={{ marginY: 0.5 }}
variant="middle"
/>,
]
: [],
),
)}
{props.actions
.filter((actionList) => actionList.length > 0)
.map((actions, actionGroupIndex, allActions) =>
[
actions.map((action, actionIndex) => (
<Box
key={`${actionGroupIndex}/${actionIndex}`}
onClick={() => {
// If the action is NOT a dialog, close and unmount the menu.
// If it is a dialog, we need to keep it mounted, so we have to keep it open
if (!action.dialog) {
handleClose();
}
props.onSelect &&
props.onSelect(action);
}}
>
<ContextualMenuItem
{...action}
onDialogClose={handleClose}
/>
</Box>
)),
].concat(
actionGroupIndex < allActions.length - 1
? [
<Divider
key={actionGroupIndex}
sx={{ marginY: 0.5 }}
variant="middle"
/>,
]
: [],
),
)}
</Menu>
</>
);
@@ -48,7 +48,7 @@ const PlaylistContextualMenu = (props: PlaylistContextualMenuProps) => {
[
UpdatePlaylistIllustrationAction(
queryClient,
props.playlist.slug,
props.playlist.id,
),
],
[SharePlaylistAction(props.playlist.slug, t)],
@@ -30,7 +30,7 @@ import { DownloadReleaseAction } from "../actions/download";
import { useConfirm } from "material-ui-confirm";
import { ReleaseWithRelations } from "../../models/release";
import { UpdateReleaseIllustrationAction } from "../actions/update-illustration";
import ChangeAlbumType from "../actions/album-type";
import { ChangeAlbumType } from "../actions/resource-type";
import { RefreshReleaseMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
@@ -46,7 +46,9 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
const confirm = useConfirm();
const { t } = useTranslation();
const masterMutation = useMutation(async () => {
return API.setReleaseAsMaster(props.release.id)
return API.updateAlbum(props.release.albumId, {
masterReleaseId: props.release.id,
})
.then(() => {
toast.success(t("releaseSetAsMaster"));
queryClient.client.invalidateQueries();
@@ -64,7 +66,12 @@ const ReleaseContextualMenu = (props: ReleaseContextualMenuProps) => {
Promise.allSettled(
tracks
.reverse()
.map((track) => API.setTrackAsMaster(track.id)),
.filter((track) => track.songId != null)
.map((track) =>
API.updateSong(track.songId!, {
masterTrackId: track.id,
}),
),
)
.then(() => {
toast.success(t("tracksUpdated"));
@@ -37,7 +37,7 @@ import { TrackWithRelations } from "../../models/track";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { useQueryClient } from "../../api/use-query";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import ChangeSongType from "../actions/song-type";
import { ChangeSongType } from "../actions/resource-type";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
@@ -48,7 +48,7 @@ type ReleaseTrackContextualMenuProps = {
};
const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
const songSlug = props.track.song.slug;
const songSlug = props.track.song?.slug;
const confirm = useConfirm();
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -59,25 +59,37 @@ const ReleaseTrackContextualMenu = (props: ReleaseTrackContextualMenuProps) => {
onSelect={props.onSelect}
actions={[
[GoToArtistAction(props.artist.slug)],
[GoToSongLyricsAction(songSlug)],
songSlug ? [GoToSongLyricsAction(songSlug)] : [],
[
PlayNextAction(async () => props, playNext),
PlayAfterAction(async () => props, playAfter),
],
[AddToPlaylistAction(props.track.song.id, queryClient)],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
: [],
songSlug
? [
GoToSongVersionAction(songSlug),
GoToRelatedTracksAction(songSlug),
]
: [],
[
GoToSongVersionAction(songSlug),
GoToRelatedTracksAction(songSlug),
],
[
ChangeSongType(props.track.song, queryClient, confirm),
...(props.track.song
? [
ChangeSongType(
props.track.song,
queryClient,
confirm,
),
]
: []),
UpdateTrackIllustrationAction(queryClient, props.track.id),
RefreshTrackMetadataAction(props.track.id, t),
],
[ShowTrackFileInfoAction(confirm, props.track.id)],
[
DownloadAction(confirm, props.track.sourceFileId, t),
ShareSongAction(songSlug, t),
...(songSlug ? [ShareSongAction(songSlug, t)] : []),
],
]}
/>
@@ -38,7 +38,7 @@ import { ShowMasterTrackFileInfoAction } from "../actions/show-track-info";
import { SongWithRelations } from "../../models/song";
import { useQueryClient } from "../../api/use-query";
import { toast } from "react-hot-toast";
import ChangeSongType from "../actions/song-type";
import { ChangeSongType } from "../actions/resource-type";
import { RefreshSongMetadataAction } from "../actions/refresh-metadata";
import { DeleteIcon } from "../icons";
import { useTranslation } from "react-i18next";
@@ -58,7 +58,7 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
const { playNext, playAfter } = usePlayerContext();
const getMasterTrack = () =>
queryClient.fetchQuery(
API.getMasterTrack(songSlug, ["release", "illustration"]),
API.getSongMasterTrack(songSlug, ["release", "illustration"]),
);
const router = useRouter();
const confirm = useConfirm();
@@ -76,7 +76,8 @@ const SongContextualMenu = (props: SongContextualMenuProps) => {
GoToArtistAction(props.song.artist.slug),
GoToReleaseAsyncAction(
router,
async () => (await getMasterTrack()).release.slug,
async () =>
(await getMasterTrack()).release?.slug ?? null,
),
],
[GoToSongLyricsAction(songSlug)],
@@ -26,11 +26,7 @@ import { RootState } from "../../state/store";
import ContextualMenu from "./contextual-menu";
import { useConfirm } from "material-ui-confirm";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongLyricsAction,
} from "../actions/link";
import { GoToReleaseAction, GoToSongLyricsAction } from "../actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
@@ -40,13 +36,13 @@ import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { TrackWithRelations } from "../../models/track";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import ChangeSongType from "../actions/song-type";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
import Action from "../actions/action";
import { ChangeSongType } from "../actions/resource-type";
type TrackContextualMenuProps = {
track: TrackWithRelations<"song" | "illustration">;
isVideo?: true; // If true, will add 'go to artist' to menu
track: TrackWithRelations<"video" | "song" | "illustration">;
onSelect?: () => void;
};
@@ -56,11 +52,16 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
);
const queryClient = useQueryClient();
const confirm = useConfirm();
const isMaster = props.track.song.masterId == props.track.id;
const isMaster = props.track.song?.masterId === props.track.id;
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(API.getArtist(props.track.song.artistId)),
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
queryClient.fetchQuery(
API.getArtist(
(props.track.song ?? props.track.video)!.artistId,
),
),
props.track.releaseId &&
queryClient.fetchQuery(API.getRelease(props.track.releaseId)),
]).then(([artist, release]) => ({
track: props.track,
artist,
@@ -69,7 +70,9 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
const masterMutation = useMutation(async () => {
return API.setTrackAsMaster(props.track.id)
return API.updateSong(props.track.songId!, {
masterTrackId: props.track.id,
})
.then(() => {
toast.success(t("trackSetAsMaster"));
queryClient.client.invalidateQueries();
@@ -81,28 +84,41 @@ const TrackContextualMenu = (props: TrackContextualMenuProps) => {
<ContextualMenu
onSelect={props.onSelect}
actions={[
props.isVideo
? [
GoToArtistAction(props.track.song.artistId),
GoToReleaseAction(props.track.releaseId),
]
: [GoToReleaseAction(props.track.releaseId)],
[GoToSongLyricsAction(props.track.song.slug)],
[
props.track.releaseId
? GoToReleaseAction(props.track.releaseId)
: undefined,
].filter((a): a is Action => a !== undefined),
props.track.song
? [GoToSongLyricsAction(props.track.song.slug)]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
],
[AddToPlaylistAction(props.track.songId, queryClient)],
props.track.songId
? [AddToPlaylistAction(props.track.songId, queryClient)]
: [],
props.track.songId
? [
{
label: "setAsMaster",
disabled: isMaster || !userIsAdmin,
icon: <MasterIcon />,
onClick: () => masterMutation.mutate(),
},
]
: [],
[
{
label: "setAsMaster",
disabled: isMaster || !userIsAdmin,
icon: <MasterIcon />,
onClick: () => masterMutation.mutate(),
},
],
[
ChangeSongType(props.track.song, queryClient, confirm),
...(props.track.song
? [
ChangeSongType(
props.track.song,
queryClient,
confirm,
),
]
: []),
UpdateTrackIllustrationAction(queryClient, props.track.id),
RefreshTrackMetadataAction(props.track.id, t),
],
@@ -0,0 +1,104 @@
/*
* 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 { useQueryClient } from "../../api/use-query";
import API from "../../api/api";
import ContextualMenu from "./contextual-menu";
import { useConfirm } from "material-ui-confirm";
import { DownloadAction } from "../actions/download";
import {
GoToArtistAction,
GoToReleaseAction,
GoToSongLyricsAction,
} from "../actions/link";
import {
AddToPlaylistAction,
PlayAfterAction,
PlayNextAction,
} from "../actions/playlist";
import { ShowTrackFileInfoAction } from "../actions/show-track-info";
import { UpdateTrackIllustrationAction } from "../actions/update-illustration";
import { RefreshTrackMetadataAction } from "../actions/refresh-metadata";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
import Action from "../actions/action";
import { VideoWithRelations } from "../../models/video";
import { ChangeVideoType } from "../actions/resource-type";
type VideoContextualMenuProps = {
video: VideoWithRelations<"master" | "illustration">;
onSelect?: () => void;
};
const VideoContextualMenu = (props: VideoContextualMenuProps) => {
const queryClient = useQueryClient();
const confirm = useConfirm();
const getPlayNextProps = () =>
Promise.all([
queryClient.fetchQuery(API.getArtist(props.video.artistId)),
props.video.master.releaseId &&
queryClient.fetchQuery(
API.getRelease(props.video.master.releaseId),
),
]).then(([artist, release]) => ({
track: {
...props.video.master,
illustration: props.video.illustration,
},
artist,
release,
}));
const { t } = useTranslation();
const { playNext, playAfter } = usePlayerContext();
return (
<ContextualMenu
onSelect={props.onSelect}
actions={[
[
GoToArtistAction(props.video.artistId),
props.video.master.releaseId
? GoToReleaseAction(props.video.master.releaseId)
: undefined,
].filter((a): a is Action => a !== undefined),
props.video.songId
? [GoToSongLyricsAction(props.video.songId)]
: [],
[
PlayNextAction(getPlayNextProps, playNext),
PlayAfterAction(getPlayNextProps, playAfter),
],
props.video.songId
? [AddToPlaylistAction(props.video.songId, queryClient)]
: [],
[
ChangeVideoType(props.video, queryClient, confirm),
UpdateTrackIllustrationAction(
queryClient,
props.video.master.id,
),
RefreshTrackMetadataAction(props.video.master.id, t),
],
[ShowTrackFileInfoAction(confirm, props.video.master.id)],
[DownloadAction(confirm, props.video.master.sourceFileId, t)],
]}
/>
);
};
export default VideoContextualMenu;
+33 -1
View File
@@ -43,6 +43,12 @@ import Fade from "../fade";
import globalLibrary from "../../utils/global-library";
import { useTranslation } from "react-i18next";
export type Toggle = {
name: string;
label: TranslationKey;
defaultValue?: boolean;
};
export type OptionState<
SortingKeys extends readonly string[],
AdditionalProps extends object = object,
@@ -60,6 +66,7 @@ type ControllerProps<
> = {
options?: Options;
actions?: Action[];
toggles?: Toggle[];
disableSorting?: true;
sortingKeys: SortingKeys;
defaultSortingKey?: SortingKeys[number];
@@ -130,6 +137,15 @@ const Controls = <
option.values,
) ?? option.values[0];
});
props.toggles?.forEach((option) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
baseOptions[option.name] =
(parseQueryParam(props.router?.query[option.name], [
"false",
"true",
]) ?? "false") === "true";
});
return baseOptions;
},
);
@@ -148,9 +164,10 @@ const Controls = <
value,
}: {
name: string;
value: string | null;
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(
@@ -283,6 +300,21 @@ const Controls = <
/>
);
})}
{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
@@ -17,35 +17,35 @@
*/
import { Box, Button, Skeleton } from "@mui/material";
import ExternalId from "../models/external-id";
import Illustration from "./illustration";
import Link from "next/link";
import { ExternalMetadataSource } from "../models/external-metadata";
type ExternalIdBadgeProps = {
externalId: ExternalId | undefined;
type ExternalMetadataBadgeProps = {
source: ExternalMetadataSource | undefined;
};
const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
const ExternalMetadataBadge = ({ source }: ExternalMetadataBadgeProps) => {
const badge = (
<Button
variant="outlined"
startIcon={
<Box sx={{ width: 30 }}>
<Illustration
url={externalId?.provider.icon ?? undefined}
url={source?.providerIcon ?? undefined}
quality="original"
/>
</Box>
}
>
{externalId?.provider.name ?? <Skeleton width="50px" />}
{source?.providerName ?? <Skeleton width="50px" />}
</Button>
);
if (externalId?.url) {
if (source?.url) {
return (
<Link
href={externalId.url ?? undefined}
href={source.url ?? undefined}
rel="noopener noreferrer"
target="_blank"
>
@@ -56,4 +56,4 @@ const ExternalIdBadge = ({ externalId }: ExternalIdBadgeProps) => {
return badge;
};
export default ExternalIdBadge;
export default ExternalMetadataBadge;
@@ -20,30 +20,23 @@ import { useTranslation } from "react-i18next";
import { AlbumWithRelations } from "../../models/album";
import getYear from "../../utils/getYear";
import HighlightCard from "./highlight-card";
import { AlbumExternalMetadata } from "../../models/external-metadata";
type AlbumHighlightCardProps = {
album:
| AlbumWithRelations<
"artist" | "externalIds" | "genres" | "illustration"
>
| undefined;
album: AlbumWithRelations<"artist" | "genres" | "illustration"> | undefined;
externalMetadata: AlbumExternalMetadata | undefined;
};
const AlbumHighlightCard = ({ album }: AlbumHighlightCardProps) => {
const AlbumHighlightCard = ({
album,
externalMetadata,
}: AlbumHighlightCardProps) => {
const { t } = useTranslation();
return (
<HighlightCard
title={album?.name}
headline={album?.name}
body={
album?.externalIds
.filter(
({ provider }) =>
provider.name.toLowerCase() !== "discogs",
)
.map((id) => id.description)
.filter((desc): desc is string => desc !== null)
.sort((descA, descB) => descA.length - descB.length)
.at(0) ||
externalMetadata?.description ||
[
album ? album.artist?.name ?? t("compilation") : undefined,
album ? getYear(album.releaseDate) : undefined,
@@ -35,7 +35,6 @@ type HighlightCardProps = {
const HighlightCard = (props: HighlightCardProps) => {
const theme = useTheme();
const accentColor = useAccentColor(props.illustration);
const cardColor = useMemo(() => {
if (accentColor !== null) {
const themePaperColor = `rgba(${theme.vars.palette.background.defaultChannel} / 0.75)`;
@@ -52,6 +51,16 @@ const HighlightCard = (props: HighlightCardProps) => {
backgroundColor: `rgba(${theme.vars.palette.background.defaultChannel} / 0.40)`,
};
}, [accentColor, theme]);
const scrollbarColorStyle = useMemo(() => {
return {
[theme.getColorSchemeSelector("light")]: {
scrollbarColor: `${accentColor?.light} transparent`,
},
[theme.getColorSchemeSelector("dark")]: {
scrollbarColor: `${accentColor?.dark} transparent`,
},
};
}, [accentColor, theme]);
const style = {
...cardColor,
boxShadow: "none",
@@ -110,10 +119,12 @@ const HighlightCard = (props: HighlightCardProps) => {
item
xs
sx={{
overflow: "scroll",
overflowY: "scroll",
overflowX: "clip",
marginRight: -2,
paddingY: 1,
paddingRight: 2,
...scrollbarColorStyle,
}}
>
<Typography
@@ -59,9 +59,9 @@ const InfiniteGrid = <T extends IllustratedResource>(
item
xs={6}
sm={3}
md={12 / 5}
// No MD because we get more space when sidebar disappears
lg={2}
xl={1.2}
xl={12 / 8}
key={`item-${index}`}
>
{props.render(item)}
@@ -95,12 +95,14 @@ const InfiniteAlbumView = (
}
renderListItem={(item) => (
<AlbumItem
onClick={() => item && props.onItemClick?.(item)}
album={item}
formatSubtitle={props.formatSubtitle}
/>
)}
renderGridItem={(item) => (
<AlbumTile
onClick={() => item && props.onItemClick?.(item)}
album={item}
formatSubtitle={props.formatSubtitle}
/>
@@ -62,8 +62,18 @@ const InfiniteArtistView = (
"asc",
})
}
renderListItem={(item) => <ArtistItem artist={item} />}
renderGridItem={(item) => <ArtistTile artist={item} />}
renderListItem={(item) => (
<ArtistItem
artist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<ArtistTile
artist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
);
@@ -69,8 +69,18 @@ const InfinitePlaylistView = (
view: "grid",
})
}
renderListItem={(item) => <PlaylistItem playlist={item} />}
renderGridItem={(item) => <PlaylistTile playlist={item} />}
renderListItem={(item) => (
<PlaylistItem
playlist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<PlaylistTile
playlist={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
);
@@ -31,6 +31,7 @@ type InfiniteResourceViewProps<
options: OptionState<SortingKeys> & AdditionalQueryParams,
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
light?: boolean;
onItemClick?: (item: ResourceType) => void;
disableSorting?: true;
defaultLayout?: LayoutOption;
initialSortingOrder?: Order;
@@ -24,7 +24,7 @@ import {
SongWithRelations,
} from "../../../models/song";
import Controls, { OptionState } from "../../controls/controls";
import SongItem from "../../list-item/song-item";
import SongItem, { SongGroupItem } from "../../list-item/song-item";
import InfiniteView from "../infinite-view";
import InfiniteResourceViewProps from "./infinite-resource-view-props";
import { PlayIcon, ShuffleIcon } from "../../icons";
@@ -35,9 +35,15 @@ import {
useQueryClient,
} from "../../../api/use-query";
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
import {
SongGroupSortingKeys,
SongGroupWithRelations,
} from "../../../models/song-group";
type AdditionalProps = {
type?: SongType;
// if true, show song groups instead of songs
groups?: boolean;
random?: number;
};
@@ -89,10 +95,18 @@ const InfiniteSongView = <
typeof SongSortingKeys,
AdditionalProps
> &
Pick<ComponentProps<typeof SongItem<T>>, "formatSubtitle"> & {
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
disableShuffle?: boolean;
groupsQuery?: (
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
) => InfiniteQuery<
SongGroupWithRelations<
"artist" | "featuring" | "master" | "illustration"
>
>;
},
) => {
const disableGroupingVersions = !props.groupsQuery;
const router = useRouter();
const [options, setOptions] =
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
@@ -105,6 +119,7 @@ const InfiniteSongView = <
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
order: options?.order ?? props.initialSortingOrder ?? "asc",
view: "grid",
groups: disableGroupingVersions ? false : options?.groups ?? false,
library: options?.library ?? null,
} as const;
const shuffleAction = {
@@ -154,28 +169,69 @@ const InfiniteSongView = <
]}
actions={[
playAction,
...(props.disableShuffle !== true ? [shuffleAction] : []),
...(props.disableShuffle !== true && !options?.groups
? [shuffleAction]
: []),
]}
disableSorting={props.disableSorting}
toggles={
disableGroupingVersions
? []
: [
{
name: "groups",
label: options?.groups
? "showAllSongs"
: "groupVersions",
},
]
}
disableSorting={options?.groups || props.disableSorting}
onChange={setOptions}
sortingKeys={SongSortingKeys}
sortingKeys={
options?.groups ? SongGroupSortingKeys : SongSortingKeys
}
defaultSortingOrder={props.initialSortingOrder}
defaultSortingKey={props.initialSortingField}
router={props.light == true ? undefined : router}
disableLayoutToggle
defaultLayout={"list"}
/>
<InfiniteView
view={options?.view ?? "list"}
query={() => props.query(query)}
renderListItem={(item) => (
<SongItem
song={item}
formatSubtitle={props.formatSubtitle}
/>
)}
renderGridItem={(item) => <></>}
/>
{options?.groups ? (
<InfiniteView
view={"list"}
query={() => {
return props.groupsQuery!({
...query,
sortBy: "name",
});
}}
renderListItem={(item) => (
<SongGroupItem
song={item}
subtitles={props.subtitles}
/>
)}
renderGridItem={() => <></>}
/>
) : (
<InfiniteView
view={"list"}
query={() => {
return props.query({
...query,
sortBy: query.sortBy,
});
}}
renderListItem={(item) => (
<SongItem
song={item}
subtitles={props.subtitles}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={() => <></>}
/>
)}
</>
);
};
@@ -26,7 +26,7 @@ import InfiniteView from "../infinite-view";
const InfiniteTrackView = (
props: InfiniteResourceViewProps<
TrackWithRelations<"song" | "release" | "illustration">,
TrackWithRelations<"video" | "song" | "release" | "illustration">,
typeof TrackSortingKeys
>,
) => {
@@ -55,7 +55,12 @@ const InfiniteTrackView = (
order: options?.order ?? "asc",
})
}
renderListItem={(item) => <TrackItem track={item} />}
renderListItem={(item) => (
<TrackItem
track={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => <></>}
/>
</>
@@ -18,65 +18,157 @@
import { useRouter } from "next/router";
import { ComponentProps, useState } from "react";
import { SongSortingKeys } from "../../../models/song";
import { VideoWithRelations } from "../../../models/video";
import {
VideoSortingKeys,
VideoType,
VideoWithRelations,
} from "../../../models/video";
import Controls, { OptionState } from "../../controls/controls";
import InfiniteView from "../infinite-view";
import InfiniteResourceViewProps from "./infinite-resource-view-props";
import VideoTile from "../../tile/video-tile";
import { PaginationParameters } from "../../../models/pagination";
import { PlayIcon, ShuffleIcon } from "../../icons";
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
import {
InfiniteQuery,
QueryClient,
prepareMeeloInfiniteQuery,
useQueryClient,
} from "../../../api/use-query";
import VideoItem from "../../list-item/video-item";
const InfiniteVideoView = <T extends VideoWithRelations<"artist">>(
props: InfiniteResourceViewProps<T, typeof SongSortingKeys> &
const playVideosAction = (
emptyPlaylist: PlayerActions["emptyPlaylist"],
playTrack: PlayerActions["playTrack"],
playAfter: PlayerActions["playAfter"],
queryClient: QueryClient,
query: () => InfiniteQuery<
VideoWithRelations<"artist" | "master" | "illustration">
>,
) => {
emptyPlaylist();
queryClient.client
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
.then(async (res) => {
const videos = res.pages
.flatMap(({ items }) => items)
.map((video) => ({
...video,
track: {
...video.master,
illustration: video.illustration,
},
}));
let i = 0;
for (const video of videos) {
if (i == 0) {
playTrack(video);
} else {
playAfter(video);
}
i++;
}
});
};
type AdditionalProps = {
type?: VideoType;
random?: number;
};
const InfiniteVideoView = <
T extends VideoWithRelations<"artist" | "illustration" | "master">,
>(
props: InfiniteResourceViewProps<
T,
typeof VideoSortingKeys,
AdditionalProps
> &
Omit<ComponentProps<typeof VideoTile>, "video">,
) => {
const router = useRouter();
const queryClient = useQueryClient();
const [options, setOptions] =
useState<OptionState<typeof SongSortingKeys>>();
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
const query = {
type:
// @ts-ignore
options?.type == "All" ? undefined : (options?.type as VideoType),
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
order: options?.order ?? props.initialSortingOrder ?? "asc",
view: "grid",
library: options?.library ?? null,
} as const;
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
const playAction = {
label: "playAll",
icon: <PlayIcon />,
onClick: () => {
playVideosAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() => props.query(query),
);
},
} as const;
const shuffleAction = {
label: "shuffle",
icon: <ShuffleIcon />,
onClick: () => {
playVideosAction(
emptyPlaylist,
playTrack,
playAfter,
queryClient,
() =>
props.query({
...query,
random: Math.floor(Math.random() * 10000),
}),
);
},
} as const;
return (
<>
<Controls
options={[
{
label: (options?.type as VideoType) ?? "All",
name: "type",
values: ["All", ...VideoType],
currentValue: options?.type,
},
]}
onChange={setOptions}
sortingKeys={SongSortingKeys}
sortingKeys={VideoSortingKeys}
defaultSortingOrder={props.initialSortingOrder}
defaultSortingKey={props.initialSortingField}
router={props.light == true ? undefined : router}
defaultLayout={"grid"}
disableLayoutToggle
actions={[playAction, shuffleAction]}
/>
<InfiniteView
view={options?.view ?? "grid"}
query={() => {
const { key, exec } = props.query({
view: options?.view ?? "grid",
library: options?.library ?? null,
sortBy:
options?.sortBy ??
props.initialSortingField ??
"name",
order:
options?.order ??
props.initialSortingOrder ??
"asc",
return props.query({
...query,
sortBy: query.sortBy,
});
return {
key,
exec: (pagination: PaginationParameters) =>
exec(pagination).then((page) => {
return {
...page,
items: page.items.map((item) => ({
...item,
illustration: item.track.illustration,
})),
};
}),
};
}}
renderListItem={(item) => <></>}
renderListItem={(item) => (
<VideoItem
video={item}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
renderGridItem={(item) => (
<VideoTile video={item} subtitle={props.subtitle} />
<VideoTile
video={item}
subtitle={props.subtitle}
onClick={() => item && props.onItemClick?.(item)}
/>
)}
/>
</>
@@ -0,0 +1,110 @@
/*
* 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,
DialogContent,
DialogTitle,
Grid,
IconButton,
List,
useTheme,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
useKeyboardBinding,
useKeyboardBindingContext,
} from "../contexts/keybindings";
import { CloseIcon } from "./icons";
export const KeyboardBindingModal = () => {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
useKeyboardBinding(
{
key: "?",
description: "openModalShortcutDescription",
handler: () => setIsOpen((x) => !x),
},
[],
);
return (
<Dialog
open={isOpen}
sx={{ zIndex: "tooltip" }}
fullWidth
onClose={() => setIsOpen(false)}
>
<DialogTitle>{t("keyboadBindings")}</DialogTitle>
<IconButton
onClick={() => setIsOpen(false)}
sx={() => ({
position: "absolute",
right: 8,
top: 8,
})}
>
<CloseIcon />
</IconButton>
<DialogContent>
<KeyboardBindingModalContent />
</DialogContent>
</Dialog>
);
};
const KeyboardBindingModalContent = () => {
const { t } = useTranslation();
const { bindings } = useKeyboardBindingContext();
const theme = useTheme();
return (
<List>
{bindings.map((binding) => (
<Grid container key={binding.key} spacing={2}>
<Grid item xs={4} sx={{ textAlign: "center" }}>
<pre>
<code
style={{
backgroundColor: theme.palette.divider,
borderColor: theme.palette.divider,
borderWidth: 1,
borderStyle: "solid",
padding: 6,
borderRadius: theme.shape.borderRadius,
}}
>
{binding.key}
</code>
</pre>
</Grid>
<Grid
item
xs={8}
sx={{
textOverflow: "ellipsis",
alignContent: "center",
}}
>
{t(binding.description)}
</Grid>
</Grid>
))}
</List>
);
};
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
type AlbumItemProps = {
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
onClick?: () => void;
};
/**
@@ -32,7 +33,7 @@ type AlbumItemProps = {
* @param props
* @returns
*/
const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
const AlbumItem = ({ album, formatSubtitle, onClick }: AlbumItemProps) => {
const artist = album?.artist;
const { t } = useTranslation();
@@ -44,6 +45,7 @@ const AlbumItem = ({ album, formatSubtitle }: AlbumItemProps) => {
quality="low"
/>
}
onClick={onClick}
href={album ? `/albums/${album.slug}` : undefined}
title={album?.name}
secondTitle={
@@ -23,6 +23,7 @@ import ArtistAvatar from "../artist-avatar";
type ArtistItemProps = {
artist: ArtistWithRelations<"illustration"> | undefined;
onClick?: () => void;
};
/**
@@ -30,7 +31,7 @@ type ArtistItemProps = {
* @param props
* @returns
*/
const ArtistItem = ({ artist }: ArtistItemProps) => {
const ArtistItem = ({ artist, onClick }: ArtistItemProps) => {
return (
<ListItem
icon={
@@ -40,6 +41,7 @@ const ArtistItem = ({ artist }: ArtistItemProps) => {
/>
}
secondTitle={null}
onClick={onClick}
href={artist ? `/artists/${artist.slug}` : undefined}
title={artist?.name}
trailing={artist && <ArtistContextualMenu artist={artist} />}
+3 -5
View File
@@ -27,7 +27,6 @@ import {
Typography,
useTheme,
} from "@mui/material";
import { RequireExactlyOne } from "type-fest";
import Link from "next/link";
type ListItemProps = {
@@ -35,10 +34,9 @@ type ListItemProps = {
title: string | undefined;
secondTitle: string | undefined | null;
trailing?: JSX.Element;
} & RequireExactlyOne<{
href: string | undefined;
onClick: (() => void) | undefined;
}>;
href?: string;
onClick?: () => void;
};
const textStyle = {
whiteSpace: "nowrap",
@@ -24,6 +24,7 @@ import PlaylistContextualMenu from "../contextual-menu/playlist-contextual-menu"
type PlaylistItemProps = {
playlist: PlaylistWithRelations<"illustration"> | undefined;
onClick?: () => void;
};
/**
@@ -31,7 +32,7 @@ type PlaylistItemProps = {
* @param props
* @returns
*/
const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
const PlaylistItem = ({ playlist, onClick }: PlaylistItemProps) => {
return (
<ListItem
icon={
@@ -45,6 +46,7 @@ const PlaylistItem = ({ playlist }: PlaylistItemProps) => {
secondTitle={null}
href={playlist ? `/playlists/${playlist.slug}` : undefined}
title={playlist?.name}
onClick={onClick}
trailing={
playlist && <PlaylistContextualMenu playlist={playlist} />
}
@@ -26,9 +26,10 @@ import { Star } from "iconsax-react";
type ReleaseItemProps = {
release: ReleaseWithRelations<"album" | "illustration">;
onClick?: () => void;
};
const ReleaseItem = ({ release }: ReleaseItemProps) => {
const ReleaseItem = ({ release, onClick }: ReleaseItemProps) => {
const isMaster = release.id == release.album.masterId;
return (
@@ -42,6 +43,7 @@ const ReleaseItem = ({ release }: ReleaseItemProps) => {
}
href={`/releases/${release.slug}`}
title={release.name}
onClick={onClick}
secondTitle={getYear(release.releaseDate)?.toString()}
trailing={
<Grid
+63 -9
View File
@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { SongIcon } from "../icons";
import formatArtists from "../../utils/formatArtists";
import { usePlayerContext } from "../../contexts/player";
import { SongGroupWithRelations } from "../../models/song-group";
type SongItemProps<
T extends SongWithRelations<
@@ -32,9 +33,48 @@ type SongItemProps<
>,
> = {
song: T | undefined;
formatSubtitle?: (song: T) => Promise<string>;
onClick?: () => void;
subtitles?: ((
song: SongWithRelations<
"artist" | "featuring" | "master" | "illustration"
>,
) => Promise<string | null>)[];
};
export const SongGroupItem = <
T extends SongGroupWithRelations<
"artist" | "featuring" | "master" | "illustration"
>,
>({
song,
subtitles,
}: SongItemProps<T>) => {
return (
<SongItem
song={
song
? { ...song, id: song.songId, groupId: song.id }
: undefined
}
subtitles={[
...(subtitles
? subtitles
: [
async (
s: SongWithRelations<"artist" | "featuring">,
) => defaultSubtitle(s),
]),
...(song && (song.versionCount ?? 0) > 1
? [async () => `${song.versionCount} Versions`]
: []),
]}
/>
);
};
const defaultSubtitle = (s: SongWithRelations<"artist" | "featuring">) =>
formatArtists(s.artist, s.featuring);
/**
* Item for a list of songs
* @param props
@@ -46,22 +86,34 @@ const SongItem = <
>,
>({
song,
formatSubtitle,
subtitles,
onClick,
}: SongItemProps<T>) => {
const artist = song?.artist;
const { playTrack } = usePlayerContext();
const queryClient = useQueryClient();
const [subtitle, setSubtitle] = useState(
formatSubtitle
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
: song
? formatArtists(song.artist, song.featuring)
? defaultSubtitle(song)
: undefined,
);
useEffect(() => {
if (formatSubtitle && song) {
formatSubtitle(song).then((newSub) => setSubtitle(newSub));
if (subtitles && song) {
Promise.allSettled(subtitles.map((s) => s(song))).then((r) =>
setSubtitle(
r
.map(
(s) =>
(s as PromiseFulfilledResult<string | null>)
.value,
)
.filter((s): s is string => s !== null)
.join(" • ") || null,
),
);
}
}, []);
return (
@@ -77,14 +129,16 @@ const SongItem = <
onClick={
song &&
artist &&
(() =>
(() => {
onClick?.();
playTrack({
artist,
track: {
...song.master,
illustration: song.illustration,
},
}))
});
})
}
secondTitle={subtitle}
trailing={song && <SongContextualMenu song={song} />}
+19 -11
View File
@@ -27,7 +27,10 @@ import { MasterIcon, TrackIcon } from "../icons";
import { usePlayerContext } from "../../contexts/player";
type TrackItemProps = {
track: TrackWithRelations<"release" | "song" | "illustration"> | undefined;
track:
| TrackWithRelations<"video" | "release" | "song" | "illustration">
| undefined;
onClick?: () => void;
};
/**
@@ -35,10 +38,10 @@ type TrackItemProps = {
* @param props
* @returns
*/
const TrackItem = ({ track }: TrackItemProps) => {
const TrackItem = ({ track, onClick }: TrackItemProps) => {
const release = track?.release;
const { playTrack } = usePlayerContext();
const isMaster = track ? track.song.masterId == track.id : false;
const isMaster = track ? track.song?.masterId == track.id : false;
const queryClient = useQueryClient();
return (
@@ -52,19 +55,24 @@ const TrackItem = ({ track }: TrackItemProps) => {
}
onClick={
track &&
release &&
(() =>
(() => {
onClick?.();
queryClient
.fetchQuery(API.getSong(track.songId, ["artist"]))
.then((song) => {
.fetchQuery(
track.song
? API.getArtist(track.song.artistId)
: API.getArtist(track.video!.artistId),
)
.then((artist) => {
playTrack({
artist: song.artist,
artist,
track,
});
}))
});
})
}
title={track?.name}
secondTitle={release?.name}
secondTitle={release?.name ?? null}
trailing={
<Grid
container
@@ -72,7 +80,7 @@ const TrackItem = ({ track }: TrackItemProps) => {
sx={{ justifyContent: "flex-end", flexWrap: "nowrap" }}
>
<Grid item sx={{ display: "flex", alignItems: "center" }}>
{isMaster ? <MasterIcon /> : undefined}
{track?.song && isMaster ? <MasterIcon /> : undefined}
</Grid>
<Grid item>
{track && <TrackContextualMenu track={track} />}
@@ -0,0 +1,106 @@
/*
* 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 Illustration from "../illustration";
import ListItem from "./item";
import { useQueryClient } from "../../api/use-query";
import { useEffect, useState } from "react";
import { VideoIcon } from "../icons";
import formatArtists from "../../utils/formatArtists";
import { usePlayerContext } from "../../contexts/player";
import { VideoWithRelations } from "../../models/video";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
type VideoType = VideoWithRelations<"artist" | "master" | "illustration">;
type VideoItemProps<T extends VideoType> = {
video: T | undefined;
onClick?: () => void;
subtitles?: ((video: VideoType) => Promise<string | null>)[];
};
const defaultSubtitle = (s: VideoType) => formatArtists(s.artist);
/**
* Item for a list of videos
* @param props
* @returns
*/
const VideoItem = <T extends VideoType>({
video,
subtitles,
onClick,
}: VideoItemProps<T>) => {
const artist = video?.artist;
const { playTrack } = usePlayerContext();
const queryClient = useQueryClient();
const [subtitle, setSubtitle] = useState<string | null | undefined>(
subtitles?.length
? ((<br />) as unknown as string)
: video
? defaultSubtitle(video)
: undefined,
);
useEffect(() => {
if (subtitles && video) {
Promise.allSettled(subtitles.map((s) => s(video))).then((r) =>
setSubtitle(
r
.map(
(s) =>
(s as PromiseFulfilledResult<string | null>)
.value,
)
.filter((s): s is string => s !== null)
.join(" • ") || null,
),
);
}
}, []);
return (
<ListItem
icon={
<Illustration
illustration={video?.illustration}
fallback={<VideoIcon />}
quality="low"
/>
}
title={video?.name}
onClick={
video &&
artist &&
(() => {
onClick?.();
playTrack({
artist,
track: {
...video.master,
illustration: video.illustration,
},
});
})
}
secondTitle={subtitle}
trailing={video && <VideoContextualMenu video={video} />}
/>
);
};
export default VideoItem;
+6 -1
View File
@@ -148,7 +148,12 @@ export const AlbumListPageSection = (
};
export const VideoListPageSection = (
props: Omit<PageSectionProps<VideoWithRelations<"artist">>, "child"> &
props: Omit<
PageSectionProps<
VideoWithRelations<"artist" | "master" | "illustration">
>,
"child"
> &
Omit<ComponentProps<typeof VideoTile>, "video">,
) => {
return (
+20 -11
View File
@@ -57,7 +57,6 @@ import ListItem from "../list-item/item";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import formatDuration from "../../utils/formatDuration";
import formatArtists from "../../utils/formatArtists";
import { useTranslation } from "react-i18next";
import { usePlayerContext } from "../../contexts/player";
const parentSongQuery = (id: number) =>
@@ -112,7 +111,10 @@ const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
<ControlButton {...props} icon={<RewindIcon />} />
);
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
const parentSong = useQuery(parentSongQuery, props.track?.songId);
const parentSong = useQuery(
parentSongQuery,
props.track?.songId ?? undefined,
);
return (
<ButtonBase
@@ -229,14 +231,14 @@ const MinimizedPlayerControls = (props: PlayerControlsProps) => {
);
};
const Panels = ["lyrics", "playlist"] as const;
const ExpandedPlayerControls = (
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
) => {
const theme = useTheme();
const { t } = useTranslation();
const parentSong = useQuery(parentSongQuery, props.track?.songId);
const parentSong = useQuery(
parentSongQuery,
props.track?.songId ?? undefined,
);
const { playlist, cursor, reorder, skipTrack } = usePlayerContext();
const [selectedTab, selectTab] = useState<"player" | "lyrics" | "playlist">(
"player",
@@ -414,7 +416,13 @@ const ExpandedPlayerControls = (
>
{props.artist && props.track ? (
<Link
href={`/releases/${props.track.releaseId}`}
href={
props.track.releaseId
? `/releases/${props.track.releaseId}`
: props.track.songId
? `/songs/${props.track.songId}/lyrics`
: {}
}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
@@ -422,6 +430,7 @@ const ExpandedPlayerControls = (
>
<Button
onClick={() =>
props.track &&
props.onExpand(false)
}
sx={{
@@ -446,14 +455,14 @@ const ExpandedPlayerControls = (
</Grid>
<Grid item xs={1}>
{
props.track &&
parentSong.data &&
props.artist ? (
props.track && props.artist ? (
<ReleaseTrackContextualMenu
artist={props.artist}
track={{
...props.track,
song: parentSong.data,
song: props.track.songId
? parentSong.data ?? null
: null,
}}
onSelect={() =>
props.onExpand(false)
+45 -1
View File
@@ -36,6 +36,7 @@ import { useReadLocalStorage } from "usehooks-ts";
import { usePlayerContext } from "../../contexts/player";
import Hls from "hls.js";
import { v4 as uuidv4 } from "uuid";
import { useKeyboardBinding } from "../../contexts/keybindings";
const Player = () => {
const { t } = useTranslation();
@@ -120,7 +121,9 @@ const Player = () => {
progress.current = player.current!.currentTime;
};
player.current!.onended = () => {
API.setSongAsPlayed(currentTrack.track.songId);
if (currentTrack.track.songId) {
API.setSongAsPlayed(currentTrack.track.songId);
}
progress.current = null;
skipTrack();
};
@@ -182,6 +185,14 @@ const Player = () => {
window.removeEventListener("blur", onBlur);
};
}, []);
useEffect(() => {
window.onbeforeunload = () => {
if (playing) {
return t("leaveWillStopPlaybackAlert");
}
return undefined;
};
}, [playing]);
useEffect(() => {
if (useTranscoding && currentTrack) {
if (!Hls.isSupported()) {
@@ -214,6 +225,39 @@ const Player = () => {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userIsAuthentified]);
useKeyboardBinding(
{
key: "p",
description: "openClosePlayer",
handler: () => setExpanded((v) => !v),
},
[expanded],
);
useKeyboardBinding(
{
key: "esc",
description: "closePlayer",
handler: () => setExpanded(false),
},
[expanded],
);
useKeyboardBinding(
{
key: "space",
description: "playPauseWhenExpanded",
handler: () => {
if (!expanded) {
return;
}
if (playing) {
pause();
} else {
play();
}
},
},
[expanded, playing, pause, play],
);
useEffect(() => {
if (player.current) {
player.current.onpause = null;
+182 -167
View File
@@ -45,16 +45,17 @@ import { Fragment, useMemo } from "react";
import { generateArray } from "../utils/gen-list";
import { TrackState, usePlayerContext } from "../contexts/player";
import { Audio } from "react-loader-spinner";
import { VideoWithRelations } from "../models/video";
import { RequireAtLeastOne } from "type-fest";
type TrackType = TrackWithRelations<"illustration"> &
RequireAtLeastOne<{
song: SongWithRelations<"artist" | "featuring">;
video: VideoWithRelations<"artist">;
}>;
type ReleaseTracklistProps = {
mainArtist: Artist | undefined | null;
tracklist:
| Tracklist<
TrackWithRelations<"illustration"> & {
song: SongWithRelations<"artist" | "featuring">;
}
>
| undefined;
tracklist: Tracklist<TrackType> | undefined;
release: Release | undefined;
};
@@ -84,10 +85,12 @@ const ReleaseTrackList = ({
const flatTracklist = tracklist
? Array.from(Object.values(tracklist)).flat()
: undefined;
const formatTracksubtitle = (
song: SongWithRelations<"artist" | "featuring">,
) => {
if (song.artistId === mainArtist?.id && song.featuring?.length === 0) {
const formatTracksubtitle = (song: {
artistId: number;
artist: Artist;
featuring?: Artist[];
}) => {
if (song.artistId === mainArtist?.id && !song.featuring?.length) {
return undefined;
}
return formatArtists(song.artist, song.featuring);
@@ -113,169 +116,181 @@ const ReleaseTrackList = ({
)
}
>
{(
disc[1] as (
| (TrackWithRelations<"illustration"> & {
song: SongWithRelations<
"artist" | "featuring"
>;
})
| undefined
)[]
).map((currentTrack, index) => (
<Fragment key={index}>
<ListItem
dense={
mainArtist === undefined
? false
: currentTrack
? currentTrack.song.artistId !=
mainArtist?.id
: false
}
disablePadding
disableGutters
secondaryAction={
currentTrack ? (
<ReleaseTrackContextualMenu
track={currentTrack}
artist={currentTrack.song.artist}
/>
) : (
<IconButton disabled>
<ContextualMenuIcon />
</IconButton>
)
}
>
<ListItemButton
onClick={
flatTracklist &&
currentTrack &&
release &&
(() =>
playTracks({
tracks: flatTracklist.map(
(flatTrack) => ({
track: flatTrack,
release,
artist: flatTrack.song
.artist,
}),
),
cursor: flatTracklist.findIndex(
(flatTrack) =>
flatTrack.id ==
currentTrack.id,
),
}))
{(disc[1] as (TrackType | undefined)[]).map(
(currentTrack, index) => (
<Fragment key={index}>
<ListItem
dense={
mainArtist === undefined
? false
: currentTrack
? (currentTrack.song ??
currentTrack.video)!
.artistId !=
mainArtist?.id
: false
}
sx={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
>
<ListItemIcon>
{currentTrack ? (
currentTrack.id ===
currentlyPlayingTrack?.track.id ? (
<PlayingIcon />
) : (
<Typography color="text.disabled">
{currentTrack.trackIndex}
</Typography>
)
) : (
<Skeleton
width="20px"
sx={{ color: "text.disabled" }}
/>
)}
</ListItemIcon>
<ListItemText
primary={
currentTrack?.name ?? (
<Skeleton width="120px" />
)
}
primaryTypographyProps={{
fontSize: "medium",
}}
secondary={
mainArtist === undefined
? null
: currentTrack
? formatTracksubtitle(
currentTrack.song,
)
: undefined
}
secondaryTypographyProps={{
fontSize: "small",
color: "text.disabled",
}}
/>
{currentTrack?.isBonus && (
<Typography
sx={{
display: {
xs: "none",
sm: "block",
},
}}
color="text.disabled"
>
{t("bonusTrack")}
</Typography>
)}
{currentTrack?.isRemastered && (
<Typography
color="text.disabled"
sx={{
display: {
xs: "none",
sm: "block",
},
}}
>
{t("remastered")}
</Typography>
)}
{currentTrack?.type == "Video" && (
<Icon
sx={{
marginLeft: 2,
display: "flex",
alignItems: "center",
}}
>
<VideoIcon
color={
theme.palette.text.disabled
disablePadding
disableGutters
secondaryAction={
currentTrack ? (
<ReleaseTrackContextualMenu
track={{
...currentTrack,
song:
currentTrack.song ??
null,
}}
artist={
(currentTrack.song ??
currentTrack.video)!
.artist
}
/>
</Icon>
)}
<Typography
color="text.disabled"
) : (
<IconButton disabled>
<ContextualMenuIcon />
</IconButton>
)
}
>
<ListItemButton
onClick={
flatTracklist &&
currentTrack &&
release &&
(() =>
playTracks({
tracks: flatTracklist.map(
(flatTrack) => ({
track: flatTrack,
release,
artist: (flatTrack.song ??
flatTrack.video)!
.artist,
}),
),
cursor: flatTracklist.findIndex(
(flatTrack) =>
flatTrack.id ==
currentTrack.id,
),
}))
}
sx={{
marginLeft: 2,
overflow: "unset",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}}
>
{currentTrack ? (
formatDuration(
currentTrack.duration,
)
) : (
<Skeleton width="30px" />
<ListItemIcon>
{currentTrack ? (
currentTrack.id ===
currentlyPlayingTrack?.track
.id ? (
<PlayingIcon />
) : (
<Typography color="text.disabled">
{
currentTrack.trackIndex
}
</Typography>
)
) : (
<Skeleton
width="20px"
sx={{
color: "text.disabled",
}}
/>
)}
</ListItemIcon>
<ListItemText
primary={
currentTrack?.name ?? (
<Skeleton width="120px" />
)
}
primaryTypographyProps={{
fontSize: "medium",
}}
secondary={
mainArtist === undefined
? null
: currentTrack
? formatTracksubtitle(
(currentTrack.song ??
currentTrack.video)!,
)
: undefined
}
secondaryTypographyProps={{
fontSize: "small",
color: "text.disabled",
}}
/>
{currentTrack?.isBonus && (
<Typography
sx={{
display: {
xs: "none",
sm: "block",
},
}}
color="text.disabled"
>
{t("bonusTrack")}
</Typography>
)}
</Typography>
</ListItemButton>
</ListItem>
<Divider variant="inset" />
</Fragment>
))}
{currentTrack?.isRemastered && (
<Typography
color="text.disabled"
sx={{
display: {
xs: "none",
sm: "block",
},
}}
>
{t("remastered")}
</Typography>
)}
{currentTrack?.type == "Video" && (
<Icon
sx={{
marginLeft: 2,
display: "flex",
alignItems: "center",
}}
>
<VideoIcon
color={
theme.palette.text
.disabled
}
/>
</Icon>
)}
<Typography
color="text.disabled"
sx={{
marginLeft: 2,
overflow: "unset",
}}
>
{currentTrack ? (
formatDuration(
currentTrack.duration,
)
) : (
<Skeleton width="30px" />
)}
</Typography>
</ListItemButton>
</ListItem>
<Divider variant="inset" />
</Fragment>
),
)}
</List>
))}
</Box>
@@ -17,17 +17,16 @@
*/
import { Box, Link, Skeleton } from "@mui/material";
import ExternalId from "../models/external-id";
import { type CommonExternalMetadata } from "../models/external-metadata";
import { useState } from "react";
import { capitalCase } from "change-case";
import { useTranslation } from "react-i18next";
import { generateArray } from "../utils/gen-list";
type Props = {
externalDescription: ExternalId | undefined;
externalMetadata: CommonExternalMetadata | undefined;
};
const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
const ResourceDescriptionExpandable = ({ externalMetadata }: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
const maxLine = 5;
const smallBoxStyle = {
@@ -42,27 +41,13 @@ const ResourceDescriptionExpandable = ({ externalDescription }: Props) => {
return (
<Box id="description" sx={isExpanded ? bigBoxStyle : smallBoxStyle}>
{externalDescription
? externalDescription.description
: generateArray(5).map((_, index) => <Skeleton key={index} />)}
{isExpanded && (
<>
{" Source: "}
<Link
href={externalDescription?.url ?? undefined}
rel="noopener noreferrer"
target="_blank"
>
{externalDescription ? (
capitalCase(externalDescription?.provider.name)
) : (
<Skeleton width={50} />
)}
</Link>
{"."}
</>
)}{" "}
{externalDescription && (
{externalMetadata?.description ||
(externalMetadata?.description === undefined &&
generateArray(5).map((_, index) => (
<Skeleton key={index} />
)))}
{externalMetadata?.description && (
<Link
href="#description"
onClick={() => setIsExpanded(!isExpanded)}
+8 -17
View File
@@ -298,30 +298,21 @@ const BottomNavigation = (props: { onDrawerOpen: () => void }) => {
const Icon = (pr: IconProps) => getPrimaryTypeIcon(item, pr);
return (
<Link
key={path}
<BottomNavigationAction
href={path}
LinkComponent={Link}
style={{
flex: 1,
display: "flex",
justifyContent: "center",
}}
>
<BottomNavigationAction
key={item}
showLabel
icon={
<Icon
variant={isSelected ? "Bold" : "Outline"}
/>
}
label={t(item)}
/>
</Link>
key={item}
icon={
<Icon variant={isSelected ? "Bold" : "Outline"} />
}
label={t(item)}
/>
);
})}
<BottomNavigationAction
showLabel
sx={{ flex: 1 }}
icon={<BurgerIcon />}
onClick={props.onDrawerOpen}
@@ -41,7 +41,6 @@ import AdminGrid from "../admin-grid";
import {
CleanAllLibrariesAction,
CleanLibraryAction,
FetchExternalMetadata,
ScanAllLibrariesAction,
ScanLibraryAction,
} from "../actions/library-task";
@@ -51,7 +50,6 @@ import { ComponentProps, useMemo, useState } from "react";
import LibraryForm from "../library-form";
import { RefreshLibraryMetadataAction } from "../actions/refresh-metadata";
import SectionHeader from "../section-header";
import { capitalCase } from "change-case";
import { useTranslation } from "react-i18next";
const actionButtonStyle = {
@@ -102,25 +100,21 @@ const LibrariesSettings = () => {
CleanAllLibrariesAction.onClick?.();
},
};
const fetchMetadata = {
...FetchExternalMetadata,
onClick: () => {
tasks.refetch();
FetchExternalMetadata.onClick?.();
},
};
const confirm = useConfirm();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [libraryEdit, setLibraryEdit] = useState<Library | undefined>(); // If set, open modal to edit library
const closeEditModal = () => setLibraryEdit(undefined);
const closeCreateModal = () => setCreateModalOpen(false);
const deletionMutation = useMutation((libraryId: number) =>
API.deleteLibrary(libraryId)
.then(() => {
toast.success(t("libraryDeleted"));
queryClient.client.invalidateQueries(["libraries"]);
toast
.promise(API.deleteLibrary(libraryId), {
loading: t("deletingLibrary"),
success: t("libraryDeleted"),
error: t("libraryDeletionFail"),
})
.catch(() => toast.error(t("libraryDeletionFail"))),
.then(() => {
queryClient.client.invalidateQueries(["libraries"]);
}),
);
const createMutation = useMutation(
(createForm: { name: string; path: string }) =>
@@ -243,19 +237,17 @@ const LibrariesSettings = () => {
{t("createLibrary")}
</Button>
</Grid>
{[cleanAllLibaries, scanAllLibaries, fetchMetadata].map(
(action, index) => (
<Grid item key={"Library-action-" + index}>
<Button
variant={index % 2 ? "contained" : "outlined"}
startIcon={action.icon}
onClick={action.onClick}
>
{t(action.label)}
</Button>
</Grid>
),
)}
{[cleanAllLibaries, scanAllLibaries].map((action, index) => (
<Grid item key={"Library-action-" + index}>
<Button
variant={index % 2 ? "contained" : "outlined"}
startIcon={action.icon}
onClick={action.onClick}
>
{t(action.label)}
</Button>
</Grid>
))}
</Grid>
<Dialog
open={libraryEdit != undefined}
@@ -298,16 +290,9 @@ const LibrariesSettings = () => {
<ListItemText
primary={
tasks.data ? (
`${t("current")}: ${capitalCase(
tasks.data.active?.name ?? t("none"),
)}`
) : (
<Skeleton />
)
}
secondary={
tasks.data ? (
tasks.data.active?.description
`${t("current")}: ${
tasks.data.current_task ?? t("none")
}`
) : (
<Skeleton />
)
@@ -319,7 +304,7 @@ const LibrariesSettings = () => {
primary={
tasks.data ? (
`${t("pending")}: ${
tasks.data.pending.length || t("none")
tasks.data.pending_tasks.length || t("none")
}`
) : (
<Skeleton />
@@ -327,13 +312,9 @@ const LibrariesSettings = () => {
}
/>
</ListItem>
{tasks.data?.pending.map((task, index) => (
{tasks.data?.pending_tasks.map((task, index) => (
<ListItem key={"task-" + index}>
<ListItemText
inset
primary={capitalCase(task.name)}
secondary={task.description}
/>
<ListItemText inset primary={task} />
</ListItem>
))}
</List>
@@ -158,6 +158,12 @@ const UserSettings = () => {
/>
</Grid>
</Grid>
<SectionHeader heading={t("keyboadBindings")} />
<Grid container sx={SettingGroupStyle}>
<Grid item xs={12}>
{t("openKeyboarBindingsModalByTypingQuestionMark")}
</Grid>
</Grid>
<SectionHeader heading={t("project")} />
<p>
<Star style={LinkIconStyle} />
+3 -3
View File
@@ -28,9 +28,9 @@ const TileRow = (props: TileRowProps) => {
const windowSize = {
xs: props.windowSize?.xs ?? 2,
sm: props.windowSize?.sm ?? 3,
md: props.windowSize?.md ?? 5,
lg: props.windowSize?.lg ?? 6,
xl: props.windowSize?.xl ?? 8,
md: props.windowSize?.md ?? 4,
lg: props.windowSize?.lg ?? 5,
xl: props.windowSize?.xl ?? 7,
};
const width = Object.entries(windowSize).reduce(
(prev, [key, size]) => ({ ...prev, [key]: 100 / size + "%" }),
+2
View File
@@ -25,6 +25,7 @@ import { useTranslation } from "react-i18next";
const AlbumTile = (props: {
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
formatSubtitle?: (album: AlbumWithRelations<"artist">) => string;
onClick?: () => void;
}) => {
const { t } = useTranslation();
@@ -41,6 +42,7 @@ const AlbumTile = (props: {
props.album?.artist?.name ??
t("compilation")
}
onClick={props.onClick}
href={props.album ? `/albums/${props.album.slug}` : undefined}
secondaryHref={
props.album
@@ -24,6 +24,7 @@ import { Box } from "@mui/material";
const ArtistTile = (props: {
artist: ArtistWithRelations<"illustration"> | undefined;
onClick?: () => void;
}) => {
return (
<Tile
@@ -31,6 +32,7 @@ const ArtistTile = (props: {
contextualMenu={
props.artist && <ArtistContextualMenu artist={props.artist} />
}
onClick={props.onClick}
subtitle={null}
title={props.artist?.name}
href={props.artist && `/artists/${props.artist.slug}`}
@@ -24,8 +24,10 @@ import { PlaylistIcon } from "../icons";
const PlaylistTile = ({
playlist,
onClick,
}: {
playlist: PlaylistWithRelations<"illustration"> | undefined;
onClick?: () => void;
}) => {
return (
<Tile
@@ -34,6 +36,7 @@ const PlaylistTile = ({
}
title={playlist?.name}
subtitle={null}
onClick={onClick}
href={playlist ? `/playlists/${playlist.slug}` : undefined}
illustration={
<Illustration
@@ -25,6 +25,7 @@ import ReleaseContextualMenu from "../contextual-menu/release-contextual-menu";
const ReleaseTile = (props: {
release: ReleaseWithRelations<"album" | "illustration"> | undefined;
formatSubtitle?: (release: ReleaseWithRelations<"album">) => string;
onClick?: () => void;
}) => {
const yearFormat = props.release
? getYear(props.release.releaseDate)?.toString() ?? ""
@@ -36,6 +37,7 @@ const ReleaseTile = (props: {
<ReleaseContextualMenu release={props.release} />
)
}
onClick={props.onClick}
title={props.release?.name}
subtitle={
props.release
+25 -20
View File
@@ -21,21 +21,28 @@ import Illustration from "../illustration";
import { useQueryClient } from "../../api/use-query";
import API from "../../api/api";
import formatDuration from "../../utils/formatDuration";
import TrackContextualMenu from "../contextual-menu/track-contextual-menu";
import { usePlayerContext } from "../../contexts/player";
import Video, { VideoWithRelations } from "../../models/video";
import { VideoWithRelations } from "../../models/video";
import VideoContextualMenu from "../contextual-menu/video-contextual-menu";
type VideoTileProps =
type VideoTileProps = (
| {
video: Video | undefined;
video: VideoWithRelations<"master" | "illustration"> | undefined;
subtitle: "duration";
}
| {
video: VideoWithRelations<"artist"> | undefined;
video:
| VideoWithRelations<"artist" | "master" | "illustration">
| undefined;
subtitle: "artist";
};
}
) & { onClick?: () => void };
const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
const VideoTile = ({
video,
subtitle: subtitleType,
onClick,
}: VideoTileProps) => {
const queryClient = useQueryClient();
const { playTrack } = usePlayerContext();
const { subtitle, secondaryHref } = !video
@@ -44,7 +51,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
subtitle:
subtitleType == "artist"
? video.artist.name
: formatDuration(video.track.duration),
: formatDuration(video.master.duration),
secondaryHref:
subtitleType == "artist"
? `/artists/${video.artist.slug}`
@@ -53,25 +60,23 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
return (
<Tile
contextualMenu={
video && (
<TrackContextualMenu
isVideo
track={{ ...video.track, song: video }}
/>
)
}
contextualMenu={video && <VideoContextualMenu video={video} />}
onClick={
video
? () =>
? () => {
onClick?.();
queryClient
.fetchQuery(API.getArtist(video.artistId))
.then((artist) =>
playTrack({
track: video.track,
track: {
...video.master,
illustration: video.illustration,
},
artist: artist,
}),
)
);
}
: undefined
}
title={video?.name}
@@ -81,7 +86,7 @@ const VideoTile = ({ video, subtitle: subtitleType }: VideoTileProps) => {
<Illustration
quality="medium"
aspectRatio={16 / 9}
illustration={video?.track.illustration}
illustration={video?.illustration}
imgProps={{ objectFit: "cover" }}
/>
}
+159
View File
@@ -0,0 +1,159 @@
/*
* 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 {
DependencyList,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { TranslationKey } from "../i18n/i18n";
import { useKey as _useKey } from "react-use";
import { Handler } from "react-use/lib/useKey";
import { useRouter } from "next/router";
type BindingKey = "esc" | "?" | "/" | "s" | "p" | "space";
const bindingKeyToUseKeyParam = (bindingKey: BindingKey): string => {
switch (bindingKey) {
case "esc":
return "Escape";
case "space":
return " ";
default:
return bindingKey;
}
};
type Binding = {
key: BindingKey;
description: TranslationKey;
};
type BindingsState = {
bindings: Binding[];
};
type KeyboardBindingsActions = {
addBinding: (binding: BindingKey, description: TranslationKey) => void;
removeBinding: (binding: BindingKey) => void;
};
const KeyboardBindings = createContext<BindingsState & KeyboardBindingsActions>(
{
bindings: [],
addBinding: () => {},
removeBinding: () => {},
},
);
const useKey = (...p: Parameters<typeof _useKey>) => {
return _useKey(
p[0],
(e) => {
const tagName = document.activeElement?.tagName.toLowerCase();
if (tagName == "input" || tagName == "textarea") {
return;
}
p[1]?.(e);
},
p[2],
p[3],
);
};
export const KeyboardBindingsProvider = (props: {
children: JSX.Element[];
}) => {
const router = useRouter();
const [{ bindings }, setBindings] = useState<BindingsState>({
bindings: [
{ key: "/", description: "goToSearchPage" },
{ key: "s", description: "goToSettingsPage" },
],
});
useKey(bindingKeyToUseKeyParam("/"), () => router.push("/search"));
useKey(bindingKeyToUseKeyParam("s"), () => router.push("/settings"));
return (
<KeyboardBindings.Provider
value={{
bindings,
addBinding: (newBindingKey, description) => {
setBindings((state) => {
const bindingsCopy = Array.of(...state.bindings);
const oldBinding = bindingsCopy.find(
(b) => b.key == newBindingKey,
);
if (!oldBinding) {
bindingsCopy.push({
key: newBindingKey,
description: description,
});
return { bindings: bindingsCopy };
}
if (oldBinding.description == description) {
return state;
}
// eslint-disable-next-line no-console
console.error(
"Keyboard binding is probably getting duplicated.",
{ newBindingKey, description },
);
oldBinding.description = description;
return { bindings: bindingsCopy };
});
},
removeBinding: (bindingKey) =>
setBindings((state) => {
return {
bindings: state.bindings.filter(
(b) => b.key !== bindingKey,
),
};
}),
}}
>
{props.children}
</KeyboardBindings.Provider>
);
};
export const useKeyboardBindingContext = () => useContext(KeyboardBindings);
export const useKeyboardBinding = (
binding: Binding & { handler: Handler },
deps: DependencyList = [],
) => {
const keyboardContext = useContext(KeyboardBindings);
useKey(
bindingKeyToUseKeyParam(binding.key),
(e) => binding.handler(e),
{},
[...deps],
);
useEffect(() => {
keyboardContext.addBinding(binding.key, binding.description);
return () => {
keyboardContext.removeBinding(binding.key);
};
}, []);
return {};
};
+27 -25
View File
@@ -32,6 +32,7 @@
"disc": "Disc",
"otherAlbumReleases": "Other releases of the same album",
"musicVideos": "Music Videos",
"livePerformances": "Live Performances",
"playlistReorderSuccess": "Playlist Reordered!",
"playlistReorderFail": "Playlist Reordering Failed",
"playlistItemDeletionSuccess": "Deletion Successful",
@@ -41,6 +42,7 @@
"artists": "Artists",
"albums": "Albums",
"album": "Album",
"video": "Video",
"songs": "Songs",
"song": "Song",
"videos": "Videos",
@@ -57,15 +59,12 @@
"path": "Path",
"registrationDate": "Registration Date",
"noLyricsFound": "No Lyrics Found",
"changeAlbumType": "Change Album Type",
"libraryDeleted": "Library deleted",
"libraryDeletionFail": "Deleting library failed",
"libraryCreated": "Library created",
"libraryCreationFail": "Creating library failed",
"libraryUpdated": "Library updated",
"nameOfLibrary": "Name of the library",
"nameIsRequired": "Name is required",
"pathOfLibrary": "Path of the library",
@@ -73,29 +72,24 @@
"goBackHome": "Go Back Home",
"oops": "Oops",
"by": "by",
"warning": "Warning",
"downloadWarning": "Downloading copyrighted material you don't own is not authorized. Please proceed if, and only if, you own the original content.",
"download": "Download",
"compilation": "Compilation",
"clean": "Clean",
"refresh": "Refresh",
"scan": "Scan",
"refreshMetadata": "Refresh Metadata",
"createLibrary": "Create Library",
"deletingLibrary": "Deleting library...",
"deleteLibrary": "Delete Library",
"deleteLibraryAction": "Delete a Library",
"deleteLibraryWarning": "You are about to delete a library. This can not be undone",
"deleteUser": "Delete User",
"deleteUserWarning": "You are about to delete a user. This can not be undone.",
"userDeleted": "User deleted",
"userDeletionFail": "User deletion failed",
"userUpdateFail": "Updating user failed",
"userNowEnabled": "User is now enabled",
"userNowDisabled": "User is now disabled",
"userNowAdmin": "User is now an admin and can run administrative tasks",
@@ -103,9 +97,9 @@
"you": "You",
"admin": "Admin",
"enabled": "Enabled",
"trackSetAsMaster": "Track set as Master",
"showAllTrack": "Show all tracks",
"showOnlyExclusiveTracks": "Show only exclusive tracks",
"All": "All",
"StudioRecording": "Studio Recording",
"Single": "Single",
@@ -114,7 +108,14 @@
"Soundtrack": "Soundtrack",
"RemixAlbum": "Remix Album",
"VideoAlbum": "Video Album",
"MusicVideo": "Music Video",
"LyricsVideo": "Lyric Video",
"BehindTheScenes": "Behind The Scenes",
"Interview": "Interview",
"Advert": "Advert",
"PhotoGallery": "Photo Gallery",
"Documentary": "Documentary",
"Other": "Other",
"pluralStudioRecording": "Studio Albums",
"pluralSingle": "Singles",
"pluralLiveRecording": "Live Albums",
@@ -122,12 +123,11 @@
"pluralSoundtrack": "Soundtrack Albums",
"pluralRemixAlbum": "Remixes",
"pluralVideoAlbum": "Video Albums",
"groupVersions": "Group Versions",
"showAllSongs": "Show All Versions",
"onThisAlbum": "Featured Artists",
"backToTop": "Back To Top",
"changeLayout": "Change layout",
"sortBy": "Sort By",
"order": "Order",
"asc": "Asc",
@@ -144,18 +144,14 @@
"bitrate": "Bitrate",
"trackIndex": "Track Index",
"discIndex": "Disc Index",
"allLibraries": "All Libraries",
"setAsMaster": "Set as Master",
"setAllTracksAsMaster": "Set all tracks as Master",
"deleteFromPlaylist": "Delete from Playlist",
"changeIllutration": "Change Illustration",
"releaseSetAsMaster": "Release set as master",
"tracksUpdated": "Tracks successfully updated",
"librariesLoadFail": "Libraries could not be loaded",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm",
@@ -191,12 +187,12 @@
"darkMode": "Dark Mode",
"autoMode": "Auto Mode",
"logout": "Logout",
"appearsOn": "Appears On",
"featuredOnPlaylists": "Featured On",
"bonusTracks": "Bonus Tracks",
"relatedAlbums": "Related Albums",
"changeSongType": "Change Song Type",
"changeVideoType": "Change Video Type",
"Original": "Original",
"Remix": "Remix",
"Live": "Live",
@@ -206,12 +202,10 @@
"Clean": "Clean",
"Demo": "Demo",
"Unknown": "Unknown",
"Acapella": "Acapella",
"Acappella": "A Cappella",
"Medley": "Medley/Megamix",
"refreshMetadataStarted": "Refreshing Metadata... (You might need to refresh the page)",
"refreshMetadataFailed": "Oops... Refreshing Metadata Failed",
"more": "More",
"networkError": "There seem to be some network issues...",
"playbackError": "The track's format is not supported by this browser. Skipping...",
@@ -240,11 +234,19 @@
"encounteredABug": "You encountered a bug?",
"openAnIssue": "Open an issue on GitHub!",
"readTheDoc": "Read the user documentation",
"en": "English",
"fr": "French",
"yes": "Yes",
"no": "No",
"featuredAlbums": "Featured Albums",
"rareSongs": "Rare Songs"
"rareSongs": "Rare Songs",
"leaveWillStopPlaybackAlert": "Leaving the website will stop playback.",
"keyboadBindings": "Keyboard Shortcuts",
"openKeyboarBindingsModalByTypingQuestionMark": "Access Keyboard Bindings list by typing '?'",
"openModalShortcutDescription": "Open / Close this modal",
"goToSearchPage": "Go to Search page",
"goToSettingsPage": "Go to Settings",
"openClosePlayer": "Open / Close Player",
"closePlayer": "Close Player",
"playPauseWhenExpanded": "Play / Pause (When player is open)"
}
+27 -14
View File
@@ -30,6 +30,7 @@
"disc": "Disque",
"otherAlbumReleases": "Autres disques du même album",
"musicVideos": "Clips Vidéos",
"livePerformances": "Performances Live",
"playlistReorderSuccess": "Playlist mise à jour!",
"playlistReorderFail": "Echec de la mise à jour de la playlist",
"topSongs": "Meilleurs Titres",
@@ -47,7 +48,6 @@
"extension": "Extension",
"path": "Chemin",
"registrationDate": "Date d'ajout",
"nameOfLibrary": "Nom de la bibliothèque",
"nameIsRequired": "Le nom est obligatoire",
"pathOfLibrary": "Chemin de la bibliothèque",
@@ -56,12 +56,11 @@
"oops": "Oups",
"by": "de",
"noLyricsFound": "Aucune Parole Trouvée",
"warning": "Attention",
"downloadWarning": "Il est interdit de télécharger du contenu sans en posséder l'original. Ne téléchagez pas si vous ne le posséder pas légalement.",
"download": "Télécharger",
"compilation": "Compilation",
"deletingLibrary": "Suppression de la bibliothèque",
"libraryDeleted": "Bibliothèque Supprimée",
"libraryDeletionFail": "Echec de l'effacement de la Bibliothèque",
"libraryCreated": "Bibliothèque créée",
@@ -75,7 +74,6 @@
"deleteLibrary": "Supprimer la Bibliothèque",
"deleteLibraryAction": "Supprimer une Bibliothèque",
"deleteLibraryWarning": "Vous êtes sur le point de supprimer une bibliothèque. Il sera impossible de revenir en arrière.",
"deleteUser": "Supprimer Compte",
"deleteUserWarning": "Vous allez supprimer un compte utilisateur. Cela ne pourra pas être annulé.",
"userDeleted": "Compte Supprimé",
@@ -88,7 +86,6 @@
"you": "Vous",
"admin": "Administrateur",
"enabled": "Activé",
"All": "Tous Types",
"StudioRecording": "Album Studio",
"Single": "Single",
@@ -97,6 +94,14 @@
"Soundtrack": "Bande Originale",
"RemixAlbum": "Album de Remixes",
"VideoAlbum": "Collection de Vidéos",
"MusicVideo": "Clip Video",
"LyricsVideo": "Paroles",
"BehindTheScenes": "Making Of",
"Interview": "Interview",
"Advert": "Teaser",
"PhotoGallery": "Galerie Photo",
"Documentary": "Documentaire",
"Other": "Autre",
"pluralStudioRecording": "Albums",
"pluralSingle": "Singles",
"pluralLiveRecording": "Albums Live",
@@ -104,13 +109,14 @@
"pluralSoundtrack": "Bandes Originales",
"pluralRemixAlbum": "Remixes",
"pluralVideoAlbum": "Albums Vidéos",
"groupVersions": "Grouper les versions",
"showAllSongs": "Afficher toutes les versions",
"backToTop": "Retour en haut",
"changeLayout": "Changer la disposition",
"showAllTrack": "Afficher toutes les morceaux",
"showOnlyExclusiveTracks": "Afficher seulement les morceaux exclusifs",
"changeAlbumType": "Changer le Type de l'Album",
"onThisAlbum": "Artistes Invités",
"sortBy": "Trier par",
"order": "Ordre",
"asc": "Asc",
@@ -130,6 +136,7 @@
"artist": "Artiste",
"artists": "Artistes",
"album": "Album",
"video": "Vidéo",
"songs": "Morceaux",
"song": "Titre",
"search": "Rechercher",
@@ -180,13 +187,12 @@
"darkMode": "Thème Sombre",
"autoMode": "Thème Automatique",
"logout": "Se Déconnecter",
"appearsOn": "Apparaît sur",
"featuredOnPlaylists": "Apparaît dans",
"bonusTracks": "Pistes Bonus",
"relatedAlbums": "Albums Associés",
"changeSongType": "Changer le type",
"changeVideoType": "Change le type",
"Original": "Version Originale",
"Remix": "Remix",
"Live": "Version Live",
@@ -196,9 +202,8 @@
"Clean": "Version Clean",
"Demo": "Maquette",
"Unknown": "Type Inconnu",
"Acapella": "Acapella",
"Acappella": "A Cappella",
"Medley": "Medley/Megamix",
"refreshMetadataStarted": "Mise à jour des métadonées... (Rechargez la page pour actualiser)",
"refreshMetadataFailed": "Oups... La mise à jour des données a échoué.",
"more": "Plus",
@@ -229,11 +234,19 @@
"encounteredABug": "Vous avez rencontré un problème?",
"openAnIssue": "Ouvrir une issue sur GitHub!",
"readTheDoc": "Accéder à la documentation utilisateur",
"en": "Anglais",
"fr": "Français",
"yes": "Oui",
"no": "Non",
"featuredAlbums": "En Vedette",
"rareSongs": "Pistes Inédites"
"rareSongs": "Pistes Inédites",
"leaveWillStopPlaybackAlert": "Quitter le site arrêtera la lecture du morceau.",
"keyboadBindings": "Raccourcis Clavier",
"openKeyboarBindingsModalByTypingQuestionMark": "Ouvrez la liste des raccourcis clavier avec la touche '?'",
"openModalShortcutDescription": "Ouvrir / Fermer cette modale",
"goToSearchPage": "Rechercher",
"goToSettingsPage": "Accéder aux réglages",
"openClosePlayer": "Ouvrir / Fermer le lecteur",
"closePlayer": "Fermer le lecteur",
"playPauseWhenExpanded": "Pauser / Jouer (Quand le lecteur est ouvert)"
}
+1 -11
View File
@@ -20,7 +20,6 @@ import * as yup from "yup";
import Artist from "./artist";
import Illustration from "./illustration";
import Resource from "./resource";
import ExternalId from "./external-id";
import Genre from "./genre";
export const AlbumType = [
@@ -71,7 +70,7 @@ type Album = yup.InferType<typeof Album>;
export default Album;
export type AlbumInclude = "artist" | "externalIds" | "genres" | "illustration";
export type AlbumInclude = "artist" | "genres" | "illustration";
const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
relation: Selection[],
@@ -82,15 +81,6 @@ const AlbumWithRelations = <Selection extends AlbumInclude | never = never>(
artist: Artist.required().nullable(),
genres: yup.array(Genre.required()).required(),
illustration: Illustration.required().nullable(),
externalIds: yup
.array(
ExternalId.required().concat(
yup.object({
rating: yup.number().required().nullable(),
}),
),
)
.required(),
})
.pick(relation),
);
+1 -3
View File
@@ -16,7 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import ExternalId from "./external-id";
import Illustration from "./illustration";
import Resource from "./resource";
import * as yup from "yup";
@@ -37,7 +36,7 @@ const Artist = Resource.concat(
type Artist = yup.InferType<typeof Artist>;
export type ArtistInclude = "externalIds" | "illustration";
export type ArtistInclude = "illustration";
const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
relation: Selection[],
@@ -45,7 +44,6 @@ const ArtistWithRelations = <Selection extends ArtistInclude | never = never>(
Artist.concat(
yup
.object({
externalIds: yup.array(ExternalId.required()).required(),
illustration: Illustration.required().nullable(),
})
.pick(relation),
-57
View File
@@ -1,57 +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 * as yup from "yup";
const ExternalId = yup.object({
/**
* Info about the provider
*/
provider: yup
.object({
/**
* Name of the Provider
*/
name: yup.string().required(),
/**
* URL to the Homepage of the provider
*/
homepage: yup.string().required(),
/**
* API-relative route to the provider's icon
*/
icon: yup.string().required(),
})
.required(),
/**
* Value of the External Identifier
*/
value: yup.string().required(),
/**
* Description of the resource, from the External Identifier
*/
description: yup.string().defined().strict(true).nullable(),
/**
* URL to the related resource
*/
url: yup.string().required().nullable(),
});
type ExternalId = yup.InferType<typeof ExternalId>;
export default ExternalId;
+70
View File
@@ -0,0 +1,70 @@
/*
* 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 * as yup from "yup";
const ExternalMetadataSource = yup.object({
url: yup.string().required(),
providerName: yup.string().required(),
providerId: yup.number().required(),
providerIcon: yup.string().required(),
});
const CommonExternalMetadata = yup.object({
description: yup.string().required().nullable(),
sources: yup.array(ExternalMetadataSource.required()).required(),
});
const ArtistExternalMetadata = CommonExternalMetadata.concat(
yup.object({
artistId: yup.number().required(),
}),
);
const SongExternalMetadata = CommonExternalMetadata.concat(
yup.object({
songId: yup.number().required(),
}),
);
const ReleaseExternalMetadata = CommonExternalMetadata.concat(
yup.object({
releaseId: yup.number().required(),
}),
);
const AlbumExternalMetadata = CommonExternalMetadata.concat(
yup.object({
albumId: yup.number().required(),
rating: yup.number().nullable(),
}),
);
type CommonExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
type ArtistExternalMetadata = yup.InferType<typeof ArtistExternalMetadata>;
type AlbumExternalMetadata = yup.InferType<typeof AlbumExternalMetadata>;
type SongExternalMetadata = yup.InferType<typeof SongExternalMetadata>;
type ReleaseExternalMetadata = yup.InferType<typeof CommonExternalMetadata>;
type ExternalMetadataSource = yup.InferType<typeof ExternalMetadataSource>;
export {
CommonExternalMetadata,
ArtistExternalMetadata,
AlbumExternalMetadata,
SongExternalMetadata,
ReleaseExternalMetadata,
ExternalMetadataSource,
};
-4
View File
@@ -25,10 +25,6 @@ const File = Resource.concat(
* Path of the track, relative to the parent library
*/
path: yup.string().required(),
/**
* MD5 checksum of the file
*/
md5Checksum: yup.string().required(),
/**
* Date of the file registration
*/
+1 -3
View File
@@ -19,7 +19,6 @@
import * as yup from "yup";
import Album from "./album";
import Resource from "./resource";
import ExternalId from "./external-id";
import Illustration from "./illustration";
/**
@@ -58,7 +57,7 @@ type Release = yup.InferType<typeof Release>;
export default Release;
export type ReleaseInclude = "album" | "externalIds" | "illustration";
export type ReleaseInclude = "album" | "illustration";
const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
relation: Selection[],
@@ -68,7 +67,6 @@ const ReleaseWithRelations = <Selection extends ReleaseInclude | never = never>(
.object({
album: Album.required(),
illustration: Illustration.required().nullable(),
externalIds: yup.array(ExternalId.required()).required(),
})
.pick(relation),
);
+14
View File
@@ -20,13 +20,18 @@ import { RequireExactlyOne } from "type-fest";
import { SongWithRelations } from "./song";
import { ArtistWithRelations } from "./artist";
import { AlbumWithRelations } from "./album";
import { VideoWithRelations } from "./video";
export type SearchResult = RequireExactlyOne<{
song: SongWithRelations<"artist" | "featuring" | "illustration" | "master">;
album: AlbumWithRelations<"artist" | "illustration">;
artist: ArtistWithRelations<"illustration">;
video: VideoWithRelations<"illustration" | "master" | "artist">;
}>;
export type SaveSearchItem = Partial<
Record<"songId" | "albumId" | "artistId" | "videoId", number>
>;
export const SearchResultTransformer = (
results: unknown,
): Promise<SearchResult[]> => {
@@ -36,6 +41,15 @@ export const SearchResultTransformer = (
return Promise.all(
results.map(async (result) => {
if ("groupId" in result) {
if ("songId" in result) {
return {
video: await VideoWithRelations([
"artist",
"illustration",
"master",
] as const).validate(result),
};
}
return {
song: await SongWithRelations([
"artist",
+50
View File
@@ -0,0 +1,50 @@
/*
* 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 * as yup from "yup";
import Song, { SongInclude, SongRelations } from "./song";
/**
* Abstract data model, instanciated by tracks
*/
const SongGroup = Song.concat(
yup.object({
versionCount: yup.number().required(),
songId: yup.number().required(),
}),
);
type SongGroup = yup.InferType<typeof SongGroup>;
type SongGroupInclude = SongInclude;
const SongGroupRelations = SongRelations;
const SongGroupWithRelations = <
Selection extends SongGroupInclude | never = never,
>(
relation: Selection[],
) => SongGroup.concat(SongGroupRelations.pick(relation));
type SongGroupWithRelations<
Selection extends SongGroupInclude | never = never,
> = yup.InferType<ReturnType<typeof SongGroupWithRelations<Selection>>>;
export default SongGroup;
export const SongGroupSortingKeys = ["name"] as const;
export { type SongGroupInclude, SongGroupWithRelations, SongGroupRelations };
+3 -5
View File
@@ -21,7 +21,6 @@ import Artist from "./artist";
import Illustration from "./illustration";
import Lyrics from "./lyrics";
import Resource from "./resource";
import ExternalId from "./external-id";
import Track from "./track";
export const SongType = [
@@ -34,9 +33,9 @@ export const SongType = [
"Clean",
"Demo",
"Unknown",
"Acapella",
"NonMusic",
"Acappella",
"Medley",
"NonMusic",
] as const;
export type SongType = (typeof SongType)[number];
@@ -61,6 +60,7 @@ const Song = Resource.concat(
* The ID of the master track
*/
masterId: yup.number().required().nullable(),
groupId: yup.number().required(),
/**
* Type of song
*/
@@ -73,7 +73,6 @@ type Song = yup.InferType<typeof Song>;
type SongInclude =
| "artist"
| "lyrics"
| "externalIds"
| "featuring"
| "master"
| "illustration";
@@ -83,7 +82,6 @@ const SongRelations = yup.object({
master: yup.lazy(() => Track.required()),
featuring: yup.array(Artist.required()).required(),
lyrics: Lyrics.required().nullable(),
externalIds: yup.array(ExternalId.required()).required(),
illustration: Illustration.required().nullable(),
});
+10 -8
View File
@@ -23,13 +23,15 @@ const Task = yup.object({
description: yup.string().required(),
});
const ActiveTask = Task.concat(
yup.object({
data: yup.mixed(),
}),
);
type Task = yup.InferType<typeof Task>;
type ActiveTask = yup.InferType<typeof ActiveTask>;
export { Task, ActiveTask };
const TaskResponse = yup.object({
/**
* Status of the task
*/
message: yup.string().required(),
});
type TaskResponse = yup.InferType<typeof TaskResponse>;
export { Task, TaskResponse };
+11 -5
View File
@@ -21,6 +21,7 @@ import Illustration from "./illustration";
import Release from "./release";
import Resource from "./resource";
import Song from "./song";
import Video from "./video";
export const TrackType = ["Audio", "Video"] as const;
export type TrackType = (typeof TrackType)[number];
@@ -33,11 +34,15 @@ const Track = Resource.concat(
/**
* Unique identifier of the parent song
*/
songId: yup.number().required(),
songId: yup.number().required().nullable(),
/**
* Unique identifier of the parent video
*/
videoId: yup.number().required().nullable(),
/**
* Unique identifier of the parent release
*/
releaseId: yup.number().required(),
releaseId: yup.number().required().nullable(),
/**
* Title of the track
*/
@@ -82,11 +87,12 @@ type Track = yup.InferType<typeof Track>;
export default Track;
export type TrackInclude = "song" | "release" | "illustration";
export type TrackInclude = "song" | "release" | "illustration" | "video";
const TrackRelations = yup.object({
song: yup.lazy(() => Song.required()),
release: Release.required(),
song: yup.lazy(() => Song.required().nullable()),
video: yup.lazy(() => Video.required().nullable()),
release: Release.required().nullable(),
illustration: Illustration.required().nullable(),
});
+11 -2
View File
@@ -19,11 +19,13 @@
import Song, { SongInclude, SongRelations } from "./song";
import { TrackWithRelations } from "./track";
import * as yup from "yup";
import Video, { VideoRelations } from "./video";
const TracklistItem = TrackWithRelations(["illustration"])
.concat(
yup.object({
song: Song.required(),
song: Song.required().nullable(),
video: Video.required().nullable(),
}),
)
.required();
@@ -34,7 +36,14 @@ const TracklistItemWithRelations = <Selection extends SongInclude | never>(
TrackWithRelations(["illustration"])
.concat(
yup.object({
song: Song.concat(SongRelations.pick(selection)),
song: Song.concat(SongRelations.pick(selection)).nullable(),
video: Video.concat(
VideoRelations.pick(
selection.includes("artist" as Selection)
? ["artist"]
: [],
),
).nullable(),
}),
)
.required();
+53 -8
View File
@@ -16,24 +16,69 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Song, { SongInclude, SongRelations } from "./song";
import { TrackWithRelations } from "./track";
import Artist from "./artist";
import Illustration from "./illustration";
import Resource from "./resource";
import Song from "./song";
import Track from "./track";
import * as yup from "yup";
const Video = Song.concat(
export const VideoType = [
"MusicVideo",
"LyricsVideo",
"Live",
"BehindTheScenes",
"Interview",
"Advert",
"PhotoGallery",
"Documentary",
"Other",
] as const;
export const VideoTypeIsExtra = (vType: VideoType) =>
!["MusicVideo", "LyricsVideo", "Live"].includes(vType);
export type VideoType = (typeof VideoType)[number];
const Video = Resource.concat(
yup.object({
track: TrackWithRelations(["illustration"]).required(),
id: yup.number().required(),
slug: yup.string().required(),
name: yup.string().required(),
nameSlug: yup.string().required(),
artistId: yup.number().required(),
songId: yup.number().required().nullable(),
groupId: yup.number().required().nullable(),
registeredAt: yup.date().required(),
type: yup.mixed<VideoType>().oneOf(VideoType).required(),
}),
);
type Video = yup.InferType<typeof Video>;
const VideoWithRelations = <Selection extends SongInclude | never = never>(
relation: Selection[],
) => Video.concat(SongRelations.pick(relation));
export type VideoInclude = "artist" | "song" | "illustration" | "master";
export const VideoRelations = yup.object({
artist: Artist.required(),
song: yup.lazy(() => Song.required().nullable()),
master: yup.lazy(() => Track.required()),
illustration: Illustration.required().nullable(),
});
type VideoWithRelations<Selection extends SongInclude | never = never> =
const VideoWithRelations = <Selection extends VideoInclude | never = never>(
relation: Selection[],
) => Video.concat(VideoRelations.pick(relation));
type VideoWithRelations<Selection extends VideoInclude | never = never> =
yup.InferType<ReturnType<typeof VideoWithRelations<Selection>>>;
export default Video;
export const videoTypeIsExtra = (type: VideoType) =>
[
"BehindTheScenes",
"Interview",
"Advert",
"PhotoGallery",
"Documentary",
].includes(type);
export const VideoSortingKeys = ["name", "artistName", "addDate"] as const;
export { VideoWithRelations };
+12 -7
View File
@@ -58,6 +58,8 @@ import { UserAccessTokenCookieKey } from "../utils/cookieKeys";
import { setAccessToken } from "../state/userSlice";
import API from "../api/api";
import { deepmerge } from "@mui/utils";
import { KeyboardBindingsProvider } from "../contexts/keybindings";
import { KeyboardBindingModal } from "../components/keyboard-bindings-modal";
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
@@ -133,13 +135,16 @@ function MyApp({
}
}}
>
<PlayerContextProvider>
<Scaffold>
<Component
{...pageProps}
/>
</Scaffold>
</PlayerContextProvider>
<KeyboardBindingsProvider>
<KeyboardBindingModal />
<PlayerContextProvider>
<Scaffold>
<Component
{...pageProps}
/>
</Scaffold>
</PlayerContextProvider>
</KeyboardBindingsProvider>
</ErrorBoundary>
</AuthenticationWall>
</Hydrate>
+32 -20
View File
@@ -23,7 +23,6 @@ import { useInfiniteQuery, useQuery } from "../../../api/use-query";
import getSlugOrId from "../../../utils/getSlugOrId";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import SectionHeader from "../../../components/section-header";
import ExternalIdBadge from "../../../components/external-id-badge";
import ResourceDescriptionExpandable from "../../../components/resource-description-expandable";
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
import { AlbumType } from "../../../models/album";
@@ -39,6 +38,8 @@ import {
VideoListPageSection,
} from "../../../components/page-section";
import { Head } from "../../../components/head";
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
import { VideoTypeIsExtra } from "../../../models/video";
// Number of Song item in the 'Top Song' section
const SongListSize = 6;
@@ -60,8 +61,8 @@ const latestAlbumsQuery = AlbumType.map((type) => ({
const videosQuery = (artistSlugOrId: string | number) =>
API.getVideos(
{ artist: artistSlugOrId },
{ sortBy: "totalPlayCount", order: "desc" },
["artist"],
{ sortBy: "addDate", order: "desc" },
["artist", "master", "illustration"],
);
const topSongsQuery = (artistSlugOrId: string | number) =>
@@ -79,8 +80,10 @@ const rareSongsQuery = (artistSlugOrId: string | number) =>
);
const artistQuery = (artistSlugOrId: string | number) =>
API.getArtist(artistSlugOrId, ["externalIds", "illustration"]);
API.getArtist(artistSlugOrId, ["illustration"]);
const externalMetadataQuery = (artistSlugOrId: string | number) =>
API.getArtistExternalMetadata(artistSlugOrId);
const appearanceQuery = (artistSlugOrId: string | number) =>
API.getAlbums(
{ appearance: artistSlugOrId },
@@ -93,7 +96,10 @@ const prepareSSR = (context: NextPageContext) => {
return {
additionalProps: { artistIdentifier },
queries: [artistQuery(artistIdentifier)],
queries: [
artistQuery(artistIdentifier),
externalMetadataQuery(artistIdentifier),
],
infiniteQueries: [
rareSongsQuery(artistIdentifier),
videosQuery(artistIdentifier),
@@ -119,16 +125,20 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const topSongs = useInfiniteQuery(topSongsQuery, artistIdentifier);
const rareSongs = useInfiniteQuery(rareSongsQuery, artistIdentifier);
const appearances = useInfiniteQuery(appearanceQuery, artistIdentifier);
const externalIdWithDescription = artist.data?.externalIds
.filter(({ provider }) => provider.name.toLowerCase() !== "discogs")
.find(({ description }) => description !== null);
const { musicVideos, extras } = useMemo(() => {
const externalMetadata = useQuery(externalMetadataQuery, artistIdentifier);
const { musicVideos, liveVideos, extras } = useMemo(() => {
const firstPage = videos.data?.pages.at(0)?.items;
return {
musicVideos:
firstPage?.filter((video) => video.type != "NonMusic") ?? [],
firstPage?.filter(
(video) =>
!VideoTypeIsExtra(video.type) && video.type != "Live",
) ?? [],
liveVideos:
firstPage?.filter((video) => video.type == "Live") ?? [],
extras:
firstPage?.filter((video) => video.type == "NonMusic") ?? [],
firstPage?.filter((video) => VideoTypeIsExtra(video.type)) ??
[],
};
}, [videos]);
const { GradientBackground } = useGradientBackground(
@@ -139,9 +149,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<Box sx={{ width: "100%" }}>
<Head
title={artist.data?.name}
description={
externalIdWithDescription?.description ?? undefined
}
description={externalMetadata.data?.description ?? undefined}
/>
<GradientBackground />
<ArtistRelationPageHeader artist={artist.data} />
@@ -189,6 +197,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
/>
{[
{ label: "topVideos", items: musicVideos } as const,
{ label: "livePerformances", items: liveVideos } as const,
{ label: "extras", items: extras } as const,
].map(
({ label, items }) =>
@@ -212,7 +221,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
subtitleIs="artistName"
query={appearances}
/>
{externalIdWithDescription && (
{externalMetadata.data?.description && (
<>
<Divider sx={{ marginBottom: SectionPadding }} />
<SectionHeader heading={t("about")} />
@@ -221,12 +230,13 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
sx={{ paddingBottom: 4, paddingTop: 3 }}
>
<ResourceDescriptionExpandable
externalDescription={externalIdWithDescription}
externalMetadata={externalMetadata.data}
/>
</Container>
</>
)}
{(!artist.data || artist.data.externalIds.length != 0) && (
{(externalMetadata.data === undefined ||
externalMetadata.data?.sources.length) && (
<>
<Divider />
<Grid
@@ -239,12 +249,14 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<SectionHeader heading={t("externalLinks")} />
</Grid>
{(
artist.data?.externalIds.filter(
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalId, index) => (
).map((externalSource, index) => (
<Grid item key={index}>
<ExternalIdBadge externalId={externalId} />
<ExternalMetadataBadge
source={externalSource}
/>
</Grid>
)) ?? []}
</Grid>
+30 -7
View File
@@ -69,8 +69,9 @@ const prepareSSR = async (
artistQuery(artistIdentifier),
...songs.pages
.flatMap(({ items }) => items)
.filter(({ master }) => master.releaseId)
.map(({ master }) =>
API.getRelease(master.releaseId, ["album"]),
API.getRelease(master.releaseId!, ["album"]),
),
],
infiniteQueries: [],
@@ -83,10 +84,12 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const router = useRouter();
const isRareSongs = isRareSongsPage(router);
const queryClient = useQueryClient();
const getTrackReleaseName = (track: Track) =>
queryClient
.fetchQuery(API.getRelease(track.releaseId))
.then((release) => release.name);
const getTrackReleaseName = (track: Track): Promise<string | null> =>
track.releaseId
? queryClient
.fetchQuery(API.getRelease(track.releaseId))
.then((release) => release.name)
: Promise.resolve(null);
const artistIdentifier =
props?.artistIdentifier ?? getSlugOrId(router.query);
const artist = useQuery(artistQuery, artistIdentifier);
@@ -123,9 +126,29 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
["artist", "featuring", "master", "illustration"],
)
}
formatSubtitle={(song) =>
getTrackReleaseName(song.master).then((name) => name)
groupsQuery={
isRareSongsPage(router)
? undefined
: ({ sortBy, order, library, type }) =>
API.getSongGroups(
{
type,
artist: artistIdentifier,
library: library ?? undefined,
},
{ sortBy, order },
[
"artist",
"featuring",
"master",
"illustration",
],
)
}
subtitles={[
(song) =>
getTrackReleaseName(song.master).then((name) => name),
]}
/>
</Box>
);
+10 -5
View File
@@ -22,7 +22,7 @@ import getSlugOrId from "../../../utils/getSlugOrId";
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
import { SongSortingKeys } from "../../../models/song";
import { VideoSortingKeys } from "../../../models/video";
import { getOrderParams, getSortingFieldParams } from "../../../utils/sorting";
import { useQuery } from "../../../api/use-query";
import { NextPageContext } from "next";
@@ -36,7 +36,10 @@ const artistQuery = (identifier: string | number) =>
const prepareSSR = (context: NextPageContext) => {
const artistIdentifier = getSlugOrId(context.query);
const order = getOrderParams(context.query.order) ?? "asc";
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
const sortBy = getSortingFieldParams(
context.query.sortBy,
VideoSortingKeys,
);
return {
additionalProps: { artistIdentifier, order, sortBy },
@@ -45,7 +48,7 @@ const prepareSSR = (context: NextPageContext) => {
API.getVideos(
{ artist: artistIdentifier },
{ sortBy: sortBy, order: order },
["artist", "featuring"],
["artist", "master", "illustration"],
),
],
};
@@ -73,14 +76,16 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
<InfiniteVideoView
initialSortingField={props?.sortBy}
initialSortingOrder={props?.order}
query={({ sortBy, order, library }) =>
query={({ sortBy, order, library, random, type }) =>
API.getVideos(
{
type,
artist: artistIdentifier,
random,
library: library ?? undefined,
},
{ sortBy, order },
["artist", "featuring"],
["artist", "master", "illustration"],
)
}
subtitle="duration"
+45 -7
View File
@@ -19,7 +19,12 @@
import { Box, Grid, Stack } from "@mui/material";
import API from "../api/api";
import { GetPropsTypesFrom, Page } from "../ssr";
import { useInfiniteQuery } from "../api/use-query";
import {
prepareMeeloInfiniteQuery,
useInfiniteQuery,
useQueries,
useQuery,
} from "../api/use-query";
import SectionHeader from "../components/section-header";
import TileRow from "../components/tile-row";
import AlbumTile from "../components/tile/album-tile";
@@ -33,6 +38,9 @@ import { useTranslation } from "react-i18next";
import { generateArray } from "../utils/gen-list";
import { getRandomNumber } from "../utils/random";
import { useGradientBackground } from "../utils/gradient-background";
import { QueryClient } from "react-query";
import { NextPageContext } from "next";
import { AlbumExternalMetadata } from "../models/external-metadata";
const newlyAddedAlbumsQuery = API.getAlbums(
{},
@@ -68,7 +76,6 @@ const albumRecommendations = (seed: number) =>
API.getAlbums({ random: seed, type: "StudioRecording" }, undefined, [
"artist",
"genres",
"externalIds",
"illustration",
]);
@@ -93,21 +100,28 @@ const HomePageSection = <T,>(props: {
);
};
const prepareSSR = () => {
const prepareSSR = async (_: NextPageContext, queryClient: QueryClient) => {
const seed = Math.floor(Math.random() * 10000000);
const albumRecs = await queryClient
.fetchInfiniteQuery(
prepareMeeloInfiniteQuery(() => albumRecommendations(seed)),
)
.then((res) => res.pages.at(0)?.items);
return {
additionalProps: {
blurhashIndex: Math.random(),
recommendationSeed: seed,
},
queries: albumRecs?.map((album) =>
API.getAlbumExternalMetadata(album.id),
),
infiniteQueries: [
newlyAddedArtistsQuery,
newestAlbumsQuery,
newlyAddedAlbumsQuery,
newlyAddedReleasesQuery,
mostListenedSongsQuery,
albumRecommendations(seed),
],
};
};
@@ -119,6 +133,21 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
albumRecommendations,
props?.recommendationSeed ?? seed,
);
const featuredAlbumsExternalMetadata = useQueries(
...(featuredAlbums.data?.pages
.at(0)
?.items.map(
(
album,
): Parameters<
typeof useQuery<
AlbumExternalMetadata | null,
Parameters<typeof API.getAlbumExternalMetadata>
>
> => [API.getAlbumExternalMetadata, album.id],
) ?? []),
);
const newlyAddedAlbums = useInfiniteQuery(() => newlyAddedAlbumsQuery);
const newlyAddedArtists = useInfiniteQuery(() => newlyAddedArtistsQuery);
const newlyAddedReleases = useInfiniteQuery(() => newlyAddedReleasesQuery);
@@ -128,9 +157,9 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const tileRowWindowSize = {
xs: 3,
sm: 4,
md: 5,
md: 4,
lg: 6,
xl: 10,
xl: 8,
};
const queries = [
newlyAddedAlbums,
@@ -195,7 +224,16 @@ const HomePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
xl={4}
key={index}
>
<AlbumHighlightCard album={album} />
<AlbumHighlightCard
album={album}
externalMetadata={
featuredAlbumsExternalMetadata.find(
({ data }) =>
data?.albumId ===
album?.id,
)?.data ?? undefined
}
/>
</Grid>
))}
</Grid>
+25 -9
View File
@@ -22,6 +22,7 @@ import RelationPageHeader from "../../../components/relation-page-header/relatio
import { GetPropsTypesFrom, Page } from "../../../ssr";
import getSlugOrId from "../../../utils/getSlugOrId";
import {
Query,
prepareMeeloQuery,
useQueries,
useQuery,
@@ -89,7 +90,16 @@ const prepareSSR = async (
additionalProps: { playlistIdentifier },
queries: [
playlistQuery(playlistIdentifier),
...entries.map((entry) => API.getRelease(entry.master.releaseId)),
...entries
.map((entry) =>
entry.master.releaseId
? API.getRelease(entry.master.releaseId)
: undefined,
)
.filter(
(promise): promise is Query<Release> =>
promise !== undefined,
),
],
};
};
@@ -219,13 +229,15 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const playlist = useQuery(playlistQuery, playlistIdentifier);
const entriesQuery = useQuery(playlistEntriesQuery, playlistIdentifier);
const masterTracksReleaseQueries = useQueries(
...(entriesQuery.data?.map(
({
master,
}): Parameters<
typeof useQuery<Release, Parameters<typeof API.getRelease>>
> => [API.getRelease, master.releaseId],
) ?? []),
...(entriesQuery.data
?.filter(({ master }) => master.releaseId)
.map(
({
master,
}): Parameters<
typeof useQuery<Release, Parameters<typeof API.getRelease>>
> => [API.getRelease, master.releaseId ?? undefined],
) ?? []),
);
const reorderMutation = useMutation((reorderedEntries: number[]) => {
return API.reorderPlaylist(playlistIdentifier, reorderedEntries)
@@ -240,7 +252,11 @@ const PlaylistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
const releases = masterTracksReleaseQueries.map((query) => query.data);
const resolvedReleases = releases.filter((data) => data !== undefined);
if (resolvedReleases.length !== entriesQuery.data?.length) {
if (
resolvedReleases.length !==
entriesQuery.data?.filter(({ master }) => master.releaseId != null)
.length
) {
return undefined;
}
+178 -85
View File
@@ -22,6 +22,7 @@ import {
Divider,
Grid,
IconButton,
ListItemButton,
ListSubheader,
Rating,
Skeleton,
@@ -33,7 +34,7 @@ import { useRouter } from "next/router";
import API from "../../api/api";
import Illustration from "../../components/illustration";
import formatDuration from "../../utils/formatDuration";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { PlayIcon, ShuffleIcon } from "../../components/icons";
import {
@@ -48,7 +49,6 @@ import { GetPropsTypesFrom, Page } from "../../ssr";
import ReleaseContextualMenu from "../../components/contextual-menu/release-contextual-menu";
import TileRow from "../../components/tile-row";
import VideoTile from "../../components/tile/video-tile";
import ExternalIdBadge from "../../components/external-id-badge";
import ArtistTile from "../../components/tile/artist-tile";
import PlaylistTile from "../../components/tile/playlist-tile";
import ReleaseTile from "../../components/tile/release-tile";
@@ -60,7 +60,7 @@ import ResourceDescriptionExpandable from "../../components/resource-description
import { Star1 } from "iconsax-react";
import GenreButton from "../../components/genre-button";
import { SongWithRelations } from "../../models/song";
import Video from "../../models/video";
import { VideoTypeIsExtra, VideoWithRelations } from "../../models/video";
import { useAccentColor } from "../../utils/accent-color";
import { useTranslation } from "react-i18next";
import { generateArray } from "../../utils/gen-list";
@@ -71,11 +71,16 @@ import { useGradientBackground } from "../../utils/gradient-background";
import Tracklist, { TracklistItemWithRelations } from "../../models/tracklist";
import { Head } from "../../components/head";
import { useThemedSxValue } from "../../utils/themed-sx-value";
import { parentScrollableDivId } from "../../components/infinite/infinite-scroll";
import ExternalMetadataBadge from "../../components/external-metadata-badge";
const releaseQuery = (releaseIdentifier: string | number) =>
API.getRelease(releaseIdentifier, ["album", "externalIds", "illustration"]);
const releaseTracklistQuery = (releaseIdentifier: number | string) => {
const query = API.getReleaseTracklist(releaseIdentifier, [
API.getRelease(releaseIdentifier, ["album", "illustration"]);
const releaseTracklistQuery = (
releaseIdentifier: number | string,
exclusiveOnly: boolean,
) => {
const query = API.getReleaseTracklist(releaseIdentifier, exclusiveOnly, [
"artist",
"featuring",
]);
@@ -102,7 +107,9 @@ const releaseTracklistQuery = (releaseIdentifier: number | string) => {
};
};
const albumQuery = (albumId: number) =>
API.getAlbum(albumId, ["externalIds", "genres", "artist"]);
API.getAlbum(albumId, ["genres", "artist"]);
const externalMetadataQuery = (albumIdentifier: string | number) =>
API.getAlbumExternalMetadata(albumIdentifier);
const artistsOnAlbumQuery = (albumId: number) => {
const query = API.getArtists({ album: albumId }, undefined, [
"illustration",
@@ -122,7 +129,8 @@ const releaseBSidesQuery = (releaseId: number) =>
"master",
"illustration",
]);
const albumVideosQuery = (albumId: number) => API.getVideos({ album: albumId });
const albumVideosQuery = (albumId: number) =>
API.getVideos({ album: albumId }, undefined, ["master", "illustration"]);
const relatedAlbumsQuery = (albumId: number) =>
API.getAlbums({ related: albumId }, { sortBy: "releaseDate" }, [
"artist",
@@ -147,9 +155,10 @@ const prepareSSR = async (
return {
additionalProps: { releaseIdentifier },
queries: [
releaseTracklistQuery(releaseIdentifier),
releaseTracklistQuery(releaseIdentifier, false),
albumQuery(release.albumId),
artistsOnAlbumQuery(release.albumId),
externalMetadataQuery(release.albumId),
],
infiniteQueries: [
albumGenreQuery(release.albumId),
@@ -187,14 +196,22 @@ const RelatedContentSection = (props: RelatedContentSectionProps) => {
const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const router = useRouter();
const { t } = useTranslation();
const [showOnlyExclusive, setShowOnlyExclusive] = useState(false);
const releaseIdentifier =
props?.releaseIdentifier ?? getSlugOrId(router.query);
const theme = useTheme();
const { playTracks } = usePlayerContext();
const release = useQuery(releaseQuery, releaseIdentifier);
const externalMetadata = useQuery(
externalMetadataQuery,
release.data?.albumId,
);
const artistId = useMemo(() => release.data?.album?.artistId, [release]);
const album = useQuery(albumQuery, release.data?.albumId);
const tracklistQuery = useQuery(releaseTracklistQuery, releaseIdentifier);
const tracklistQuery = useQuery(
(rId) => releaseTracklistQuery(rId, showOnlyExclusive),
releaseIdentifier,
);
const albumGenres = useInfiniteQuery(
albumGenreQuery,
release.data?.albumId,
@@ -231,7 +248,7 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
() =>
(bSidesQuery.data?.pages.at(0)?.items ?? []).reduce(
(prev, current) => {
if (current.type === "NonMusic") {
if (["NonMusic", "Medley"].includes(current.type)) {
return {
bSides: prev.bSides,
extras: prev.extras.concat(current),
@@ -251,28 +268,6 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
),
[bSidesQuery.data],
);
const { videos, videoExtras } = useMemo(
() =>
(albumVideos.data?.pages.at(0)?.items ?? []).reduce(
(prev, current) => {
if (current.type === "NonMusic") {
return {
videos: prev.videos,
videoExtras: prev.videoExtras.concat(current),
};
}
return {
videos: prev.videos.concat(current),
videoExtras: prev.videoExtras,
};
},
{ videos: [], videoExtras: [] } as Record<
"videos" | "videoExtras",
Video[]
>,
),
[albumVideos.data],
);
const [tracks, totalDuration, trackList] = useMemo(() => {
if (tracklistQuery.data) {
const discMap = tracklistQuery.data;
@@ -290,35 +285,77 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
return [[], undefined, undefined];
}, [tracklistQuery.data]);
const { videos, liveVideos, videoExtras } = useMemo(
() =>
(albumVideos.data?.pages.at(0)?.items ?? [])
.map((video) => {
const videoIndex = tracks.findIndex(
(track) =>
(track.song ?? track.video)!.groupId ==
video.groupId,
);
return [
video,
videoIndex == -1 ? tracks.length : videoIndex,
] as const;
})
.sort(
([v1, i1], [v2, i2]) =>
i1 - i2 || v1.slug.localeCompare(v2.slug),
)
.map(([video, tracklistIndex], _, videosWithIndexes) => {
if (album.data?.type === "Single") {
return [video, tracklistIndex] as const;
}
const firstVideoOfSameGroup = videosWithIndexes.find(
([__, i]) => i == tracklistIndex,
)!;
return [
video,
firstVideoOfSameGroup[0].id == video.id
? tracklistIndex
: 10000 + tracklistIndex,
] as const;
})
.sort(([_, i1], [__, i2]) => i1 - i2)
.map(([v, _]) => v)
.reduce(
(prev, current) => {
if (VideoTypeIsExtra(current.type)) {
return {
videos: prev.videos,
liveVideos: prev.liveVideos,
videoExtras: prev.videoExtras.concat(current),
};
} else if (current.type === "Live") {
return {
videos: prev.videos,
liveVideos: prev.liveVideos.concat(current),
videoExtras: prev.videoExtras,
};
}
return {
videos: prev.videos.concat(current),
liveVideos: prev.liveVideos,
videoExtras: prev.videoExtras,
};
},
{
videos: [],
videoExtras: [],
liveVideos: [],
} as Record<
"videos" | "videoExtras" | "liveVideos",
VideoWithRelations<"master" | "illustration">[]
>,
),
[albumVideos.data, tracks],
);
const illustration = useMemo(
() => release.data?.illustration,
[release.data],
);
const externalIdWithDescription = useMemo(
() =>
album.data?.externalIds
.filter(
({ provider }) => provider.name.toLowerCase() !== "discogs",
)
.find(({ description }) => description !== null),
[album.data],
);
const externalIds = useMemo(() => {
if (album.data === undefined || release.data === undefined) {
return undefined;
}
return [...album.data.externalIds, ...release.data.externalIds];
}, [album.data, release.data]);
const albumRating = useMemo(() => {
return album.data
? album.data.externalIds
.map(({ rating }) => rating)
.filter((rating) => rating !== null)
.sort()
.at(-1) ?? null
: undefined;
}, [album.data]);
const accentColor = useAccentColor(illustration);
const { GradientBackground } = useGradientBackground(illustration?.colors);
const ratingColor = useThemedSxValue(
@@ -331,6 +368,10 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
accentColor?.light,
accentColor?.dark,
);
useEffect(() => {
//for some unknown reason, this state is persisted between release pages?!
setShowOnlyExclusive(false);
}, [router.asPath]);
return (
<>
@@ -453,14 +494,14 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<Skeleton width={"100px"} />
)}
</Typography>
{albumRating && (
{externalMetadata.data?.rating && (
<Rating
sx={{
paddingLeft: 1.5,
...ratingColor,
}}
readOnly
value={albumRating / 20}
value={externalMetadata.data.rating / 20}
icon={
<Star1
size={18}
@@ -509,7 +550,9 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
artist: artists.data.find(
(artist) =>
artist.id ==
track.song.artistId,
(track.song ??
track.video)!
.artistId,
)!,
release: release.data,
}),
@@ -596,7 +639,13 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
</Fade>
</Grid>
)}
<Grid item xl lg={hasGenres ? 9 : true} xs={12}>
<Grid
item
xl
lg={hasGenres ? 9 : true}
xs={12}
id={"release-tracklist"}
>
<ReleaseTrackList
mainArtist={albumArtist}
tracklist={
@@ -608,7 +657,8 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
discKey,
discTracks.map((discTrack) => ({
...discTrack,
song: discTrack.song,
song: discTrack.song!,
video: discTrack.video!,
})),
]),
)
@@ -616,6 +666,37 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
release={release.data}
/>
{release.data?.album.type === "Compilation" &&
/* It does not make sense to show the button if the album is from 'Various Artists' */
albumArtist !== null && (
<>
<ListItemButton
sx={{
textAlign: "center",
justifyContent: "center",
width: "100%",
fontWeight: "bolder",
}}
onClick={() => {
setShowOnlyExclusive((v) => !v);
document
.getElementById(
parentScrollableDivId,
)
?.scrollTo({
top: 0,
behavior: "smooth",
});
}}
>
{t(
showOnlyExclusive
? "showAllTrack"
: "showOnlyExclusiveTracks",
)}
</ListItemButton>
</>
)}
</Grid>
</Grid>
<RelatedContentSection
@@ -658,22 +739,30 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
/>
</RelatedContentSection>
<RelatedContentSection
display={videos !== undefined && videos.length != 0}
title={t("musicVideos")}
>
<TileRow
tiles={
videos?.map((video, videoIndex) => (
<VideoTile
key={videoIndex}
video={video}
subtitle="duration"
/>
)) ?? []
{[
[videos, "musicVideos"] as const,
[liveVideos, "livePerformances"] as const,
].map(([videoList, sectionLabel]) => (
<RelatedContentSection
key={`videos-${sectionLabel}`}
display={
videoList !== undefined && videoList.length != 0
}
/>
</RelatedContentSection>
title={t(sectionLabel)}
>
<TileRow
tiles={
videoList?.map((video, videoIndex) => (
<VideoTile
key={videoIndex}
video={video}
subtitle="duration"
/>
)) ?? []
}
/>
</RelatedContentSection>
))}
<RelatedContentSection
display={extras.length > 0 || videoExtras.length > 0}
title={t("extras")}
@@ -752,28 +841,32 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
/>
</RelatedContentSection>
{externalIdWithDescription && (
{externalMetadata.data?.description && (
<RelatedContentSection display title={t("about")}>
<Box sx={{ paddingBottom: 2 }}>
<ResourceDescriptionExpandable
externalDescription={externalIdWithDescription}
externalMetadata={externalMetadata.data}
/>
</Box>
</RelatedContentSection>
)}
<RelatedContentSection
display={
externalIds === undefined || externalIds.length > 0
externalMetadata.data === undefined ||
(externalMetadata.data?.sources.length ?? 0) > 0
}
title={t("externalLinks")}
>
<Grid container spacing={1}>
{(
externalIds?.filter(({ url }) => url !== null) ??
generateArray(2)
).map((externalId, index) => (
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalSource, index) => (
<Grid item key={index}>
<ExternalIdBadge externalId={externalId} />
<ExternalMetadataBadge
source={externalSource}
/>
</Grid>
))}
</Grid>
+146 -37
View File
@@ -30,10 +30,20 @@ import InfiniteArtistView from "../../components/infinite/infinite-resource-view
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
import InfiniteView from "../../components/infinite/infinite-view";
import { toInfiniteQuery, transformPage } from "../../api/use-query";
import {
Query,
toInfiniteQuery,
transformPage,
useQueryClient,
} from "../../api/use-query";
import AlbumItem from "../../components/list-item/album-item";
import SongItem from "../../components/list-item/song-item";
import ArtistItem from "../../components/list-item/artist-item";
import { useMutation } from "react-query";
import { SaveSearchItem, SearchResult } from "../../models/search";
import formatArtists from "../../utils/formatArtists";
import InfiniteVideoView from "../../components/infinite/infinite-resource-view/infinite-video-view";
import VideoItem from "../../components/list-item/video-item";
const prepareSSR = (context: NextPageContext) => {
const searchQuery = context.query.query?.at(0) ?? null;
@@ -41,26 +51,47 @@ const prepareSSR = (context: NextPageContext) => {
return {
additionalProps: { searchQuery, type },
infiniteQueries: searchQuery
? [
API.getArtists({ query: searchQuery }, undefined, [
"illustration",
]),
API.getAlbums({ query: searchQuery }, undefined, [
"artist",
"illustration",
]),
API.getSongs({ query: searchQuery }, undefined, [
"artist",
"featuring",
"master",
"illustration",
]),
]
: [],
infiniteQueries: [
searchResultQueryToInfinite(API.getSearchHistory()),
...(searchQuery
? [
API.getArtists({ query: searchQuery }, undefined, [
"illustration",
]),
API.getAlbums({ query: searchQuery }, undefined, [
"artist",
"illustration",
]),
API.getSongs({ query: searchQuery }, undefined, [
"artist",
"featuring",
"master",
"illustration",
]),
API.getVideos({ query: searchQuery }, undefined, [
"artist",
"master",
"illustration",
]),
searchResultQueryToInfinite(API.searchAll(searchQuery)),
]
: []),
],
};
};
const searchResultQueryToInfinite = (q: Query<SearchResult[]>) => {
return transformPage(toInfiniteQuery(q), (item, index) => ({
...item,
id: index,
illustration:
item.song?.illustration ??
item.artist?.illustration ??
item.album?.illustration ??
null,
}));
};
const buildSearchUrl = (
query: string | undefined,
type: string | undefined,
@@ -68,7 +99,7 @@ const buildSearchUrl = (
return "/search/" + (query ?? "") + (type ? `?t=${type}` : "");
};
const tabs = ["all", "artist", "album", "song"] as const;
const tabs = ["all", "artist", "album", "song", "video"] as const;
const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const router = useRouter();
@@ -83,8 +114,25 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
(newTab) => buildSearchUrl(query, newTab),
...tabs,
);
const queryClient = useQueryClient();
const [inputValue, setInputValue] = useState(query);
const [debounceId, setDebounceId] = useState<NodeJS.Timeout>();
const saveSearch = useMutation((dto: SaveSearchItem) => {
return (
API.saveSearchHistoryEntry(dto)
.then(() => {
// Sometimes, it refreshes to fast, and shifts the history
// before openning a page (for artists) is done
setTimeout(() => {
queryClient.client.invalidateQueries(
API.getSearchHistory().key,
);
}, 500);
})
// eslint-disable-next-line no-console
.catch((error: Error) => console.error(error))
);
});
useEffect(() => {
if (debounceId) {
clearTimeout(debounceId);
@@ -100,7 +148,7 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
},
);
setDebounceId(undefined);
}, 500),
}, 400),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue]);
@@ -124,10 +172,14 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
sx={{ display: "flex", justifyContent: "center", paddingY: 2 }}
>
<TextField
id="outlined-basic"
label="Search"
variant="outlined"
autoFocus
onKeyDown={(e) => {
if (e.key == "Escape") {
(document.activeElement as any)?.blur();
}
}}
InputProps={{
value: inputValue,
startAdornment: (
@@ -156,27 +208,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
))}
</Tabs>
<Box sx={{ paddingBottom: 2 }} />
{query && selectedTab == "all" && (
{selectedTab == "all" && (
<InfiniteView
view="list"
query={() =>
transformPage(
toInfiniteQuery(API.searchAll(query)),
(item, index) => ({
...item,
id: index,
illustration:
item.song?.illustration ??
item.artist?.illustration ??
item.album?.illustration ??
null,
}),
searchResultQueryToInfinite(
query
? API.searchAll(query)
: API.getSearchHistory(),
)
}
renderGridItem={(item) => <></>}
renderListItem={(item) =>
!item || item.album ? (
<AlbumItem
onClick={() =>
item &&
saveSearch.mutate({
albumId: item.album.id,
})
}
album={item?.album}
formatSubtitle={(album) =>
`${t("album")} • ${
@@ -186,19 +237,51 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
/>
) : item.song ? (
<SongItem
song={item.song}
formatSubtitle={async (song) =>
`${t("song")} • ${song.artist.name}`
onClick={() =>
saveSearch.mutate({ songId: item.song.id })
}
song={item.song}
subtitles={[
async (song) =>
`${t("song")} • ${formatArtists(
song.artist,
song.featuring,
)}`,
]}
/>
) : item.video ? (
<VideoItem
onClick={() =>
saveSearch.mutate({
videoId: item.video.id,
})
}
video={item.video}
subtitles={[
async (video) =>
`${t("video")} • ${formatArtists(
video.artist,
)}`,
]}
/>
) : (
<ArtistItem artist={item.artist} />
<ArtistItem
artist={item.artist}
onClick={() =>
saveSearch.mutate({
artistId: item.artist.id,
})
}
/>
)
}
/>
)}
{query && selectedTab == "artist" && (
<InfiniteArtistView
onItemClick={(item) =>
item && saveSearch.mutate({ artistId: item.id })
}
query={({ library }) =>
API.getArtists(
{
@@ -213,6 +296,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
)}
{query && selectedTab == "album" && (
<InfiniteAlbumView
onItemClick={(item) =>
item && saveSearch.mutate({ albumId: item.id })
}
defaultAlbumType={null}
query={({ library, type: newType }) =>
API.getAlbums(
@@ -229,6 +315,9 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
)}
{query && selectedTab == "song" && (
<InfiniteSongView
onItemClick={(item) =>
item && saveSearch.mutate({ songId: item.id })
}
query={({ library, type: newType }) =>
API.getSongs(
{
@@ -242,6 +331,26 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
/>
)}
{query && selectedTab == "video" && (
<InfiniteVideoView
onItemClick={(item) =>
item && saveSearch.mutate({ videoId: item.id })
}
subtitle="artist"
query={({ library, type: newType }) =>
API.getVideos(
{
query: encodeURIComponent(query),
type: newType,
library: library ?? undefined,
},
undefined,
["artist", "master", "illustration"],
)
}
/>
)}
</Box>
);
};
+66 -21
View File
@@ -21,6 +21,7 @@ import API from "../../../api/api";
import { GetPropsTypesFrom, Page } from "../../../ssr";
import getSlugOrId from "../../../utils/getSlugOrId";
import {
prepareMeeloQuery,
useInfiniteQuery,
useQuery,
useQueryClient,
@@ -38,7 +39,6 @@ import LyricsBox from "../../../components/lyrics";
import SongRelationPageHeader from "../../../components/relation-page-header/song-relation-page-header";
import InfiniteSongView from "../../../components/infinite/infinite-resource-view/infinite-song-view";
import InfiniteTrackView from "../../../components/infinite/infinite-resource-view/infinite-track-view";
import ExternalIdBadge from "../../../components/external-id-badge";
import { PlayIcon } from "../../../components/icons";
import GenreButton from "../../../components/genre-button";
import { useTranslation } from "react-i18next";
@@ -50,22 +50,33 @@ import { Head } from "../../../components/head";
import { useThemedSxValue } from "../../../utils/themed-sx-value";
import { useAccentColor } from "../../../utils/accent-color";
import { useTabRouter } from "../../../components/tab-router";
import ExternalMetadataBadge from "../../../components/external-metadata-badge";
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
import { QueryClient } from "react-query";
const prepareSSR = (context: NextPageContext) => {
const externalMetadataQuery = (songIdentifier: string | number) =>
API.getSongExternalMetadata(songIdentifier);
const prepareSSR = async (
context: NextPageContext,
queryClient: QueryClient,
) => {
const songIdentifier = getSlugOrId(context.query);
return {
additionalProps: { songIdentifier },
queries: [
const song = await queryClient.fetchQuery(
prepareMeeloQuery(() =>
API.getSong(songIdentifier, [
"artist",
"externalIds",
"featuring",
"lyrics",
"master",
"illustration",
]),
],
),
);
return {
additionalProps: { songIdentifier },
queries: [externalMetadataQuery(songIdentifier)],
infiniteQueries: [
API.getGenres({ song: songIdentifier }),
API.getSongs(
@@ -73,16 +84,22 @@ const prepareSSR = (context: NextPageContext) => {
{ sortBy: "name", order: "asc" },
["artist", "featuring", "master", "illustration"],
),
API.getVideos(
{ group: song.groupId },
{ sortBy: "name", order: "asc" },
["artist", "master", "illustration"],
),
API.getTracks(
{ song: songIdentifier },
{ sortBy: "name", order: "asc" },
["release", "song", "illustration"],
["release", "song", "illustration", "video"],
),
],
};
};
const tabs = ["lyrics", "versions", "tracks", "more"] as const;
const tabs = ["lyrics", "versions", "videos", "tracks", "more"] as const;
const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const { selectedTab, selectTab } = useTabRouter(
@@ -98,13 +115,13 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const song = useQuery(() =>
API.getSong(songIdentifier, [
"artist",
"externalIds",
"featuring",
"lyrics",
"master",
"illustration",
]),
);
const externalMetadata = useQuery(externalMetadataQuery, songIdentifier);
const genres = useInfiniteQuery(API.getGenres, { song: songIdentifier });
const { GradientBackground } = useGradientBackground(
song.data?.illustration?.colors,
@@ -189,7 +206,8 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
))}
</Stack>
)}
{(!song.data || song.data.externalIds.length != 0) && (
{(song.data === undefined ||
externalMetadata.data?.sources.length) && (
<Stack
direction="row"
sx={{
@@ -203,22 +221,19 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
{`${t("externalLinks")}: `}
</Typography>
{(
song.data?.externalIds.filter(
externalMetadata.data?.sources.filter(
({ url }) => url !== null,
) ?? generateArray(2)
).map((externalId, index) => (
<ExternalIdBadge
).map((source, index) => (
<ExternalMetadataBadge
key={index}
externalId={externalId}
source={source}
/>
))}
</Stack>
)}
<Typography variant="body1" sx={{ paddingTop: 2 }}>
{song.data?.externalIds
.map(({ description }) => description)
.filter((desc) => desc !== null)
.at(0)}
{externalMetadata.data?.description}
</Typography>
</>
)}
@@ -270,6 +285,31 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
/>
</>
)}
{selectedTab == "videos" && (
<>
<Head
title={
song.data &&
`${song.data.name} (${t("videos")})`
}
/>
<InfiniteVideoView
query={({ library, sortBy, order, type }) =>
API.getVideos(
{
library: library ?? undefined,
type,
group: song.data?.groupId,
},
{ sortBy, order },
["artist", "master", "illustration"],
)
}
subtitle="duration"
/>
</>
)}
{selectedTab == "tracks" && (
<>
<Head
@@ -283,7 +323,12 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
API.getTracks(
{ song: songIdentifier },
{ sortBy, order },
["release", "song", "illustration"],
[
"release",
"song",
"illustration",
"video",
],
)
}
/>
+15 -1
View File
@@ -58,7 +58,21 @@ const LibrarySongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
initialSortingOrder={props?.order}
query={({ sortBy, order, type, library, random }) =>
API.getSongs(
{ type, library: library ?? undefined, random },
{
type,
library: library ?? undefined,
random,
},
{ sortBy, order },
["artist", "featuring", "master", "illustration"],
)
}
groupsQuery={({ sortBy, order, library, type }) =>
API.getSongGroups(
{
type,
library: library ?? undefined,
},
{ sortBy, order },
["artist", "featuring", "master", "illustration"],
)
+13 -6
View File
@@ -18,7 +18,7 @@
import React from "react";
import { useRouter } from "next/router";
import { SongSortingKeys } from "../../models/song";
import { VideoSortingKeys } from "../../models/video";
import API from "../../api/api";
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
import { GetPropsTypesFrom, Page } from "../../ssr";
@@ -29,12 +29,19 @@ import { Head } from "../../components/head";
const prepareSSR = (context: NextPageContext) => {
const order = getOrderParams(context.query.order) ?? "asc";
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
const sortBy = getSortingFieldParams(
context.query.sortBy,
VideoSortingKeys,
);
return {
additionalProps: { sortBy, order },
infiniteQueries: [
API.getVideos({}, { sortBy, order }, ["artist", "featuring"]),
API.getVideos({}, { sortBy, order }, [
"artist",
"master",
"illustration",
]),
],
};
};
@@ -51,11 +58,11 @@ const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
<InfiniteVideoView
initialSortingField={props?.sortBy}
initialSortingOrder={props?.order}
query={({ library, sortBy, order }) =>
query={({ library, sortBy, order, random, type }) =>
API.getVideos(
{ library: library ?? undefined },
{ library: library ?? undefined, random, type },
{ sortBy, order },
["artist", "featuring"],
["artist", "master", "illustration"],
)
}
subtitle="artist"
Loaded 100 of 438 files, more files were not shown because too many files have changed in this diff. Show more