Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dfd6903c | ||
|
|
e00d551389 | ||
|
|
55e0831e67 | ||
|
|
c6cf808bea | ||
|
|
0f25f7f282 | ||
|
|
06247901a7 | ||
|
|
2c6f0cbc8b | ||
|
|
ef235fc264 | ||
|
|
9a70d27793 | ||
|
|
7532e199b5 | ||
|
|
5581c69cfa | ||
|
|
155e3ca9cb | ||
|
|
061b890a6a | ||
|
|
78991fb145 | ||
|
|
8f77c7e7ed | ||
|
|
db6d04817d | ||
|
|
1cd4024f63 | ||
|
|
da6fdc7f91 | ||
|
|
cd5f262435 | ||
|
|
b9917027c6 |
No files matched your search
@@ -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
@@ -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:
|
||||
- "*"
|
||||
- "*"
|
||||
@@ -1,65 +1,81 @@
|
||||

|
||||
# <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:
|
||||
|
||||

|
||||
|
||||
## :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>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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!
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 + "%" }),
|
||||
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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,
|
||||
}
|
||||
+8
-2
@@ -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);
|
||||
+10
-2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -355,6 +355,7 @@ export default class SongService extends SearchableRepositoryService {
|
||||
}),
|
||||
});
|
||||
break;
|
||||
case "userPlayCount":
|
||||
case "totalPlayCount":
|
||||
sort.push({
|
||||
playHistory: {
|
||||
|
||||
@@ -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;
|
||||
}>
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user