Compare commits

..
20 Commits
Author SHA1 Message Date
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 06247901a7 Readme: Change First screenshot 2024-09-06 10:29:19 +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 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 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 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
65 changed files with 1778 additions and 944 deletions

No files matched your search

+4
View File
@@ -12,6 +12,10 @@ POSTGRES_DB=
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')
+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:
- "*"
- "*"
+77 -61
View File
@@ -1,65 +1,81 @@
![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
- Access them directly from an album's or an artist'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
- 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

+5
View File
@@ -76,6 +76,11 @@ 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
+5
View File
@@ -76,6 +76,11 @@ 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
+5
View File
@@ -53,6 +53,11 @@ 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
+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
+1 -1
View File
@@ -35,6 +35,6 @@ If a provider is disabled, no call to its service will be done.
| 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.
+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",
+3
View File
@@ -1007,6 +1007,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 +1015,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 ?? []),
),
@@ -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)}
@@ -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>
);
};
+42
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();
@@ -182,6 +183,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 +223,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;
+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}
@@ -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 + "%" }),
+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 {};
};
+14 -1
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",
@@ -106,6 +107,9 @@
"trackSetAsMaster": "Track set as Master",
"showAllTrack": "Show all tracks",
"showOnlyExclusiveTracks": "Show only exclusive tracks",
"All": "All",
"StudioRecording": "Studio Recording",
"Single": "Single",
@@ -246,5 +250,14 @@
"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)"
}
+14 -1
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",
@@ -108,6 +109,9 @@
"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",
@@ -235,5 +239,14 @@
"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)"
}
+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>
+7 -2
View File
@@ -122,11 +122,15 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const externalIdWithDescription = artist.data?.externalIds
.filter(({ provider }) => provider.name.toLowerCase() !== "discogs")
.find(({ description }) => description !== null);
const { musicVideos, extras } = useMemo(() => {
const { musicVideos, liveVideos, extras } = useMemo(() => {
const firstPage = videos.data?.pages.at(0)?.items;
return {
musicVideos:
firstPage?.filter((video) => video.type != "NonMusic") ?? [],
firstPage?.filter(
(video) => video.type != "NonMusic" && video.type != "Live",
) ?? [],
liveVideos:
firstPage?.filter((video) => video.type == "Live") ?? [],
extras:
firstPage?.filter((video) => video.type == "NonMusic") ?? [],
};
@@ -189,6 +193,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 }) =>
+2 -2
View File
@@ -128,9 +128,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,
+91 -26
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 {
@@ -71,11 +72,15 @@ 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";
const releaseQuery = (releaseIdentifier: string | number) =>
API.getRelease(releaseIdentifier, ["album", "externalIds", "illustration"]);
const releaseTracklistQuery = (releaseIdentifier: number | string) => {
const query = API.getReleaseTracklist(releaseIdentifier, [
const releaseTracklistQuery = (
releaseIdentifier: number | string,
exclusiveOnly: boolean,
) => {
const query = API.getReleaseTracklist(releaseIdentifier, exclusiveOnly, [
"artist",
"featuring",
]);
@@ -147,7 +152,7 @@ const prepareSSR = async (
return {
additionalProps: { releaseIdentifier },
queries: [
releaseTracklistQuery(releaseIdentifier),
releaseTracklistQuery(releaseIdentifier, false),
albumQuery(release.albumId),
artistsOnAlbumQuery(release.albumId),
],
@@ -187,6 +192,7 @@ 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();
@@ -194,7 +200,10 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
const release = useQuery(releaseQuery, releaseIdentifier);
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,
@@ -251,25 +260,34 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
),
[bSidesQuery.data],
);
const { videos, videoExtras } = useMemo(
const { videos, liveVideos, videoExtras } = useMemo(
() =>
(albumVideos.data?.pages.at(0)?.items ?? []).reduce(
(prev, current) => {
if (current.type === "NonMusic") {
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: [] } as Record<
"videos" | "videoExtras",
Video[]
>,
{
videos: [] as Video[],
videoExtras: [] as Video[],
liveVideos: [] as Video[],
},
),
[albumVideos.data],
);
@@ -331,6 +349,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 (
<>
@@ -596,7 +618,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={
@@ -616,6 +644,35 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}
release={release.data}
/>
{release.data?.album.type === "Compilation" && (
<>
<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 +715,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")}
+5 -1
View File
@@ -124,10 +124,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: (
+276 -1
View File
@@ -571,6 +571,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.1.2":
version: 7.25.4
resolution: "@babel/runtime@npm:7.25.4"
dependencies:
regenerator-runtime: "npm:^0.14.0"
checksum: 10c0/33e937e685f0bfc2d40c219261e2e50d0df7381a6e7cbf56b770e0c5d77cb0c21bf4d97da566cf0164317ed7508e992082c7b6cce7aaa3b17da5794f93fbfb46
languageName: node
linkType: hard
"@babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.2, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2":
version: 7.20.1
resolution: "@babel/runtime@npm:7.20.1"
@@ -1605,7 +1614,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/sourcemap-codec@npm:^1.4.14":
"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.4.15":
version: 1.5.0
resolution: "@jridgewell/sourcemap-codec@npm:1.5.0"
checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18
@@ -2181,6 +2190,13 @@ __metadata:
languageName: node
linkType: hard
"@types/js-cookie@npm:^2.2.6":
version: 2.2.7
resolution: "@types/js-cookie@npm:2.2.7"
checksum: 10c0/29196c6829982b5efa79117122a7d62cf4bc2f6397ce8eac1539319ff5dce3b44b2d86f2ac064f2ed3488fb24439358f24af6914fde5c5c4bab9a85728a13a6f
languageName: node
linkType: hard
"@types/json-schema@npm:^7.0.12":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@@ -2551,6 +2567,13 @@ __metadata:
languageName: node
linkType: hard
"@xobotyi/scrollbar-width@npm:^1.9.5":
version: 1.9.5
resolution: "@xobotyi/scrollbar-width@npm:1.9.5"
checksum: 10c0/4ebc79e4f798e2a5e89a5122f8fc4a086f08a92a44ac020599c4fe20d105b7d76ba06c094260b5f386a75e7ce6f6c518d9fc295228b651296b99c4477f986ac4
languageName: node
linkType: hard
"abbrev@npm:^2.0.0":
version: 2.0.0
resolution: "abbrev@npm:2.0.0"
@@ -3332,6 +3355,15 @@ __metadata:
languageName: node
linkType: hard
"copy-to-clipboard@npm:^3.3.1":
version: 3.3.3
resolution: "copy-to-clipboard@npm:3.3.3"
dependencies:
toggle-selection: "npm:^1.0.6"
checksum: 10c0/3ebf5e8ee00601f8c440b83ec08d838e8eabb068c1fae94a9cda6b42f288f7e1b552f3463635f419af44bf7675afc8d0390d30876cf5c2d5d35f86d9c56a3e5f
languageName: node
linkType: hard
"core-js@npm:^3.26.1":
version: 3.26.1
resolution: "core-js@npm:3.26.1"
@@ -3403,6 +3435,15 @@ __metadata:
languageName: node
linkType: hard
"css-in-js-utils@npm:^3.1.0":
version: 3.1.0
resolution: "css-in-js-utils@npm:3.1.0"
dependencies:
hyphenate-style-name: "npm:^1.0.3"
checksum: 10c0/8bb042e8f7701a7edadc3cce5ce2d5cf41189631d7e2aed194d5a7059b25776dded2a0466cb9da1d1f3fc6c99dcecb51e45671148d073b8a2a71e34755152e52
languageName: node
linkType: hard
"css-to-react-native@npm:^3.0.0":
version: 3.0.0
resolution: "css-to-react-native@npm:3.0.0"
@@ -3414,6 +3455,16 @@ __metadata:
languageName: node
linkType: hard
"css-tree@npm:^1.1.2":
version: 1.1.3
resolution: "css-tree@npm:1.1.3"
dependencies:
mdn-data: "npm:2.0.14"
source-map: "npm:^0.6.1"
checksum: 10c0/499a507bfa39b8b2128f49736882c0dd636b0cd3370f2c69f4558ec86d269113286b7df469afc955de6a68b0dba00bc533e40022a73698081d600072d5d83c1c
languageName: node
linkType: hard
"csstype@npm:^3.0.2, csstype@npm:^3.1.1":
version: 3.1.1
resolution: "csstype@npm:3.1.1"
@@ -3421,6 +3472,13 @@ __metadata:
languageName: node
linkType: hard
"csstype@npm:^3.1.2":
version: 3.1.3
resolution: "csstype@npm:3.1.3"
checksum: 10c0/80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248
languageName: node
linkType: hard
"d3-array@npm:^3.2.0":
version: 3.2.0
resolution: "d3-array@npm:3.2.0"
@@ -3690,6 +3748,15 @@ __metadata:
languageName: node
linkType: hard
"error-stack-parser@npm:^2.0.6":
version: 2.1.4
resolution: "error-stack-parser@npm:2.1.4"
dependencies:
stackframe: "npm:^1.3.4"
checksum: 10c0/7679b780043c98b01fc546725484e0cfd3071bf5c906bbe358722972f04abf4fc3f0a77988017665bab367f6ef3fc2d0185f7528f45966b83e7c99c02d5509b9
languageName: node
linkType: hard
"es-abstract@npm:^1.19.0, es-abstract@npm:^1.20.4":
version: 1.22.1
resolution: "es-abstract@npm:1.22.1"
@@ -4251,6 +4318,20 @@ __metadata:
languageName: node
linkType: hard
"fast-shallow-equal@npm:^1.0.0":
version: 1.0.0
resolution: "fast-shallow-equal@npm:1.0.0"
checksum: 10c0/526c393c011ab5a0ca5a36c5ea25c9730acd027503ccbec6c7825397ab9375f51f67f14c8829b4c4b1ccccede695391dd14863a15e40a37fc4af08c1440a1b66
languageName: node
linkType: hard
"fastest-stable-stringify@npm:^2.0.2":
version: 2.0.2
resolution: "fastest-stable-stringify@npm:2.0.2"
checksum: 10c0/abbe5ff48f13f5819e7312dbb38bae5d9960694cffd315b464df9adcd02a8fa7e9eec32c314655674c7134905c544b7a0c14b05bfbe30b3f678609bebc9fecb9
languageName: node
linkType: hard
"fastq@npm:^1.6.0":
version: 1.15.0
resolution: "fastq@npm:1.15.0"
@@ -4760,6 +4841,13 @@ __metadata:
languageName: node
linkType: hard
"hyphenate-style-name@npm:^1.0.3":
version: 1.1.0
resolution: "hyphenate-style-name@npm:1.1.0"
checksum: 10c0/bfe88deac2414a41a0d08811e277c8c098f23993d6a1eb17f14a0f11b54c4d42865a63d3cfe1914668eefb9a188e2de58f38b55a179a238fd1fef606893e194f
languageName: node
linkType: hard
"i18next@npm:^23.7.16":
version: 23.7.16
resolution: "i18next@npm:23.7.16"
@@ -4863,6 +4951,15 @@ __metadata:
languageName: node
linkType: hard
"inline-style-prefixer@npm:^7.0.1":
version: 7.0.1
resolution: "inline-style-prefixer@npm:7.0.1"
dependencies:
css-in-js-utils: "npm:^3.1.0"
checksum: 10c0/15da5a396b7f286b5b6742efe315218cd577bc96b43de08aeb76af7697d9f1ab3bfc66cf19fad2173957dd5d617a790240b9d51898bdcf4c2efb40d3f8bcb370
languageName: node
linkType: hard
"internal-slot@npm:^1.0.3, internal-slot@npm:^1.0.5":
version: 1.0.5
resolution: "internal-slot@npm:1.0.5"
@@ -5686,6 +5783,13 @@ __metadata:
languageName: node
linkType: hard
"js-cookie@npm:^2.2.1":
version: 2.2.1
resolution: "js-cookie@npm:2.2.1"
checksum: 10c0/ee67fc0f8495d0800b851910b5eb5bf49d3033adff6493d55b5c097ca6da46f7fe666b10e2ecb13cfcaf5b88d71c205ce00a7e646de791689bfd053bbb36a376
languageName: node
linkType: hard
"js-sha3@npm:0.8.0":
version: 0.8.0
resolution: "js-sha3@npm:0.8.0"
@@ -5966,6 +6070,13 @@ __metadata:
languageName: node
linkType: hard
"mdn-data@npm:2.0.14":
version: 2.0.14
resolution: "mdn-data@npm:2.0.14"
checksum: 10c0/67241f8708c1e665a061d2b042d2d243366e93e5bf1f917693007f6d55111588b952dcbfd3ea9c2d0969fb754aad81b30fdcfdcc24546495fc3b24336b28d4bd
languageName: node
linkType: hard
"meelo-front@workspace:.":
version: 0.0.0-use.local
resolution: "meelo-front@workspace:."
@@ -6023,6 +6134,7 @@ __metadata:
react-loader-spinner: "npm:^5.2.0"
react-query: "npm:^3.39.2"
react-redux: "npm:^8.0.2"
react-use: "npm:^17.5.1"
redux-persist: "npm:^6.0.0"
sharp: "npm:^0.33.4"
type-fest: "npm:^2.19.0"
@@ -6242,6 +6354,25 @@ __metadata:
languageName: node
linkType: hard
"nano-css@npm:^5.6.2":
version: 5.6.2
resolution: "nano-css@npm:5.6.2"
dependencies:
"@jridgewell/sourcemap-codec": "npm:^1.4.15"
css-tree: "npm:^1.1.2"
csstype: "npm:^3.1.2"
fastest-stable-stringify: "npm:^2.0.2"
inline-style-prefixer: "npm:^7.0.1"
rtl-css-js: "npm:^1.16.1"
stacktrace-js: "npm:^2.0.2"
stylis: "npm:^4.3.0"
peerDependencies:
react: "*"
react-dom: "*"
checksum: 10c0/566fb9403815d78a110d68f011e1125cbeeb7299e2e6c60700f316ba0c48dc702c039163eae7a8f213a1390e45cedfdeccc203794d61a61116598adbb83029ec
languageName: node
linkType: hard
"nano-time@npm:1.0.0":
version: 1.0.0
resolution: "nano-time@npm:1.0.0"
@@ -7077,6 +7208,41 @@ __metadata:
languageName: node
linkType: hard
"react-universal-interface@npm:^0.6.2":
version: 0.6.2
resolution: "react-universal-interface@npm:0.6.2"
peerDependencies:
react: "*"
tslib: "*"
checksum: 10c0/97c32ecb7a425c3bcaa92dcf84c46146b49610d928efde9e9ee5518c475a0db942f01634dd490e4f42fcd95cc2f49657c1b96dcef96423c06f077147fe1968ab
languageName: node
linkType: hard
"react-use@npm:^17.5.1":
version: 17.5.1
resolution: "react-use@npm:17.5.1"
dependencies:
"@types/js-cookie": "npm:^2.2.6"
"@xobotyi/scrollbar-width": "npm:^1.9.5"
copy-to-clipboard: "npm:^3.3.1"
fast-deep-equal: "npm:^3.1.3"
fast-shallow-equal: "npm:^1.0.0"
js-cookie: "npm:^2.2.1"
nano-css: "npm:^5.6.2"
react-universal-interface: "npm:^0.6.2"
resize-observer-polyfill: "npm:^1.5.1"
screenfull: "npm:^5.1.0"
set-harmonic-interval: "npm:^1.0.1"
throttle-debounce: "npm:^3.0.1"
ts-easing: "npm:^0.2.0"
tslib: "npm:^2.1.0"
peerDependencies:
react: "*"
react-dom: "*"
checksum: 10c0/3d6a8f46539b32698d31600239e72b5c23376a5343d0d687c6520e14532ed7f5c72c9b99d222be4eeacb0401ce3ae763d5648d0476440c8b4a6afbd56dc98bfa
languageName: node
linkType: hard
"react@npm:18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
@@ -7186,6 +7352,13 @@ __metadata:
languageName: node
linkType: hard
"resize-observer-polyfill@npm:^1.5.1":
version: 1.5.1
resolution: "resize-observer-polyfill@npm:1.5.1"
checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95
languageName: node
linkType: hard
"resolve-cwd@npm:^3.0.0":
version: 3.0.0
resolution: "resolve-cwd@npm:3.0.0"
@@ -7328,6 +7501,15 @@ __metadata:
languageName: node
linkType: hard
"rtl-css-js@npm:^1.16.1":
version: 1.16.1
resolution: "rtl-css-js@npm:1.16.1"
dependencies:
"@babel/runtime": "npm:^7.1.2"
checksum: 10c0/4b81ef50e50c97455d61c9bb576e2892651c79bac5d0c52b4123ebb9d6a2c5144590a79c9db0a3212a81b4eb83bf317e03637220f20b387a37b96cbac324d3d2
languageName: node
linkType: hard
"run-applescript@npm:^5.0.0":
version: 5.0.0
resolution: "run-applescript@npm:5.0.0"
@@ -7392,6 +7574,13 @@ __metadata:
languageName: node
linkType: hard
"screenfull@npm:^5.1.0":
version: 5.2.0
resolution: "screenfull@npm:5.2.0"
checksum: 10c0/86fd49983e2edc153ee2e674a570c711cb0961a9cacca659309f79636ccc8ca8a0b830ea4dacdae7403a8bb7ba6affd5bcdce053aa97782961247a49bfd2ba68
languageName: node
linkType: hard
"semver@npm:^6.0.0, semver@npm:^6.3.0, semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"
@@ -7421,6 +7610,13 @@ __metadata:
languageName: node
linkType: hard
"set-harmonic-interval@npm:^1.0.1":
version: 1.0.1
resolution: "set-harmonic-interval@npm:1.0.1"
checksum: 10c0/49014d928a62c8418507bf66ffef7066783e8fb19f76e955318bbae5a8c4b56e1a7176b370f9040ef9de51531aa522a3f96fa5c47b1534635aa577ff7c12f9c6
languageName: node
linkType: hard
"shallowequal@npm:^1.1.0":
version: 1.1.0
resolution: "shallowequal@npm:1.1.0"
@@ -7606,6 +7802,13 @@ __metadata:
languageName: node
linkType: hard
"source-map@npm:0.5.6":
version: 0.5.6
resolution: "source-map@npm:0.5.6"
checksum: 10c0/beb2c5974bb58954d75e86249953d47ae16f7df1a8531abb9fcae0cd262d9fa09c2db3a134e20e99358b1adba42b6b054a32c8e16b571b3efcf6af644c329f0d
languageName: node
linkType: hard
"source-map@npm:^0.5.7":
version: 0.5.7
resolution: "source-map@npm:0.5.7"
@@ -7643,6 +7846,15 @@ __metadata:
languageName: node
linkType: hard
"stack-generator@npm:^2.0.5":
version: 2.0.10
resolution: "stack-generator@npm:2.0.10"
dependencies:
stackframe: "npm:^1.3.4"
checksum: 10c0/c3f6f6c580488e65c0fee806a57f6ae4b79e6435f144be471c1f20328a8d9d8492d4f3beed31840f6dae03e2633325e2764fd3aca5c3126a0639e7c9ddfa45ce
languageName: node
linkType: hard
"stack-utils@npm:^2.0.3":
version: 2.0.6
resolution: "stack-utils@npm:2.0.6"
@@ -7652,6 +7864,34 @@ __metadata:
languageName: node
linkType: hard
"stackframe@npm:^1.3.4":
version: 1.3.4
resolution: "stackframe@npm:1.3.4"
checksum: 10c0/18410f7a1e0c5d211a4effa83bdbf24adbe8faa8c34db52e1cd3e89837518c592be60b60d8b7270ac53eeeb8b807cd11b399a41667f6c9abb41059c3ccc8a989
languageName: node
linkType: hard
"stacktrace-gps@npm:^3.0.4":
version: 3.1.2
resolution: "stacktrace-gps@npm:3.1.2"
dependencies:
source-map: "npm:0.5.6"
stackframe: "npm:^1.3.4"
checksum: 10c0/0dcc1aa46e364a2b4d1eabce4777fecf337576a11ee3cfc92f07b9ec79ccb76810752431eeb9771289d250d0bb58dbe19a178b96bf7b2e9f773334d03aa96bb9
languageName: node
linkType: hard
"stacktrace-js@npm:^2.0.2":
version: 2.0.2
resolution: "stacktrace-js@npm:2.0.2"
dependencies:
error-stack-parser: "npm:^2.0.6"
stack-generator: "npm:^2.0.5"
stacktrace-gps: "npm:^3.0.4"
checksum: 10c0/9a10c222524ca03690bcb27437b39039885223e39320367f2be36e6f750c2d198ae99189869a22c255bf60072631eb609d47e8e33661e95133686904e01121ec
languageName: node
linkType: hard
"streamsearch@npm:^1.1.0":
version: 1.1.0
resolution: "streamsearch@npm:1.1.0"
@@ -7868,6 +8108,13 @@ __metadata:
languageName: node
linkType: hard
"stylis@npm:^4.3.0":
version: 4.3.4
resolution: "stylis@npm:4.3.4"
checksum: 10c0/4899c2674cd2538e314257abd1ba7ea3c2176439659ddac6593c78192cfd4a06f814a0a4fc69bc7f8fcc6b997e13d383dd9b578b71074746a0fb86045a83e42d
languageName: node
linkType: hard
"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0":
version: 5.5.0
resolution: "supports-color@npm:5.5.0"
@@ -7944,6 +8191,13 @@ __metadata:
languageName: node
linkType: hard
"throttle-debounce@npm:^3.0.1":
version: 3.0.1
resolution: "throttle-debounce@npm:3.0.1"
checksum: 10c0/c8e558479463b7ed8bac30d6b10cc87abd1c9fc64edfce2db4109be1a04acaef5d2d0557f49c1a3845ea07d9f79e6e0389b1b60db0a77c44e5b7a1216596f285
languageName: node
linkType: hard
"through2@npm:~0.4.1":
version: 0.4.2
resolution: "through2@npm:0.4.2"
@@ -8005,6 +8259,13 @@ __metadata:
languageName: node
linkType: hard
"toggle-selection@npm:^1.0.6":
version: 1.0.6
resolution: "toggle-selection@npm:1.0.6"
checksum: 10c0/f2cf1f2c70f374fd87b0cdc8007453ba9e981c4305a8bf4eac10a30e62ecdfd28bca7d18f8f15b15a506bf8a7bfb20dbe3539f0fcf2a2c8396c1a78d53e1f179
languageName: node
linkType: hard
"toposort@npm:^2.0.2":
version: 2.0.2
resolution: "toposort@npm:2.0.2"
@@ -8021,6 +8282,13 @@ __metadata:
languageName: node
linkType: hard
"ts-easing@npm:^0.2.0":
version: 0.2.0
resolution: "ts-easing@npm:0.2.0"
checksum: 10c0/84ec20192310c697ff890ca2e0625e131a32596a7c5956326c9632faca9037abf2dd3de4d81ac358ae9f26a6a2cfe2300f13756b26995f753d882e3d0463e327
languageName: node
linkType: hard
"tsconfig-paths@npm:^3.14.1":
version: 3.14.2
resolution: "tsconfig-paths@npm:3.14.2"
@@ -8040,6 +8308,13 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.1.0":
version: 2.7.0
resolution: "tslib@npm:2.7.0"
checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6
languageName: node
linkType: hard
"tslib@npm:^2.4.0":
version: 2.4.1
resolution: "tslib@npm:2.4.1"
+8 -8
View File
@@ -24,12 +24,12 @@
"dependencies": {
"@nestjs/axios": "^3.0.2",
"@nestjs/bull": "^10.2.0",
"@nestjs/common": "^10.3.10",
"@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/core": "^10.4.1",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/platform-express": "^10.4.1",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^5.18.0",
@@ -45,7 +45,7 @@
"deepmerge": "^4.3.1",
"fluent-ffmpeg": "^2.1.2",
"fs": "^0.0.1-security",
"genius-lyrics-api": "^3.2.0",
"genius-lyrics-api": "^3.2.1",
"get-image-colors": "^4.0.1",
"glob": "^8.0.3",
"helmet": "^6.0.0",
@@ -72,10 +72,10 @@
"jpeg-js": "^0.4.4"
},
"devDependencies": {
"@nestjs/cli": "^8.2.6",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^10.3.10",
"@types/archiver": "^5.3.1",
"@nestjs/cli": "^10.4.4",
"@nestjs/schematics": "^10.1.3",
"@nestjs/testing": "^10.4.1",
"@types/archiver": "^6.0.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/damerau-levenshtein": "^1.0.0",
+1 -1
View File
@@ -33,7 +33,7 @@ import { ApiOperation, ApiPropertyOptional, ApiTags } from "@nestjs/swagger";
import UpdateAlbumDTO from "./models/update-album.dto";
import { AlbumResponseBuilder } from "./models/album.response";
import RelationIncludeQuery from "src/relation-include/relation-include-query.decorator";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import IdentifierParam from "src/identifier/identifier.pipe";
import Response, { ResponseType } from "src/response/response.decorator";
import GenreService from "src/genre/genre.service";
+1 -1
View File
@@ -18,7 +18,7 @@
import { Controller, Get } from "@nestjs/common";
import { ApiExcludeController } from "@nestjs/swagger";
import { Public } from "./authentication/roles/public.decorator";
import { Public } from "./authentication/roles/roles.decorators";
@ApiExcludeController()
@Controller()
+1 -1
View File
@@ -41,7 +41,7 @@ import LibraryQueryParameters from "src/library/models/library.query-parameters"
import GenreQueryParameters from "src/genre/models/genre.query-parameters";
import AlbumQueryParameters from "src/album/models/album.query-parameters";
import AlbumService from "src/album/album.service";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import { IllustrationDownloadDto } from "src/illustration/models/illustration-dl.dto";
import IllustrationService from "src/illustration/illustration.service";
import IllustrationRepository from "src/illustration/illustration.repository";
@@ -17,13 +17,10 @@
*/
import AuthenticationService from "./authentication.service";
import { Controller, Post, Request, UseGuards } from "@nestjs/common";
import * as Express from "express";
import { LocalAuthGuard } from "./local/local-auth.guard";
import { ApiBody, ApiOperation, ApiTags } from "@nestjs/swagger";
import { User } from "src/prisma/models";
import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import LoginDTO from "./models/login.dto";
import { Public } from "./roles/public.decorator";
import { Public } from "./roles/roles.decorators";
@ApiTags("Authentication")
@Controller("auth")
@@ -33,13 +30,13 @@ export default class AuthenticationController {
@ApiOperation({
summary: "Login user",
})
@ApiBody({
type: LoginDTO,
})
@Public()
@UseGuards(LocalAuthGuard)
@Post("login")
async login(@Request() request: Express.Request) {
return this.authenticationService.login(request.user as User);
async login(@Body() loginDTO: LoginDTO) {
const user = await this.authenticationService.validateUser(
loginDTO.username,
loginDTO.password,
);
return this.authenticationService.login(user);
}
}
@@ -23,18 +23,21 @@ import UserModule from "src/user/user.module";
import AuthenticationController from "./authentication.controller";
import AuthenticationService from "./authentication.service";
import { JwtStrategy } from "./jwt/jwt.strategy";
import LocalStrategy from "./local/local.strategy";
import SettingsModule from "src/settings/settings.module";
import RolesGuard from "./roles/roles.guard";
@Module({
imports: [
UserModule,
PassportModule,
SettingsModule,
JwtModule.register({
secret: process.env.JWT_SIGNATURE,
signOptions: { expiresIn: "100 days" },
}),
],
controllers: [AuthenticationController],
providers: [AuthenticationService, LocalStrategy, JwtStrategy],
exports: [RolesGuard],
providers: [AuthenticationService, JwtStrategy, RolesGuard],
})
export default class AuthenticationModule {}
@@ -24,8 +24,7 @@ import {
DisabledUserAccountException,
UnknownUserException,
} from "./authentication.exception";
import JwtPayload from "./models/jwt.payload";
import JwtResponse from "./models/jwt.response";
import { JwtPayload, JwtResponse } from "./models/jwt.models";
@Injectable()
export default class AuthenticationService {
+28 -12
View File
@@ -19,12 +19,14 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { UnauthorizedAnonymousRequestException } from "../authentication.exception";
import { IS_PUBLIC_KEY } from "../roles/public.decorator";
import { ROLES_KEY } from "../roles/roles.decorators";
import Roles from "../roles/roles.enum";
import RolesGuard from "../roles/roles.guard";
import { AuthMethod } from "../models/auth.enum";
@Injectable()
export default class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
constructor(private reflector: Reflector, private rolesGuard: RolesGuard) {
super();
}
@@ -32,24 +34,38 @@ export default class JwtAuthGuard extends AuthGuard("jwt") {
err: any,
user: any,
_info: any,
_context: ExecutionContext,
context: ExecutionContext,
_status?: any,
): any {
if (err || !user) {
throw err || new UnauthorizedAnonymousRequestException();
// Note: Injecting RolesGuard is a hack to enable JWT Guard conditionally,
// as it is enabled by default globally.
if (this.rolesGuard.getAuthMethod(context) !== AuthMethod.JWT) {
return undefined;
}
if (err) {
throw err;
}
return user;
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) {
if (!this.needsJwt(context)) {
return true;
}
return super.canActivate(context);
}
private needsJwt(context: ExecutionContext) {
const roles = this.reflector.getAllAndOverride<Roles[] | undefined>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (
roles?.includes(Roles.Anonymous) ||
roles?.includes(Roles.Microservice)
) {
return false;
}
return true;
}
}
@@ -19,7 +19,7 @@
import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import JwtPayload from "../models/jwt.payload";
import { JwtPayload } from "../models/jwt.models";
import UserService from "src/user/user.service";
import { UnknownUserFromAccessTokenException } from "../authentication.exception";
@@ -1,34 +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 { Strategy } from "passport-local";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import AuthService from "../authentication.service";
import { User } from "src/prisma/models";
@Injectable()
export default class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<User> {
return this.authService.validateUser(username, password);
}
}
@@ -16,8 +16,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}
// The Authentication Method used for the request
// eslint-disable-next-line no-shadow
export enum AuthMethod {
Nothing,
JWT,
}
@@ -16,15 +16,21 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, PickType } from "@nestjs/swagger";
import { User } from "src/prisma/models";
/**
* Response type on login
*/
export default class JwtResponse {
export class JwtResponse {
@ApiProperty({
description:
"JWT Access Token. To add to request's header for authenticated requests",
})
access_token: string;
}
/**
* Type of the decoded JWT Payload
*/
export class JwtPayload extends PickType(User, ["id", "name"]) {}
@@ -1,24 +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 { PickType } from "@nestjs/swagger";
import { User } from "src/prisma/models";
/**
* Type of the decoded JWT Payload
*/
export default class JwtPayload extends PickType(User, ["id", "name"]) {}
@@ -1,26 +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 Roles from "./roles.decorator";
import Role from "./roles.enum";
/**
* Controller / Route decorator to allow only admin users to use it
*/
const Admin = () => Roles(Role.Admin);
export default Admin;
@@ -1,25 +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 { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
/**
* Route decorator to allow anonymous user to request methods
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -20,6 +20,14 @@ import { SetMetadata } from "@nestjs/common";
import RoleEnum from "./roles.enum";
export const ROLES_KEY = "roles";
const Roles = (...roles: RoleEnum[]) => SetMetadata(ROLES_KEY, roles);
export const Role = (...roles: RoleEnum[]) => SetMetadata(ROLES_KEY, roles);
export default Roles;
/**
* Controller / Route decorator to allow only admin users to use it
*/
export const Admin = () => Role(RoleEnum.Admin);
/**
* Route decorator to allow anonymous user to request methods
*/
export const Public = () => Role(RoleEnum.Anonymous);
@@ -20,7 +20,7 @@
enum Roles {
Admin,
User,
DisabledUser,
Microservice,
Anonymous,
}
+45 -22
View File
@@ -18,43 +18,66 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { InsufficientPermissionsException } from "src/authentication/authentication.exception";
import { IS_PUBLIC_KEY } from "src/authentication/roles/public.decorator";
import {
InsufficientPermissionsException,
UnauthorizedAnonymousRequestException,
} from "src/authentication/authentication.exception";
import UserService from "src/user/user.service";
import RoleEnum from "./roles.enum";
import { ROLES_KEY } from "./roles.decorator";
import { ROLES_KEY } from "./roles.decorators";
import SettingsService from "src/settings/settings.service";
import { User } from "@prisma/client";
import { AuthMethod } from "../models/auth.enum";
@Injectable()
export default class RolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private userService: UserService,
private settingsService: SettingsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<RoleEnum[]>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic) {
return true;
}
const requiredRoles = this.reflector.getAllAndOverride<RoleEnum[]>(
public getAuthMethod(context: ExecutionContext): AuthMethod {
const roles = this.reflector.getAllAndOverride<RoleEnum[] | undefined>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const userPayload = request.user;
const user = await this.userService.get({ id: userPayload.id });
// there is no decoration, we apply the default policy
if (roles === undefined) {
if (this.settingsService.settingsValues.allowAnonymous) {
return AuthMethod.Nothing;
}
return AuthMethod.JWT;
}
if (roles?.includes(RoleEnum.Anonymous)) {
return AuthMethod.Nothing;
}
// Admin or User role
return AuthMethod.JWT;
}
if (requiredRoles.includes(RoleEnum.Admin) && !user.admin) {
throw new InsufficientPermissionsException();
async canActivate(context: ExecutionContext): Promise<boolean> {
const roles = this.reflector.getAllAndOverride<RoleEnum[] | undefined>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
const request = context.switchToHttp().getRequest();
switch (this.getAuthMethod(context)) {
case AuthMethod.Nothing:
return true;
case AuthMethod.JWT: {
const userPayload = request.user as User | undefined;
if (!userPayload) {
throw new UnauthorizedAnonymousRequestException();
}
const user = await this.userService.get({ id: userPayload.id });
if (roles?.includes(RoleEnum.Admin) && !user.admin) {
throw new InsufficientPermissionsException();
}
return true;
}
}
return true;
}
}
@@ -29,7 +29,7 @@ import {
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import IllustrationService from "./illustration.service";
import { IllustrationDimensionsDto } from "./models/illustration-dimensions.dto";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import { parse } from "path";
import { NoIllustrationException } from "./illustration.exceptions";
import ProviderIllustrationService from "src/providers/provider-illustration.service";
+1 -1
View File
@@ -30,7 +30,7 @@ import { Library } from "src/prisma/models";
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
import LibraryQueryParameters from "./models/library.query-parameters";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import UpdateLibraryDto from "./models/update-library.dto";
import CreateLibraryDto from "./models/create-library.dto";
import IdentifierParam from "src/identifier/identifier.pipe";
+1 -1
View File
@@ -50,7 +50,7 @@ import { IsOptional } from "class-validator";
import TransformIdentifier from "src/identifier/identifier.transform";
import AlbumService from "src/album/album.service";
import AlbumQueryParameters from "src/album/models/album.query-parameters";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import { IllustrationDownloadDto } from "src/illustration/models/illustration-dl.dto";
import IllustrationRepository from "src/illustration/illustration.repository";
import IllustrationService from "src/illustration/illustration.service";
@@ -82,7 +82,10 @@ export default class AllMusicProvider
'div[title="AllMusic Rating"]',
).first();
const scoreOutTen = parseInt(
ratingDiv.attr("class")?.match(/\d/)?.[0] ?? "",
(
ratingDiv.attr("class")?.match(/ratingAllmusic\d$/)?.[0] ??
""
).replace("ratingAllmusic", ""),
);
const description = null; // Can't get description, the page uses JS to get it
@@ -68,12 +68,11 @@ describe("Discogs Provider", () => {
"24744",
);
expect(metadata.value).toBe("24744");
expect(metadata.description).not.toBeNull();
expect(metadata.genres).toContain("Electronic");
expect(metadata.genres).toContain("Rock");
expect(metadata!.description).toContain(
"Exciter is the tenth studio album by English electronic music band Depeche Mode",
);
// expect(metadata!.description).toContain(
// "Exciter is the tenth studio album by English electronic music band Depeche Mode",
// );
});
it("Should throw, as the album does not exist", () => {
return expect(
@@ -87,10 +86,9 @@ describe("Discogs Provider", () => {
const metadata =
await discogsProvider.getReleaseMetadataByIdentifier("9442778");
expect(metadata.value).toBe("9442778");
expect(metadata.description).not.toBeNull();
expect(metadata!.description).toContain(
"Reissue. Comes with a printed inner sleeve with lyrics, credits and photos.",
);
// expect(metadata!.description).toContain(
// "Comes with a printed inner sleeve with lyrics, credits and photos.",
// );
});
it("Should throw, as the Release does not exist", () => {
return expect(
@@ -97,13 +97,14 @@ export default class DiscogsProvider
): Promise<AlbumMetadata> {
try {
const album = await this.fetch(`/masters/${masterIdentifier}`);
if (isNumber(album.id) && isString(album.notes_plaintext)) {
// We used to be able to use notes as description
// https://www.discogs.com/forum/thread/1084771?message_id=11214064#11214064
if (isNumber(album.id)) {
return {
genres: album.genres,
value: album.id.toString(),
rating: null,
description: album.notes_plaintext,
description: null,
};
}
throw new ProviderActionFailedError(
@@ -126,10 +127,10 @@ export default class DiscogsProvider
try {
const release = await this.fetch(`/releases/${releaseIdentifier}`);
if (isNumber(release.id) && isString(release.notes_plaintext)) {
if (isNumber(release.id)) {
return {
value: release.id.toString(),
description: release.notes_plaintext,
description: null,
};
}
throw new ProviderActionFailedError(
@@ -137,7 +138,7 @@ export default class DiscogsProvider
"getReleaseMetadataByIdentifier",
"Invalid Data Type",
);
} catch {
} catch (e) {
throw new ProviderActionFailedError(
this.name,
"getReleaseMetadataByIdentifier",
+10 -1
View File
@@ -19,8 +19,10 @@
import {
Body,
Controller,
DefaultValuePipe,
Get,
Inject,
ParseBoolPipe,
Post,
Put,
Query,
@@ -37,7 +39,7 @@ import type { Response as ExpressResponse } from "express";
import { ApiOperation, ApiPropertyOptional, ApiTags } from "@nestjs/swagger";
import { TrackResponseBuilder } from "src/track/models/track.response";
import RelationIncludeQuery from "src/relation-include/relation-include-query.decorator";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import IdentifierParam from "src/identifier/identifier.pipe";
import Response, { ResponseType } from "src/response/response.decorator";
import { ReleaseResponseBuilder } from "./models/release.response";
@@ -146,12 +148,19 @@ export default class ReleaseController {
include: SongQueryParameters.RelationInclude,
@Query()
paginationParameters: PaginationParameters,
@Query(
"exclusive",
new ParseBoolPipe({ optional: true }),
new DefaultValuePipe(false),
)
exclusiveOnly: boolean,
@IdentifierParam(ReleaseService)
where: ReleaseQueryParameters.WhereInput,
) {
return this.trackService.getTracklist(
where,
paginationParameters,
exclusiveOnly,
include,
);
}
+16
View File
@@ -556,6 +556,22 @@ describe("Parser Service", () => {
"CCCC",
]);
});
it("'Vs.' Separator", async () => {
const res =
await parserService.extractFeaturedArtistsFromArtistName(
"Snoop Dogg Vs. David Guetta",
);
expect(res.artist).toBe("Snoop Dogg");
expect(res.featuring).toStrictEqual(["David Guetta"]);
});
it("'Vs' Separator", async () => {
const res =
await parserService.extractFeaturedArtistsFromArtistName(
"Snoop Dogg Vs David Guetta",
);
expect(res.artist).toBe("Snoop Dogg");
expect(res.featuring).toStrictEqual(["David Guetta"]);
});
});
describe("Get Song Type", () => {
+6 -1
View File
@@ -268,7 +268,12 @@ export default class ParserService {
const [main, ...feats] = (
await Promise.all(
artistName.split(/\s*,\s*/).map(async (s) => {
const splitted = s.split(/\s+&\s+/);
const splitted = s
.split(/\s+&\s+/)
.map((t) => t.split(/\s+vs\.\s+/i))
.flat()
.map((t) => t.split(/\s+vs\s+/i))
.flat();
if (splitted.length == 1) {
return splitted;
+6
View File
@@ -72,6 +72,12 @@ class MetadataSettings {
* Global settings of the Meelo server
*/
export default class Settings {
/**
* If true, endpoints with base access control will allow unauth-ed requests
*/
@ApiProperty()
@IsBoolean()
allowAnonymous: boolean;
/**
* The folder where `settings.json` and metadata are stored
*/
@@ -47,6 +47,7 @@ describe("Settings Controller", () => {
...JSON.parse(
fs.readFileSync("test/assets/settings.json").toString(),
),
allowAnonymous: false,
providers: {
genius: { enabled: true },
musicbrainz: { enabled: true },
+1 -1
View File
@@ -20,7 +20,7 @@ import { Controller, Get, HttpStatus, Redirect } from "@nestjs/common";
import type Settings from "./models/settings";
import SettingsService from "./settings.service";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
@Admin()
@ApiTags("Settings")
+1
View File
@@ -72,6 +72,7 @@ export default class SettingsService {
...object,
meeloFolder: process.env.INTERNAL_CONFIG_DIR!,
dataFolder: process.env.INTERNAL_DATA_DIR!,
allowAnonymous: process.env.ALLOW_ANONYMOUS === "1",
});
// Validation
const validationError = validateSync(uncheckedSettings, {
+4 -2
View File
@@ -38,7 +38,7 @@ import { LyricsService } from "src/lyrics/lyrics.service";
import LyricsDto from "src/lyrics/models/update-lyrics.dto";
import { SongResponseBuilder } from "./models/song.response";
import RelationIncludeQuery from "src/relation-include/relation-include-query.decorator";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin, Role } from "src/authentication/roles/roles.decorators";
import IdentifierParam from "src/identifier/identifier.pipe";
import Response, { ResponseType } from "src/response/response.decorator";
import { IsEnum, IsNumber, IsOptional, IsPositive } from "class-validator";
@@ -57,6 +57,7 @@ import SongGroupQueryParameters from "./models/song-group.query-params";
import { LyricsResponse } from "src/lyrics/models/lyrics.response";
import { formatIdentifier } from "src/repository/repository.utils";
import Slug from "src/slug/slug";
import Roles from "src/authentication/roles/roles.enum";
export class Selector {
@IsEnum(SongType)
@@ -180,7 +181,7 @@ export class SongController {
paginationParameters,
include,
);
} else if (sort.sortBy == "userPlayCount") {
} else if (sort.sortBy == "userPlayCount" && request.user) {
return this.songService.getManyByPlayCount(
(request.user as User).id,
selector,
@@ -241,6 +242,7 @@ export class SongController {
@ApiOperation({
summary: "Increment a song's play count",
})
@Role(Roles.User)
@Response({ handler: SongResponseBuilder })
@Put(":idOrSlug/played")
async incrementSongPlayCount(
+1
View File
@@ -355,6 +355,7 @@ export default class SongService extends SearchableRepositoryService {
}),
});
break;
case "userPlayCount":
case "totalPlayCount":
sort.push({
playHistory: {
+1 -1
View File
@@ -20,7 +20,7 @@ import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import LibraryService from "src/library/library.service";
import { Timeout } from "@nestjs/schedule";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import IdentifierParam from "src/identifier/identifier.pipe";
import LibraryQueryParameters from "src/library/models/library.query-parameters";
import { TaskQueue } from "./tasks.runner";
@@ -68,6 +68,7 @@ namespace TrackQueryParameters {
artist: ArtistQueryParameters.WhereInput;
album: AlbumQueryParameters.WhereInput;
release: ReleaseQueryParameters.WhereInput;
exclusiveOn: ReleaseQueryParameters.WhereInput;
}>
>
>;
+1 -1
View File
@@ -33,7 +33,7 @@ import { ApiOperation, ApiPropertyOptional, ApiTags } from "@nestjs/swagger";
import { IllustrationType, TrackType } from "@prisma/client";
import { TrackResponseBuilder } from "./models/track.response";
import RelationIncludeQuery from "src/relation-include/relation-include-query.decorator";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin } from "src/authentication/roles/roles.decorators";
import IdentifierParam from "src/identifier/identifier.pipe";
import Response, { ResponseType } from "src/response/response.decorator";
import SongService from "src/song/song.service";
+40 -1
View File
@@ -187,6 +187,42 @@ export default class TrackService {
release: ReleaseService.formatWhereInput(where.release),
});
}
if (where.exclusiveOn) {
queryParameters = deepmerge<Prisma.TrackWhereInput>(
queryParameters,
{
type: "Audio",
song: {
group: {
versions: {
some: {
type: "Original",
tracks: {
every: {
release: {
OR: [
{
album: {
type: {
not: "StudioRecording",
},
},
},
ReleaseService.formatWhereInput(
where.exclusiveOn,
),
],
},
},
},
},
},
},
},
release: ReleaseService.formatWhereInput(where.exclusiveOn),
},
);
}
if (where.album) {
queryParameters = deepmerge(queryParameters, {
song: {
@@ -315,10 +351,13 @@ export default class TrackService {
async getTracklist(
where: ReleaseQueryParameters.WhereInput,
pagination?: PaginationParameters,
exclusiveOnly?: boolean,
include?: SongQueryParameters.RelationInclude,
) {
const tracks = await this.prismaService.track.findMany({
where: TrackService.formatManyWhereInput({ release: where }),
where: TrackService.formatManyWhereInput(
exclusiveOnly ? { exclusiveOn: where } : { release: where },
),
orderBy: [
{ trackIndex: "asc" },
{ discIndex: { sort: "asc", nulls: "last" } },
+3 -2
View File
@@ -31,15 +31,15 @@ import { User } from "@prisma/client";
import UserService from "./user.service";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import UserCreateDTO from "./models/create-user.dto";
import Admin from "src/authentication/roles/admin.decorator";
import { Admin, Public, Role } from "src/authentication/roles/roles.decorators";
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
import { UserResponseBuilder } from "./models/user.response";
import UserQueryParameters from "./models/user.query-params";
import UpdateUserDTO from "./models/update-user.dto";
import { Public } from "src/authentication/roles/public.decorator";
import { InvalidRequestException } from "src/exceptions/meelo-exception";
import IdentifierParam from "src/identifier/identifier.pipe";
import Response, { ResponseType } from "src/response/response.decorator";
import Roles from "src/authentication/roles/roles.enum";
@ApiTags("Users")
@Controller("users")
@@ -50,6 +50,7 @@ export default class UserController {
summary: "Get info about the currently authentified user",
})
@Get("me")
@Role(Roles.User)
@Response({ handler: UserResponseBuilder })
async getAuthenticatedUserProfile(@Request() request: Express.Request) {
// Required to return a proper build response
+677 -587
View File
File diff suppressed because it is too large. Load diff