Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e5abb1b4 | ||
|
|
c3bd9b44fd | ||
|
|
21bb5586b7 | ||
|
|
06e24586db | ||
|
|
e3255cb428 | ||
|
|
c81ed9dd2e | ||
|
|
45afd3b36b | ||
|
|
2b5875b146 | ||
|
|
3176bc5c36 | ||
|
|
87efc74920 | ||
|
|
e5fc7a8428 | ||
|
|
aced630093 | ||
|
|
bf28a74155 | ||
|
|
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,82 @@
|
||||

|
||||
# <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
|
||||
- Browse Songs [grouped by version](./assets/examples/song-groups.png)
|
||||
- Album and Song types
|
||||
- Finding instrumental songs or live recordings has never been this simple!
|
||||
- Filter Songs that are exclusive to an album (compilations only)
|
||||
- Supports all formats!
|
||||
- Thanks to transcoding and the way we parse files, any audio and video format is virtually supported
|
||||
- (Note: Transcoding is only used when the file format is not supported in the browser)
|
||||
- Flexible metadata parsing
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
## :book: How to get started
|
||||
|
||||
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
|
||||
|
||||
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
We are actively working on a public demo, so that you can try out Meelo. Stay tuned...
|
||||
|
||||
## :camera: Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 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",
|
||||
|
||||
@@ -67,6 +67,10 @@ import Playlist, {
|
||||
import { isSSR } from "../utils/is-ssr";
|
||||
import { ActiveTask, Task } from "../models/task";
|
||||
import { SearchResult, SearchResultTransformer } from "../models/search";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../models/song-group";
|
||||
|
||||
const AuthenticationResponse = yup.object({
|
||||
access_token: yup.string().required(),
|
||||
@@ -695,6 +699,37 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
static getSongGroups<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
query?: string;
|
||||
type?: SongType;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongGroupSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<SongGroupWithRelations<I>> {
|
||||
return {
|
||||
key: [
|
||||
"song-groups",
|
||||
...API.formatObject(filter),
|
||||
...API.formatObject(sort),
|
||||
...API.formatIncludeKeys(include),
|
||||
],
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: `/song-groups`,
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
validator: PaginatedResponse(
|
||||
SongGroupWithRelations(include ?? []),
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all songs
|
||||
* @returns An InfiniteQuery of Songs
|
||||
@@ -705,6 +740,8 @@ export default class API {
|
||||
artist?: Identifier;
|
||||
album?: Identifier;
|
||||
song?: Identifier;
|
||||
random?: number;
|
||||
type?: SongType;
|
||||
},
|
||||
sort?: SortingParameters<typeof SongSortingKeys>,
|
||||
include?: I[],
|
||||
@@ -1007,6 +1044,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 +1052,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 ?? []),
|
||||
),
|
||||
|
||||
@@ -43,6 +43,12 @@ import Fade from "../fade";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalProps extends object = object,
|
||||
@@ -60,6 +66,7 @@ type ControllerProps<
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
@@ -130,6 +137,15 @@ const Controls = <
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
});
|
||||
props.toggles?.forEach((option) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
});
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
@@ -148,9 +164,10 @@ const Controls = <
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: string | null;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({ ...optionsState, [name]: value });
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(
|
||||
@@ -283,6 +300,21 @@ const Controls = <
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.toggles?.map((toggle) => (
|
||||
<Button
|
||||
key={toggle.name}
|
||||
onClick={() => {
|
||||
updateOptionState({
|
||||
name: toggle.name,
|
||||
value: !optionsState[
|
||||
toggle.name as keyof OptionState<SortingKeys>
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(toggle.label)}
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import SongItem from "../../list-item/song-item";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
@@ -35,9 +35,15 @@ import {
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
// if true, show song groups instead of songs
|
||||
groups?: boolean;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
@@ -89,10 +95,18 @@ const InfiniteSongView = <
|
||||
typeof SongSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "formatSubtitle"> & {
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
|
||||
disableShuffle?: boolean;
|
||||
groupsQuery?: (
|
||||
p: OptionState<typeof SongGroupSortingKeys> & AdditionalProps,
|
||||
) => InfiniteQuery<
|
||||
SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const disableGroupingVersions = !props.groupsQuery;
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
@@ -105,6 +119,7 @@ const InfiniteSongView = <
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
groups: disableGroupingVersions ? false : options?.groups ?? false,
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
@@ -154,28 +169,65 @@ const InfiniteSongView = <
|
||||
]}
|
||||
actions={[
|
||||
playAction,
|
||||
...(props.disableShuffle !== true ? [shuffleAction] : []),
|
||||
...(props.disableShuffle !== true && !options?.groups
|
||||
? [shuffleAction]
|
||||
: []),
|
||||
]}
|
||||
disableSorting={props.disableSorting}
|
||||
toggles={
|
||||
disableGroupingVersions
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "groups",
|
||||
label: options?.groups
|
||||
? "showAllSongs"
|
||||
: "groupVersions",
|
||||
},
|
||||
]
|
||||
}
|
||||
disableSorting={options?.groups || props.disableSorting}
|
||||
onChange={setOptions}
|
||||
sortingKeys={SongSortingKeys}
|
||||
sortingKeys={
|
||||
options?.groups ? SongGroupSortingKeys : SongSortingKeys
|
||||
}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light == true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? "list"}
|
||||
query={() => props.query(query)}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => <></>}
|
||||
/>
|
||||
{options?.groups ? (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.groupsQuery!({
|
||||
...query,
|
||||
sortBy: "name",
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
) : (
|
||||
<InfiniteView
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem song={item} subtitles={props.subtitles} />
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,25 +18,129 @@
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { SongSortingKeys } from "../../../models/song";
|
||||
import { SongSortingKeys, SongType } from "../../../models/song";
|
||||
import { VideoWithRelations } from "../../../models/video";
|
||||
import Controls, { OptionState } from "../../controls/controls";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import { PaginationParameters } from "../../../models/pagination";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import { PlayerActions, usePlayerContext } from "../../../contexts/player";
|
||||
import {
|
||||
InfiniteQuery,
|
||||
QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
|
||||
const InfiniteVideoView = <T extends VideoWithRelations<"artist">>(
|
||||
props: InfiniteResourceViewProps<T, typeof SongSortingKeys> &
|
||||
const playVideosAction = (
|
||||
emptyPlaylist: PlayerActions["emptyPlaylist"],
|
||||
playTrack: PlayerActions["playTrack"],
|
||||
playAfter: PlayerActions["playAfter"],
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<VideoWithRelations<"artist" | "featuring">>,
|
||||
) => {
|
||||
emptyPlaylist();
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const videos = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const video of videos) {
|
||||
if (i == 0) {
|
||||
playTrack(video);
|
||||
} else {
|
||||
playAfter(video);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const InfiniteVideoView = <
|
||||
T extends VideoWithRelations<"artist" | "featuring">,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof SongSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys>>();
|
||||
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type == "All" ? undefined : (options?.type as SongType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const { emptyPlaylist, playAfter, playTrack } = usePlayerContext();
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() => props.query(query),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(
|
||||
emptyPlaylist,
|
||||
playTrack,
|
||||
playAfter,
|
||||
queryClient,
|
||||
() =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as SongType) ?? "All",
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter(
|
||||
(type) =>
|
||||
![
|
||||
"Unknown",
|
||||
"Demo",
|
||||
"Clean",
|
||||
"Edit",
|
||||
"Acapella",
|
||||
"Instrumental",
|
||||
].includes(type),
|
||||
),
|
||||
],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={SongSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
@@ -44,22 +148,12 @@ const InfiniteVideoView = <T extends VideoWithRelations<"artist">>(
|
||||
router={props.light == true ? undefined : router}
|
||||
defaultLayout={"grid"}
|
||||
disableLayoutToggle
|
||||
actions={[playAction, shuffleAction]}
|
||||
/>
|
||||
<InfiniteView
|
||||
view={options?.view ?? "grid"}
|
||||
query={() => {
|
||||
const { key, exec } = props.query({
|
||||
view: options?.view ?? "grid",
|
||||
library: options?.library ?? null,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
});
|
||||
const { key, exec } = props.query(query);
|
||||
return {
|
||||
key,
|
||||
exec: (pagination: PaginationParameters) =>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
List,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useKeyboardBinding,
|
||||
useKeyboardBindingContext,
|
||||
} from "../contexts/keybindings";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
export const KeyboardBindingModal = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
useKeyboardBinding(
|
||||
{
|
||||
key: "?",
|
||||
description: "openModalShortcutDescription",
|
||||
handler: () => setIsOpen((x) => !x),
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
sx={{ zIndex: "tooltip" }}
|
||||
fullWidth
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<DialogTitle>{t("keyboadBindings")}</DialogTitle>
|
||||
<IconButton
|
||||
onClick={() => setIsOpen(false)}
|
||||
sx={() => ({
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 8,
|
||||
})}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<DialogContent>
|
||||
<KeyboardBindingModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyboardBindingModalContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const { bindings } = useKeyboardBindingContext();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<List>
|
||||
{bindings.map((binding) => (
|
||||
<Grid container key={binding.key} spacing={2}>
|
||||
<Grid item xs={4} sx={{ textAlign: "center" }}>
|
||||
<pre>
|
||||
<code
|
||||
style={{
|
||||
backgroundColor: theme.palette.divider,
|
||||
borderColor: theme.palette.divider,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
padding: 6,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}}
|
||||
>
|
||||
{binding.key}
|
||||
</code>
|
||||
</pre>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={8}
|
||||
sx={{
|
||||
textOverflow: "ellipsis",
|
||||
alignContent: "center",
|
||||
}}
|
||||
>
|
||||
{t(binding.description)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
|
||||
import { SongIcon } from "../icons";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import { usePlayerContext } from "../../contexts/player";
|
||||
import { SongGroupWithRelations } from "../../models/song-group";
|
||||
|
||||
type SongItemProps<
|
||||
T extends SongWithRelations<
|
||||
@@ -32,9 +33,47 @@ type SongItemProps<
|
||||
>,
|
||||
> = {
|
||||
song: T | undefined;
|
||||
formatSubtitle?: (song: T) => Promise<string>;
|
||||
subtitles?: ((
|
||||
song: SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
) => Promise<string>)[];
|
||||
};
|
||||
|
||||
export const SongGroupItem = <
|
||||
T extends SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
>({
|
||||
song,
|
||||
subtitles,
|
||||
}: SongItemProps<T>) => {
|
||||
return (
|
||||
<SongItem
|
||||
song={
|
||||
song
|
||||
? { ...song, id: song.songId, groupId: song.id }
|
||||
: undefined
|
||||
}
|
||||
subtitles={[
|
||||
...(subtitles
|
||||
? subtitles
|
||||
: [
|
||||
async (
|
||||
s: SongWithRelations<"artist" | "featuring">,
|
||||
) => defaultSubtitle(s),
|
||||
]),
|
||||
...(song && (song.versionCount ?? 0) > 1
|
||||
? [async () => `${song.versionCount} Versions`]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultSubtitle = (s: SongWithRelations<"artist" | "featuring">) =>
|
||||
formatArtists(s.artist, s.featuring);
|
||||
|
||||
/**
|
||||
* Item for a list of songs
|
||||
* @param props
|
||||
@@ -46,22 +85,28 @@ const SongItem = <
|
||||
>,
|
||||
>({
|
||||
song,
|
||||
formatSubtitle,
|
||||
subtitles,
|
||||
}: SongItemProps<T>) => {
|
||||
const artist = song?.artist;
|
||||
const { playTrack } = usePlayerContext();
|
||||
const queryClient = useQueryClient();
|
||||
const [subtitle, setSubtitle] = useState(
|
||||
formatSubtitle
|
||||
subtitles?.length
|
||||
? ((<br />) as unknown as string)
|
||||
: song
|
||||
? formatArtists(song.artist, song.featuring)
|
||||
? defaultSubtitle(song)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formatSubtitle && song) {
|
||||
formatSubtitle(song).then((newSub) => setSubtitle(newSub));
|
||||
if (subtitles && song) {
|
||||
Promise.allSettled(subtitles.map((s) => s(song))).then((r) =>
|
||||
setSubtitle(
|
||||
r
|
||||
.map((s) => (s as PromiseFulfilledResult<string>).value)
|
||||
.join(" • "),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
|
||||
@@ -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",
|
||||
@@ -123,6 +127,9 @@
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Video Albums",
|
||||
|
||||
"groupVersions": "Group Versions",
|
||||
"showAllSongs": "Show All Versions",
|
||||
|
||||
"onThisAlbum": "Featured Artists",
|
||||
|
||||
"backToTop": "Back To Top",
|
||||
@@ -246,5 +253,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",
|
||||
@@ -105,9 +106,15 @@
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Albums Vidéos",
|
||||
|
||||
"groupVersions": "Grouper les versions",
|
||||
"showAllSongs": "Afficher toutes les versions",
|
||||
|
||||
"backToTop": "Retour en haut",
|
||||
"changeLayout": "Changer la disposition",
|
||||
|
||||
"showAllTrack": "Afficher toutes les morceaux",
|
||||
"showOnlyExclusiveTracks": "Afficher seulement les morceaux exclusifs",
|
||||
|
||||
"changeAlbumType": "Changer le Type de l'Album",
|
||||
"onThisAlbum": "Artistes Invités",
|
||||
|
||||
@@ -235,5 +242,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)"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as yup from "yup";
|
||||
import Song, { SongInclude, SongRelations } from "./song";
|
||||
|
||||
/**
|
||||
* Abstract data model, instanciated by tracks
|
||||
*/
|
||||
const SongGroup = Song.concat(
|
||||
yup.object({
|
||||
versionCount: yup.number().required(),
|
||||
songId: yup.number().required(),
|
||||
}),
|
||||
);
|
||||
|
||||
type SongGroup = yup.InferType<typeof SongGroup>;
|
||||
|
||||
type SongGroupInclude = SongInclude;
|
||||
|
||||
const SongGroupRelations = SongRelations;
|
||||
|
||||
const SongGroupWithRelations = <
|
||||
Selection extends SongGroupInclude | never = never,
|
||||
>(
|
||||
relation: Selection[],
|
||||
) => SongGroup.concat(SongGroupRelations.pick(relation));
|
||||
|
||||
type SongGroupWithRelations<
|
||||
Selection extends SongGroupInclude | never = never,
|
||||
> = yup.InferType<ReturnType<typeof SongGroupWithRelations<Selection>>>;
|
||||
|
||||
export default SongGroup;
|
||||
export const SongGroupSortingKeys = ["name"] as const;
|
||||
export { type SongGroupInclude, SongGroupWithRelations, SongGroupRelations };
|
||||
@@ -35,8 +35,8 @@ export const SongType = [
|
||||
"Demo",
|
||||
"Unknown",
|
||||
"Acapella",
|
||||
"NonMusic",
|
||||
"Medley",
|
||||
"NonMusic",
|
||||
] as const;
|
||||
export type SongType = (typeof SongType)[number];
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -123,9 +123,29 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
formatSubtitle={(song) =>
|
||||
getTrackReleaseName(song.master).then((name) => name)
|
||||
groupsQuery={
|
||||
isRareSongsPage(router)
|
||||
? undefined
|
||||
: ({ sortBy, order, library, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type,
|
||||
artist: artistIdentifier,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
[
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
],
|
||||
)
|
||||
}
|
||||
subtitles={[
|
||||
(song) =>
|
||||
getTrackReleaseName(song.master).then((name) => name),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -73,10 +73,12 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, library }) =>
|
||||
query={({ sortBy, order, library, random, type }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
type,
|
||||
artist: artistIdentifier,
|
||||
random,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
|
||||
@@ -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,37 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
release={release.data}
|
||||
/>
|
||||
{release.data?.album.type === "Compilation" &&
|
||||
/* It does not make sense to show the button if the album is from 'Various Artists' */
|
||||
albumArtist !== null && (
|
||||
<>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontWeight: "bolder",
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowOnlyExclusive((v) => !v);
|
||||
document
|
||||
.getElementById(
|
||||
parentScrollableDivId,
|
||||
)
|
||||
?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
showOnlyExclusive
|
||||
? "showAllTrack"
|
||||
: "showOnlyExclusiveTracks",
|
||||
)}
|
||||
</ListItemButton>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<RelatedContentSection
|
||||
@@ -658,22 +717,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: (
|
||||
@@ -187,9 +191,10 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
) : item.song ? (
|
||||
<SongItem
|
||||
song={item.song}
|
||||
formatSubtitle={async (song) =>
|
||||
`${t("song")} • ${song.artist.name}`
|
||||
}
|
||||
subtitles={[
|
||||
async (song) =>
|
||||
`${t("song")} • ${song.artist.name}`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<ArtistItem artist={item.artist} />
|
||||
|
||||
@@ -58,7 +58,21 @@ const LibrarySongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, type, library, random }) =>
|
||||
API.getSongs(
|
||||
{ type, library: library ?? undefined, random },
|
||||
{
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
random,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
groupsQuery={({ sortBy, order, library, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
|
||||
@@ -51,9 +51,9 @@ const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ library, sortBy, order }) =>
|
||||
query={({ library, sortBy, order, random, type }) =>
|
||||
API.getVideos(
|
||||
{ library: library ?? undefined },
|
||||
{ library: library ?? undefined, random, type },
|
||||
{ sortBy, order },
|
||||
["artist", "featuring"],
|
||||
)
|
||||
|
||||
+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";
|
||||
|
||||
@@ -45,10 +45,10 @@ import { PaginationParameters } from "src/pagination/models/pagination-parameter
|
||||
import {
|
||||
formatIdentifierToIdOrSlug,
|
||||
formatPaginationParameters,
|
||||
getRandomIds,
|
||||
sortItemsUsingOrderedIdList,
|
||||
} from "src/repository/repository.utils";
|
||||
import { AlbumModel } from "./models/album.model";
|
||||
import { shuffle } from "src/utils/shuffle";
|
||||
|
||||
@Injectable()
|
||||
export default class AlbumService extends SearchableRepositoryService {
|
||||
@@ -213,11 +213,10 @@ export default class AlbumService extends SearchableRepositoryService {
|
||||
include?: I,
|
||||
): Promise<AlbumModel[]> {
|
||||
if (typeof sort == "number") {
|
||||
const randomIds = await getRandomIds(
|
||||
"albums",
|
||||
this.prismaService,
|
||||
const randomIds = await this.getManyRandomIds(
|
||||
where,
|
||||
sort,
|
||||
pagination ?? {},
|
||||
pagination,
|
||||
);
|
||||
return this.getMany(
|
||||
{ ...where, id: { in: randomIds } },
|
||||
@@ -239,6 +238,27 @@ export default class AlbumService extends SearchableRepositoryService {
|
||||
return albums;
|
||||
}
|
||||
|
||||
private async getManyRandomIds(
|
||||
where: AlbumQueryParameters.ManyWhereInput,
|
||||
shuffleSeed: number,
|
||||
pagination?: PaginationParameters,
|
||||
) {
|
||||
const ids = await this.prismaService.album
|
||||
.findMany({
|
||||
where: this.formatManyWhereInput(where),
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
cursor: pagination?.afterId
|
||||
? { id: pagination.afterId }
|
||||
: undefined,
|
||||
})
|
||||
.then((items) => items.map(({ id }) => id));
|
||||
return shuffle(shuffleSeed, ids).slice(
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take,
|
||||
);
|
||||
}
|
||||
|
||||
static formatManyWhereInput(where: AlbumQueryParameters.ManyWhereInput) {
|
||||
let query: Prisma.AlbumWhereInput = {
|
||||
type: where.type,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+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,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";
|
||||
|
||||
@@ -54,21 +54,36 @@ class PaginationMetadata {
|
||||
page: number | null;
|
||||
}
|
||||
|
||||
export default class PaginatedResponse<T extends { id: number }> {
|
||||
export default class PaginatedResponse<
|
||||
T extends Record<PaginationKey, number>,
|
||||
PaginationKey extends string = "id",
|
||||
> {
|
||||
@ApiProperty()
|
||||
metadata: PaginationMetadata;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
items: T[];
|
||||
|
||||
static async awaiting<P extends { id: number }>(
|
||||
static async awaiting<
|
||||
P extends Record<PaginationKey1, number>,
|
||||
PaginationKey1 extends string = "id",
|
||||
>(
|
||||
items: Promise<P>[],
|
||||
request: Request | any,
|
||||
paginationIdKey?: PaginationKey1,
|
||||
) {
|
||||
return new PaginatedResponse<P>(await Promise.all(items), request);
|
||||
return new PaginatedResponse<P, PaginationKey1>(
|
||||
await Promise.all(items),
|
||||
request,
|
||||
paginationIdKey,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(items: T[], request: Request | any) {
|
||||
constructor(
|
||||
items: T[],
|
||||
request: Request | any,
|
||||
paginationIdKey?: PaginationKey,
|
||||
) {
|
||||
this.items = items;
|
||||
const route: string = request.path;
|
||||
const itemsCount = items.length;
|
||||
@@ -88,7 +103,10 @@ export default class PaginatedResponse<T extends { id: number }> {
|
||||
itemsCount >= take
|
||||
? this.buildUrl(route, {
|
||||
...request.query,
|
||||
afterId: items.at(-1)?.id ?? null,
|
||||
afterId:
|
||||
items.at(-1)?.[
|
||||
(paginationIdKey ?? "id") as keyof T
|
||||
] ?? null,
|
||||
})
|
||||
: null,
|
||||
previous: null,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,30 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import Identifier from "src/identifier/models/identifier";
|
||||
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
|
||||
import Slug from "src/slug/slug";
|
||||
import { RequireExactlyOne } from "type-fest";
|
||||
|
||||
export async function getRandomIds(
|
||||
tableName: string,
|
||||
prisma: PrismaClient,
|
||||
seed: number,
|
||||
pagination: PaginationParameters,
|
||||
): Promise<number[]> {
|
||||
const ids = await prisma
|
||||
.$queryRawUnsafe(
|
||||
`SELECT id FROM ${tableName} ORDER BY MD5(${seed.toString()} || id::text)`,
|
||||
)
|
||||
.then((res: { id: number }[]) => res.map(({ id }) => id));
|
||||
if (pagination?.afterId !== undefined) {
|
||||
const indexOfFirstItem = ids.indexOf(pagination.afterId) + 1;
|
||||
return ids.slice(indexOfFirstItem, pagination?.take);
|
||||
}
|
||||
return ids.slice(pagination?.skip, pagination?.take);
|
||||
}
|
||||
|
||||
export function sortItemsUsingOrderedIdList<T extends { id: number }>(
|
||||
matches: number[],
|
||||
items: T[],
|
||||
|
||||
@@ -33,11 +33,21 @@ import PaginatedResponse from "src/pagination/models/paginated-response";
|
||||
export default class PaginatedResponseBuilderInterceptor
|
||||
implements NestInterceptor
|
||||
{
|
||||
constructor(private paginationIdKey: string) {}
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
return next
|
||||
.handle()
|
||||
.pipe(map((items) => new PaginatedResponse(items, request)));
|
||||
.pipe(
|
||||
map(
|
||||
(items) =>
|
||||
new PaginatedResponse(
|
||||
items,
|
||||
request,
|
||||
this.paginationIdKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import ResponseBuilderInterceptor from "./interceptors/response.interceptor";
|
||||
import type { Constructor, RequireExactlyOne } from "type-fest";
|
||||
|
||||
type ResponseDecoratorParam<
|
||||
ToType extends Type<{ id: number }>,
|
||||
ToType extends Type<Record<PaginationKey, number>>,
|
||||
PaginationKey extends string = "id",
|
||||
FromType = unknown,
|
||||
> = {
|
||||
/**
|
||||
@@ -34,6 +35,7 @@ type ResponseDecoratorParam<
|
||||
* @default SINGLE
|
||||
*/
|
||||
type?: ResponseType;
|
||||
paginationIdKey?: PaginationKey;
|
||||
} & RequireExactlyOne<{
|
||||
handler: Constructor<ResponseBuilderInterceptor<FromType, ToType>>;
|
||||
returns: ToType;
|
||||
@@ -45,8 +47,11 @@ type ResponseDecoratorParam<
|
||||
* - 'Build' the response (i.e. finding the illustration path)
|
||||
* - Format pagination, if the response is a page
|
||||
*/
|
||||
const Response = <ToType extends Type<{ id: number }>>(
|
||||
params: ResponseDecoratorParam<ToType>,
|
||||
const Response = <
|
||||
ToType extends Type<Record<PaginationKey, number>>,
|
||||
PaginationKey extends string = "id",
|
||||
>(
|
||||
params: ResponseDecoratorParam<ToType, PaginationKey>,
|
||||
): MethodDecorator => {
|
||||
return function (target: any, propertyKey: string, descriptor: any) {
|
||||
const interceptors = [];
|
||||
@@ -56,7 +61,11 @@ const Response = <ToType extends Type<{ id: number }>>(
|
||||
|
||||
if (params.type == ResponseType.Page) {
|
||||
openApiDecorators.push(ApiPaginatedResponse(returnType));
|
||||
interceptors.push(PaginatedResponseBuilderInterceptor);
|
||||
interceptors.push(
|
||||
new PaginatedResponseBuilderInterceptor(
|
||||
params.paginationIdKey ?? "id",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
openApiDecorators.push(
|
||||
ApiOkResponse({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
+20
-11
@@ -16,19 +16,28 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Strategy } from "passport-local";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { SongResponse, SongResponseBuilder } from "./song.response";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import AuthService from "../authentication.service";
|
||||
import { User } from "src/prisma/models";
|
||||
import { SongWithRelations } from "src/prisma/models";
|
||||
|
||||
export class SongGroupResponse extends SongResponse {
|
||||
@ApiProperty()
|
||||
versionCount: number;
|
||||
@ApiProperty()
|
||||
songId: number;
|
||||
}
|
||||
|
||||
@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);
|
||||
export class SongGroupResponseBuilder extends SongResponseBuilder {
|
||||
async buildResponse(
|
||||
song: SongWithRelations & { versionCount: number },
|
||||
): Promise<SongGroupResponse> {
|
||||
return {
|
||||
...(await super.buildResponse(song)),
|
||||
versionCount: song.versionCount,
|
||||
id: song.groupId,
|
||||
songId: song.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createTestingModule } from "test/test-module";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import type { Song } from "src/prisma/models";
|
||||
import PrismaService from "src/prisma/prisma.service";
|
||||
import request from "supertest";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import SongModule from "src/song/song.module";
|
||||
import TestPrismaService from "test/test-prisma.service";
|
||||
import SetupApp from "test/setup-app";
|
||||
import {
|
||||
expectedArtistResponse,
|
||||
expectedSongGroupResponse,
|
||||
} from "test/expected-responses";
|
||||
import ProviderService from "src/providers/provider.service";
|
||||
import SettingsService from "src/settings/settings.service";
|
||||
import { SongGroupResponse } from "./models/song-group.response";
|
||||
|
||||
describe("Song Group Controller", () => {
|
||||
let dummyRepository: TestPrismaService;
|
||||
let app: INestApplication;
|
||||
let providerService: ProviderService;
|
||||
|
||||
let module: TestingModule;
|
||||
beforeAll(async () => {
|
||||
module = await createTestingModule({
|
||||
imports: [SongModule],
|
||||
})
|
||||
.overrideProvider(PrismaService)
|
||||
.useClass(TestPrismaService)
|
||||
.compile();
|
||||
app = await SetupApp(module);
|
||||
dummyRepository = module.get(PrismaService);
|
||||
providerService = module.get(ProviderService);
|
||||
module.get(SettingsService).loadFromFile();
|
||||
await dummyRepository.onModuleInit();
|
||||
await providerService.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("Get Songs Groups (GET /song-groups)", () => {
|
||||
it("should return songs w/ artist", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/song-groups?take=1&with=artist,featuring`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const songs: Song[] = res.body.items;
|
||||
expect(songs.length).toBe(1);
|
||||
expect(songs[0]).toStrictEqual({
|
||||
...expectedSongGroupResponse(dummyRepository.songA1, 1),
|
||||
artist: expectedArtistResponse(dummyRepository.artistA),
|
||||
featuring: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
it("should get all the artist's songs", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/song-groups?artist=${dummyRepository.artistA.id}`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const songs: SongGroupResponse[] = res.body.items;
|
||||
expect(songs.length).toBe(2);
|
||||
expect(songs[0]).toStrictEqual(
|
||||
expectedSongGroupResponse(dummyRepository.songA1, 1),
|
||||
);
|
||||
expect(songs[1]).toStrictEqual(
|
||||
expectedSongGroupResponse(dummyRepository.songA2, 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
it("should get all the artist's song-groups, sorted by name", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/song-groups?artist=${dummyRepository.artistA.id}&sortBy=name`,
|
||||
)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const songs: SongGroupResponse[] = res.body.items;
|
||||
expect(songs.length).toBe(2);
|
||||
expect(songs[0]).toStrictEqual(
|
||||
expectedSongGroupResponse(dummyRepository.songA2, 1),
|
||||
);
|
||||
expect(songs[1]).toStrictEqual(
|
||||
expectedSongGroupResponse(dummyRepository.songA1, 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
it("should get some song-groups (w/ selector + pagination)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/song-groups?artist=${dummyRepository.artistA.id}&skip=1`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const songs: SongGroupResponse[] = res.body.items;
|
||||
expect(songs.length).toBe(1);
|
||||
expect(songs[0]).toStrictEqual(
|
||||
expectedSongGroupResponse(dummyRepository.songA2, 1),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 { Controller, Get, Query } from "@nestjs/common";
|
||||
import SongGroupService from "./song-group.service";
|
||||
import { ApiOperation, PickType } from "@nestjs/swagger";
|
||||
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
|
||||
import RelationIncludeQuery from "src/relation-include/relation-include-query.decorator";
|
||||
import SongQueryParameters from "./models/song.query-params";
|
||||
import { Selector } from "./song.controller";
|
||||
import Response, { ResponseType } from "src/response/response.decorator";
|
||||
import { SongGroupResponseBuilder } from "./models/song-group.response";
|
||||
import SongGroupQueryParameters from "./models/song-group.query-params";
|
||||
|
||||
class SongGroupSelector extends PickType(Selector, [
|
||||
"artist",
|
||||
"album",
|
||||
"library",
|
||||
"genre",
|
||||
"query",
|
||||
"type",
|
||||
]) {}
|
||||
|
||||
@Controller("song-groups")
|
||||
export class SongGroupController {
|
||||
constructor(private songGroupService: SongGroupService) {}
|
||||
@ApiOperation({
|
||||
summary: "Get many songs",
|
||||
})
|
||||
@Response({
|
||||
handler: SongGroupResponseBuilder,
|
||||
type: ResponseType.Page,
|
||||
paginationIdKey: "groupId",
|
||||
})
|
||||
@Get()
|
||||
async getSongs(
|
||||
@Query() selector: SongGroupSelector,
|
||||
@Query() sort: SongGroupQueryParameters.SortingParameter,
|
||||
@Query()
|
||||
paginationParameters: PaginationParameters,
|
||||
@RelationIncludeQuery(SongQueryParameters.AvailableAtomicIncludes)
|
||||
include: SongQueryParameters.RelationInclude,
|
||||
) {
|
||||
return this.songGroupService.getMany(
|
||||
selector,
|
||||
sort,
|
||||
paginationParameters,
|
||||
include,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 { Injectable } from "@nestjs/common";
|
||||
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
|
||||
import { SongWithRelations } from "src/prisma/models";
|
||||
import PrismaService from "src/prisma/prisma.service";
|
||||
import { formatPaginationParameters } from "src/repository/repository.utils";
|
||||
import SongQueryParameters from "./models/song.query-params";
|
||||
import SongService from "./song.service";
|
||||
import SongGroupQueryParameters from "./models/song-group.query-params";
|
||||
|
||||
@Injectable()
|
||||
export default class SongGroupService {
|
||||
constructor(private prismaService: PrismaService) {}
|
||||
async getMany<I extends SongQueryParameters.RelationInclude = {}>(
|
||||
where: SongQueryParameters.ManyWhereInput,
|
||||
sort?: SongGroupQueryParameters.SortingParameter,
|
||||
pagination?: PaginationParameters,
|
||||
include?: I,
|
||||
): Promise<(SongWithRelations & { versionCount: number })[]> {
|
||||
const groups = await this.prismaService.songGroup.findMany({
|
||||
where: {
|
||||
versions: {
|
||||
some: SongService.formatManyWhereInput(where),
|
||||
},
|
||||
},
|
||||
orderBy: sort?.sortBy
|
||||
? ({
|
||||
[sort.sortBy == "name" ? "slug" : sort.sortBy]:
|
||||
sort?.order ?? "asc",
|
||||
} as const)
|
||||
: undefined,
|
||||
include: {
|
||||
_count: { select: { versions: true } },
|
||||
versions: {
|
||||
where: SongService.formatManyWhereInput(where),
|
||||
orderBy: { slug: "asc" },
|
||||
include: include ?? ({} as I),
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
...formatPaginationParameters(pagination),
|
||||
});
|
||||
return groups.map((g) => ({
|
||||
...g.versions[0],
|
||||
versionCount: g._count.versions,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -29,6 +29,9 @@ import { SongResponseBuilder } from "./models/song.response";
|
||||
import ReleaseModule from "src/release/release.module";
|
||||
import ProvidersModule from "src/providers/providers.module";
|
||||
import ScannerModule from "src/scanner/scanner.module";
|
||||
import { SongGroupResponseBuilder } from "./models/song-group.response";
|
||||
import SongGroupService from "./song-group.service";
|
||||
import { SongGroupController } from "./song-group.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,8 +45,18 @@ import ScannerModule from "src/scanner/scanner.module";
|
||||
forwardRef(() => IllustrationModule),
|
||||
forwardRef(() => ScannerModule),
|
||||
],
|
||||
exports: [SongService, SongResponseBuilder],
|
||||
providers: [SongService, SongResponseBuilder],
|
||||
controllers: [SongController],
|
||||
exports: [
|
||||
SongService,
|
||||
SongGroupService,
|
||||
SongResponseBuilder,
|
||||
SongGroupResponseBuilder,
|
||||
],
|
||||
providers: [
|
||||
SongService,
|
||||
SongGroupService,
|
||||
SongResponseBuilder,
|
||||
SongGroupResponseBuilder,
|
||||
],
|
||||
controllers: [SongController, SongGroupController],
|
||||
})
|
||||
export default class SongModule {}
|
||||
@@ -50,10 +50,10 @@ import SearchableRepositoryService from "src/repository/searchable-repository.se
|
||||
import {
|
||||
formatIdentifierToIdOrSlug,
|
||||
formatPaginationParameters,
|
||||
getRandomIds,
|
||||
sortItemsUsingOrderedIdList,
|
||||
} from "src/repository/repository.utils";
|
||||
import ArtistQueryParameters from "src/artist/models/artist.query-parameters";
|
||||
import { shuffle } from "src/utils/shuffle";
|
||||
|
||||
@Injectable()
|
||||
export default class SongService extends SearchableRepositoryService {
|
||||
@@ -237,11 +237,10 @@ export default class SongService extends SearchableRepositoryService {
|
||||
include?: I,
|
||||
): Promise<SongWithRelations[]> {
|
||||
if (typeof sort == "number") {
|
||||
const randomIds = await getRandomIds(
|
||||
"songs",
|
||||
this.prismaService,
|
||||
const randomIds = await this.getManyRandomIds(
|
||||
where,
|
||||
sort,
|
||||
pagination ?? {},
|
||||
pagination,
|
||||
);
|
||||
return this.getMany(
|
||||
{ ...where, id: { in: randomIds } },
|
||||
@@ -262,6 +261,27 @@ export default class SongService extends SearchableRepositoryService {
|
||||
>(args);
|
||||
}
|
||||
|
||||
private async getManyRandomIds(
|
||||
where: SongQueryParameters.ManyWhereInput,
|
||||
shuffleSeed: number,
|
||||
pagination?: PaginationParameters,
|
||||
) {
|
||||
const ids = await this.prismaService.song
|
||||
.findMany({
|
||||
where: this.formatManyWhereInput(where),
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
cursor: pagination?.afterId
|
||||
? { id: pagination.afterId }
|
||||
: undefined,
|
||||
})
|
||||
.then((items) => items.map(({ id }) => id));
|
||||
return shuffle(shuffleSeed, ids).slice(
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take,
|
||||
);
|
||||
}
|
||||
|
||||
static formatManyWhereInput(where: SongQueryParameters.ManyWhereInput) {
|
||||
if (where.artist?.compilationArtist) {
|
||||
throw new CompilationArtistException("Song");
|
||||
@@ -355,6 +375,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
|
||||
|
||||
@@ -16,11 +16,22 @@
|
||||
* 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);
|
||||
// Stolen from https://stackoverflow.com/questions/16801687/javascript-random-ordering-with-seed
|
||||
|
||||
export default Admin;
|
||||
export function shuffle<T>(seed: number, array: T[]) {
|
||||
let m = array.length;
|
||||
while (m) {
|
||||
const i = Math.floor(random(seed) * m--);
|
||||
const t = array[m];
|
||||
array[m] = array[i];
|
||||
array[i] = t;
|
||||
++seed;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
function random(seed: number) {
|
||||
const x = Math.sin(seed++) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
@@ -47,11 +47,11 @@ export class VideoController {
|
||||
@RelationIncludeQuery(SongQueryParameters.AvailableAtomicIncludes)
|
||||
include: SongQueryParameters.RelationInclude,
|
||||
) {
|
||||
return this.videoService.getVideos(
|
||||
return this.videoService.getMany(
|
||||
selector,
|
||||
paginationParameters,
|
||||
include,
|
||||
sort,
|
||||
selector.random ?? sort,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ describe("Video Service", () => {
|
||||
|
||||
describe("Get Songs With Videos", () => {
|
||||
it("should return the songs With video", async () => {
|
||||
const videoSongs = await videoService.getVideos({});
|
||||
const videoSongs = await videoService.getMany({});
|
||||
expect(videoSongs.length).toBe(1);
|
||||
expect(videoSongs[0]).toStrictEqual({
|
||||
...dummyRepository.songA1,
|
||||
@@ -70,11 +70,11 @@ describe("Video Service", () => {
|
||||
});
|
||||
});
|
||||
it("should return an empty list (pagination)", async () => {
|
||||
const videoSongs = await videoService.getVideos({}, { skip: 1 });
|
||||
const videoSongs = await videoService.getMany({}, { skip: 1 });
|
||||
expect(videoSongs.length).toBe(0);
|
||||
});
|
||||
it("should return songs with their artist", async () => {
|
||||
const videoSongs = await videoService.getVideos(
|
||||
const videoSongs = await videoService.getMany(
|
||||
{},
|
||||
{},
|
||||
{ artist: true },
|
||||
|
||||
@@ -19,9 +19,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PaginationParameters } from "src/pagination/models/pagination-parameters";
|
||||
import PrismaService from "src/prisma/prisma.service";
|
||||
import { formatPaginationParameters } from "src/repository/repository.utils";
|
||||
import {
|
||||
formatPaginationParameters,
|
||||
sortItemsUsingOrderedIdList,
|
||||
} from "src/repository/repository.utils";
|
||||
import SongQueryParameters from "src/song/models/song.query-params";
|
||||
import SongService from "src/song/song.service";
|
||||
import { Prisma, Song, Track } from "@prisma/client";
|
||||
import { shuffle } from "src/utils/shuffle";
|
||||
|
||||
@Injectable()
|
||||
export default class VideoService {
|
||||
@@ -30,6 +35,54 @@ export default class VideoService {
|
||||
private songService: SongService,
|
||||
) {}
|
||||
|
||||
static formatManyWhereInput(
|
||||
where: SongQueryParameters.ManyWhereInput,
|
||||
): Prisma.SongWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
SongService.formatManyWhereInput(where),
|
||||
{
|
||||
group: {
|
||||
versions: {
|
||||
some: SongService.formatManyWhereInput(where),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: {
|
||||
tracks: {
|
||||
some: {
|
||||
type: "Video",
|
||||
song: {
|
||||
type: where.type,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getManyRandomIds(
|
||||
where: SongQueryParameters.ManyWhereInput,
|
||||
shuffleSeed: number,
|
||||
pagination?: PaginationParameters,
|
||||
) {
|
||||
const ids = await this.prismaService.song
|
||||
.findMany({
|
||||
where: VideoService.formatManyWhereInput(where),
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
cursor: pagination?.afterId
|
||||
? { id: pagination.afterId }
|
||||
: undefined,
|
||||
})
|
||||
.then((items) => items.map(({ id }) => id));
|
||||
return shuffle(shuffleSeed, ids).slice(
|
||||
pagination?.skip ?? 0,
|
||||
pagination?.take,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get songs with at least one video track
|
||||
* The songs are returned with its first video track
|
||||
@@ -37,14 +90,26 @@ export default class VideoService {
|
||||
* @param pagination the pagination parameters
|
||||
* @param include the relations to include with the returned songs
|
||||
*/
|
||||
async getVideos<
|
||||
async getMany<
|
||||
I extends Omit<SongQueryParameters.RelationInclude, "tracks">,
|
||||
>(
|
||||
where: SongQueryParameters.ManyWhereInput,
|
||||
pagination?: PaginationParameters,
|
||||
include?: I,
|
||||
sort?: SongQueryParameters.SortingParameter,
|
||||
) {
|
||||
sort?: SongQueryParameters.SortingParameter | number,
|
||||
): Promise<(Song & { track: Track })[]> {
|
||||
if (typeof sort == "number") {
|
||||
const randomIds = await this.getManyRandomIds(
|
||||
where,
|
||||
sort,
|
||||
pagination,
|
||||
);
|
||||
return this.getMany(
|
||||
{ ...where, id: { in: randomIds } },
|
||||
undefined,
|
||||
include,
|
||||
).then((items) => sortItemsUsingOrderedIdList(randomIds, items));
|
||||
}
|
||||
return this.prismaService.song
|
||||
.findMany({
|
||||
orderBy: sort?.sortBy
|
||||
@@ -81,27 +146,7 @@ export default class VideoService {
|
||||
},
|
||||
},
|
||||
...formatPaginationParameters(pagination),
|
||||
where: {
|
||||
OR: [
|
||||
SongService.formatManyWhereInput(where),
|
||||
{
|
||||
group: {
|
||||
versions: {
|
||||
some: SongService.formatManyWhereInput(
|
||||
where,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: {
|
||||
tracks: {
|
||||
some: {
|
||||
type: "Video",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: VideoService.formatManyWhereInput(where),
|
||||
})
|
||||
.then((songs) =>
|
||||
songs.map(({ tracks, ...song }) => ({
|
||||
|
||||
@@ -25,6 +25,16 @@ export const expectedSongResponse = (song: SongWithRelations) => ({
|
||||
type: SongType.Original,
|
||||
});
|
||||
|
||||
export const expectedSongGroupResponse = (
|
||||
song: SongWithRelations,
|
||||
versionCount: number,
|
||||
) => ({
|
||||
...expectedSongResponse(song),
|
||||
id: song.groupId,
|
||||
songId: song.id,
|
||||
versionCount,
|
||||
});
|
||||
|
||||
export const expectedReleaseResponse = (release: Release) => ({
|
||||
...release,
|
||||
registeredAt: release.registeredAt.toISOString(),
|
||||
|
||||
+677
-587
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user