Compare commits

...
15 Commits
35 changed files with 968 additions and 159 deletions

No files matched your search

+1
View File
@@ -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

+37
View File
@@ -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[],
+33 -1
View File
@@ -43,6 +43,12 @@ import Fade from "../fade";
import globalLibrary from "../../utils/global-library";
import { useTranslation } from "react-i18next";
export type Toggle = {
name: string;
label: TranslationKey;
defaultValue?: boolean;
};
export type OptionState<
SortingKeys extends readonly string[],
AdditionalProps extends object = object,
@@ -60,6 +66,7 @@ type ControllerProps<
> = {
options?: Options;
actions?: Action[];
toggles?: Toggle[];
disableSorting?: true;
sortingKeys: SortingKeys;
defaultSortingKey?: SortingKeys[number];
@@ -130,6 +137,15 @@ const Controls = <
option.values,
) ?? option.values[0];
});
props.toggles?.forEach((option) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
baseOptions[option.name] =
(parseQueryParam(props.router?.query[option.name], [
"false",
"true",
]) ?? "false") === "true";
});
return baseOptions;
},
);
@@ -148,9 +164,10 @@ const Controls = <
value,
}: {
name: string;
value: string | null;
value: boolean | string | null;
}) => {
setOptionState({ ...optionsState, [name]: value });
value = value?.toString() ?? null;
if (props.router) {
const path = props.router.asPath.split("?")[0];
const params = new URLSearchParams(
@@ -283,6 +300,21 @@ const Controls = <
/>
);
})}
{props.toggles?.map((toggle) => (
<Button
key={toggle.name}
onClick={() => {
updateOptionState({
name: toggle.name,
value: !optionsState[
toggle.name as keyof OptionState<SortingKeys>
],
});
}}
>
{t(toggle.label)}
</Button>
))}
{props.disableLayoutToggle !== true && (
<Tooltip title={t("changeLayout")}>
<Button
@@ -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) =>
+51 -6
View File
@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { SongIcon } from "../icons";
import formatArtists from "../../utils/formatArtists";
import { usePlayerContext } from "../../contexts/player";
import { SongGroupWithRelations } from "../../models/song-group";
type SongItemProps<
T extends SongWithRelations<
@@ -32,9 +33,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 (
+17 -7
View File
@@ -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],
);
+3
View File
@@ -127,6 +127,9 @@
"pluralRemixAlbum": "Remixes",
"pluralVideoAlbum": "Video Albums",
"groupVersions": "Group Versions",
"showAllSongs": "Show All Versions",
"onThisAlbum": "Featured Artists",
"backToTop": "Back To Top",
+3
View File
@@ -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",
+50
View File
@@ -0,0 +1,50 @@
/*
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
* Copyright (C) 2023
*
* Meelo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Meelo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import * as yup from "yup";
import Song, { SongInclude, SongRelations } from "./song";
/**
* Abstract data model, instanciated by tracks
*/
const SongGroup = Song.concat(
yup.object({
versionCount: yup.number().required(),
songId: yup.number().required(),
}),
);
type SongGroup = yup.InferType<typeof SongGroup>;
type SongGroupInclude = SongInclude;
const SongGroupRelations = SongRelations;
const SongGroupWithRelations = <
Selection extends SongGroupInclude | never = never,
>(
relation: Selection[],
) => SongGroup.concat(SongGroupRelations.pick(relation));
type SongGroupWithRelations<
Selection extends SongGroupInclude | never = never,
> = yup.InferType<ReturnType<typeof SongGroupWithRelations<Selection>>>;
export default SongGroup;
export const SongGroupSortingKeys = ["name"] as const;
export { type SongGroupInclude, SongGroupWithRelations, SongGroupRelations };
+1 -1
View File
@@ -35,8 +35,8 @@ export const SongType = [
"Demo",
"Unknown",
"Acapella",
"NonMusic",
"Medley",
"NonMusic",
] as const;
export type SongType = (typeof SongType)[number];
+22 -2
View File
@@ -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 },
+31 -29
View File
@@ -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
+4 -3
View File
@@ -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} />
+15 -1
View File
@@ -58,7 +58,21 @@ const LibrarySongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
initialSortingOrder={props?.order}
query={({ sortBy, order, type, library, random }) =>
API.getSongs(
{ type, library: library ?? undefined, random },
{
type,
library: library ?? undefined,
random,
},
{ sortBy, order },
["artist", "featuring", "master", "illustration"],
)
}
groupsQuery={({ sortBy, order, library, type }) =>
API.getSongGroups(
{
type,
library: library ?? undefined,
},
{ sortBy, order },
["artist", "featuring", "master", "illustration"],
)
+2 -2
View File
@@ -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"],
)
+25 -5
View File
@@ -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,
-19
View File
@@ -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,
),
),
);
}
}
+13 -4
View File
@@ -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),
);
});
});
});
});
+66
View File
@@ -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,
);
}
}
+65
View File
@@ -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,
}));
}
}
+16 -3
View File
@@ -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 {}
+25 -5
View File
@@ -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");
+4 -1
View File
@@ -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(
+37
View File
@@ -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);
}
+2 -2
View File
@@ -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,
);
}
}
+3 -3
View File
@@ -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 },
+70 -25
View File
@@ -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 }) => ({
+10
View File
@@ -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(),