Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e5abb1b4 | ||
|
|
c3bd9b44fd | ||
|
|
21bb5586b7 | ||
|
|
06e24586db | ||
|
|
e3255cb428 | ||
|
|
c81ed9dd2e | ||
|
|
45afd3b36b | ||
|
|
2b5875b146 | ||
|
|
3176bc5c36 | ||
|
|
87efc74920 | ||
|
|
e5fc7a8428 | ||
|
|
aced630093 | ||
|
|
bf28a74155 | ||
|
|
f2dfd6903c | ||
|
|
e00d551389 |
No files matched your search
@@ -26,6 +26,7 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- 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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { TranslationKey } from "../i18n/i18n";
|
||||
import { useKey } from "react-use";
|
||||
import { useKey as _useKey } from "react-use";
|
||||
import { Handler } from "react-use/lib/useKey";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -63,6 +63,21 @@ const KeyboardBindings = createContext<BindingsState & KeyboardBindingsActions>(
|
||||
},
|
||||
);
|
||||
|
||||
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[];
|
||||
}) => {
|
||||
@@ -130,12 +145,7 @@ export const useKeyboardBinding = (
|
||||
|
||||
useKey(
|
||||
bindingKeyToUseKeyParam(binding.key),
|
||||
(e) => {
|
||||
if (document.activeElement?.tagName.toLowerCase() == "input") {
|
||||
return;
|
||||
}
|
||||
binding.handler(e);
|
||||
},
|
||||
(e) => binding.handler(e),
|
||||
{},
|
||||
[...deps],
|
||||
);
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Video Albums",
|
||||
|
||||
"groupVersions": "Group Versions",
|
||||
"showAllSongs": "Show All Versions",
|
||||
|
||||
"onThisAlbum": "Featured Artists",
|
||||
|
||||
"backToTop": "Back To Top",
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
"pluralRemixAlbum": "Remixes",
|
||||
"pluralVideoAlbum": "Albums Vidéos",
|
||||
|
||||
"groupVersions": "Grouper les versions",
|
||||
"showAllSongs": "Afficher toutes les versions",
|
||||
|
||||
"backToTop": "Retour en haut",
|
||||
"changeLayout": "Changer la disposition",
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -644,35 +644,37 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
release={release.data}
|
||||
/>
|
||||
{release.data?.album.type === "Compilation" && (
|
||||
<>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontWeight: "bolder",
|
||||
}}
|
||||
onClick={() => {
|
||||
setShowOnlyExclusive((v) => !v);
|
||||
document
|
||||
.getElementById(
|
||||
parentScrollableDivId,
|
||||
)
|
||||
?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
showOnlyExclusive
|
||||
? "showAllTrack"
|
||||
: "showOnlyExclusiveTracks",
|
||||
)}
|
||||
</ListItemButton>
|
||||
</>
|
||||
)}
|
||||
{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
|
||||
|
||||
@@ -191,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"],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { ApiProperty } from "@nestjs/swagger";
|
||||
import { SongResponse, SongResponseBuilder } from "./song.response";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { SongWithRelations } from "src/prisma/models";
|
||||
|
||||
export class SongGroupResponse extends SongResponse {
|
||||
@ApiProperty()
|
||||
versionCount: number;
|
||||
@ApiProperty()
|
||||
songId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -191,6 +191,7 @@ export default class TrackService {
|
||||
queryParameters = deepmerge<Prisma.TrackWhereInput>(
|
||||
queryParameters,
|
||||
{
|
||||
type: "Audio",
|
||||
song: {
|
||||
group: {
|
||||
versions: {
|
||||
@@ -202,7 +203,9 @@ export default class TrackService {
|
||||
OR: [
|
||||
{
|
||||
album: {
|
||||
type: "Single",
|
||||
type: {
|
||||
not: "StudioRecording",
|
||||
},
|
||||
},
|
||||
},
|
||||
ReleaseService.formatWhereInput(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Stolen from https://stackoverflow.com/questions/16801687/javascript-random-ordering-with-seed
|
||||
|
||||
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(),
|
||||
|
||||
Reference in new issue
Block a user