Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3095b4dfa | ||
|
|
385ca37848 | ||
|
|
ec02d780ea | ||
|
|
ecdb82782f | ||
|
|
ace1d78505 | ||
|
|
87dc8a3f66 | ||
|
|
e8b6db6ed5 | ||
|
|
7aa484b1e4 | ||
|
|
988a2e38f0 | ||
|
|
af359358d5 | ||
|
|
fd3e5c9e5f | ||
|
|
bae28fb94a | ||
|
|
8964086a61 | ||
|
|
e0c2711fac | ||
|
|
27146f2bb0 |
No files matched your search
@@ -12,4 +12,5 @@ runs:
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: 'scanner/go.mod'
|
||||
cache-dependency-path: scanner/go.sum
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: '1.24.1'
|
||||
@@ -37,7 +37,7 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
@@ -65,6 +65,7 @@ https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
+1
-2
@@ -33,7 +33,7 @@
|
||||
"jotai": "^2.12.1",
|
||||
"material-ui-confirm": "^3.0.7",
|
||||
"mui-react-hook-form-plus": "^1.3.2",
|
||||
"next": "14.2.23",
|
||||
"next": "14.2.25",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
@@ -43,7 +43,6 @@
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-loader-spinner": "^5.2.0",
|
||||
"react-query": "^3.39.2",
|
||||
"react-use": "^17.5.1",
|
||||
"sharp": "^0.33.4",
|
||||
|
||||
+50
-18
@@ -38,6 +38,7 @@ import {
|
||||
import File from "../models/file";
|
||||
import Genre from "../models/genre";
|
||||
import Library from "../models/library";
|
||||
import { Lyrics } from "../models/lyrics";
|
||||
import PaginatedResponse, {
|
||||
type PaginationParameters,
|
||||
} from "../models/pagination";
|
||||
@@ -140,7 +141,8 @@ export default class API {
|
||||
([_, value]) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== "view",
|
||||
value !== "view" &&
|
||||
(Array.isArray(value) ? value.length > 0 : true),
|
||||
)
|
||||
.map(([key, value]) => `params-${key}-${value}`)
|
||||
: [];
|
||||
@@ -600,7 +602,7 @@ export default class API {
|
||||
*/
|
||||
static getArtists<I extends ArtistInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
album?: Identifier;
|
||||
genre?: Identifier;
|
||||
query?: Identifier;
|
||||
@@ -623,6 +625,7 @@ export default class API {
|
||||
otherParameters: {
|
||||
albumArtistOnly: filter.album ? undefined : "true",
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
ArtistWithRelations(include ?? []),
|
||||
@@ -637,10 +640,10 @@ export default class API {
|
||||
*/
|
||||
static getAlbums<I extends AlbumInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
genre?: Identifier;
|
||||
type?: AlbumType;
|
||||
type?: AlbumType[];
|
||||
related?: Identifier;
|
||||
appearance?: Identifier;
|
||||
query?: Identifier;
|
||||
@@ -661,7 +664,11 @@ export default class API {
|
||||
route: "/albums",
|
||||
errorMessage: "Albums could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
AlbumWithRelations(include ?? []),
|
||||
),
|
||||
@@ -704,8 +711,8 @@ export default class API {
|
||||
*/
|
||||
static getSongs<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
type?: SongType;
|
||||
library?: Identifier[];
|
||||
type?: SongType[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
versionsOf?: Identifier;
|
||||
@@ -729,7 +736,11 @@ export default class API {
|
||||
route: "/songs",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
SongWithRelations(include ?? []),
|
||||
),
|
||||
@@ -739,7 +750,7 @@ export default class API {
|
||||
|
||||
static getSongGroups<I extends SongInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
genre?: Identifier;
|
||||
artist?: Identifier;
|
||||
query?: string;
|
||||
@@ -760,7 +771,10 @@ export default class API {
|
||||
route: "/song-groups",
|
||||
errorMessage: "Songs could not be loaded",
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
otherParameters: { ...filter },
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
SongGroupWithRelations(include ?? []),
|
||||
),
|
||||
@@ -773,13 +787,13 @@ export default class API {
|
||||
*/
|
||||
static getVideos<I extends VideoInclude | never = never>(
|
||||
filter: {
|
||||
library?: Identifier;
|
||||
library?: Identifier[];
|
||||
artist?: Identifier;
|
||||
album?: Identifier;
|
||||
song?: Identifier;
|
||||
group?: Identifier;
|
||||
random?: number;
|
||||
type?: VideoType;
|
||||
type?: VideoType[];
|
||||
query?: string;
|
||||
},
|
||||
sort?: SortingParameters<typeof VideoSortingKeys>,
|
||||
@@ -801,7 +815,11 @@ export default class API {
|
||||
include,
|
||||
sort,
|
||||
},
|
||||
otherParameters: filter,
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
type: API.formatOr(filter.type),
|
||||
},
|
||||
validator: PaginatedResponse(
|
||||
VideoWithRelations(include ?? []),
|
||||
),
|
||||
@@ -1079,7 +1097,7 @@ export default class API {
|
||||
* @returns an Infinite Query of tracks
|
||||
*/
|
||||
static getTracks<I extends TrackInclude | never = never>(
|
||||
filter: { song?: string | number },
|
||||
filter: { song?: string | number; library?: Identifier[] },
|
||||
sort?: SortingParameters<typeof TrackSortingKeys>,
|
||||
include?: I[],
|
||||
): InfiniteQuery<TrackWithRelations<I>> {
|
||||
@@ -1093,7 +1111,10 @@ export default class API {
|
||||
exec: (pagination) =>
|
||||
API.fetch({
|
||||
route: "/tracks",
|
||||
otherParameters: filter,
|
||||
otherParameters: {
|
||||
...filter,
|
||||
library: API.formatOr(filter.library),
|
||||
},
|
||||
parameters: { pagination: pagination, include, sort },
|
||||
validator: PaginatedResponse(
|
||||
TrackWithRelations(include ?? []),
|
||||
@@ -1158,7 +1179,7 @@ export default class API {
|
||||
* @param slugOrId the id of the song
|
||||
* @returns A query for an array of strings
|
||||
*/
|
||||
static getSongLyrics(slugOrId: string | number): Query<string[] | null> {
|
||||
static getSongLyrics(slugOrId: string | number): Query<Lyrics | null> {
|
||||
return {
|
||||
key: ["song", slugOrId, "lyrics"],
|
||||
exec: () =>
|
||||
@@ -1166,8 +1187,7 @@ export default class API {
|
||||
route: `/songs/${slugOrId}/lyrics`,
|
||||
errorMessage: "Lyrics loading failed",
|
||||
parameters: {},
|
||||
customValidator: async (value) =>
|
||||
(value as { lyrics: string }).lyrics.split("\n"),
|
||||
validator: Lyrics,
|
||||
}).catch(() => null),
|
||||
};
|
||||
}
|
||||
@@ -1519,4 +1539,16 @@ export default class API {
|
||||
formattedParameters.push(`take=${pageSize}`);
|
||||
return formattedParameters.join("&");
|
||||
}
|
||||
|
||||
private static formatOr(
|
||||
items: (string | number)[] | undefined,
|
||||
): string | undefined {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
return items[0].toString();
|
||||
}
|
||||
return `or:${items.map((i) => i.toString()).join(",")}`;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
import Illustration from "../illustration";
|
||||
import InfiniteList from "../infinite/infinite-list";
|
||||
import ListItem from "../list-item/item";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import type Action from "./action";
|
||||
|
||||
export const PlayNextAction = (
|
||||
@@ -258,7 +257,6 @@ const SelectPlaylistForm = (props: SelectPlaylistFormProps) => {
|
||||
/>
|
||||
<Divider sx={{ paddingTop: 1 }} />
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.playlistQuery}
|
||||
render={(item, index) => (
|
||||
<ListItem
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import API from "../../api/api";
|
||||
import { prepareMeeloInfiniteQuery } from "../../api/use-query";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import globalLibrary from "../../utils/global-library";
|
||||
import {
|
||||
DefaultItemSize,
|
||||
ItemSize,
|
||||
type LayoutOption,
|
||||
getLayoutParams,
|
||||
} from "../../utils/layout";
|
||||
import parseQueryParam from "../../utils/parse-query-param";
|
||||
import { type Order, getOrderParams } from "../../utils/sorting";
|
||||
import type Action from "../actions/action";
|
||||
import Fade from "../fade";
|
||||
import {
|
||||
AscIcon,
|
||||
DescIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "../icons";
|
||||
import type Option from "./option";
|
||||
import OptionButton from "./option-button";
|
||||
|
||||
export type Toggle = {
|
||||
name: string;
|
||||
label: TranslationKey;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type OptionState<
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalProps extends object = object,
|
||||
> = {
|
||||
view: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
order: Order;
|
||||
sortBy: SortingKeys[number];
|
||||
library: string | null;
|
||||
} & AdditionalProps;
|
||||
|
||||
type ControllerProps<
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
> = {
|
||||
options?: Options;
|
||||
actions?: Action[];
|
||||
toggles?: Toggle[];
|
||||
disableSorting?: true;
|
||||
sortingKeys: SortingKeys;
|
||||
defaultSortingKey?: SortingKeys[number];
|
||||
defaultSortingOrder?: Order;
|
||||
defaultLayout: LayoutOption;
|
||||
disableLayoutToggle?: boolean;
|
||||
onChange: (state: OptionState<SortingKeys>) => void;
|
||||
/**
|
||||
* If defined, will push changes as query parameters in router
|
||||
*/
|
||||
router?: NextRouter;
|
||||
disableLibrarySelector?: true;
|
||||
};
|
||||
|
||||
const Controls = <
|
||||
SortingKeys extends readonly string[],
|
||||
Options extends Option<Values[number]>[],
|
||||
Values extends string[][],
|
||||
>(
|
||||
props: ControllerProps<SortingKeys, Options, Values>,
|
||||
) => {
|
||||
const _theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
useErrorBoundary: false,
|
||||
onError: () => {
|
||||
toast.error(t("librariesLoadFail"));
|
||||
},
|
||||
});
|
||||
const libraries = useMemo(
|
||||
() => librariesQuery.data?.pages.at(0)?.items ?? null,
|
||||
[librariesQuery.data],
|
||||
);
|
||||
const [optionsState, setOptionState] = useState<OptionState<SortingKeys>>(
|
||||
() => {
|
||||
const libraryQuery = Array.isArray(props.router?.query.library)
|
||||
? props.router?.query.library?.at(0)
|
||||
: props.router?.query.library;
|
||||
const baseOptions: OptionState<SortingKeys> = {
|
||||
itemSize: DefaultItemSize,
|
||||
view:
|
||||
((props.disableLayoutToggle !== true &&
|
||||
getLayoutParams(props.router?.query.view)) ||
|
||||
undefined) ??
|
||||
props.defaultLayout,
|
||||
sortBy:
|
||||
parseQueryParam(
|
||||
props.router?.query.sortBy,
|
||||
props.sortingKeys,
|
||||
) ??
|
||||
props.defaultSortingKey ??
|
||||
props.sortingKeys[0],
|
||||
order:
|
||||
getOrderParams(props.router?.query.order) ??
|
||||
props.defaultSortingOrder ??
|
||||
"asc",
|
||||
library: props.disableLibrarySelector
|
||||
? null
|
||||
: (libraryQuery ?? null),
|
||||
};
|
||||
|
||||
for (const option of props.options ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
parseQueryParam(
|
||||
props.router?.query[option.name],
|
||||
option.values,
|
||||
) ?? option.values[0];
|
||||
}
|
||||
for (const option of props.toggles ?? []) {
|
||||
// @ts-ignore
|
||||
baseOptions[option.name] =
|
||||
(parseQueryParam(props.router?.query[option.name], [
|
||||
"false",
|
||||
"true",
|
||||
]) ?? "false") === "true";
|
||||
}
|
||||
return baseOptions;
|
||||
},
|
||||
);
|
||||
const [openActionModal, setOpenActionModal] = useState<string | null>(null);
|
||||
const closeModal = () => setOpenActionModal(null);
|
||||
|
||||
/**
|
||||
* To pass the options to parent after initialization
|
||||
*/
|
||||
useEffect(() => {
|
||||
props.onChange(optionsState);
|
||||
}, [optionsState]);
|
||||
|
||||
const updateOptionState = ({
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: boolean | string | null;
|
||||
}) => {
|
||||
setOptionState({
|
||||
...optionsState,
|
||||
[name]: value,
|
||||
// Reset itemsize when changing layout
|
||||
itemSize: name === "view" ? DefaultItemSize : optionsState.itemSize,
|
||||
});
|
||||
value = value?.toString() ?? null;
|
||||
if (props.router) {
|
||||
const path = props.router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(
|
||||
props.router.asPath.split("?").at(1) ?? "",
|
||||
);
|
||||
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
props.router.push(`${path}?${params.toString()}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (libraries === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in>
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.disableLibrarySelector !== true && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name:
|
||||
optionsState.library ??
|
||||
t("allLibraries"),
|
||||
options: [
|
||||
{
|
||||
name: "library",
|
||||
values: [
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].map((library) => library.name),
|
||||
currentValue:
|
||||
[
|
||||
globalLibrary,
|
||||
...libraries,
|
||||
].find(
|
||||
({ slug }) =>
|
||||
slug ===
|
||||
optionsState.library,
|
||||
)?.name ?? globalLibrary.name,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={({ name, value }) => {
|
||||
if (value === globalLibrary.name) {
|
||||
updateOptionState({
|
||||
name,
|
||||
value: null,
|
||||
});
|
||||
} else {
|
||||
updateOptionState({
|
||||
name,
|
||||
value:
|
||||
libraries.find(
|
||||
(lib) => lib.name === value,
|
||||
)?.slug ?? null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.disableSorting && (
|
||||
<OptionButton
|
||||
optionGroup={{
|
||||
name: `${t("sortBy")} ${t(
|
||||
optionsState.sortBy as TranslationKey,
|
||||
)}`,
|
||||
icon:
|
||||
optionsState.order === "desc" ? (
|
||||
<DescIcon />
|
||||
) : (
|
||||
<AscIcon />
|
||||
),
|
||||
options: [
|
||||
{
|
||||
name: "sortBy",
|
||||
values: props.sortingKeys,
|
||||
currentValue: optionsState.sortBy,
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
values: ["asc", "desc"],
|
||||
currentValue: optionsState.order,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
)}
|
||||
{props.options?.map((option) => {
|
||||
return (
|
||||
<OptionButton
|
||||
key={option.name}
|
||||
optionGroup={{
|
||||
name: option.label ?? option.name,
|
||||
icon: option.icon,
|
||||
options: [
|
||||
{
|
||||
...option,
|
||||
|
||||
currentValue:
|
||||
optionsState[
|
||||
option.name as keyof typeof optionsState
|
||||
] ?? undefined,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSelect={updateOptionState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{optionsState.view === "grid" &&
|
||||
[
|
||||
[
|
||||
"xs",
|
||||
(currentIndex: number) => currentIndex - 1,
|
||||
MinusIcon,
|
||||
] as const,
|
||||
[
|
||||
"xl",
|
||||
(currentIndex: number) => currentIndex + 1,
|
||||
PlusIcon,
|
||||
] as const,
|
||||
].map(([size, f, Icon]) => (
|
||||
<Button
|
||||
key={`size-button-${size}`}
|
||||
disabled={optionsState.itemSize === size}
|
||||
onClick={() =>
|
||||
setOptionState(
|
||||
({ itemSize, ...rest }) => ({
|
||||
...rest,
|
||||
itemSize:
|
||||
ItemSize[
|
||||
f(
|
||||
ItemSize.indexOf(
|
||||
itemSize,
|
||||
),
|
||||
)
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
))}
|
||||
{props.disableLayoutToggle !== true && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateOptionState({
|
||||
name: "view",
|
||||
value:
|
||||
optionsState.view === "grid"
|
||||
? "list"
|
||||
: "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{optionsState.view === "grid" ? (
|
||||
<ListIcon />
|
||||
) : (
|
||||
<GridIcon />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.actions?.map((action) => (
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog &&
|
||||
setOpenActionModal(action.label);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
open={openActionModal === action.label}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</Button>
|
||||
)) ?? []}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Button, Divider, ListItemIcon, Menu, MenuItem } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../i18n/i18n";
|
||||
import { CheckIcon } from "../icons";
|
||||
import type Option from "./option";
|
||||
import type { OptionGroup } from "./option";
|
||||
|
||||
type OptionButtonProps<
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
> = {
|
||||
optionGroup: OptionGroup<Options, OptionsKeys>;
|
||||
onSelect?: (selected: {
|
||||
name: Options[number]["name"];
|
||||
value: OptionsKeys[number][number];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param props an option group
|
||||
* @returns Dropdown Button with selector.
|
||||
*/
|
||||
|
||||
const OptionButton = <
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
>(
|
||||
props: OptionButtonProps<Options, OptionsKeys>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.optionGroup.icon}>
|
||||
{t(props.optionGroup.name as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.optionGroup.options.map((option, index, array) =>
|
||||
option.values
|
||||
.map((value: OptionsKeys[number][number]) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
sx={{ borderRadius: "0" }}
|
||||
selected={option.currentValue === value}
|
||||
onClick={() => {
|
||||
if (props.onSelect) {
|
||||
props.onSelect({
|
||||
name: option.name,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{option.currentValue === value && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
{/* If value is not a Translation Key, will fall through */}
|
||||
{t(value as TranslationKey)}
|
||||
</MenuItem>
|
||||
))
|
||||
.concat(
|
||||
index + 1 !== array.length
|
||||
? [<Divider key={index} />]
|
||||
: [],
|
||||
),
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionButton;
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
type Option<Values extends readonly string[]> = {
|
||||
name: string;
|
||||
label?: string;
|
||||
icon?: JSX.Element;
|
||||
currentValue?: Values[number];
|
||||
values: Values;
|
||||
};
|
||||
|
||||
export type OptionGroup<
|
||||
Options extends Option<OptionsKeys[number]>[],
|
||||
OptionsKeys extends (readonly string[])[],
|
||||
> = {
|
||||
name: string;
|
||||
icon?: JSX.Element;
|
||||
options: Options;
|
||||
};
|
||||
|
||||
export default Option;
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import * as Iconsax from "iconsax-react";
|
||||
|
||||
type IconProps = Iconsax.IconProps;
|
||||
export type IconProps = Iconsax.IconProps;
|
||||
|
||||
export const TranscodeIcon = Iconsax.Polyswarm;
|
||||
export const PlaylistIcon = Iconsax.MusicFilter;
|
||||
@@ -91,3 +91,16 @@ export const MinusIcon = Iconsax.Minus;
|
||||
export const DoneIcon = Iconsax.TickSquare;
|
||||
|
||||
export const EmptyStateIcon = Iconsax.SearchStatus1;
|
||||
|
||||
// Icons for song type
|
||||
export const SongTypeOriginalIcon = Iconsax.Music;
|
||||
export const SongTypeRemixIcon = Iconsax.Repeat;
|
||||
export const SongTypeLiveIcon = Iconsax.Microphone2;
|
||||
export const SongTypeAcousticIcon = Iconsax.VoiceSquare;
|
||||
export const SongTypeInstrumentalIcon = Iconsax.Musicnote;
|
||||
export const SongTypeEditIcon = Iconsax.Cut;
|
||||
export const SongTypeCleanIcon = Iconsax.TickCircle;
|
||||
export const SongTypeDemoIcon = Iconsax.ReceiptEdit;
|
||||
export const SongTypeAcapellaIcon = Iconsax.VoiceCricle;
|
||||
export const SongTypeMedleyIcon = Iconsax.Layer;
|
||||
export const SongTypeNonMusicIcon = Iconsax.VolumeSlash;
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
Grid,
|
||||
ListItemIcon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ThemeProvider,
|
||||
Tooltip,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { deepmerge } from "@mui/utils";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../../i18n/i18n";
|
||||
import { ItemSize } from "../../../utils/layout";
|
||||
import type Action from "../../actions/action";
|
||||
import {
|
||||
AscIcon,
|
||||
CheckIcon,
|
||||
DescIcon,
|
||||
GridIcon,
|
||||
ListIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "../../icons";
|
||||
import type { FilterControl } from "./filters/control";
|
||||
import type { LayoutControl } from "./layout";
|
||||
import type { SortControl } from "./sort";
|
||||
|
||||
// Controls should not maintain state regarding options
|
||||
// It should rely on the onUpdate function to update external state
|
||||
export const Controls = <SortingKey extends string>(props: {
|
||||
layout?: LayoutControl;
|
||||
sort?: SortControl<SortingKey>;
|
||||
actions?: Action[][];
|
||||
filters?: FilterControl<any>[]; // Putting string prevent from typechecking if filter is library + song type
|
||||
}) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
gap={1}
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{props.filters && (
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
{props.filters?.map((filter, idx) => (
|
||||
<FilterMenuButton key={idx} filter={filter} />
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.sort && (
|
||||
<Grid item>
|
||||
<ButtonGroup variant="contained">
|
||||
<SortMenuButton sort={props.sort} />
|
||||
</ButtonGroup>
|
||||
</Grid>
|
||||
)}
|
||||
{props.layout && (
|
||||
<Grid item>
|
||||
<LayoutButtonGroup layout={props.layout} />
|
||||
</Grid>
|
||||
)}
|
||||
{props.actions?.map((actions, idx) => (
|
||||
<Grid item key={idx}>
|
||||
<ActionButtonGroup actions={actions} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterMenuButton = <FilterKeys extends string>({
|
||||
filter,
|
||||
}: { filter: FilterControl<FilterKeys> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={filter.values === undefined ? "loading" : filter.buttonLabel}
|
||||
icon={filter.values && filter.buttonIcon}
|
||||
items={(closeMenu) =>
|
||||
filter.values?.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={
|
||||
filter.multipleChoice
|
||||
? filter.selected?.includes(key)
|
||||
: filter.selected === key
|
||||
}
|
||||
onClick={() => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
key,
|
||||
)
|
||||
? filter.selected.filter((k) => k !== key)
|
||||
: [key, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === key ? null : key,
|
||||
);
|
||||
}
|
||||
// TODO Not close at every select
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{(filter.multipleChoice &&
|
||||
filter.selected.includes(key)) ||
|
||||
filter.selected === key ? (
|
||||
<CheckIcon />
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(filter.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SortMenuButton = <SortingKey extends string>({
|
||||
sort,
|
||||
}: { sort: SortControl<SortingKey> }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuButton
|
||||
label={sort.buttonLabel}
|
||||
icon={sort.selected.order === "asc" ? <AscIcon /> : <DescIcon />}
|
||||
items={(closeMenu) =>
|
||||
sort.sortingKeys.map((key) => (
|
||||
<MenuItem
|
||||
key={key}
|
||||
selected={sort.selected.sort === key}
|
||||
onClick={() => {
|
||||
sort?.onUpdate({
|
||||
sort: key,
|
||||
order:
|
||||
sort.selected.sort !== key
|
||||
? "asc"
|
||||
: sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
});
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{sort?.selected.sort === key ? (
|
||||
sort.selected.order === "asc" ? (
|
||||
<AscIcon />
|
||||
) : (
|
||||
<DescIcon />
|
||||
)
|
||||
) : undefined}
|
||||
</ListItemIcon>
|
||||
{t(sort!.formatItem(key))}
|
||||
</MenuItem>
|
||||
)) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutButtonGroup = ({ layout }: { layout: LayoutControl }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{layout.layout === "grid" &&
|
||||
[
|
||||
[
|
||||
"xs",
|
||||
(currentIndex: number) => currentIndex - 1,
|
||||
MinusIcon,
|
||||
] as const,
|
||||
[
|
||||
"xl",
|
||||
(currentIndex: number) => currentIndex + 1,
|
||||
PlusIcon,
|
||||
] as const,
|
||||
].map(([size, f, Icon]) => (
|
||||
<Button
|
||||
key={`size-button-${size}`}
|
||||
disabled={layout.itemSize === size}
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
layout: layout.layout,
|
||||
itemSize:
|
||||
ItemSize[
|
||||
f(ItemSize.indexOf(layout.itemSize))
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
))}
|
||||
{layout.enableToggle && (
|
||||
<Tooltip title={t("changeLayout")}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
layout.onUpdate({
|
||||
itemSize: layout.itemSize,
|
||||
layout:
|
||||
layout.layout === "grid" ? "list" : "grid",
|
||||
})
|
||||
}
|
||||
>
|
||||
{layout.layout === "grid" ? <ListIcon /> : <GridIcon />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButtonGroup = ({ actions }: { actions: Action[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [openedDialogIndex, setOpenedDialogIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const closeModal = () => setOpenedDialogIndex(null);
|
||||
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{actions.map((action, idx) => (
|
||||
<>
|
||||
<Button
|
||||
key={`action-${action.label}`}
|
||||
startIcon={action.icon}
|
||||
variant="contained"
|
||||
onClickCapture={() => {
|
||||
if (action.disabled === true) {
|
||||
return;
|
||||
}
|
||||
action.onClick?.();
|
||||
action.dialog && setOpenedDialogIndex(idx);
|
||||
}}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Button>
|
||||
{action.dialog && (
|
||||
<Dialog
|
||||
key={idx}
|
||||
open={openedDialogIndex === idx}
|
||||
onClose={closeModal}
|
||||
fullWidth
|
||||
>
|
||||
{action.dialog?.({ close: closeModal })}
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuButton = (props: {
|
||||
label: TranslationKey;
|
||||
icon: JSX.Element | undefined;
|
||||
items: (closeMenu: () => void) => JSX.Element | JSX.Element[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const extendedTheme = useMemo(
|
||||
() =>
|
||||
deepmerge(theme, {
|
||||
components: {
|
||||
MuiMenuItem: { defaultProps: { sx: { borderRadius: 0 } } },
|
||||
},
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setAnchorEl(event.currentTarget);
|
||||
const handleMenuClose = () => setAnchorEl(null);
|
||||
// TODO Remove border radius or menu items
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={extendedTheme}>
|
||||
<Button onClick={handleMenuOpen} endIcon={props.icon}>
|
||||
{t(props.label as TranslationKey)}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={menuOpen} onClose={handleMenuClose}>
|
||||
{props.items(handleMenuClose)}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { parseQueryParam, setQueryParam } from "../../../../utils/query-param";
|
||||
|
||||
export type FilterControl<Key extends string> = {
|
||||
// Gives the translation key from an item to choose from
|
||||
formatItem: (k: Key) => TranslationKey;
|
||||
values: readonly Key[] | undefined;
|
||||
buttonLabel: TranslationKey;
|
||||
buttonIcon: JSX.Element | undefined;
|
||||
} & (
|
||||
| {
|
||||
multipleChoice: true;
|
||||
selected: Key[];
|
||||
onUpdate: (keys: Key[]) => void;
|
||||
}
|
||||
| {
|
||||
multipleChoice: false;
|
||||
selected: Key | null;
|
||||
onUpdate: (key: Key | null) => void;
|
||||
}
|
||||
);
|
||||
|
||||
// Control for filter that accept multiple values
|
||||
export const useFiltersControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey[],
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
// Used to persist Filter in query param, must be unique
|
||||
filterId: string;
|
||||
}) => {
|
||||
// TODO Check layout update does not trigger infinite loop
|
||||
const router = useRouter();
|
||||
const [selectedFilters, setFilterState] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (typeof query === "string") {
|
||||
query = query.trim().split(",");
|
||||
}
|
||||
return query
|
||||
.map((f) => parseQueryParam(f, filterKeys))
|
||||
.filter((f): f is FilterKey => f !== null);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilters),
|
||||
multipleChoice: true,
|
||||
selected: selectedFilters,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam(
|
||||
[[filterId, selected.length ? selected.join(",") : null]],
|
||||
router,
|
||||
);
|
||||
setFilterState(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilters, control] as const;
|
||||
};
|
||||
|
||||
export const useFilterControl = <FilterKey extends string>({
|
||||
filterKeys,
|
||||
filterId,
|
||||
buttonLabel,
|
||||
buttonIcon,
|
||||
formatItem,
|
||||
}: {
|
||||
buttonLabel: (
|
||||
selected: FilterKey | null,
|
||||
) => FilterControl<FilterKey>["buttonLabel"];
|
||||
buttonIcon: FilterControl<FilterKey>["buttonIcon"];
|
||||
formatItem: FilterControl<FilterKey>["formatItem"];
|
||||
filterKeys: readonly FilterKey[] | undefined;
|
||||
filterId: string;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [selectedFilter, setSelectedFilter] = useState(() => {
|
||||
let query = router.query[filterId];
|
||||
if (!filterKeys || query === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof query !== "string") {
|
||||
query = query[0];
|
||||
}
|
||||
return parseQueryParam(query, filterKeys);
|
||||
});
|
||||
const control: FilterControl<FilterKey> = {
|
||||
values: filterKeys,
|
||||
buttonIcon,
|
||||
buttonLabel: buttonLabel(selectedFilter),
|
||||
multipleChoice: false,
|
||||
selected: selectedFilter,
|
||||
formatItem: (t: FilterKey) => formatItem(t),
|
||||
onUpdate: (selected) => {
|
||||
setQueryParam([[filterId, selected ?? null]], router);
|
||||
setSelectedFilter(selected);
|
||||
},
|
||||
};
|
||||
return [selectedFilter, control] as const;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "react-query";
|
||||
import API from "../../../../api/api";
|
||||
import { prepareMeeloInfiniteQuery } from "../../../../api/use-query";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { useFilterControl, useFiltersControl } from "./control";
|
||||
|
||||
function useLibraryFilterControl(p: { multipleChoices: true }): ReturnType<
|
||||
typeof useFiltersControl<string>
|
||||
>;
|
||||
function useLibraryFilterControl(p: { multipleChoices: false }): ReturnType<
|
||||
typeof useFilterControl<string>
|
||||
>;
|
||||
|
||||
function useLibraryFilterControl(p: { multipleChoices: boolean }): never;
|
||||
function useLibraryFilterControl(p: { multipleChoices: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...prepareMeeloInfiniteQuery(API.getLibraries),
|
||||
useErrorBoundary: false,
|
||||
onError: () => {
|
||||
toast.error(t("librariesLoadFail"));
|
||||
},
|
||||
});
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
const libraryNameBySlug = (s: string) =>
|
||||
libraries!.find((l) => l.slug === s)!.name;
|
||||
|
||||
if (p.multipleChoices) {
|
||||
return useFiltersControl<string>({
|
||||
formatItem: (s) => libraryNameBySlug(s) as TranslationKey,
|
||||
filterKeys: libraries?.map((l) => l.slug),
|
||||
buttonLabel: (selected) => {
|
||||
switch (selected.length) {
|
||||
case 0:
|
||||
return "allLibraries";
|
||||
case 1:
|
||||
return libraryNameBySlug(selected[0]) as TranslationKey;
|
||||
default:
|
||||
return `${selected.length} ${t("libraries")}` as TranslationKey;
|
||||
}
|
||||
},
|
||||
buttonIcon: undefined,
|
||||
filterId: "library",
|
||||
});
|
||||
}
|
||||
|
||||
return useFilterControl<string>({
|
||||
formatItem: (s) => libraryNameBySlug(s) as TranslationKey,
|
||||
filterKeys: libraries?.map((l) => l.slug),
|
||||
buttonLabel: (selected) =>
|
||||
selected
|
||||
? (libraryNameBySlug(selected) as TranslationKey)
|
||||
: "allLibraries",
|
||||
buttonIcon: undefined,
|
||||
filterId: "library",
|
||||
});
|
||||
}
|
||||
|
||||
export { useLibraryFilterControl };
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import type { TranslationKey } from "../../../../i18n/i18n";
|
||||
import { useFilterControl, useFiltersControl } from "./control";
|
||||
|
||||
type TypeFilterControlArg<TypeKey extends TranslationKey> = {
|
||||
types: readonly TypeKey[];
|
||||
filterId?: string;
|
||||
};
|
||||
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & {
|
||||
multipleChoices: true;
|
||||
},
|
||||
): ReturnType<typeof useFiltersControl<T>>;
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & {
|
||||
multipleChoices: false;
|
||||
},
|
||||
): ReturnType<typeof useFilterControl<T>>;
|
||||
function useTypeFilterControl<T extends TranslationKey>(
|
||||
p: TypeFilterControlArg<T> & { multipleChoices: boolean },
|
||||
): never;
|
||||
function useTypeFilterControl<TypeKey extends TranslationKey>(
|
||||
props: TypeFilterControlArg<TypeKey> & {
|
||||
multipleChoices: boolean;
|
||||
},
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (props.multipleChoices) {
|
||||
return useFiltersControl<TypeKey>({
|
||||
formatItem: (t: TypeKey) => t,
|
||||
filterKeys: props.types,
|
||||
buttonLabel: (selected) => {
|
||||
switch (selected.length) {
|
||||
case 0:
|
||||
return "allTypes";
|
||||
case 1:
|
||||
return selected[0];
|
||||
default:
|
||||
return `${selected.length} ${t("types")}` as TranslationKey;
|
||||
}
|
||||
},
|
||||
buttonIcon: undefined,
|
||||
filterId: props.filterId ?? "type",
|
||||
});
|
||||
}
|
||||
|
||||
return useFilterControl<TypeKey>({
|
||||
formatItem: (t: TypeKey) => t,
|
||||
filterKeys: props.types,
|
||||
buttonLabel: (selected) => selected ?? "allTypes",
|
||||
buttonIcon: undefined,
|
||||
filterId: props.filterId ?? "type",
|
||||
});
|
||||
}
|
||||
|
||||
export { useTypeFilterControl };
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type ItemSize,
|
||||
type LayoutOption,
|
||||
LayoutOptions,
|
||||
} from "../../../utils/layout";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
|
||||
export type LayoutControl =
|
||||
| { layout: "list"; itemSize: never; enableToggle: false; onUpdate: never }
|
||||
| {
|
||||
layout: "grid";
|
||||
itemSize: ItemSize;
|
||||
enableToggle: false;
|
||||
onUpdate: (p: { itemSize: ItemSize }) => void;
|
||||
}
|
||||
| {
|
||||
layout: LayoutOption;
|
||||
enableToggle: true;
|
||||
itemSize: ItemSize;
|
||||
onUpdate: (p: {
|
||||
layout: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// Hook to get Layout data to pass to Controls
|
||||
export const useLayoutControl = ({
|
||||
defaultLayout,
|
||||
enableToggle,
|
||||
}: {
|
||||
defaultLayout: LayoutOption;
|
||||
enableToggle: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
const layoutQuery = parseQueryParam(router.query["view"], LayoutOptions);
|
||||
const [layoutState, setLayoutState] = useState<{
|
||||
layout: LayoutOption;
|
||||
itemSize: ItemSize;
|
||||
}>(() => ({
|
||||
layout: !enableToggle ? defaultLayout : (layoutQuery ?? defaultLayout),
|
||||
itemSize: "m",
|
||||
}));
|
||||
const control: LayoutControl = {
|
||||
layout: layoutState.layout,
|
||||
enableToggle: enableToggle as true,
|
||||
itemSize: layoutState.itemSize,
|
||||
onUpdate: (p) => {
|
||||
setQueryParam([["view", p.layout]], router);
|
||||
setLayoutState(p);
|
||||
},
|
||||
};
|
||||
return [layoutState, control] as const;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 type { ParsedUrlQuery } from "node:querystring";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { TranslationKey } from "../../../i18n/i18n";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
import { type Order, Orders } from "../../../utils/sorting";
|
||||
|
||||
export type SortControl<SortingKey extends string> = {
|
||||
formatItem: (k: SortingKey) => TranslationKey;
|
||||
// returns the label of the menu + icon
|
||||
buttonLabel: TranslationKey;
|
||||
sortingKeys: readonly SortingKey[];
|
||||
selected: { sort: SortingKey; order: Order };
|
||||
onUpdate: (p: { sort: SortingKey; order: Order }) => void;
|
||||
};
|
||||
|
||||
// Hook to get Sorting data to pass to Controls
|
||||
export const useSortControl = <SortingKey extends TranslationKey>({
|
||||
sortingKeys,
|
||||
}: {
|
||||
sortingKeys: readonly SortingKey[];
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const orderQuery = getOrderQuery(router);
|
||||
const sortQuery = getSortQuery(router, sortingKeys);
|
||||
// Note: getSortQuery does not return null,
|
||||
// falling back on the first key in the key list
|
||||
const [sortState, setSortState] = useState(() => ({
|
||||
sort: sortQuery,
|
||||
order: orderQuery ?? "asc",
|
||||
}));
|
||||
const control: SortControl<SortingKey> = {
|
||||
sortingKeys: sortingKeys,
|
||||
selected: sortState,
|
||||
buttonLabel: sortState.sort,
|
||||
formatItem: (t) => t,
|
||||
onUpdate: (p) => {
|
||||
setQueryParam(
|
||||
[
|
||||
["sort", p.sort],
|
||||
["order", p.order],
|
||||
],
|
||||
router,
|
||||
);
|
||||
setSortState(p);
|
||||
},
|
||||
};
|
||||
return [sortState, control] as const;
|
||||
};
|
||||
|
||||
export const getOrderQuery = (router: { query: ParsedUrlQuery }) =>
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
parseQueryParam(router.query["order"], Orders);
|
||||
|
||||
export const getSortQuery = <SortKey extends string>(
|
||||
router: { query: ParsedUrlQuery },
|
||||
sortingKeys: readonly SortKey[],
|
||||
) =>
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Clarity
|
||||
parseQueryParam(router.query["sort"], sortingKeys) ?? sortingKeys[0];
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
type AlbumWithRelations,
|
||||
} from "../../../models/album";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import AlbumItem from "../../list-item/album-item";
|
||||
import AlbumTile from "../../tile/album-tile";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = { type?: AlbumType };
|
||||
|
||||
const InfiniteAlbumView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
AlbumWithRelations<"artist" | "illustration">,
|
||||
typeof AlbumSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof AlbumTile>, "formatSubtitle"> & {
|
||||
defaultAlbumType: AlbumType | null;
|
||||
},
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState<
|
||||
OptionState<typeof AlbumSortingKeys, AdditionalProps>
|
||||
>({
|
||||
type: props.defaultAlbumType ?? undefined,
|
||||
library: null,
|
||||
order: props.initialSortingOrder ?? "asc",
|
||||
sortBy: props.initialSortingField ?? "name",
|
||||
view: props.defaultLayout ?? "grid",
|
||||
itemSize: DefaultItemSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: t((options?.type as AlbumType) ?? "All"),
|
||||
name: "type",
|
||||
values: ["All", ...AlbumType],
|
||||
currentValue: options?.type ?? undefined,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={AlbumSortingKeys}
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "grid"}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options.itemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "grid"}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library,
|
||||
view: options?.view ?? props.defaultLayout ?? "grid",
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : options?.type,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
AlbumSortingKeys[0],
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteAlbumView;
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { MeeloInfiniteQueryFn } from "../../../api/use-query";
|
||||
import type Resource from "../../../models/resource";
|
||||
import type { LayoutOption } from "../../../utils/layout";
|
||||
import type { Order } from "../../../utils/sorting";
|
||||
import type { OptionState } from "../../controls/controls";
|
||||
import type { EmptyStateProps } from "../../empty-state";
|
||||
|
||||
type InfiniteResourceViewProps<
|
||||
ResourceType extends Resource,
|
||||
SortingKeys extends readonly string[],
|
||||
AdditionalQueryParams extends object = object,
|
||||
> = {
|
||||
query: (
|
||||
options: Omit<OptionState<SortingKeys>, "layout" | "itemSize"> &
|
||||
AdditionalQueryParams,
|
||||
) => ReturnType<MeeloInfiniteQueryFn<ResourceType>>;
|
||||
light?: boolean;
|
||||
onItemClick?: (item: ResourceType) => void;
|
||||
disableSorting?: true;
|
||||
defaultLayout?: LayoutOption;
|
||||
initialSortingOrder?: Order;
|
||||
initialSortingField?: SortingKeys[number];
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
|
||||
export default InfiniteResourceViewProps;
|
||||
@@ -1,236 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import {
|
||||
SongGroupSortingKeys,
|
||||
type SongGroupWithRelations,
|
||||
} from "../../../models/song-group";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: SongType;
|
||||
// if true, show song groups instead of songs
|
||||
groups?: boolean;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const playSongsAction = (
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<
|
||||
SongWithRelations<"artist" | "featuring" | "master" | "illustration">
|
||||
>,
|
||||
) => {
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
} else {
|
||||
store.set(playAfterAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const InfiniteSongView = <
|
||||
T extends SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof SongSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Pick<ComponentProps<typeof SongItem<T>>, "subtitles"> & {
|
||||
disableShuffle?: boolean;
|
||||
groupsQuery?: (
|
||||
p: Omit<
|
||||
OptionState<typeof SongGroupSortingKeys>,
|
||||
"layout" | "itemSize"
|
||||
> &
|
||||
AdditionalProps,
|
||||
) => InfiniteQuery<
|
||||
SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const disableGroupingVersions = !props.groupsQuery;
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof SongSortingKeys, AdditionalProps>>();
|
||||
const queryClient = useQueryClient();
|
||||
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",
|
||||
groups: disableGroupingVersions ? false : (options?.groups ?? false),
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as SongType) ?? "All",
|
||||
name: "type",
|
||||
values: [
|
||||
"All",
|
||||
...SongType.filter((type) => type !== "Unknown"),
|
||||
],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
playAction,
|
||||
...(props.disableShuffle !== true && !options?.groups
|
||||
? [shuffleAction]
|
||||
: []),
|
||||
]}
|
||||
toggles={
|
||||
disableGroupingVersions
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "groups",
|
||||
label: options?.groups
|
||||
? "showAllSongs"
|
||||
: "groupVersions",
|
||||
},
|
||||
]
|
||||
}
|
||||
disableSorting={options?.groups || props.disableSorting}
|
||||
onChange={setOptions}
|
||||
sortingKeys={
|
||||
options?.groups ? SongGroupSortingKeys : SongSortingKeys
|
||||
}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
{options?.groups ? (
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.groupsQuery!({
|
||||
...query,
|
||||
sortBy: "name",
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
) : (
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteSongView;
|
||||
@@ -43,10 +43,6 @@ type InfiniteScrollProps<T extends Resource> = {
|
||||
* Query to use
|
||||
*/
|
||||
query: MeeloInfiniteQueryFn<T>;
|
||||
/**
|
||||
* Component to display on page fetching (except first)
|
||||
*/
|
||||
loader: () => JSX.Element;
|
||||
|
||||
emptyState?: EmptyStateProps;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { ItemSize } from "../../utils/layout";
|
||||
import type { EmptyStateProps } from "../empty-state";
|
||||
import Fade from "../fade";
|
||||
import { GoBackTopIcon } from "../icons";
|
||||
import { WideLoadingComponent } from "../loading/loading";
|
||||
import InfiniteGrid from "./infinite-grid";
|
||||
import InfiniteList from "./infinite-list";
|
||||
import { parentScrollableDivId } from "./infinite-scroll";
|
||||
@@ -97,7 +96,6 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
<ScrollToTopButton />
|
||||
{props.view.toLowerCase() === "list" ? (
|
||||
<InfiniteList
|
||||
loader={() => <WideLoadingComponent />}
|
||||
query={props.query}
|
||||
render={(item, index) => (
|
||||
<Fade in>
|
||||
@@ -111,7 +109,6 @@ const InfiniteView = <ItemType extends IllustratedResource>(
|
||||
<InfiniteGrid
|
||||
query={props.query}
|
||||
itemSize={props.itemSize}
|
||||
loader={() => <WideLoadingComponent />}
|
||||
render={(item) => (
|
||||
<Fade in>
|
||||
<Box sx={{ height: "100%" }}>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
AlbumSortingKeys,
|
||||
AlbumType,
|
||||
type AlbumWithRelations,
|
||||
} from "../../../models/album";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import AlbumItem from "../../list-item/album-item";
|
||||
import AlbumTile from "../../tile/album-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
|
||||
type QueryProps = {
|
||||
types?: AlbumType[];
|
||||
libraries?: string[];
|
||||
} & SortingParameters<typeof AlbumSortingKeys>;
|
||||
|
||||
type AlbumModel = AlbumWithRelations<"artist" | "illustration">;
|
||||
|
||||
type ViewProps = {
|
||||
query: (q: QueryProps) => InfiniteQuery<AlbumModel>;
|
||||
onItemClick?: (song: AlbumModel) => void;
|
||||
disableShuffle?: boolean;
|
||||
disableSort?: boolean;
|
||||
formatSubtitle?: Parameters<typeof AlbumItem>[0]["formatSubtitle"];
|
||||
};
|
||||
|
||||
const InfiniteAlbumView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [typeFilter, typeFilterControl] = useTypeFilterControl({
|
||||
types: AlbumType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: AlbumSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
layout={layoutControl}
|
||||
filters={[libraryFilterControl, typeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
libraries: libraryFilter,
|
||||
types: typeFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
<AlbumItem
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<AlbumTile
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
album={item}
|
||||
formatSubtitle={props.formatSubtitle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteAlbumView;
|
||||
+34
-33
@@ -16,55 +16,56 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
ArtistSortingKeys,
|
||||
type ArtistWithRelations,
|
||||
} from "../../../models/artist";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import ArtistItem from "../../list-item/artist-item";
|
||||
import ArtistTile from "../../tile/artist-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteArtistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
ArtistWithRelations<"illustration">,
|
||||
typeof ArtistSortingKeys
|
||||
>,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof ArtistSortingKeys>>();
|
||||
type QueryProps = { libraries?: string[] } & SortingParameters<
|
||||
typeof ArtistSortingKeys
|
||||
>;
|
||||
type ArtistModel = ArtistWithRelations<"illustration">;
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<ArtistModel>;
|
||||
disableSort?: boolean;
|
||||
onItemClick?: (p: ArtistModel) => void;
|
||||
};
|
||||
|
||||
const InfiniteArtistView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: ArtistSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "list",
|
||||
enableToggle: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
onChange={setOptions}
|
||||
sortingKeys={ArtistSortingKeys}
|
||||
disableSorting={props.disableSorting}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
filters={[libraryFilterControl]}
|
||||
layout={layoutControl}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library ?? null,
|
||||
view: options?.view ?? props.defaultLayout ?? "list",
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
libraries: libraryFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
+28
-36
@@ -16,60 +16,52 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "../../../api/use-query";
|
||||
import { type InfiniteQuery, useQueryClient } from "../../../api/use-query";
|
||||
import {
|
||||
PlaylistSortingKeys,
|
||||
type PlaylistWithRelations,
|
||||
} from "../../../models/playlist";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { EmptyStateProps } from "../../empty-state";
|
||||
import PlaylistItem from "../../list-item/playlist-item";
|
||||
import PlaylistTile from "../../tile/playlist-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfinitePlaylistView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
PlaylistWithRelations<"illustration">,
|
||||
typeof PlaylistSortingKeys
|
||||
>,
|
||||
) => {
|
||||
type QueryProps = SortingParameters<typeof PlaylistSortingKeys>;
|
||||
type PlaylistModel = PlaylistWithRelations<"illustration">;
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<PlaylistModel>;
|
||||
onItemClick?: (p: PlaylistModel) => void;
|
||||
emptyState?: Partial<EmptyStateProps>;
|
||||
};
|
||||
const InfinitePlaylistView = (props: ViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof PlaylistSortingKeys>>();
|
||||
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: PlaylistSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
actions={[CreatePlaylistAction(queryClient)]}
|
||||
onChange={setOptions}
|
||||
disableLibrarySelector
|
||||
sortingKeys={PlaylistSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={props.defaultLayout ?? "list"}
|
||||
sort={sortControl}
|
||||
layout={layoutControl}
|
||||
actions={[[CreatePlaylistAction(queryClient)]]}
|
||||
/>
|
||||
<InfiniteView
|
||||
emptyState={props.emptyState}
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? props.defaultLayout ?? "list"}
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: null,
|
||||
sortBy:
|
||||
options?.sortBy ??
|
||||
props.initialSortingField ??
|
||||
"name",
|
||||
order:
|
||||
options?.order ??
|
||||
props.initialSortingOrder ??
|
||||
"asc",
|
||||
view: "grid",
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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 { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
prepareMeeloInfiniteQuery,
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "../../../models/song";
|
||||
import type { SongGroupWithRelations } from "../../../models/song-group";
|
||||
import {
|
||||
emptyPlaylistAtom,
|
||||
playAfterAtom,
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import { parseQueryParam, setQueryParam } from "../../../utils/query-param";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import type Action from "../../actions/action";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import SongItem, { SongGroupItem } from "../../list-item/song-item";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
|
||||
type SongModel = SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type SongQueryProps = {
|
||||
libraries?: string[];
|
||||
types?: SongType[];
|
||||
random?: number;
|
||||
} & SortingParameters<typeof SongSortingKeys>;
|
||||
|
||||
type SongGroupModel = SongGroupWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type SongGroupQueryProps = {
|
||||
libraries?: string[];
|
||||
type?: SongType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const playSongsAction = (
|
||||
queryClient: QueryClient,
|
||||
query: () => InfiniteQuery<SongModel>,
|
||||
) => {
|
||||
store.set(emptyPlaylistAtom);
|
||||
queryClient.client
|
||||
.fetchInfiniteQuery(prepareMeeloInfiniteQuery(query))
|
||||
.then(async (res) => {
|
||||
const songs = res.pages.flatMap(({ items }) => items);
|
||||
let i = 0;
|
||||
for (const song of songs) {
|
||||
if (i === 0) {
|
||||
store.set(playTrackAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
} else {
|
||||
store.set(playAfterAtom, {
|
||||
track: {
|
||||
...song.master,
|
||||
illustration: song.illustration,
|
||||
},
|
||||
artist: song.artist,
|
||||
});
|
||||
}
|
||||
i++;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const shuffleActionBase = {
|
||||
label: "shuffle" as const,
|
||||
icon: <ShuffleIcon />,
|
||||
};
|
||||
|
||||
const playActionBase = {
|
||||
label: "playAll" as const,
|
||||
icon: <PlayIcon />,
|
||||
};
|
||||
|
||||
type SongViewProps = {
|
||||
query: (q: SongQueryProps) => InfiniteQuery<SongModel>;
|
||||
onItemClick?: (song: SongModel) => void;
|
||||
disableShuffle?: boolean;
|
||||
disableSort?: boolean;
|
||||
subtitles?: Parameters<typeof SongItem>[0]["subtitles"];
|
||||
additionalActions?: Action[];
|
||||
};
|
||||
|
||||
export const InfiniteSongView = (props: SongViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const shuffleAction = {
|
||||
...shuffleActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
const playAction = {
|
||||
...playActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
/// state
|
||||
const [libraries, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [types, songTypeFilterControl] = useTypeFilterControl({
|
||||
types: SongType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: SongSortingKeys,
|
||||
});
|
||||
const query: SongQueryProps = {
|
||||
libraries: libraries,
|
||||
types: types,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
filters={[libraryFilterControl, songTypeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
actions={[
|
||||
[
|
||||
playAction,
|
||||
...(props.disableShuffle ? [] : [shuffleAction]),
|
||||
],
|
||||
...(props.additionalActions
|
||||
? [props.additionalActions]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query(query);
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SongGroupViewProps = {
|
||||
query: (q: SongGroupQueryProps) => InfiniteQuery<SongGroupModel>;
|
||||
onItemClick?: (song: SongGroupModel) => void;
|
||||
subtitles?: Parameters<typeof SongGroupItem>[0]["subtitles"];
|
||||
additionalActions?: Action[];
|
||||
};
|
||||
|
||||
export const InfiniteSongGroupView = (props: SongGroupViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const playAction = {
|
||||
...playActionBase,
|
||||
onClick: () => {
|
||||
playSongsAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
|
||||
/// state
|
||||
const [libraries, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [type, songTypeFilterControl] = useTypeFilterControl({
|
||||
types: SongType,
|
||||
multipleChoices: false,
|
||||
});
|
||||
const query: SongGroupQueryProps = {
|
||||
libraries: libraries,
|
||||
type: type ?? undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
filters={[libraryFilterControl, songTypeFilterControl]}
|
||||
actions={[
|
||||
[playAction],
|
||||
...(props.additionalActions
|
||||
? [props.additionalActions]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() => {
|
||||
return props.query(query);
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<SongGroupItem
|
||||
song={item}
|
||||
subtitles={props.subtitles}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={() => <></>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type HybridSongViewProps = {
|
||||
song: SongViewProps;
|
||||
// If not specified, there will be no toggle button
|
||||
songGroup?: SongGroupViewProps;
|
||||
};
|
||||
|
||||
// Infinite song View with a toggle for song groups
|
||||
export const HybridInfiniteSongView = (props: HybridSongViewProps) => {
|
||||
const router = useRouter();
|
||||
const [useSongGroup, setUseSongGroup] = useState(
|
||||
parseQueryParam(router.query.groups, ["true", "false"]) === "true",
|
||||
);
|
||||
const toggleSongGroupAction: Action = {
|
||||
label: useSongGroup ? "showAllSongs" : "groupVersions",
|
||||
onClick: () => {
|
||||
setUseSongGroup((prev) => {
|
||||
setQueryParam(
|
||||
[
|
||||
["groups", (!prev).toString()],
|
||||
["type", null],
|
||||
["library", null],
|
||||
["order", null],
|
||||
["sort", null],
|
||||
],
|
||||
router,
|
||||
);
|
||||
return !prev;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (useSongGroup && props.songGroup) {
|
||||
return (
|
||||
<InfiniteSongGroupView
|
||||
{...props.songGroup}
|
||||
additionalActions={[
|
||||
...(props.songGroup.additionalActions ?? []),
|
||||
toggleSongGroupAction,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteSongView
|
||||
{...props.song}
|
||||
additionalActions={[
|
||||
...(props.songGroup ? [toggleSongGroupAction] : []),
|
||||
...(props.song.additionalActions ?? []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+32
-28
@@ -16,48 +16,52 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import type { InfiniteQuery } from "../../../api/use-query";
|
||||
import {
|
||||
TrackSortingKeys,
|
||||
type TrackWithRelations,
|
||||
} from "../../../models/track";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import TrackItem from "../../list-item/track-item";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
const InfiniteTrackView = (
|
||||
props: InfiniteResourceViewProps<
|
||||
TrackWithRelations<"video" | "song" | "release" | "illustration">,
|
||||
typeof TrackSortingKeys
|
||||
>,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof TrackSortingKeys>>();
|
||||
type QueryProps = {
|
||||
libraries?: string[];
|
||||
} & SortingParameters<typeof TrackSortingKeys>;
|
||||
|
||||
type TrackModel = TrackWithRelations<
|
||||
"video" | "release" | "song" | "illustration"
|
||||
>;
|
||||
|
||||
type ViewProps = {
|
||||
query: (q: QueryProps) => InfiniteQuery<TrackModel>;
|
||||
onItemClick?: (track: TrackModel) => void;
|
||||
disableSort?: boolean;
|
||||
};
|
||||
|
||||
const InfiniteTrackView = (props: ViewProps) => {
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: TrackSortingKeys,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
onChange={setOptions}
|
||||
sortingKeys={TrackSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
disableLayoutToggle
|
||||
defaultLayout={"list"}
|
||||
/>
|
||||
<Controls filters={[libraryFilterControl]} sort={sortControl} />
|
||||
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? "list"}
|
||||
itemSize={DefaultItemSize}
|
||||
view={"list"}
|
||||
query={() =>
|
||||
props.query({
|
||||
library: options?.library ?? null,
|
||||
view: "list",
|
||||
sortBy: options?.sortBy ?? "name",
|
||||
order: options?.order ?? "asc",
|
||||
libraries: libraryFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
})
|
||||
}
|
||||
renderListItem={(item) => (
|
||||
+95
-99
@@ -16,8 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type QueryClient,
|
||||
@@ -35,13 +33,105 @@ import {
|
||||
playTrackAtom,
|
||||
} from "../../../state/player";
|
||||
import { store } from "../../../state/store";
|
||||
import { DefaultItemSize } from "../../../utils/layout";
|
||||
import Controls, { type OptionState } from "../../controls/controls";
|
||||
import type { SortingParameters } from "../../../utils/sorting";
|
||||
import { PlayIcon, ShuffleIcon } from "../../icons";
|
||||
import VideoItem from "../../list-item/video-item";
|
||||
import VideoTile from "../../tile/video-tile";
|
||||
import { Controls } from "../controls/controls";
|
||||
import { useLibraryFilterControl } from "../controls/filters/library";
|
||||
import { useTypeFilterControl } from "../controls/filters/resource-type";
|
||||
import { useLayoutControl } from "../controls/layout";
|
||||
import { useSortControl } from "../controls/sort";
|
||||
import InfiniteView from "../infinite-view";
|
||||
import type InfiniteResourceViewProps from "./infinite-resource-view-props";
|
||||
|
||||
type QueryProps = {
|
||||
types?: VideoType[];
|
||||
libraries?: string[];
|
||||
random?: number;
|
||||
} & SortingParameters<typeof VideoSortingKeys>;
|
||||
|
||||
type VideoModel = VideoWithRelations<"artist" | "illustration" | "master">;
|
||||
|
||||
type ViewProps = {
|
||||
query: (qp: QueryProps) => InfiniteQuery<VideoModel>;
|
||||
onItemClick?: (video: VideoModel) => void;
|
||||
disableSort?: boolean;
|
||||
subtitle: Parameters<typeof VideoTile>[0]["subtitle"];
|
||||
};
|
||||
|
||||
const InfiniteVideoView = (props: ViewProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [libraryFilter, libraryFilterControl] = useLibraryFilterControl({
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [typeFilter, typeFilterControl] = useTypeFilterControl({
|
||||
types: VideoType,
|
||||
multipleChoices: true,
|
||||
});
|
||||
const [sort, sortControl] = useSortControl({
|
||||
sortingKeys: VideoSortingKeys,
|
||||
});
|
||||
const [layout, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
const query = {
|
||||
libraries: libraryFilter,
|
||||
types: typeFilter,
|
||||
sortBy: sort.sort,
|
||||
order: sort.order,
|
||||
};
|
||||
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
filters={[libraryFilterControl, typeFilterControl]}
|
||||
sort={props.disableSort ? undefined : sortControl}
|
||||
layout={layoutControl}
|
||||
actions={[[playAction, shuffleAction]]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={layout.itemSize}
|
||||
view={layout.layout}
|
||||
query={() => props.query(query)}
|
||||
renderListItem={(item) => (
|
||||
<VideoItem
|
||||
video={item}
|
||||
// TODO Subtitle
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
subtitle={props.subtitle}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const playVideosAction = (
|
||||
queryClient: QueryClient,
|
||||
@@ -74,98 +164,4 @@ const playVideosAction = (
|
||||
});
|
||||
};
|
||||
|
||||
type AdditionalProps = {
|
||||
type?: VideoType;
|
||||
random?: number;
|
||||
};
|
||||
|
||||
const InfiniteVideoView = <
|
||||
T extends VideoWithRelations<"artist" | "illustration" | "master">,
|
||||
>(
|
||||
props: InfiniteResourceViewProps<
|
||||
T,
|
||||
typeof VideoSortingKeys,
|
||||
AdditionalProps
|
||||
> &
|
||||
Omit<ComponentProps<typeof VideoTile>, "video">,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [options, setOptions] =
|
||||
useState<OptionState<typeof VideoSortingKeys, AdditionalProps>>();
|
||||
const query = {
|
||||
type:
|
||||
// @ts-ignore
|
||||
options?.type === "All" ? undefined : (options?.type as VideoType),
|
||||
sortBy: options?.sortBy ?? props.initialSortingField ?? "name",
|
||||
order: options?.order ?? props.initialSortingOrder ?? "asc",
|
||||
view: "grid",
|
||||
itemSize: DefaultItemSize,
|
||||
library: options?.library ?? null,
|
||||
} as const;
|
||||
const playAction = {
|
||||
label: "playAll",
|
||||
icon: <PlayIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(queryClient, () => props.query(query));
|
||||
},
|
||||
} as const;
|
||||
const shuffleAction = {
|
||||
label: "shuffle",
|
||||
icon: <ShuffleIcon />,
|
||||
onClick: () => {
|
||||
playVideosAction(queryClient, () =>
|
||||
props.query({
|
||||
...query,
|
||||
random: Math.floor(Math.random() * 10000),
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
return (
|
||||
<>
|
||||
<Controls
|
||||
options={[
|
||||
{
|
||||
label: (options?.type as VideoType) ?? "All",
|
||||
name: "type",
|
||||
values: ["All", ...VideoType],
|
||||
currentValue: options?.type,
|
||||
},
|
||||
]}
|
||||
onChange={setOptions}
|
||||
sortingKeys={VideoSortingKeys}
|
||||
defaultSortingOrder={props.initialSortingOrder}
|
||||
defaultSortingKey={props.initialSortingField}
|
||||
router={props.light === true ? undefined : router}
|
||||
defaultLayout={"grid"}
|
||||
actions={[playAction, shuffleAction]}
|
||||
/>
|
||||
<InfiniteView
|
||||
itemSize={options?.itemSize ?? DefaultItemSize}
|
||||
view={options?.view ?? "grid"}
|
||||
query={() => {
|
||||
return props.query({
|
||||
...query,
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
}}
|
||||
renderListItem={(item) => (
|
||||
<VideoItem
|
||||
video={item}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
renderGridItem={(item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
subtitle={props.subtitle}
|
||||
onClick={() => item && props.onItemClick?.(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteVideoView;
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
import LoadingComponent from "./loading";
|
||||
/**
|
||||
* Component that take the whole page for a loading animation
|
||||
* It should be at the root of the component tree as its content position's will be `fixed`
|
||||
*/
|
||||
const LoadingPage = () => {
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100vh"
|
||||
position="fixed"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<LoadingComponent />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingPage;
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bars } from "react-loader-spinner";
|
||||
import Fade from "../fade";
|
||||
|
||||
/**
|
||||
* Base loading component
|
||||
* @returns
|
||||
*/
|
||||
const LoadingComponent = () => {
|
||||
const theme = useTheme();
|
||||
const [displayLoad, setDisplay] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeId = setTimeout(() => setDisplay(true), 2000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeId);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Fade in={displayLoad} timeout={500} mountOnEnter unmountOnExit>
|
||||
<Box>
|
||||
<Bars
|
||||
height="40"
|
||||
width="40"
|
||||
color={theme.palette.text.primary}
|
||||
ariaLabel="bars-loading"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading component that take the whole width and center the loading animation
|
||||
* @returns
|
||||
*/
|
||||
type WideLoadingComponentProps = {
|
||||
verticalPadding?: number;
|
||||
};
|
||||
const WideLoadingComponent = (props: WideLoadingComponentProps) => (
|
||||
<Box
|
||||
width="100%"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
paddingY={props.verticalPadding ?? 10}
|
||||
>
|
||||
<LoadingComponent />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default LoadingComponent;
|
||||
export { WideLoadingComponent };
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { IconButton, Typography } from "@mui/material";
|
||||
import type { ComponentProps } from "react";
|
||||
import API from "../../../api/api";
|
||||
import type Artist from "../../../models/artist";
|
||||
import type { TrackWithRelations } from "../../../models/track";
|
||||
import formatDuration from "../../../utils/formatDuration";
|
||||
import { ForwardIcon, PauseIcon, PlayIcon, RewindIcon } from "../../icons";
|
||||
import type PlayerSlider from "./slider";
|
||||
|
||||
export const parentSongQuery = (id: number) =>
|
||||
API.getSong(id, ["artist", "lyrics", "featuring"]);
|
||||
|
||||
type PlayerButtonControlsProps = {
|
||||
playing: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
onSkipTrack: () => void;
|
||||
onRewind: () => void;
|
||||
};
|
||||
|
||||
export type PlayerControlsProps = ComponentProps<typeof PlayerSlider> &
|
||||
PlayerButtonControlsProps & {
|
||||
isTranscoding: boolean;
|
||||
expanded: boolean;
|
||||
onExpand: (expand: boolean) => void;
|
||||
artist?: Artist;
|
||||
track?: TrackWithRelations<"illustration">;
|
||||
};
|
||||
|
||||
export const playerTextStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
export type ControlButtonProps = {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const ControlButton = (props: ControlButtonProps) => (
|
||||
<IconButton onClick={props.onClick} color="inherit">
|
||||
{props.icon}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
export const PlayButton = (props: {
|
||||
isPlaying: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}) => (
|
||||
<ControlButton
|
||||
icon={props.isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
onClick={props.isPlaying ? props.onPause : props.onPlay}
|
||||
/>
|
||||
);
|
||||
|
||||
export const SkipButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<ForwardIcon />} />
|
||||
);
|
||||
export const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
|
||||
export const DurationComponent = ({ time }: { time?: number }) => (
|
||||
<Typography>{formatDuration(time)}</Typography>
|
||||
);
|
||||
+38
-211
@@ -19,7 +19,6 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonBase,
|
||||
Container,
|
||||
Dialog,
|
||||
Divider,
|
||||
@@ -33,223 +32,48 @@ import {
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type LegacyRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type LegacyRef, useCallback, useState } from "react";
|
||||
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQuery, useQueryClient } from "../../api/use-query";
|
||||
import type Artist from "../../models/artist";
|
||||
import type { TrackWithRelations } from "../../models/track";
|
||||
import API from "../../../api/api";
|
||||
import { useQuery, useQueryClient } from "../../../api/use-query";
|
||||
import {
|
||||
cursorAtom,
|
||||
playlistAtom,
|
||||
removeTrackAtom,
|
||||
reorderAtom,
|
||||
skipTrackAtom,
|
||||
} from "../../state/player";
|
||||
import formatArtists from "../../utils/formatArtists";
|
||||
import formatDuration from "../../utils/formatDuration";
|
||||
import { CreatePlaylistAction } from "../actions/playlist";
|
||||
import ReleaseTrackContextualMenu from "../contextual-menu/release-track-contextual-menu";
|
||||
} from "../../../state/player";
|
||||
import formatArtists from "../../../utils/formatArtists";
|
||||
import formatDuration from "../../../utils/formatDuration";
|
||||
import { CreatePlaylistAction } from "../../actions/playlist";
|
||||
import ReleaseTrackContextualMenu from "../../contextual-menu/release-track-contextual-menu";
|
||||
import {
|
||||
CloseIcon,
|
||||
ContextualMenuIcon,
|
||||
DeleteIcon,
|
||||
DragHandleIcon,
|
||||
ForwardIcon,
|
||||
FullscreenIcon,
|
||||
LyricsIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlayerIcon,
|
||||
PlaylistIcon,
|
||||
RewindIcon,
|
||||
TrackIcon,
|
||||
} from "../icons";
|
||||
import Illustration from "../illustration";
|
||||
import ListItem from "../list-item/item";
|
||||
import LyricsBox from "../lyrics";
|
||||
import PlayerSlider from "./controls/slider";
|
||||
} from "../../icons";
|
||||
import Illustration from "../../illustration";
|
||||
import ListItem from "../../list-item/item";
|
||||
import {
|
||||
PlayButton,
|
||||
type PlayerControlsProps,
|
||||
PreviousButton,
|
||||
SkipButton,
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
} from "./common";
|
||||
import { LyricsComponent } from "./lyrics";
|
||||
import PlayerSlider from "./slider";
|
||||
|
||||
const parentSongQuery = (id: number) =>
|
||||
API.getSong(id, ["artist", "lyrics", "featuring"]);
|
||||
|
||||
type PlayerButtonControlsProps = {
|
||||
playing: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
onSkipTrack: () => void;
|
||||
onRewind: () => void;
|
||||
};
|
||||
|
||||
type PlayerControlsProps = ComponentProps<typeof PlayerSlider> &
|
||||
PlayerButtonControlsProps & {
|
||||
isTranscoding: boolean;
|
||||
expanded: boolean;
|
||||
onExpand: (expand: boolean) => void;
|
||||
artist?: Artist;
|
||||
track?: TrackWithRelations<"illustration">;
|
||||
};
|
||||
|
||||
const playerTextStyle = {
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
type ControlButtonProps = {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const ControlButton = (props: ControlButtonProps) => (
|
||||
<IconButton onClick={props.onClick} color="inherit">
|
||||
{props.icon}
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const PlayButton = (props: {
|
||||
isPlaying: boolean;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}) => (
|
||||
<ControlButton
|
||||
icon={props.isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
onClick={props.isPlaying ? props.onPause : props.onPlay}
|
||||
/>
|
||||
);
|
||||
const SkipButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<ForwardIcon />} />
|
||||
);
|
||||
const PreviousButton = (props: Omit<ControlButtonProps, "icon">) => (
|
||||
<ControlButton {...props} icon={<RewindIcon />} />
|
||||
);
|
||||
const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => props.onExpand(true)}
|
||||
disableTouchRipple
|
||||
sx={{ width: "100%", height: "100%", padding: 0, margin: 0 }}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Grid item sx={{ minWidth: "60px" }}>
|
||||
{props.track ? (
|
||||
<Illustration
|
||||
illustration={props.track?.illustration ?? null}
|
||||
quality="low"
|
||||
fallback={<TrackIcon />}
|
||||
imgProps={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
marginX: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TrackIcon />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "space-evenly",
|
||||
marginLeft: { xs: 0, sm: 1 },
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ fontWeight: "bold", ...playerTextStyle }}
|
||||
>
|
||||
{props.track?.name ?? <br />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
...playerTextStyle,
|
||||
fontSize: "medium",
|
||||
}}
|
||||
>
|
||||
{props.artist ? (
|
||||
formatArtists(
|
||||
props.artist,
|
||||
parentSong.data?.featuring,
|
||||
)
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs={4}
|
||||
sm={3}
|
||||
md={2}
|
||||
flexWrap="nowrap"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grid item xs>
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpandedPlayerControls = (
|
||||
export const ExpandedPlayerControls = (
|
||||
props: PlayerControlsProps & { videoRef: LegacyRef<HTMLVideoElement> },
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -367,7 +191,7 @@ const ExpandedPlayerControls = (
|
||||
</IconButton>
|
||||
</Box>
|
||||
{selectedTab !== "player" && (
|
||||
<Divider sx={{ margin: 1 }} variant="middle" />
|
||||
<Divider sx={{ margin: 1, marginBottom: 0 }} variant="middle" />
|
||||
)}
|
||||
|
||||
<Grid
|
||||
@@ -601,15 +425,13 @@ const ExpandedPlayerControls = (
|
||||
maxWidth: theme.breakpoints.values.md,
|
||||
}}
|
||||
>
|
||||
{parentSong.data && (
|
||||
<LyricsBox
|
||||
lyrics={
|
||||
parentSong.data.lyrics?.content.split("\n") ??
|
||||
null
|
||||
}
|
||||
songName={props.track?.name}
|
||||
/>
|
||||
)}
|
||||
<LyricsComponent
|
||||
lyrics={parentSong.data?.lyrics}
|
||||
songName={props.track?.name}
|
||||
progress={props.progress}
|
||||
setProgress={props.onSlide}
|
||||
playerIsExpanded={props.expanded}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{selectedTab === "playlist" && (
|
||||
@@ -785,7 +607,14 @@ const ExpandedPlayerControls = (
|
||||
close: () => openPlaylistModal(false),
|
||||
})}
|
||||
</Dialog>
|
||||
<Divider variant="middle" sx={{ margin: 1, marginBottom: 2 }} />
|
||||
<Divider
|
||||
variant="middle"
|
||||
sx={{
|
||||
margin: 1,
|
||||
marginBottom: 2,
|
||||
marginTop: selectedTab === "lyrics" ? 0 : undefined,
|
||||
}}
|
||||
/>
|
||||
<Grid
|
||||
container
|
||||
sx={{
|
||||
@@ -823,5 +652,3 @@ const ExpandedPlayerControls = (
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { MinimizedPlayerControls, ExpandedPlayerControls };
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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 { Box, Stack, Typography } from "@mui/material";
|
||||
import { type MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import type { Lyrics, SyncedLyric } from "../../../models/lyrics";
|
||||
import LyricsBox from "../../lyrics";
|
||||
|
||||
export const LyricsComponent = ({
|
||||
lyrics,
|
||||
songName,
|
||||
progress,
|
||||
setProgress,
|
||||
playerIsExpanded,
|
||||
}: {
|
||||
lyrics: Lyrics | null | undefined;
|
||||
songName?: string;
|
||||
progress?: MutableRefObject<number | null>;
|
||||
setProgress: (newProgress: number) => void;
|
||||
playerIsExpanded: boolean;
|
||||
}) => {
|
||||
if (lyrics?.synced && progress) {
|
||||
return (
|
||||
<SyncedLyricsComponent
|
||||
syncedLyrics={lyrics.synced}
|
||||
progress={progress}
|
||||
setProgress={setProgress}
|
||||
playerIsExpanded={playerIsExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: "scroll",
|
||||
marginLeft: 2,
|
||||
paddingRight: 3,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
{
|
||||
<LyricsBox
|
||||
lyrics={lyrics?.plain.split("\n") ?? null}
|
||||
songName={songName}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type SyncedLyricWithNext = SyncedLyric & { next: number | null };
|
||||
|
||||
const SyncedLyricsComponent = ({
|
||||
syncedLyrics,
|
||||
progress,
|
||||
setProgress,
|
||||
playerIsExpanded,
|
||||
}: {
|
||||
syncedLyrics: NonNullable<Lyrics["synced"]>;
|
||||
progress: MutableRefObject<number | null>;
|
||||
setProgress: (newProgress: number) => void;
|
||||
playerIsExpanded: boolean;
|
||||
}) => {
|
||||
const [syncedLyricsWithNext, setLyricsWithNext] =
|
||||
useState<SyncedLyricWithNext[]>();
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState<number>(-1);
|
||||
const intervalRef = useRef<NodeJS.Timer>();
|
||||
|
||||
// Do this async-ly, to avoid lag
|
||||
useEffect(() => {
|
||||
const sortedLyrics = syncedLyrics.toSorted(
|
||||
(la, lb) => la.timestamp - lb.timestamp,
|
||||
);
|
||||
setLyricsWithNext(
|
||||
sortedLyrics.map((entry, index) => {
|
||||
return {
|
||||
...entry,
|
||||
next: sortedLyrics.at(index + 1)?.timestamp ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!playerIsExpanded) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
return;
|
||||
}
|
||||
intervalRef.current = setInterval(() => {
|
||||
const progressValue = progress.current;
|
||||
if (progressValue === null || syncedLyricsWithNext === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentLyric = syncedLyricsWithNext.at(currentLyricIndex);
|
||||
if (
|
||||
currentLyric &&
|
||||
currentLyric.timestamp <= progressValue &&
|
||||
(currentLyric.next ? progressValue < currentLyric.next : true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLyricIndex = syncedLyricsWithNext?.findIndex(
|
||||
(entry) =>
|
||||
entry.timestamp <= progressValue &&
|
||||
(entry.next ? progressValue < entry.next : true),
|
||||
);
|
||||
if (nextLyricIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`lyric-${nextLyricIndex}`)?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
});
|
||||
setCurrentLyricIndex(nextLyricIndex);
|
||||
}, 100);
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [playerIsExpanded, syncedLyricsWithNext]);
|
||||
return (
|
||||
<Box sx={{ height: "100%", width: "100%", overflow: "hidden" }}>
|
||||
<Stack spacing={4}>
|
||||
{syncedLyricsWithNext?.map((entry, i) => (
|
||||
<Typography
|
||||
id={`lyric-${i}`}
|
||||
key={`lyric-${i}`}
|
||||
variant={i === currentLyricIndex ? "h4" : "h6"}
|
||||
component={"div"}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
transition:
|
||||
"font-size .2s ease-in; font-weight .2s ease-in",
|
||||
filter:
|
||||
i === currentLyricIndex
|
||||
? undefined
|
||||
: "blur(1px)",
|
||||
fontWeight:
|
||||
i === currentLyricIndex ? "bold" : undefined,
|
||||
}}
|
||||
onClick={() => setProgress(entry.timestamp)}
|
||||
>
|
||||
{entry.content || <br />}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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 { Box, ButtonBase, Grid, Typography } from "@mui/material";
|
||||
import { useQuery } from "../../../api/use-query";
|
||||
import formatArtists from "../../../utils/formatArtists";
|
||||
import { TrackIcon } from "../../icons";
|
||||
import Illustration from "../../illustration";
|
||||
import {
|
||||
PlayButton,
|
||||
type PlayerControlsProps,
|
||||
SkipButton,
|
||||
parentSongQuery,
|
||||
playerTextStyle,
|
||||
} from "./common";
|
||||
|
||||
export const MinimizedPlayerControls = (props: PlayerControlsProps) => {
|
||||
const parentSong = useQuery(
|
||||
parentSongQuery,
|
||||
props.track?.songId ?? undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => props.onExpand(true)}
|
||||
disableTouchRipple
|
||||
sx={{ width: "100%", height: "100%", padding: 0, margin: 0 }}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Grid item sx={{ minWidth: "60px" }}>
|
||||
{props.track ? (
|
||||
<Illustration
|
||||
illustration={props.track?.illustration ?? null}
|
||||
quality="low"
|
||||
fallback={<TrackIcon />}
|
||||
imgProps={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
marginX: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TrackIcon />
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "space-evenly",
|
||||
marginLeft: { xs: 0, sm: 1 },
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ fontWeight: "bold", ...playerTextStyle }}
|
||||
>
|
||||
{props.track?.name ?? <br />}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
...playerTextStyle,
|
||||
justifyContent: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.disabled",
|
||||
...playerTextStyle,
|
||||
fontSize: "medium",
|
||||
}}
|
||||
>
|
||||
{props.artist ? (
|
||||
formatArtists(
|
||||
props.artist,
|
||||
parentSong.data?.featuring,
|
||||
)
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
xs={4}
|
||||
sm={3}
|
||||
md={2}
|
||||
flexWrap="nowrap"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Grid item xs>
|
||||
<PlayButton
|
||||
onPause={props.onPause}
|
||||
onPlay={props.onPlay}
|
||||
isPlaying={props.playing}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<SkipButton onClick={props.onSkipTrack} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { Grid, Slider } from "@mui/material";
|
||||
import { type MutableRefObject, useEffect, useState } from "react";
|
||||
import DurationComponent from "./duration";
|
||||
import { DurationComponent } from "./common";
|
||||
|
||||
type PlayerSliderProps = {
|
||||
onSlide: (newProgress: number) => void;
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
} from "../../state/player";
|
||||
import { userAtom } from "../../state/user";
|
||||
import { DrawerBreakpoint } from "../scaffold/scaffold";
|
||||
import { ExpandedPlayerControls, MinimizedPlayerControls } from "./controls";
|
||||
import { ExpandedPlayerControls } from "./controls/expanded";
|
||||
import { MinimizedPlayerControls } from "./controls/minimized";
|
||||
|
||||
const Player = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -28,16 +28,16 @@ import {
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Audio } from "react-loader-spinner";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
import type Artist from "../models/artist";
|
||||
import type Release from "../models/release";
|
||||
import type { ReleaseWithRelations } from "../models/release";
|
||||
import type { SongWithRelations } from "../models/song";
|
||||
import type { TrackWithRelations } from "../models/track";
|
||||
import type Tracklist from "../models/tracklist";
|
||||
@@ -52,7 +52,7 @@ import formatArtists from "../utils/formatArtists";
|
||||
import formatDuration from "../utils/formatDuration";
|
||||
import { generateArray } from "../utils/gen-list";
|
||||
import ReleaseTrackContextualMenu from "./contextual-menu/release-track-contextual-menu";
|
||||
import { ContextualMenuIcon, VideoIcon } from "./icons";
|
||||
import { ContextualMenuIcon, PlayIcon, VideoIcon } from "./icons";
|
||||
|
||||
type TrackType = TrackWithRelations<"illustration"> &
|
||||
RequireAtLeastOne<{
|
||||
@@ -62,7 +62,7 @@ type TrackType = TrackWithRelations<"illustration"> &
|
||||
type ReleaseTracklistProps = {
|
||||
mainArtist: Artist | undefined | null;
|
||||
tracklist: Tracklist<TrackType> | undefined;
|
||||
release: Release | undefined;
|
||||
release: ReleaseWithRelations<"discs"> | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,11 +76,12 @@ const ReleaseTrackList = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const PlayingIcon = () => (
|
||||
<Audio
|
||||
height="25"
|
||||
width="20"
|
||||
color={theme.palette.text.disabled}
|
||||
ariaLabel="bars-loading"
|
||||
<PlayIcon
|
||||
style={{
|
||||
fill: theme.palette.text.disabled,
|
||||
color: "transparent",
|
||||
marginLeft: -4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const [playlist] = useAtom(playlistAtom);
|
||||
@@ -103,6 +104,19 @@ const ReleaseTrackList = ({
|
||||
}
|
||||
return formatArtists(song.artist, song.featuring);
|
||||
};
|
||||
// Note, at this point disc index is a string /shrug
|
||||
const formatDisc = (discIndex: string) => {
|
||||
const disc = release?.discs.find((d) =>
|
||||
d.index === null
|
||||
? discIndex === "?"
|
||||
: d.index.toString() === discIndex,
|
||||
);
|
||||
const base = `${t("disc")} ${discIndex}`;
|
||||
if (disc?.name) {
|
||||
return `${base} — ${disc.name}`;
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -119,7 +133,7 @@ const ReleaseTrackList = ({
|
||||
subheader={
|
||||
discs.length !== 1 && (
|
||||
<ListSubheader disableSticky>
|
||||
{`${t("disc")} ${disc[0]}`}
|
||||
{formatDisc(disc[0])}
|
||||
</ListSubheader>
|
||||
)
|
||||
}
|
||||
@@ -214,12 +228,21 @@ const ReleaseTrackList = ({
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
currentTrack?.name ?? (
|
||||
currentTrack ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
alignItems="center"
|
||||
>
|
||||
{currentTrack.name}
|
||||
</Stack>
|
||||
) : (
|
||||
<Skeleton width="120px" />
|
||||
)
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
fontSize: "medium",
|
||||
component: "div",
|
||||
}}
|
||||
secondary={
|
||||
mainArtist === undefined
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 type { SongType } from "../models/song";
|
||||
import type { IconProps } from "./icons";
|
||||
import {
|
||||
SongIcon,
|
||||
SongTypeAcapellaIcon,
|
||||
SongTypeAcousticIcon,
|
||||
SongTypeCleanIcon,
|
||||
SongTypeDemoIcon,
|
||||
SongTypeEditIcon,
|
||||
SongTypeInstrumentalIcon,
|
||||
SongTypeLiveIcon,
|
||||
SongTypeMedleyIcon,
|
||||
SongTypeNonMusicIcon,
|
||||
SongTypeOriginalIcon,
|
||||
SongTypeRemixIcon,
|
||||
} from "./icons";
|
||||
|
||||
const SongTypeIcon = ({ type, ...props }: { type: SongType } & IconProps) => {
|
||||
const Icon = () => {
|
||||
switch (type) {
|
||||
case "Original":
|
||||
return SongTypeOriginalIcon;
|
||||
case "Remix":
|
||||
return SongTypeRemixIcon;
|
||||
case "Live":
|
||||
return SongTypeLiveIcon;
|
||||
case "Acoustic":
|
||||
return SongTypeAcousticIcon;
|
||||
case "Instrumental":
|
||||
return SongTypeInstrumentalIcon;
|
||||
case "Edit":
|
||||
return SongTypeEditIcon;
|
||||
case "Clean":
|
||||
return SongTypeCleanIcon;
|
||||
case "Demo":
|
||||
return SongTypeDemoIcon;
|
||||
case "Acappella":
|
||||
return SongTypeAcapellaIcon;
|
||||
case "Medley":
|
||||
return SongTypeMedleyIcon;
|
||||
case "NonMusic":
|
||||
return SongTypeNonMusicIcon;
|
||||
default:
|
||||
return SongIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const IconComponent = Icon();
|
||||
return <IconComponent {...props} />;
|
||||
};
|
||||
|
||||
export default SongTypeIcon;
|
||||
@@ -18,11 +18,13 @@
|
||||
|
||||
import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import type { useConfirm } from "material-ui-confirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -30,6 +32,7 @@ import API from "../api/api";
|
||||
import { useQuery } from "../api/use-query";
|
||||
import type { TranslationKey } from "../i18n/i18n";
|
||||
import formatDuration from "../utils/formatDuration";
|
||||
import SongTypeIcon from "./song-type-icon";
|
||||
|
||||
/**
|
||||
* Table component, listing info of track and its source file
|
||||
@@ -37,13 +40,29 @@ import formatDuration from "../utils/formatDuration";
|
||||
*/
|
||||
const TrackFileInfo = ({ trackId }: { trackId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const track = useQuery((id) => API.getTrack(id, ["song"]), trackId);
|
||||
const sourceFile = useQuery(API.getSourceFile, track.data?.sourceFileId);
|
||||
const songType = track.data?.song?.type;
|
||||
|
||||
const tableContent: Partial<
|
||||
Record<TranslationKey, string | number | undefined>
|
||||
Record<TranslationKey, string | number | undefined | JSX.Element>
|
||||
> = {
|
||||
name: track.data?.name,
|
||||
type: track.data?.type,
|
||||
songType: track.data?.song
|
||||
? songType && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SongTypeIcon
|
||||
type={songType}
|
||||
size={theme.typography.body1.fontSize as number}
|
||||
/>
|
||||
<Typography>{t(songType)}</Typography>
|
||||
</Stack>
|
||||
)
|
||||
: track.data?.song === undefined
|
||||
? undefined
|
||||
: "N/A",
|
||||
remastered: track.data
|
||||
? t(track.data.isRemastered ? "yes" : "no")
|
||||
: undefined,
|
||||
@@ -58,7 +77,6 @@ const TrackFileInfo = ({ trackId }: { trackId: number }) => {
|
||||
? `${track.data.bitrate} kbps`
|
||||
: t("Unknown")
|
||||
: undefined,
|
||||
type: track.data?.type,
|
||||
extension: sourceFile.data
|
||||
? (sourceFile.data.path
|
||||
.split(".")
|
||||
|
||||
@@ -71,6 +71,10 @@ const useKey = (...p: Parameters<typeof _useKey>) => {
|
||||
if (tagName === "input" || tagName === "textarea") {
|
||||
return;
|
||||
}
|
||||
// Prevent default for the "/" key to stop Firefox's quick find
|
||||
if (p[0] === "/") {
|
||||
e.preventDefault();
|
||||
}
|
||||
p[1]?.(e);
|
||||
},
|
||||
p[2],
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"trackSetAsMaster": "Track set as Master",
|
||||
"showAllTrack": "Show all tracks",
|
||||
"showOnlyExclusiveTracks": "Show only exclusive tracks",
|
||||
"All": "All",
|
||||
"allTypes": "All Types",
|
||||
"StudioRecording": "Studio Recording",
|
||||
"Single": "Single",
|
||||
"LiveRecording": "Live Recording",
|
||||
@@ -257,5 +257,9 @@
|
||||
"emptyStateLyrics": "No lyrics to sing along to :(",
|
||||
"emptyStatePlaylists": "There are no playlists... yet!",
|
||||
"emptyStatePlaylist": "There are no songs in this playlist... yet!",
|
||||
"emptyStateHome": "Oh wow, it's quiet in here..."
|
||||
"emptyStateHome": "Oh wow, it's quiet in here...",
|
||||
"songType": "Song Type",
|
||||
"loading": "Chargement",
|
||||
"types": "Types",
|
||||
"all": "All"
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
"you": "Vous",
|
||||
"admin": "Administrateur",
|
||||
"enabled": "Activé",
|
||||
"All": "Tous Types",
|
||||
"allTypes": "Tous Types",
|
||||
"StudioRecording": "Album Studio",
|
||||
"Single": "Single",
|
||||
"LiveRecording": "Album Live",
|
||||
@@ -257,5 +257,9 @@
|
||||
"emptyStateLyrics": "Pas de paroles pour chanter à tue-tête :(",
|
||||
"emptyStatePlaylists": "Il n'y a aucune playlist... pour l'instant!",
|
||||
"emptyStatePlaylist": "Il n'y a aucun morceau dans cette playlist... pour l'instant!",
|
||||
"emptyStateHome": "Oh wow, c'est silencieux par ici.."
|
||||
"emptyStateHome": "Oh wow, c'est silencieux par ici..",
|
||||
"songType": "Type du Titre",
|
||||
"loading": "Chargement",
|
||||
"types": "Types",
|
||||
"all": "Tous"
|
||||
}
|
||||
@@ -16,11 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Typography } from "@mui/material";
|
||||
import formatDuration from "../../../utils/formatDuration";
|
||||
import * as yup from "yup";
|
||||
|
||||
const DurationComponent = ({ time }: { time?: number }) => (
|
||||
<Typography>{formatDuration(time)}</Typography>
|
||||
);
|
||||
export const Disc = yup.object({
|
||||
index: yup.number().required().nullable(),
|
||||
name: yup.string().required().nullable(),
|
||||
});
|
||||
|
||||
export default DurationComponent;
|
||||
export type Disc = yup.InferType<typeof Disc>;
|
||||
@@ -18,11 +18,16 @@
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
const Lyrics = yup.object({
|
||||
id: yup.number().required(),
|
||||
content: yup.string().required(),
|
||||
export const SyncedLyric = yup.object({
|
||||
timestamp: yup.number().required(),
|
||||
content: yup.string().nonNullable(),
|
||||
});
|
||||
|
||||
export type SyncedLyric = yup.InferType<typeof SyncedLyric>;
|
||||
|
||||
export const Lyrics = yup.object({
|
||||
plain: yup.string().required(),
|
||||
synced: yup.array(SyncedLyric.required()).required().nullable(),
|
||||
});
|
||||
|
||||
export type Lyrics = yup.InferType<typeof Lyrics>;
|
||||
|
||||
export default Lyrics;
|
||||
@@ -19,6 +19,7 @@
|
||||
import * as yup from "yup";
|
||||
import { yupdate } from "../utils/yup";
|
||||
import Album from "./album";
|
||||
import { Disc } from "./disc";
|
||||
import Illustration from "./illustration";
|
||||
import Resource from "./resource";
|
||||
|
||||
@@ -58,7 +59,7 @@ type Release = yup.InferType<typeof Release>;
|
||||
|
||||
export default Release;
|
||||
|
||||
export type ReleaseInclude = "album" | "illustration";
|
||||
export type ReleaseInclude = "album" | "illustration" | "discs";
|
||||
|
||||
export const ReleaseWithRelations = <
|
||||
Selection extends ReleaseInclude | never = never,
|
||||
@@ -70,6 +71,7 @@ export const ReleaseWithRelations = <
|
||||
.object({
|
||||
album: Album.required(),
|
||||
illustration: Illustration.required().nullable(),
|
||||
discs: yup.array(Disc.required()).required(),
|
||||
})
|
||||
.pick(relation),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import * as yup from "yup";
|
||||
import Artist from "./artist";
|
||||
import Illustration from "./illustration";
|
||||
import Lyrics from "./lyrics";
|
||||
import { Lyrics } from "./lyrics";
|
||||
import Resource from "./resource";
|
||||
import Track from "./track";
|
||||
|
||||
|
||||
@@ -3,31 +3,25 @@ import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { Head } from "../../components/head";
|
||||
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../components/infinite/controls/sort";
|
||||
import InfiniteAlbumView from "../../components/infinite/resource/album";
|
||||
import { AlbumSortingKeys } from "../../models/album";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import { getAlbumTypeParam } from "../../utils/album-type";
|
||||
import { getLayoutParams } from "../../utils/layout";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
|
||||
const isCompilationPage = ({ asPath }: { asPath?: string }) =>
|
||||
asPath?.includes("/compilations") ?? false;
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
AlbumSortingKeys,
|
||||
);
|
||||
const type = getAlbumTypeParam(context.query.type);
|
||||
const defaultLayout = getLayoutParams(context.query.view) ?? "grid";
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, AlbumSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { sortBy, order, defaultLayout, type: type ?? null },
|
||||
infiniteQueries: [
|
||||
API.getAlbums(
|
||||
{
|
||||
type: type ?? undefined,
|
||||
artist: isCompilationPage(context)
|
||||
? "compilations"
|
||||
: undefined,
|
||||
@@ -39,10 +33,7 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
};
|
||||
};
|
||||
|
||||
const LibraryAlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
props,
|
||||
}) => {
|
||||
const defaultType = props?.type ?? null;
|
||||
const AlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -50,15 +41,11 @@ const LibraryAlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<>
|
||||
<Head title={t("albums")} />
|
||||
<InfiniteAlbumView
|
||||
defaultAlbumType={defaultType}
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
defaultLayout={props?.defaultLayout}
|
||||
query={({ sortBy, order, type, library }) =>
|
||||
query={({ sortBy, order, types, libraries }) =>
|
||||
API.getAlbums(
|
||||
{
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
artist: isCompilationPage(router)
|
||||
? "compilations"
|
||||
: undefined,
|
||||
@@ -72,6 +59,6 @@ const LibraryAlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
);
|
||||
};
|
||||
|
||||
LibraryAlbumsPage.prepareSSR = prepareSSR;
|
||||
AlbumsPage.prepareSSR = prepareSSR;
|
||||
|
||||
export default LibraryAlbumsPage;
|
||||
export default AlbumsPage;
|
||||
@@ -23,14 +23,12 @@ import { useTranslation } from "react-i18next";
|
||||
import API from "../../../api/api";
|
||||
import { useQuery } from "../../../api/use-query";
|
||||
import { Head } from "../../../components/head";
|
||||
import InfiniteAlbumView from "../../../components/infinite/infinite-resource-view/infinite-album-view";
|
||||
import InfiniteAlbumView from "../../../components/infinite/resource/album";
|
||||
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
|
||||
import type { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import { getAlbumTypeParam } from "../../../utils/album-type";
|
||||
import { getYear } from "../../../utils/date";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import { useGradientBackground } from "../../../utils/gradient-background";
|
||||
import { getLayoutParams } from "../../../utils/layout";
|
||||
|
||||
const defaultSort = {
|
||||
sortBy: "releaseDate",
|
||||
@@ -42,22 +40,17 @@ const artistQuery = (artistIdentifier: string | number) =>
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const artistIdentifier = getSlugOrId(context.query);
|
||||
const defaultLayout = getLayoutParams(context.query.view) ?? "grid";
|
||||
const type = getAlbumTypeParam(context.query.type);
|
||||
|
||||
return {
|
||||
additionalProps: {
|
||||
artistIdentifier,
|
||||
defaultLayout,
|
||||
type: type ?? null,
|
||||
},
|
||||
queries: [artistQuery(artistIdentifier)],
|
||||
infiniteQueries: [
|
||||
API.getAlbums(
|
||||
{ artist: artistIdentifier, type: type },
|
||||
defaultSort,
|
||||
["artist", "illustration"],
|
||||
),
|
||||
API.getAlbums({ artist: artistIdentifier }, defaultSort, [
|
||||
"artist",
|
||||
"illustration",
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -69,7 +62,6 @@ const ArtistAlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
const artistIdentifier =
|
||||
props?.artistIdentifier ?? getSlugOrId(router.query);
|
||||
const artist = useQuery(artistQuery, artistIdentifier);
|
||||
const defaultType = props?.type ?? null;
|
||||
const { GradientBackground } = useGradientBackground(
|
||||
artist.data?.illustration?.colors,
|
||||
);
|
||||
@@ -83,19 +75,15 @@ const ArtistAlbumsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
/>
|
||||
<ArtistRelationPageHeader artist={artist.data} />
|
||||
<InfiniteAlbumView
|
||||
defaultLayout={props?.defaultLayout}
|
||||
defaultAlbumType={defaultType}
|
||||
initialSortingField={defaultSort.sortBy}
|
||||
initialSortingOrder={defaultSort.order}
|
||||
formatSubtitle={(album) =>
|
||||
getYear(album.releaseDate)?.toString() ?? ""
|
||||
}
|
||||
query={({ sortBy, order, library, type }) =>
|
||||
query={({ sortBy, order, libraries, types }) =>
|
||||
API.getAlbums(
|
||||
{
|
||||
artist: artistIdentifier,
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "illustration"],
|
||||
|
||||
@@ -51,7 +51,7 @@ const latestAlbumsQuery = AlbumType.map((type) => ({
|
||||
type: type,
|
||||
query: (artistSlugOrId: string | number) => {
|
||||
return API.getAlbums(
|
||||
{ artist: artistSlugOrId, type: type },
|
||||
{ artist: artistSlugOrId, type: [type] },
|
||||
{ sortBy: "releaseDate", order: "desc" },
|
||||
["illustration"],
|
||||
);
|
||||
|
||||
@@ -28,14 +28,17 @@ import {
|
||||
useQueryClient,
|
||||
} from "../../../api/use-query";
|
||||
import { Head } from "../../../components/head";
|
||||
import InfiniteSongView from "../../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../../components/infinite/controls/sort";
|
||||
import { HybridInfiniteSongView } from "../../../components/infinite/resource/song";
|
||||
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
|
||||
import { SongSortingKeys } from "../../../models/song";
|
||||
import type Track from "../../../models/track";
|
||||
import type { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import { useGradientBackground } from "../../../utils/gradient-background";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../../utils/sorting";
|
||||
|
||||
const artistQuery = (identifier: string | number) =>
|
||||
API.getArtist(identifier, ["illustration"]);
|
||||
@@ -48,8 +51,8 @@ const prepareSSR = async (
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
const artistIdentifier = getSlugOrId(context.query);
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, SongSortingKeys);
|
||||
const songs = await queryClient.fetchInfiniteQuery(
|
||||
prepareMeeloInfiniteQuery(() =>
|
||||
API.getSongs(
|
||||
@@ -110,45 +113,48 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
/>
|
||||
<GradientBackground />
|
||||
<ArtistRelationPageHeader artist={artist.data} />
|
||||
<InfiniteSongView
|
||||
initialSortingField={props?.sortBy ?? "name"}
|
||||
initialSortingOrder={props?.order ?? "asc"}
|
||||
query={({ library, sortBy, order, type, random }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
[isRareSongsPage(router) ? "rare" : "artist"]:
|
||||
artistIdentifier,
|
||||
type,
|
||||
random,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
)
|
||||
}
|
||||
groupsQuery={
|
||||
<HybridInfiniteSongView
|
||||
song={{
|
||||
query: ({ libraries, sortBy, order, types, random }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
[isRareSongsPage(router) ? "rare" : "artist"]:
|
||||
artistIdentifier,
|
||||
type: types,
|
||||
random,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
|
||||
subtitles: [(song) => getTrackReleaseName(song.master)],
|
||||
}}
|
||||
songGroup={
|
||||
isRareSongsPage(router)
|
||||
? undefined
|
||||
: ({ sortBy, order, library, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type,
|
||||
artist: artistIdentifier,
|
||||
library: library ?? undefined,
|
||||
},
|
||||
{ sortBy, order },
|
||||
[
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
],
|
||||
)
|
||||
: {
|
||||
query: ({ libraries, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type: type,
|
||||
artist: artistIdentifier,
|
||||
library: libraries,
|
||||
},
|
||||
undefined,
|
||||
[
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
],
|
||||
),
|
||||
|
||||
subtitles: [
|
||||
(song) => getTrackReleaseName(song.master),
|
||||
],
|
||||
}
|
||||
}
|
||||
subtitles={[
|
||||
(song) =>
|
||||
getTrackReleaseName(song.master).then((name) => name),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -22,24 +22,24 @@ import { useTranslation } from "react-i18next";
|
||||
import API from "../../../api/api";
|
||||
import { useQuery } from "../../../api/use-query";
|
||||
import { Head } from "../../../components/head";
|
||||
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../../components/infinite/controls/sort";
|
||||
import InfiniteVideoView from "../../../components/infinite/resource/video";
|
||||
import ArtistRelationPageHeader from "../../../components/relation-page-header/artist-relation-page-header";
|
||||
import { VideoSortingKeys } from "../../../models/video";
|
||||
import type { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import getSlugOrId from "../../../utils/getSlugOrId";
|
||||
import { useGradientBackground } from "../../../utils/gradient-background";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../../utils/sorting";
|
||||
|
||||
const artistQuery = (identifier: string | number) =>
|
||||
API.getArtist(identifier, ["illustration"]);
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const artistIdentifier = getSlugOrId(context.query);
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
VideoSortingKeys,
|
||||
);
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, VideoSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { artistIdentifier, order, sortBy },
|
||||
@@ -74,15 +74,13 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
<GradientBackground />
|
||||
<ArtistRelationPageHeader artist={artist.data} />
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, library, random, type }) =>
|
||||
query={({ sortBy, order, libraries, random, types }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
type,
|
||||
type: types,
|
||||
artist: artistIdentifier,
|
||||
random,
|
||||
library: library ?? undefined,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "master", "illustration"],
|
||||
|
||||
@@ -2,44 +2,36 @@ import type { NextPageContext } from "next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { Head } from "../../components/head";
|
||||
import InfiniteArtistView from "../../components/infinite/infinite-resource-view/infinite-artist-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../components/infinite/controls/sort";
|
||||
import InfiniteArtistView from "../../components/infinite/resource/artist";
|
||||
import { ArtistSortingKeys } from "../../models/artist";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import { getLayoutParams } from "../../utils/layout";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
ArtistSortingKeys,
|
||||
);
|
||||
const defaultLayout = getLayoutParams(context.query.view) ?? "list";
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, ArtistSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { defaultLayout, sortBy, order },
|
||||
infiniteQueries: [
|
||||
API.getArtists({}, { sortBy, order }, ["illustration"]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const ArtistsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
const ArtistsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t("artists")} />
|
||||
<InfiniteArtistView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
defaultLayout={props?.defaultLayout}
|
||||
query={({ library, sortBy, order }) =>
|
||||
API.getArtists(
|
||||
library ? { library } : {},
|
||||
{ sortBy, order },
|
||||
["illustration"],
|
||||
)
|
||||
query={({ libraries, sortBy, order }) =>
|
||||
API.getArtists({ library: libraries }, { sortBy, order }, [
|
||||
"illustration",
|
||||
])
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -23,9 +23,9 @@ import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQuery } from "../../api/use-query";
|
||||
import { Head } from "../../components/head";
|
||||
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
|
||||
import InfiniteArtistView from "../../components/infinite/infinite-resource-view/infinite-artist-view";
|
||||
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import InfiniteAlbumView from "../../components/infinite/resource/album";
|
||||
import InfiniteArtistView from "../../components/infinite/resource/artist";
|
||||
import { InfiniteSongView } from "../../components/infinite/resource/song";
|
||||
import { useTabRouter } from "../../components/tab-router";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import getSlugOrId from "../../utils/getSlugOrId";
|
||||
@@ -103,11 +103,11 @@ const GenrePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
<Box sx={{ paddingBottom: 2 }} />
|
||||
{selectedTab === "artist" && (
|
||||
<InfiniteArtistView
|
||||
query={({ library, sortBy, order }) =>
|
||||
query={({ libraries, sortBy, order }) =>
|
||||
API.getArtists(
|
||||
{
|
||||
genre: genreIdentifier,
|
||||
library: library ?? undefined,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["illustration"],
|
||||
@@ -117,13 +117,12 @@ const GenrePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{selectedTab === "album" && (
|
||||
<InfiniteAlbumView
|
||||
defaultAlbumType={null}
|
||||
query={({ library, type, sortBy, order }) =>
|
||||
query={({ libraries, types, sortBy, order }) =>
|
||||
API.getAlbums(
|
||||
{
|
||||
genre: genreIdentifier,
|
||||
type,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "illustration"],
|
||||
@@ -133,13 +132,13 @@ const GenrePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{selectedTab === "song" && (
|
||||
<InfiniteSongView
|
||||
query={({ library, type, random, sortBy, order }) =>
|
||||
query={({ libraries, types, random, sortBy, order }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
genre: genreIdentifier,
|
||||
type,
|
||||
type: types,
|
||||
random,
|
||||
library: library ?? undefined,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
|
||||
@@ -76,7 +76,7 @@ const mostListenedSongsQuery = API.getSongs(
|
||||
);
|
||||
|
||||
const albumRecommendations = (seed: number) =>
|
||||
API.getAlbums({ random: seed, type: "StudioRecording" }, undefined, [
|
||||
API.getAlbums({ random: seed, type: ["StudioRecording"] }, undefined, [
|
||||
"artist",
|
||||
"genres",
|
||||
"illustration",
|
||||
|
||||
@@ -20,31 +20,26 @@ import type { NextPageContext } from "next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { Head } from "../../components/head";
|
||||
import InfinitePlaylistView from "../../components/infinite/infinite-resource-view/infinite-playlist-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../components/infinite/controls/sort";
|
||||
import InfinitePlaylistView from "../../components/infinite/resource/playlist";
|
||||
import { PlaylistSortingKeys } from "../../models/playlist";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import { getLayoutParams } from "../../utils/layout";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const defaultLayout = getLayoutParams(context.query.view) ?? "list";
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
PlaylistSortingKeys,
|
||||
);
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, PlaylistSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { defaultLayout, order, sortBy },
|
||||
infiniteQueries: [
|
||||
API.getPlaylists({}, { sortBy, order }, ["illustration"]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const PlaylistsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
props,
|
||||
}) => {
|
||||
const PlaylistsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -53,12 +48,7 @@ const PlaylistsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
emptyState={{
|
||||
text: "emptyStatePlaylists",
|
||||
}}
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
defaultLayout={props?.defaultLayout}
|
||||
query={({ view, library, ...sort }) =>
|
||||
API.getPlaylists({}, sort, ["illustration"])
|
||||
}
|
||||
query={(sort) => API.getPlaylists({}, sort, ["illustration"])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ const formatReleaseDate = (date: Date, lang: string) => {
|
||||
};
|
||||
|
||||
const releaseQuery = (releaseIdentifier: string | number) =>
|
||||
API.getRelease(releaseIdentifier, ["album", "illustration"]);
|
||||
API.getRelease(releaseIdentifier, ["album", "illustration", "discs"]);
|
||||
const releaseTracklistQuery = (
|
||||
releaseIdentifier: number | string,
|
||||
exclusiveOnly: boolean,
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
} from "../../api/use-query";
|
||||
import { Head } from "../../components/head";
|
||||
import { SearchIcon } from "../../components/icons";
|
||||
import InfiniteAlbumView from "../../components/infinite/infinite-resource-view/infinite-album-view";
|
||||
import InfiniteArtistView from "../../components/infinite/infinite-resource-view/infinite-artist-view";
|
||||
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import InfiniteVideoView from "../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import InfiniteView from "../../components/infinite/infinite-view";
|
||||
import InfiniteAlbumView from "../../components/infinite/resource/album";
|
||||
import InfiniteArtistView from "../../components/infinite/resource/artist";
|
||||
import { InfiniteSongView } from "../../components/infinite/resource/song";
|
||||
import InfiniteVideoView from "../../components/infinite/resource/video";
|
||||
import AlbumItem from "../../components/list-item/album-item";
|
||||
import ArtistItem from "../../components/list-item/artist-item";
|
||||
import SongItem from "../../components/list-item/song-item";
|
||||
@@ -205,7 +205,7 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
key={index}
|
||||
value={value}
|
||||
sx={{ minWidth: "fit-content", flex: 1 }}
|
||||
label={t(value === "all" ? "All" : `${value}s`)}
|
||||
label={t(value === "all" ? "all" : `${value}s`)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
@@ -282,14 +282,15 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{query && selectedTab === "artist" && (
|
||||
<InfiniteArtistView
|
||||
disableSort
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ artistId: item.id })
|
||||
}
|
||||
query={({ library }) =>
|
||||
query={({ libraries }) =>
|
||||
API.getArtists(
|
||||
{
|
||||
query: encodeURIComponent(query),
|
||||
library: library ?? undefined,
|
||||
library: libraries,
|
||||
},
|
||||
undefined,
|
||||
["illustration"],
|
||||
@@ -299,16 +300,16 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{query && selectedTab === "album" && (
|
||||
<InfiniteAlbumView
|
||||
disableSort
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ albumId: item.id })
|
||||
}
|
||||
defaultAlbumType={null}
|
||||
query={({ library, type: newType }) =>
|
||||
query={({ libraries, types }) =>
|
||||
API.getAlbums(
|
||||
{
|
||||
query: encodeURIComponent(query),
|
||||
type: newType,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
},
|
||||
undefined,
|
||||
["artist", "illustration"],
|
||||
@@ -318,15 +319,16 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
)}
|
||||
{query && selectedTab === "song" && (
|
||||
<InfiniteSongView
|
||||
disableSort
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ songId: item.id })
|
||||
}
|
||||
query={({ library, type: newType }) =>
|
||||
query={({ libraries, types }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
query: encodeURIComponent(query),
|
||||
type: newType,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
},
|
||||
undefined,
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
@@ -340,13 +342,14 @@ const SearchPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
onItemClick={(item) =>
|
||||
item && saveSearch.mutate({ videoId: item.id })
|
||||
}
|
||||
disableSort
|
||||
subtitle="artist"
|
||||
query={({ library, type: newType }) =>
|
||||
query={({ libraries, types }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
query: encodeURIComponent(query),
|
||||
type: newType,
|
||||
library: library ?? undefined,
|
||||
type: types,
|
||||
library: libraries,
|
||||
},
|
||||
undefined,
|
||||
["artist", "master", "illustration"],
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { useQuery } from "../../api/use-query";
|
||||
import { Head } from "../../components/head";
|
||||
import LoadingPage from "../../components/loading/loading-page";
|
||||
import LibrariesSettings from "../../components/settings/libraries-settings";
|
||||
import UserSettings from "../../components/settings/user-settings";
|
||||
import UsersSettings from "../../components/settings/users-settings";
|
||||
@@ -77,7 +76,7 @@ const SettingsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
}, [panel]);
|
||||
|
||||
if (!userQuery.data) {
|
||||
return <LoadingPage />;
|
||||
return <></>;
|
||||
}
|
||||
if (userQuery.data.admin === false) {
|
||||
return <UserSettings />;
|
||||
|
||||
@@ -41,11 +41,12 @@ import ExternalMetadataBadge from "../../../components/external-metadata-badge";
|
||||
import GenreButton from "../../../components/genre-button";
|
||||
import { Head } from "../../../components/head";
|
||||
import { PlayIcon } from "../../../components/icons";
|
||||
import InfiniteSongView from "../../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import InfiniteTrackView from "../../../components/infinite/infinite-resource-view/infinite-track-view";
|
||||
import InfiniteVideoView from "../../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import { InfiniteSongView } from "../../../components/infinite/resource/song";
|
||||
import InfiniteTrackView from "../../../components/infinite/resource/track";
|
||||
import InfiniteVideoView from "../../../components/infinite/resource/video";
|
||||
import LyricsBox from "../../../components/lyrics";
|
||||
import SongRelationPageHeader from "../../../components/relation-page-header/song-relation-page-header";
|
||||
import SongTypeIcon from "../../../components/song-type-icon";
|
||||
import { useTabRouter } from "../../../components/tab-router";
|
||||
import type { GetPropsTypesFrom, Page } from "../../../ssr";
|
||||
import { playTrackAtom } from "../../../state/player";
|
||||
@@ -179,6 +180,38 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
song.data && `${song.data?.name} (${t("info")})`
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
overflowX: "scroll",
|
||||
alignItems: "center",
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
spacing={1}
|
||||
>
|
||||
<Typography sx={{ overflow: "unset" }}>
|
||||
{`${t("songType")}: `}
|
||||
</Typography>
|
||||
{song.data ? (
|
||||
song.data.type ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<SongTypeIcon
|
||||
type={song.data.type}
|
||||
size={20}
|
||||
/>
|
||||
<Typography>
|
||||
{t(song.data.type)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null
|
||||
) : (
|
||||
<Skeleton width="100px" />
|
||||
)}
|
||||
</Stack>
|
||||
{song.data ? (
|
||||
song.data.bpm ? (
|
||||
<Typography
|
||||
@@ -255,7 +288,7 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
songName={song.data?.name}
|
||||
lyrics={
|
||||
song.data
|
||||
? (song.data.lyrics?.content.split("\n") ??
|
||||
? (song.data.lyrics?.plain.split("\n") ??
|
||||
null)
|
||||
: undefined
|
||||
}
|
||||
@@ -272,11 +305,11 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
/>
|
||||
<InfiniteSongView
|
||||
disableShuffle
|
||||
query={({ library, sortBy, order, type }) =>
|
||||
query={({ libraries, sortBy, order, types }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
library: library ?? undefined,
|
||||
type,
|
||||
library: libraries,
|
||||
type: types,
|
||||
versionsOf: songIdentifier,
|
||||
},
|
||||
{ sortBy, order },
|
||||
@@ -301,11 +334,11 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
/>
|
||||
<InfiniteVideoView
|
||||
query={({ library, sortBy, order, type }) =>
|
||||
query={({ libraries, sortBy, order, types }) =>
|
||||
API.getVideos(
|
||||
{
|
||||
library: library ?? undefined,
|
||||
type,
|
||||
library: libraries,
|
||||
type: types,
|
||||
group: song.data?.groupId,
|
||||
},
|
||||
{ sortBy, order },
|
||||
@@ -325,9 +358,12 @@ const SongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
|
||||
}
|
||||
/>
|
||||
<InfiniteTrackView
|
||||
query={({ sortBy, order }) =>
|
||||
query={({ sortBy, order, libraries }) =>
|
||||
API.getTracks(
|
||||
{ song: songIdentifier },
|
||||
{
|
||||
song: songIdentifier,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy, order },
|
||||
[
|
||||
"release",
|
||||
|
||||
@@ -2,14 +2,17 @@ import type { NextPageContext } from "next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { Head } from "../../components/head";
|
||||
import InfiniteSongView from "../../components/infinite/infinite-resource-view/infinite-song-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../components/infinite/controls/sort";
|
||||
import { HybridInfiniteSongView } from "../../components/infinite/resource/song";
|
||||
import { SongSortingKeys } from "../../models/song";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, SongSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { order, sortBy },
|
||||
@@ -24,43 +27,41 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
};
|
||||
};
|
||||
|
||||
const LibrarySongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
props,
|
||||
}) => {
|
||||
const SongsPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t("songs")} />
|
||||
<InfiniteSongView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ sortBy, order, type, library, random }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
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"],
|
||||
)
|
||||
}
|
||||
<HybridInfiniteSongView
|
||||
song={{
|
||||
query: ({ sortBy, order, types, libraries, random }) =>
|
||||
API.getSongs(
|
||||
{
|
||||
type: types,
|
||||
library: libraries,
|
||||
random,
|
||||
},
|
||||
{ sortBy, order },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
}}
|
||||
songGroup={{
|
||||
query: ({ libraries, type }) =>
|
||||
API.getSongGroups(
|
||||
{
|
||||
type: type,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy: "name", order: "asc" },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LibrarySongsPage.prepareSSR = prepareSSR;
|
||||
SongsPage.prepareSSR = prepareSSR;
|
||||
|
||||
export default LibrarySongsPage;
|
||||
export default SongsPage;
|
||||
@@ -2,20 +2,19 @@ import type { NextPageContext } from "next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import API from "../../api/api";
|
||||
import { Head } from "../../components/head";
|
||||
import InfiniteVideoView from "../../components/infinite/infinite-resource-view/infinite-video-view";
|
||||
import {
|
||||
getOrderQuery,
|
||||
getSortQuery,
|
||||
} from "../../components/infinite/controls/sort";
|
||||
import InfiniteVideoView from "../../components/infinite/resource/video";
|
||||
import { VideoSortingKeys } from "../../models/video";
|
||||
import type { GetPropsTypesFrom, Page } from "../../ssr";
|
||||
import { getOrderParams, getSortingFieldParams } from "../../utils/sorting";
|
||||
|
||||
const prepareSSR = (context: NextPageContext) => {
|
||||
const order = getOrderParams(context.query.order) ?? "asc";
|
||||
const sortBy = getSortingFieldParams(
|
||||
context.query.sortBy,
|
||||
VideoSortingKeys,
|
||||
);
|
||||
const order = getOrderQuery(context) ?? "asc";
|
||||
const sortBy = getSortQuery(context, VideoSortingKeys);
|
||||
|
||||
return {
|
||||
additionalProps: { sortBy, order },
|
||||
infiniteQueries: [
|
||||
API.getVideos({}, { sortBy, order }, [
|
||||
"artist",
|
||||
@@ -26,20 +25,16 @@ const prepareSSR = (context: NextPageContext) => {
|
||||
};
|
||||
};
|
||||
|
||||
const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
|
||||
props,
|
||||
}) => {
|
||||
const LibraryVideosPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t("videos")} />
|
||||
<InfiniteVideoView
|
||||
initialSortingField={props?.sortBy}
|
||||
initialSortingOrder={props?.order}
|
||||
query={({ library, sortBy, order, random, type }) =>
|
||||
query={({ libraries, sortBy, order, random, types }) =>
|
||||
API.getVideos(
|
||||
{ library: library ?? undefined, random, type },
|
||||
{ library: libraries, random, type: types },
|
||||
{ sortBy, order },
|
||||
["artist", "master", "illustration"],
|
||||
)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { AlbumType } from "../models/album";
|
||||
|
||||
export const getAlbumTypeParam = (input: any) => {
|
||||
if (Array.isArray(input)) {
|
||||
input = input[0];
|
||||
}
|
||||
for (const type of AlbumType) {
|
||||
if (input?.toLowerCase() === type.toLowerCase()) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -20,18 +20,6 @@ export const LayoutOptions = ["grid", "list"] as const;
|
||||
|
||||
export type LayoutOption = (typeof LayoutOptions)[number];
|
||||
|
||||
export const getLayoutParams = (input: any) => {
|
||||
if (Array.isArray(input)) {
|
||||
input = input[0];
|
||||
}
|
||||
for (const layout of LayoutOptions) {
|
||||
if (input?.toLowerCase() === layout) {
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const ItemSize = ["xs", "s", "m", "l", "xl"] as const;
|
||||
|
||||
export type ItemSize = (typeof ItemSize)[number];
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const parseQueryParam = <Keys extends readonly string[]>(
|
||||
import type { NextRouter } from "next/router";
|
||||
|
||||
export const parseQueryParam = <Keys extends readonly string[]>(
|
||||
input: any,
|
||||
optionValues: Keys,
|
||||
): Keys[number] | undefined => {
|
||||
): Keys[number] | null => {
|
||||
if (Array.isArray(input)) {
|
||||
input = input[0];
|
||||
}
|
||||
@@ -28,7 +30,24 @@ const parseQueryParam = <Keys extends readonly string[]>(
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return null;
|
||||
};
|
||||
|
||||
export default parseQueryParam;
|
||||
export const setQueryParam = (
|
||||
keysAndValues: [string, string | null][],
|
||||
router: NextRouter,
|
||||
) => {
|
||||
const path = router.asPath.split("?")[0];
|
||||
const params = new URLSearchParams(router.asPath.split("?").at(1) ?? "");
|
||||
|
||||
for (const [key, value] of keysAndValues) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
router.push(`${path}?${params.toString()}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
};
|
||||
@@ -16,8 +16,6 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import parseQueryParam from "./parse-query-param";
|
||||
|
||||
export const Orders = ["asc", "desc"] as const;
|
||||
|
||||
export type Order = (typeof Orders)[number];
|
||||
@@ -26,14 +24,3 @@ export type SortingParameters<Keys extends readonly string[]> = {
|
||||
sortBy: Keys[number];
|
||||
order?: Order;
|
||||
};
|
||||
|
||||
const getOrderParams = (order: any) => parseQueryParam(order, Orders);
|
||||
|
||||
const getSortingFieldParams = <Keys extends readonly string[]>(
|
||||
field: any,
|
||||
availableKeys: Keys,
|
||||
): Keys[number] => {
|
||||
return parseQueryParam(field, availableKeys) ?? availableKeys[0];
|
||||
};
|
||||
|
||||
export { getOrderParams, getSortingFieldParams };
|
||||
+58
-242
@@ -15,7 +15,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.18.6, @babel/code-frame@npm:^7.22.5":
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/code-frame@npm:7.22.5"
|
||||
dependencies:
|
||||
@@ -94,17 +94,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/generator@npm:^7.20.1":
|
||||
version: 7.20.7
|
||||
resolution: "@babel/generator@npm:7.20.7"
|
||||
dependencies:
|
||||
"@babel/types": "npm:^7.20.7"
|
||||
"@jridgewell/gen-mapping": "npm:^0.3.2"
|
||||
jsesc: "npm:^2.5.1"
|
||||
checksum: 10c0/b22032867dfad3115404ea74fd063079883428cf838ec490a1f64d2e5e4dd82f94f77df90eb95a57740fb387a115b5ffe655e768cb50862832c6f9f6ffb4be79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/generator@npm:^7.22.7, @babel/generator@npm:^7.22.9, @babel/generator@npm:^7.7.2":
|
||||
version: 7.22.9
|
||||
resolution: "@babel/generator@npm:7.22.9"
|
||||
@@ -129,15 +118,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-annotate-as-pure@npm:^7.16.0":
|
||||
version: 7.18.6
|
||||
resolution: "@babel/helper-annotate-as-pure@npm:7.18.6"
|
||||
dependencies:
|
||||
"@babel/types": "npm:^7.18.6"
|
||||
checksum: 10c0/e413cd022e1e21232c1ce98f3e1198ec5f4774c7eceb81155a45f9cb6d8481f3983c52f83252309856668e728c751f0340d29854b604530a694899208df6bcc3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-compilation-targets@npm:^7.22.9":
|
||||
version: 7.22.9
|
||||
resolution: "@babel/helper-compilation-targets@npm:7.22.9"
|
||||
@@ -166,14 +146,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-environment-visitor@npm:^7.18.9, @babel/helper-environment-visitor@npm:^7.22.5":
|
||||
"@babel/helper-environment-visitor@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/helper-environment-visitor@npm:7.22.5"
|
||||
checksum: 10c0/c9377464c1839741a0a77bbad56de94c896f4313eb034c988fc2ab01293e7c4027244c93b4256606c5f4e34c68cf599a7d31a548d537577c7da836bbca40551b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-function-name@npm:^7.19.0, @babel/helper-function-name@npm:^7.22.5":
|
||||
"@babel/helper-function-name@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/helper-function-name@npm:7.22.5"
|
||||
dependencies:
|
||||
@@ -183,7 +163,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-hoist-variables@npm:^7.18.6, @babel/helper-hoist-variables@npm:^7.22.5":
|
||||
"@babel/helper-hoist-variables@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/helper-hoist-variables@npm:7.22.5"
|
||||
dependencies:
|
||||
@@ -192,7 +172,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-module-imports@npm:^7.0.0, @babel/helper-module-imports@npm:^7.16.0, @babel/helper-module-imports@npm:^7.16.7":
|
||||
"@babel/helper-module-imports@npm:^7.16.7":
|
||||
version: 7.18.6
|
||||
resolution: "@babel/helper-module-imports@npm:7.18.6"
|
||||
dependencies:
|
||||
@@ -275,7 +255,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-split-export-declaration@npm:^7.18.6, @babel/helper-split-export-declaration@npm:^7.22.6":
|
||||
"@babel/helper-split-export-declaration@npm:^7.22.6":
|
||||
version: 7.22.6
|
||||
resolution: "@babel/helper-split-export-declaration@npm:7.22.6"
|
||||
dependencies:
|
||||
@@ -284,7 +264,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-string-parser@npm:^7.19.4, @babel/helper-string-parser@npm:^7.22.5":
|
||||
"@babel/helper-string-parser@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/helper-string-parser@npm:7.22.5"
|
||||
checksum: 10c0/6b0ff8af724377ec41e5587fffa7605198da74cb8e7d8d48a36826df0c0ba210eb9fedb3d9bef4d541156e0bd11040f021945a6cbb731ccec4aefb4affa17aa4
|
||||
@@ -298,7 +278,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-validator-identifier@npm:^7.19.1, @babel/helper-validator-identifier@npm:^7.22.5":
|
||||
"@babel/helper-validator-identifier@npm:^7.22.5":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/helper-validator-identifier@npm:7.22.5"
|
||||
checksum: 10c0/2ff1d3833154d17ccf773b8a71fdc0cd0e7356aa8033179d0e3133787dfb33d97796cbff8b92a97c56268205337dfc720227aeddc677c1bc08ae1b67a95252d7
|
||||
@@ -379,15 +359,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/parser@npm:^7.20.1":
|
||||
version: 7.20.7
|
||||
resolution: "@babel/parser@npm:7.20.7"
|
||||
bin:
|
||||
parser: ./bin/babel-parser.js
|
||||
checksum: 10c0/fd75ef65d5b98b88f8cfa14b72909473ddb783d536399a8a911eff4b3a5022b71d12725ddf6ee796de7b3d2243ce33b991efaab3921a28ece91668c7887dce18
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/parser@npm:^7.23.9, @babel/parser@npm:^7.25.0, @babel/parser@npm:^7.25.3":
|
||||
version: 7.25.3
|
||||
resolution: "@babel/parser@npm:7.25.3"
|
||||
@@ -664,24 +635,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/traverse@npm:^7.4.5":
|
||||
version: 7.20.1
|
||||
resolution: "@babel/traverse@npm:7.20.1"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:^7.18.6"
|
||||
"@babel/generator": "npm:^7.20.1"
|
||||
"@babel/helper-environment-visitor": "npm:^7.18.9"
|
||||
"@babel/helper-function-name": "npm:^7.19.0"
|
||||
"@babel/helper-hoist-variables": "npm:^7.18.6"
|
||||
"@babel/helper-split-export-declaration": "npm:^7.18.6"
|
||||
"@babel/parser": "npm:^7.20.1"
|
||||
"@babel/types": "npm:^7.20.0"
|
||||
debug: "npm:^4.1.0"
|
||||
globals: "npm:^11.1.0"
|
||||
checksum: 10c0/6b2611f26bcc52bcdf515ed4932c316b20511f4595ca26a1db71b18273d7e2aaf435156708f968914bbf34a2dfac7c3e6618fffc9169eed5537dcdb85143da5a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.6, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5, @babel/types@npm:^7.3.3":
|
||||
version: 7.22.5
|
||||
resolution: "@babel/types@npm:7.22.5"
|
||||
@@ -693,17 +646,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.20.0":
|
||||
version: 7.20.7
|
||||
resolution: "@babel/types@npm:7.20.7"
|
||||
dependencies:
|
||||
"@babel/helper-string-parser": "npm:^7.19.4"
|
||||
"@babel/helper-validator-identifier": "npm:^7.19.1"
|
||||
to-fast-properties: "npm:^2.0.0"
|
||||
checksum: 10c0/df0061f306bd95389604075ba5a88e984a801635c70c77b3b6ae8ab44675064b9ef4088c6c78dbf786a28efc662ad37f9c09f8658ba44c12cb8dd6f450a8bde7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.24.7, @babel/types@npm:^7.25.0, @babel/types@npm:^7.25.2, @babel/types@npm:^7.8.3":
|
||||
version: 7.25.2
|
||||
resolution: "@babel/types@npm:7.25.2"
|
||||
@@ -940,7 +882,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/is-prop-valid@npm:^1.1.0, @emotion/is-prop-valid@npm:^1.2.0":
|
||||
"@emotion/is-prop-valid@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "@emotion/is-prop-valid@npm:1.2.0"
|
||||
dependencies:
|
||||
@@ -1054,20 +996,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/stylis@npm:^0.8.4":
|
||||
version: 0.8.5
|
||||
resolution: "@emotion/stylis@npm:0.8.5"
|
||||
checksum: 10c0/f109e3f11cb0d48e8658aaa23578c5bcfe35e297819cfb089a3de6ba8dc0f89b0960474922690c6028df5d2e1895b4967f2fb280642c030054c312f1e137ce26
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/unitless@npm:^0.7.4":
|
||||
version: 0.7.5
|
||||
resolution: "@emotion/unitless@npm:0.7.5"
|
||||
checksum: 10c0/4d0d94f53cb97b4481bbfa394953e1899a0b877644642ba9dd7247c27eb8c48e14e22aeb11411d7d9874685ad85dd5fb5b50eb78c6d8840eb56a84b92dcef2f4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/unitless@npm:^0.8.0":
|
||||
version: 0.8.0
|
||||
resolution: "@emotion/unitless@npm:0.8.0"
|
||||
@@ -1860,72 +1788,72 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/env@npm:14.2.23"
|
||||
checksum: 10c0/00313ffbfa0ca990ec53a24ff007a114db95064034370f97ffa5733ca41d288e0d3b140e070bd07073db4da922a1ab93df84651cc61fc495572d7cdc65c10329
|
||||
"@next/env@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/env@npm:14.2.25"
|
||||
checksum: 10c0/8a4f4226a4e31b6189512c13faf039dced2a1e48236b1be30da72b85cdf87d08876a8fb242ed8fd25380dc1f4ae77613650214d1566f04f0cc32f2384cad1785
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-darwin-arm64@npm:14.2.23"
|
||||
"@next/swc-darwin-arm64@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-darwin-arm64@npm:14.2.25"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-darwin-x64@npm:14.2.23"
|
||||
"@next/swc-darwin-x64@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-darwin-x64@npm:14.2.25"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:14.2.23"
|
||||
"@next/swc-linux-arm64-gnu@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:14.2.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:14.2.23"
|
||||
"@next/swc-linux-arm64-musl@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:14.2.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:14.2.23"
|
||||
"@next/swc-linux-x64-gnu@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:14.2.25"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-linux-x64-musl@npm:14.2.23"
|
||||
"@next/swc-linux-x64-musl@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-linux-x64-musl@npm:14.2.25"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:14.2.23"
|
||||
"@next/swc-win32-arm64-msvc@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:14.2.25"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-ia32-msvc@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:14.2.23"
|
||||
"@next/swc-win32-ia32-msvc@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:14.2.25"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:14.2.23"
|
||||
"@next/swc-win32-x64-msvc@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:14.2.25"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -2425,28 +2353,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"babel-plugin-styled-components@npm:>= 1.12.0":
|
||||
version: 2.0.7
|
||||
resolution: "babel-plugin-styled-components@npm:2.0.7"
|
||||
dependencies:
|
||||
"@babel/helper-annotate-as-pure": "npm:^7.16.0"
|
||||
"@babel/helper-module-imports": "npm:^7.16.0"
|
||||
babel-plugin-syntax-jsx: "npm:^6.18.0"
|
||||
lodash: "npm:^4.17.11"
|
||||
picomatch: "npm:^2.3.0"
|
||||
peerDependencies:
|
||||
styled-components: ">= 2"
|
||||
checksum: 10c0/1c639742fb177f36648077a44fd473fe050aa5c664a16ecaa8e366426c44520c2a0011682b90f5b62f3c4317c22938410a6044b7cd99eaba831c00d41a2395c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"babel-plugin-syntax-jsx@npm:^6.18.0":
|
||||
version: 6.18.0
|
||||
resolution: "babel-plugin-syntax-jsx@npm:6.18.0"
|
||||
checksum: 10c0/d5954e9c2a3dd519f23e78674ecfba61394a8fae63499afdeca4214fad68997556ebd15ce012bbc4d527ae0e3cecc98d3e8f78004a68707122642d0df4ab7213
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"babel-preset-current-node-syntax@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "babel-preset-current-node-syntax@npm:1.0.1"
|
||||
@@ -2647,13 +2553,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"camelize@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "camelize@npm:1.0.1"
|
||||
checksum: 10c0/4c9ac55efd356d37ac483bad3093758236ab686192751d1c9daa43188cc5a07b09bd431eb7458a4efd9ca22424bba23253e7b353feb35d7c749ba040de2385fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001503, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001646":
|
||||
version: 1.0.30001650
|
||||
resolution: "caniuse-lite@npm:1.0.30001650"
|
||||
@@ -2928,13 +2827,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-color-keywords@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "css-color-keywords@npm:1.0.0"
|
||||
checksum: 10c0/af205a86c68e0051846ed91eb3e30b4517e1904aac040013ff1d742019b3f9369ba5658ba40901dbbc121186fc4bf0e75a814321cc3e3182fbb2feb81c6d9cb7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-in-js-utils@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "css-in-js-utils@npm:3.1.0"
|
||||
@@ -2944,17 +2836,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-to-react-native@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "css-to-react-native@npm:3.0.0"
|
||||
dependencies:
|
||||
camelize: "npm:^1.0.0"
|
||||
css-color-keywords: "npm:^1.0.0"
|
||||
postcss-value-parser: "npm:^4.0.2"
|
||||
checksum: 10c0/03dcf5381ff6a888e3621aaffd812fd52df803d01c264b99142a9f73942007c2e953bd27339d6e2da2504a940a2b0d3cc702d3a737165703a0d8ae9c5626c1c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-tree@npm:^1.1.2":
|
||||
version: 1.1.3
|
||||
resolution: "css-tree@npm:1.1.3"
|
||||
@@ -3492,7 +3373,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2":
|
||||
"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2":
|
||||
version: 3.3.2
|
||||
resolution: "hoist-non-react-statics@npm:3.3.2"
|
||||
dependencies:
|
||||
@@ -4394,13 +4275,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash@npm:^4.17.11":
|
||||
version: 4.17.21
|
||||
resolution: "lodash@npm:4.17.21"
|
||||
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
@@ -4538,7 +4412,7 @@ __metadata:
|
||||
jotai: "npm:^2.12.1"
|
||||
material-ui-confirm: "npm:^3.0.7"
|
||||
mui-react-hook-form-plus: "npm:^1.3.2"
|
||||
next: "npm:14.2.23"
|
||||
next: "npm:14.2.25"
|
||||
react: "npm:18.2.0"
|
||||
react-beautiful-dnd: "npm:^13.1.1"
|
||||
react-blurhash: "npm:^0.3.0"
|
||||
@@ -4548,7 +4422,6 @@ __metadata:
|
||||
react-hot-toast: "npm:^2.3.0"
|
||||
react-i18next: "npm:^12.2.2"
|
||||
react-infinite-scroller: "npm:^1.2.6"
|
||||
react-loader-spinner: "npm:^5.2.0"
|
||||
react-query: "npm:^3.39.2"
|
||||
react-use: "npm:^17.5.1"
|
||||
sharp: "npm:^0.33.4"
|
||||
@@ -4799,20 +4672,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:14.2.23":
|
||||
version: 14.2.23
|
||||
resolution: "next@npm:14.2.23"
|
||||
"next@npm:14.2.25":
|
||||
version: 14.2.25
|
||||
resolution: "next@npm:14.2.25"
|
||||
dependencies:
|
||||
"@next/env": "npm:14.2.23"
|
||||
"@next/swc-darwin-arm64": "npm:14.2.23"
|
||||
"@next/swc-darwin-x64": "npm:14.2.23"
|
||||
"@next/swc-linux-arm64-gnu": "npm:14.2.23"
|
||||
"@next/swc-linux-arm64-musl": "npm:14.2.23"
|
||||
"@next/swc-linux-x64-gnu": "npm:14.2.23"
|
||||
"@next/swc-linux-x64-musl": "npm:14.2.23"
|
||||
"@next/swc-win32-arm64-msvc": "npm:14.2.23"
|
||||
"@next/swc-win32-ia32-msvc": "npm:14.2.23"
|
||||
"@next/swc-win32-x64-msvc": "npm:14.2.23"
|
||||
"@next/env": "npm:14.2.25"
|
||||
"@next/swc-darwin-arm64": "npm:14.2.25"
|
||||
"@next/swc-darwin-x64": "npm:14.2.25"
|
||||
"@next/swc-linux-arm64-gnu": "npm:14.2.25"
|
||||
"@next/swc-linux-arm64-musl": "npm:14.2.25"
|
||||
"@next/swc-linux-x64-gnu": "npm:14.2.25"
|
||||
"@next/swc-linux-x64-musl": "npm:14.2.25"
|
||||
"@next/swc-win32-arm64-msvc": "npm:14.2.25"
|
||||
"@next/swc-win32-ia32-msvc": "npm:14.2.25"
|
||||
"@next/swc-win32-x64-msvc": "npm:14.2.25"
|
||||
"@swc/helpers": "npm:0.5.5"
|
||||
busboy: "npm:1.6.0"
|
||||
caniuse-lite: "npm:^1.0.30001579"
|
||||
@@ -4853,7 +4726,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: 10c0/76eb77263f6c8064fe01d7652dc4a877a265ddc4d34e57196361c9266488a9c0c0cd5428ec3496593a09face78e5f9b6ec556c8a4bc1a01194b3ba5f1aca1a4b
|
||||
checksum: 10c0/2ee3859b957208e93dd10925d1059bc8580fedb612348d67a34523c586f19dc4666b28bc4b7107f268fc501a6ad7ea50de65a4f076b41101e633cbac68dfa142
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5094,7 +4967,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.3, picomatch@npm:^2.3.0, picomatch@npm:^2.3.1":
|
||||
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "picomatch@npm:2.3.1"
|
||||
checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be
|
||||
@@ -5117,13 +4990,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-value-parser@npm:^4.0.2":
|
||||
version: 4.2.0
|
||||
resolution: "postcss-value-parser@npm:4.2.0"
|
||||
checksum: 10c0/f4142a4f56565f77c1831168e04e3effd9ffcc5aebaf0f538eee4b2d465adfd4b85a44257bb48418202a63806a7da7fe9f56c330aebb3cac898e46b4cbf49161
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss@npm:8.4.31":
|
||||
version: 8.4.31
|
||||
resolution: "postcss@npm:8.4.31"
|
||||
@@ -5334,20 +5200,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-loader-spinner@npm:^5.2.0":
|
||||
version: 5.3.4
|
||||
resolution: "react-loader-spinner@npm:5.3.4"
|
||||
dependencies:
|
||||
react-is: "npm:^18.2.0"
|
||||
styled-components: "npm:^5.3.5"
|
||||
styled-tools: "npm:^1.7.2"
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/929b4004d1f0ee770af7276804366bdcceb3c14610783f78b4c5d2c3e8053c0a18da58e048b49584aec2145cbafb3460c43929f4a824316a54736b44ac9dbe95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-query@npm:^3.39.2":
|
||||
version: 3.39.2
|
||||
resolution: "react-query@npm:3.39.2"
|
||||
@@ -5708,13 +5560,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shallowequal@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "shallowequal@npm:1.1.0"
|
||||
checksum: 10c0/b926efb51cd0f47aa9bc061add788a4a650550bbe50647962113a4579b60af2abe7b62f9b02314acc6f97151d4cf87033a2b15fc20852fae306d1a095215396c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sharp@npm:^0.33.4":
|
||||
version: 0.33.4
|
||||
resolution: "sharp@npm:0.33.4"
|
||||
@@ -6066,28 +5911,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"styled-components@npm:^5.3.5":
|
||||
version: 5.3.6
|
||||
resolution: "styled-components@npm:5.3.6"
|
||||
dependencies:
|
||||
"@babel/helper-module-imports": "npm:^7.0.0"
|
||||
"@babel/traverse": "npm:^7.4.5"
|
||||
"@emotion/is-prop-valid": "npm:^1.1.0"
|
||||
"@emotion/stylis": "npm:^0.8.4"
|
||||
"@emotion/unitless": "npm:^0.7.4"
|
||||
babel-plugin-styled-components: "npm:>= 1.12.0"
|
||||
css-to-react-native: "npm:^3.0.0"
|
||||
hoist-non-react-statics: "npm:^3.0.0"
|
||||
shallowequal: "npm:^1.1.0"
|
||||
supports-color: "npm:^5.5.0"
|
||||
peerDependencies:
|
||||
react: ">= 16.8.0"
|
||||
react-dom: ">= 16.8.0"
|
||||
react-is: ">= 16.8.0"
|
||||
checksum: 10c0/14c0c1fa2944a753fcdaef10478c340046076163ad419eb7a4a37602e953cf63df3603961abdce7b4e780c2cc828cdf1c7a5f7d96fb07fabc24f23997980d69a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"styled-jsx@npm:5.1.1":
|
||||
version: 5.1.1
|
||||
resolution: "styled-jsx@npm:5.1.1"
|
||||
@@ -6104,13 +5927,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"styled-tools@npm:^1.7.2":
|
||||
version: 1.7.2
|
||||
resolution: "styled-tools@npm:1.7.2"
|
||||
checksum: 10c0/486a6ecbefec4cb46d9ca533ef8b5767a7e811e9af6c0047337a7fe41732fbb50a614942d7c28f28b6c6e38490f602b7929dce45cf417c9eb755702324b0c891
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"stylis@npm:4.1.3":
|
||||
version: 4.1.3
|
||||
resolution: "stylis@npm:4.1.3"
|
||||
@@ -6132,7 +5948,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0":
|
||||
"supports-color@npm:^5.3.0":
|
||||
version: 5.5.0
|
||||
resolution: "supports-color@npm:5.5.0"
|
||||
dependencies:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
+11
-2
@@ -13,6 +13,7 @@ from matcher.models.api.dto import (
|
||||
)
|
||||
from matcher.models.api.page import Page
|
||||
from matcher.models.api.provider import Provider
|
||||
from matcher.models.match_result import SyncedLyrics
|
||||
from matcher.providers.domain import AlbumType
|
||||
|
||||
T = TypeVar("T", bound=DataClassJsonMixin)
|
||||
@@ -119,8 +120,16 @@ class API:
|
||||
)
|
||||
self._put(f"/albums/{album_id}", json=dto.to_dict())
|
||||
|
||||
def post_song_lyrics(self, song_id: int, lyrics: str):
|
||||
self._post(f"/songs/{song_id}/lyrics", json={"lyrics": lyrics})
|
||||
def post_song_lyrics(
|
||||
self, song_id: int, plain_lyrics: str, synced_lyrics: SyncedLyrics | None
|
||||
):
|
||||
dto = {"plain": plain_lyrics}
|
||||
if synced_lyrics:
|
||||
formatted = [
|
||||
{"timestamp": t, "content": line} for (t, line) in synced_lyrics
|
||||
]
|
||||
dto = {**dto, "synced": formatted}
|
||||
self._post(f"/songs/{song_id}/lyrics", json=dto)
|
||||
|
||||
def post_song_genres(self, song_id: int, genres: List[str]):
|
||||
self._put(f"/songs/{song_id}", json={"genres": genres})
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
from matcher.models.api.dto import ExternalMetadataDto
|
||||
from matcher.models.match_result import AlbumMatchResult
|
||||
from matcher.providers.domain import AlbumType
|
||||
from matcher.providers.features import (
|
||||
GetAlbumDescriptionFeature,
|
||||
@@ -20,7 +21,7 @@ def match_and_post_album(album_id: int, album_name: str):
|
||||
try:
|
||||
context = Context.get()
|
||||
album = context.client.get_album(album_id)
|
||||
(dto, release_date, album_type, genres) = match_album(
|
||||
res = match_album(
|
||||
album_id,
|
||||
album_name,
|
||||
album.artist.name if album.artist else None,
|
||||
@@ -28,46 +29,50 @@ def match_and_post_album(album_id: int, album_name: str):
|
||||
)
|
||||
# We only care about the new album type if the previous type is Studio
|
||||
album_type = (
|
||||
album_type if album.type == AlbumType.STUDIO and album_type else album.type
|
||||
res.album_type
|
||||
if album.type == AlbumType.STUDIO and res.album_type
|
||||
else album.type
|
||||
)
|
||||
old_release_date = (
|
||||
datetime.fromisoformat(album.release_date).date()
|
||||
if album.release_date
|
||||
else None
|
||||
)
|
||||
if dto:
|
||||
if res.metadata:
|
||||
logging.info(
|
||||
f"Matched with {len(dto.sources)} providers for album {album_name}"
|
||||
f"Matched with {len(res.metadata.sources)} providers for album {album_name}"
|
||||
)
|
||||
context.client.post_external_metadata(dto)
|
||||
context.client.post_external_metadata(res.metadata)
|
||||
|
||||
if old_release_date and release_date:
|
||||
if abs(release_date.year - old_release_date.year) > 2:
|
||||
if old_release_date and res.release_date:
|
||||
if abs(res.release_date.year - old_release_date.year) > 2:
|
||||
logging.info(
|
||||
f"Release date found ({release_date.year}) is too far from the one from the API ({old_release_date.year})."
|
||||
f"Release date found ({res.release_date.year}) is too far from the one from the API ({old_release_date.year})."
|
||||
)
|
||||
logging.info("Probably a mismatch. Ignoring...")
|
||||
release_date = None
|
||||
if release_date:
|
||||
res.release_date = None
|
||||
if res.release_date:
|
||||
logging.info(f"Updating release date for album {album_name}")
|
||||
if genres:
|
||||
logging.info(f"Found {len(genres)} genres for album {album_name}")
|
||||
if res.genres:
|
||||
logging.info(f"Found {len(res.genres)} genres for album {album_name}")
|
||||
if album_type != album.type and album_type != AlbumType.OTHER:
|
||||
logging.info(f"Found type for album {album_name}: {album_type.value}")
|
||||
if (
|
||||
release_date
|
||||
or genres
|
||||
res.release_date
|
||||
or res.genres
|
||||
or (album_type != album.type)
|
||||
and album_type != AlbumType.OTHER
|
||||
):
|
||||
context.client.post_album_update(album_id, release_date, genres, album_type)
|
||||
context.client.post_album_update(
|
||||
album_id, res.release_date, res.genres, album_type
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
|
||||
|
||||
def match_album(
|
||||
album_id: int, album_name: str, artist_name: str | None, type: AlbumType
|
||||
) -> tuple[ExternalMetadataDto | None, date | None, AlbumType | None, List[str]]:
|
||||
) -> AlbumMatchResult:
|
||||
need_genres = Context.get().settings.push_genres
|
||||
context = Context.get()
|
||||
release_date: date | None = None
|
||||
@@ -143,7 +148,7 @@ def match_album(
|
||||
)
|
||||
if description and release_date and rating and genres:
|
||||
break
|
||||
return (
|
||||
return AlbumMatchResult(
|
||||
ExternalMetadataDto(
|
||||
description,
|
||||
artist_id=None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from matcher.models.api.dto import ExternalMetadataDto, ExternalMetadataSourceDto
|
||||
from matcher.models.match_result import ArtistMatchResult
|
||||
from matcher.providers.features import (
|
||||
GetArtistDescriptionFeature,
|
||||
GetArtistIllustrationUrlFeature,
|
||||
@@ -11,23 +12,21 @@ from . import common
|
||||
|
||||
def match_and_post_artist(artist_id: int, artist_name: str):
|
||||
try:
|
||||
(dto, illustration_url) = match_artist(artist_id, artist_name)
|
||||
res = match_artist(artist_id, artist_name)
|
||||
context = Context.get()
|
||||
if dto:
|
||||
if res.metadata:
|
||||
logging.info(
|
||||
f"Matched with {len(dto.sources)} providers for artist {artist_name}"
|
||||
f"Matched with {len(res.metadata.sources)} providers for artist {artist_name}"
|
||||
)
|
||||
context.client.post_external_metadata(dto)
|
||||
if illustration_url:
|
||||
context.client.post_external_metadata(res.metadata)
|
||||
if res.illustration_url:
|
||||
logging.info(f"Found image for artist {artist_name}")
|
||||
context.client.post_artist_illustration(artist_id, illustration_url)
|
||||
context.client.post_artist_illustration(artist_id, res.illustration_url)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
|
||||
|
||||
def match_artist(
|
||||
artist_id: int, artist_name: str
|
||||
) -> tuple[ExternalMetadataDto | None, str | None]:
|
||||
def match_artist(artist_id: int, artist_name: str) -> ArtistMatchResult:
|
||||
context = Context.get()
|
||||
artist_illustration_url: str | None = None
|
||||
(wikidata_id, external_sources) = common.get_sources_from_musicbrainz(
|
||||
@@ -82,7 +81,7 @@ def match_artist(
|
||||
artist_illustration_url = provider.get_artist_illustration_url(artist)
|
||||
if description and artist_illustration_url:
|
||||
break
|
||||
return (
|
||||
return ArtistMatchResult(
|
||||
ExternalMetadataDto(
|
||||
description,
|
||||
artist_id=artist_id,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
|
||||
from typing import List, Tuple
|
||||
from typing import List
|
||||
from matcher.models.api.dto import ExternalMetadataDto
|
||||
from matcher.models.match_result import LyricsMatchResult, SongMatchResult, SyncedLyrics
|
||||
from matcher.providers.features import (
|
||||
GetAlbumGenresFeature,
|
||||
GetSongDescriptionFeature,
|
||||
GetSongLyricsFeature,
|
||||
GetPlainSongLyricsFeature,
|
||||
GetSyncedSongLyricsFeature,
|
||||
)
|
||||
from . import common
|
||||
from ..models.api.dto import ExternalMetadataSourceDto
|
||||
@@ -16,28 +18,33 @@ def match_and_post_song(song_id: int, song_name: str):
|
||||
try:
|
||||
context = Context.get()
|
||||
song = context.client.get_song(song_id)
|
||||
source_file = context.client.get_file(song.master.source_file_id)
|
||||
(dto, lyrics, genres) = match_song(
|
||||
source_file = (
|
||||
context.client.get_file(song.master.source_file_id) if song.master else None
|
||||
)
|
||||
res = match_song(
|
||||
song_id,
|
||||
song.name,
|
||||
song.artist.name,
|
||||
[f.name for f in song.featuring],
|
||||
(source_file.fingerprint, song.master.duration)
|
||||
if source_file.fingerprint and song.master.duration
|
||||
else None,
|
||||
song.master.duration if song.master else None,
|
||||
source_file.fingerprint if source_file else None,
|
||||
)
|
||||
if dto:
|
||||
if res.metadata:
|
||||
logging.info(
|
||||
f"Matched with {len(dto.sources)} providers for song {song_name}"
|
||||
f"Matched with {len(res.metadata.sources)} providers for song {song_name}"
|
||||
)
|
||||
context.client.post_external_metadata(dto)
|
||||
context.client.post_external_metadata(res.metadata)
|
||||
|
||||
if lyrics:
|
||||
logging.info(f"Found lyrics for song {song.name}")
|
||||
context.client.post_song_lyrics(song_id, lyrics)
|
||||
if genres:
|
||||
logging.info(f"Found {len(genres)} genres for song {song.name}")
|
||||
context.client.post_song_genres(song_id, genres)
|
||||
if res.lyrics:
|
||||
logging.info(
|
||||
f"Found {'synced lyrics' if res.lyrics.synced else 'lyrics'} for song {song.name}"
|
||||
)
|
||||
context.client.post_song_lyrics(
|
||||
song_id, res.lyrics.plain, res.lyrics.synced
|
||||
)
|
||||
if res.genres:
|
||||
logging.info(f"Found {len(res.genres)} genres for song {song.name}")
|
||||
context.client.post_song_genres(song_id, res.genres)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
|
||||
@@ -47,12 +54,14 @@ def match_song(
|
||||
song_name: str,
|
||||
artist_name: str,
|
||||
featuring: List[str],
|
||||
acoustid_and_duration: Tuple[str, int] | None,
|
||||
) -> tuple[ExternalMetadataDto | None, str | None, List[str]]:
|
||||
duration: int | None,
|
||||
acoustid: str | None,
|
||||
) -> SongMatchResult:
|
||||
need_genres = Context.get().settings.push_genres
|
||||
context = Context.get()
|
||||
genres: List[str] = []
|
||||
lyrics: str | None = None
|
||||
plain_lyrics: str | None = None
|
||||
synced_lyrics: SyncedLyrics | None = None
|
||||
description: str | None = None
|
||||
external_sources: List[ExternalMetadataSourceDto] = []
|
||||
# We could skip using crossreference using wikidata,
|
||||
@@ -60,13 +69,11 @@ def match_song(
|
||||
# + Searching using Genius seems efficient enough
|
||||
(wikidata_id, external_sources) = common.get_sources_from_musicbrainz(
|
||||
lambda mb: (
|
||||
mb.search_song_with_acoustid(
|
||||
acoustid_and_duration[0], acoustid_and_duration[1], song_name
|
||||
)
|
||||
or mb.search_song(song_name, artist_name, featuring)
|
||||
mb.search_song_with_acoustid(acoustid, duration, song_name)
|
||||
or mb.search_song(song_name, artist_name, featuring, duration)
|
||||
)
|
||||
if acoustid_and_duration
|
||||
else mb.search_song(song_name, artist_name, featuring),
|
||||
if acoustid is not None and duration is not None
|
||||
else mb.search_song(song_name, artist_name, featuring, duration),
|
||||
lambda mb, mbid: mb.get_song(mbid),
|
||||
lambda mb, mbid: mb.get_song_url_from_id(mbid),
|
||||
)
|
||||
@@ -84,7 +91,7 @@ def match_song(
|
||||
# Resolve by searching
|
||||
sources_ids = [source.provider_id for source in external_sources]
|
||||
for provider in [p for p in context.providers if p.api_model.id not in sources_ids]:
|
||||
search_res = provider.search_song(song_name, artist_name, featuring)
|
||||
search_res = provider.search_song(song_name, artist_name, featuring, duration)
|
||||
if not search_res:
|
||||
continue
|
||||
song_url = provider.get_song_url_from_id(str(search_res.id))
|
||||
@@ -98,7 +105,16 @@ def match_song(
|
||||
provider = common.get_provider_from_external_source(source)
|
||||
is_useful = (
|
||||
(not description and provider.has_feature(GetSongDescriptionFeature))
|
||||
or (not lyrics and provider.has_feature(GetSongLyricsFeature))
|
||||
or (
|
||||
not plain_lyrics
|
||||
and (
|
||||
provider.has_feature(GetPlainSongLyricsFeature)
|
||||
or (provider.has_feature(GetSyncedSongLyricsFeature))
|
||||
)
|
||||
)
|
||||
or (
|
||||
not synced_lyrics and (provider.has_feature(GetSyncedSongLyricsFeature))
|
||||
)
|
||||
or (
|
||||
need_genres
|
||||
and len(genres) == 0
|
||||
@@ -115,16 +131,22 @@ def match_song(
|
||||
continue
|
||||
if not description:
|
||||
description = provider.get_song_description(song)
|
||||
if not lyrics:
|
||||
lyrics = provider.get_song_lyrics(song)
|
||||
if not synced_lyrics:
|
||||
synced_lyrics = provider.get_synced_song_lyrics(song)
|
||||
if not plain_lyrics:
|
||||
plain_lyrics = provider.get_plain_song_lyrics(song)
|
||||
if not plain_lyrics and synced_lyrics:
|
||||
plain_lyrics = "\n".join([line for (_, line) in synced_lyrics])
|
||||
genres = genres + (
|
||||
[g for g in provider.get_song_genres(song) or [] if g not in genres]
|
||||
if need_genres
|
||||
else []
|
||||
)
|
||||
if description and lyrics and genres:
|
||||
if description and plain_lyrics and synced_lyrics and genres:
|
||||
break
|
||||
return (
|
||||
if not plain_lyrics and synced_lyrics:
|
||||
plain_lyrics = "\n".join([line for (_, line) in synced_lyrics])
|
||||
return SongMatchResult(
|
||||
ExternalMetadataDto(
|
||||
description,
|
||||
artist_id=None,
|
||||
@@ -135,6 +157,6 @@ def match_song(
|
||||
)
|
||||
if len(external_sources) > 0 or description
|
||||
else None,
|
||||
lyrics,
|
||||
LyricsMatchResult(plain_lyrics, synced_lyrics) if plain_lyrics else None,
|
||||
genres,
|
||||
)
|
||||
@@ -36,8 +36,8 @@ class Track(DataClassJsonMixin):
|
||||
class Song(DataClassJsonMixin):
|
||||
name: str
|
||||
artist: Artist
|
||||
master: Track
|
||||
featuring: List[Artist]
|
||||
master: Optional[Track] = None
|
||||
|
||||
|
||||
@dataclass_json(letter_case=LetterCase.CAMEL, undefined=Undefined.EXCLUDE) # type: ignore
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import List, Tuple
|
||||
from matcher.models.api.dto import ExternalMetadataDto
|
||||
from datetime import date
|
||||
from dataclasses import dataclass
|
||||
|
||||
from matcher.providers.domain import AlbumType
|
||||
|
||||
type SyncedLyrics = List[Tuple[float, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LyricsMatchResult:
|
||||
plain: str
|
||||
synced: SyncedLyrics | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongMatchResult:
|
||||
metadata: ExternalMetadataDto | None
|
||||
lyrics: LyricsMatchResult | None
|
||||
genres: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumMatchResult:
|
||||
metadata: ExternalMetadataDto | None
|
||||
release_date: date | None
|
||||
album_type: AlbumType | None
|
||||
genres: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtistMatchResult:
|
||||
metadata: ExternalMetadataDto | None
|
||||
illustration_url: str | None
|
||||
@@ -1,3 +1,4 @@
|
||||
from matcher.models.match_result import SyncedLyrics
|
||||
from matcher.providers.base import BaseProvider
|
||||
from datetime import date
|
||||
from .features import (
|
||||
@@ -19,7 +20,8 @@ from .features import (
|
||||
GetSongFeature,
|
||||
GetSongGenresFeature,
|
||||
GetSongIdFromUrlFeature,
|
||||
GetSongLyricsFeature,
|
||||
GetSyncedSongLyricsFeature,
|
||||
GetPlainSongLyricsFeature,
|
||||
GetSongUrlFromIdFeature,
|
||||
GetWikidataAlbumRelationKeyFeature,
|
||||
GetWikidataArtistRelationKeyFeature,
|
||||
@@ -34,11 +36,6 @@ from .domain import AlbumSearchResult, AlbumType, ArtistSearchResult, SongSearch
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
# A = ParamSpec("A")
|
||||
# R = TypeVar("R")
|
||||
# F = TypeVar("F", bound=BaseFeature, covariant=True)
|
||||
|
||||
|
||||
class BaseProviderBoilerplate[S](BaseProvider[S]):
|
||||
# def _run_feature_if_exists[A, R, F: BaseFeature[A, R]](
|
||||
# self, featureClass: Type[F]
|
||||
@@ -129,10 +126,14 @@ class BaseProviderBoilerplate[S](BaseProvider[S]):
|
||||
# Song
|
||||
|
||||
def search_song(
|
||||
self, song_name: str, artist_name: str, featuring_artists: List[str]
|
||||
self,
|
||||
song_name: str,
|
||||
artist_name: str,
|
||||
featuring_artists: List[str],
|
||||
duration: int | None,
|
||||
) -> SongSearchResult | None:
|
||||
f = self.get_feature(SearchSongFeature)
|
||||
return f.run(song_name, artist_name, featuring_artists) if f else None
|
||||
return f.run(song_name, artist_name, featuring_artists, duration) if f else None
|
||||
|
||||
def search_song_with_acoustid(
|
||||
self, song_acoustid: str, duration: int, song_name: str
|
||||
@@ -152,8 +153,12 @@ class BaseProviderBoilerplate[S](BaseProvider[S]):
|
||||
f = self.get_feature(GetSongGenresFeature)
|
||||
return f.run(song) if f else None
|
||||
|
||||
def get_song_lyrics(self, song: Any) -> str | None:
|
||||
f = self.get_feature(GetSongLyricsFeature)
|
||||
def get_plain_song_lyrics(self, song: Any) -> str | None:
|
||||
f = self.get_feature(GetPlainSongLyricsFeature)
|
||||
return f.run(song) if f else None
|
||||
|
||||
def get_synced_song_lyrics(self, song: Any) -> SyncedLyrics | None:
|
||||
f = self.get_feature(GetSyncedSongLyricsFeature)
|
||||
return f.run(song) if f else None
|
||||
|
||||
def get_wikidata_song_relation_key(self) -> str | None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from matcher.providers.allmusic import AllMusicProvider
|
||||
from matcher.providers.boilerplate import BaseProviderBoilerplate
|
||||
from matcher.providers.discogs import DiscogsProvider
|
||||
from matcher.providers.genius import GeniusProvider
|
||||
from matcher.providers.lrclib import LrcLibProvider
|
||||
from matcher.providers.metacritic import MetacriticProvider
|
||||
from matcher.providers.musicbrainz import MusicBrainzProvider
|
||||
from matcher.settings import (
|
||||
@@ -10,6 +11,7 @@ from matcher.settings import (
|
||||
BaseProviderSettings,
|
||||
DiscogsSettings,
|
||||
GeniusSettings,
|
||||
LrcLibSettings,
|
||||
MetacriticSettings,
|
||||
MusicBrainzSettings,
|
||||
WikipediaSettings,
|
||||
@@ -40,4 +42,6 @@ class ProviderFactory:
|
||||
)
|
||||
case "Wikipedia":
|
||||
return WikipediaProvider(api_model, cast(WikipediaSettings, settings))
|
||||
case "LrcLib":
|
||||
return LrcLibProvider(api_model, cast(LrcLibSettings, settings))
|
||||
raise Exception(f"Unknown provider name: {settings.name}")
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import date
|
||||
from typing import Any, List
|
||||
from matcher.models.match_result import SyncedLyrics
|
||||
from matcher.providers.base import BaseFeature
|
||||
from matcher.providers.domain import (
|
||||
AlbumSearchResult,
|
||||
@@ -114,7 +115,13 @@ class GetWikidataAlbumRelationKeyFeature(GetWikidataRelationKeyFeature):
|
||||
|
||||
|
||||
class SearchSongFeature(
|
||||
BaseFeature[ResourceName, ResourceName, List[ResourceName], SongSearchResult | None]
|
||||
BaseFeature[
|
||||
ResourceName,
|
||||
ResourceName,
|
||||
List[ResourceName],
|
||||
int | None,
|
||||
SongSearchResult | None,
|
||||
]
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -137,7 +144,11 @@ class GetSongGenresFeature(BaseFeature[Any, List[str] | None]):
|
||||
pass
|
||||
|
||||
|
||||
class GetSongLyricsFeature(BaseFeature[Any, str | None]):
|
||||
class GetPlainSongLyricsFeature(BaseFeature[Any, str | None]):
|
||||
pass
|
||||
|
||||
|
||||
class GetSyncedSongLyricsFeature(BaseFeature[Any, SyncedLyrics | None]):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from matcher.providers.features import (
|
||||
GetSongDescriptionFeature,
|
||||
GetSongFeature,
|
||||
GetSongIdFromUrlFeature,
|
||||
GetSongLyricsFeature,
|
||||
GetPlainSongLyricsFeature,
|
||||
GetSongUrlFromIdFeature,
|
||||
GetWikidataAlbumRelationKeyFeature,
|
||||
GetWikidataArtistRelationKeyFeature,
|
||||
@@ -87,9 +87,9 @@ class GeniusProvider(BaseProviderBoilerplate[GeniusSettings]):
|
||||
)
|
||||
),
|
||||
GetSongFeature(lambda id: self._get_song(id)),
|
||||
GetSongLyricsFeature(lambda s: self._get_song_lyrics(s)),
|
||||
GetPlainSongLyricsFeature(lambda s: self._get_song_lyrics(s)),
|
||||
GetSongDescriptionFeature(lambda s: self._get_song_description(s)),
|
||||
SearchSongFeature(lambda s, a, f: self._search_song(s, a, f)),
|
||||
SearchSongFeature(lambda s, a, f, _: self._search_song(s, a, f)),
|
||||
]
|
||||
|
||||
def _fetch(self, url: str, params={}, host="https://genius.com/api"):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, List
|
||||
import requests
|
||||
from matcher.models.match_result import SyncedLyrics
|
||||
from matcher.providers.boilerplate import BaseProviderBoilerplate
|
||||
from matcher.providers.domain import SongSearchResult
|
||||
from matcher.providers.features import (
|
||||
GetSongFeature,
|
||||
GetSyncedSongLyricsFeature,
|
||||
SearchSongFeature,
|
||||
GetPlainSongLyricsFeature,
|
||||
GetSongUrlFromIdFeature,
|
||||
GetSongIdFromUrlFeature,
|
||||
)
|
||||
from matcher.settings import LrcLibSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class LrcLibProvider(BaseProviderBoilerplate[LrcLibSettings]):
|
||||
def __post_init__(self):
|
||||
self.features = [
|
||||
SearchSongFeature(
|
||||
lambda song, artist, feat, duration: self._search_song(
|
||||
song, artist, feat, duration
|
||||
)
|
||||
),
|
||||
GetSongFeature(lambda song_id: self._get_song(song_id)),
|
||||
GetPlainSongLyricsFeature(lambda song: song.get("plainLyrics")),
|
||||
GetSyncedSongLyricsFeature(
|
||||
lambda song: self._parse_synced_lyrics(song.get("syncedLyrics"))
|
||||
),
|
||||
GetSongUrlFromIdFeature(lambda id: f"https://lrclib.net/api/get/{id}"),
|
||||
GetSongIdFromUrlFeature(
|
||||
lambda url: url.replace("https://lrclib.net/api/get/", "")
|
||||
),
|
||||
]
|
||||
|
||||
def _fetch(self, route: str):
|
||||
return requests.get(
|
||||
"https://lrclib.net/api" + route,
|
||||
headers={"User-Agent": "Meelo Matcher/0.0.1"},
|
||||
).json()
|
||||
|
||||
def _search_song(
|
||||
self,
|
||||
song_name: str,
|
||||
artist_name: str,
|
||||
featuring: List[str],
|
||||
duration: int | None,
|
||||
):
|
||||
def _candidate_is_valid(item: Any):
|
||||
if item.get("id") is None:
|
||||
return False
|
||||
item_duration = item.get("duration")
|
||||
if duration and item_duration:
|
||||
if abs(duration - item_duration) > 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _search_with_get():
|
||||
res = self._fetch(
|
||||
f"/get?artist_name={','.join([artist_name, *featuring])}&track_name={song_name}{f'&duration={duration}' if duration else ''}"
|
||||
)
|
||||
return (
|
||||
SongSearchResult(str(res["id"])) if _candidate_is_valid(res) else None
|
||||
)
|
||||
|
||||
def _search_with_query():
|
||||
res = self._fetch(
|
||||
f"/search?q={', '.join([artist_name, *featuring])} - {song_name}"
|
||||
)
|
||||
for item in res:
|
||||
if _candidate_is_valid(item):
|
||||
return SongSearchResult(str(item["id"]))
|
||||
|
||||
try:
|
||||
return _search_with_get() or _search_with_query()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
):
|
||||
try:
|
||||
res = self._fetch(f"/get/{song_id}")
|
||||
if not res.get("id"): # Fail
|
||||
return
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics: str | None,
|
||||
) -> SyncedLyrics | None:
|
||||
try:
|
||||
if not synced_lyrics:
|
||||
return
|
||||
parsed_lyrics: SyncedLyrics = []
|
||||
for line in synced_lyrics.split("\n"):
|
||||
res = re.search("\\[(\\d{2}):(\\d{2})\\.(\\d{2})\\] (.*)", line)
|
||||
if not res:
|
||||
return
|
||||
timestamp = (
|
||||
float(res.group(1)) * 60
|
||||
+ float(res.group(2))
|
||||
+ (float(res.group(3)) * 0.01)
|
||||
)
|
||||
content = res.group(4)
|
||||
if not timestamp:
|
||||
return
|
||||
parsed_lyrics.append((timestamp, content))
|
||||
return parsed_lyrics
|
||||
except Exception:
|
||||
pass
|
||||
@@ -81,7 +81,7 @@ class MusicBrainzProvider(BaseProviderBoilerplate[MusicBrainzSettings]):
|
||||
lambda album: self._get_album_release_date(album)
|
||||
),
|
||||
GetAlbumGenresFeature(lambda album: self._get_album_genres(album)),
|
||||
SearchSongFeature(lambda s, a, f: self._search_song(s, a, f)),
|
||||
SearchSongFeature(lambda s, a, f, _: self._search_song(s, a, f)),
|
||||
SearchSongWithAcoustIdFeature(
|
||||
lambda acoustid, dur, name: self._search_song_with_acoustid(
|
||||
acoustid, dur, name
|
||||
|
||||
@@ -49,6 +49,11 @@ class MetacriticSettings(BaseProviderSettings):
|
||||
name = "Metacritic"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LrcLibSettings(BaseProviderSettings):
|
||||
name = "LrcLib"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
push_genres: bool
|
||||
@@ -75,6 +80,7 @@ class Settings:
|
||||
"allmusic": AllMusicSettings,
|
||||
"wikipedia": WikipediaSettings,
|
||||
"metacritic": MetacriticSettings,
|
||||
"lrclib": LrcLibSettings,
|
||||
}
|
||||
if key not in provider_dict:
|
||||
logging.warning(
|
||||
|
||||
@@ -6,6 +6,7 @@ from .matcher.album import *
|
||||
from .matcher.song import *
|
||||
from .providers.metacritic import *
|
||||
from .providers.allmusic import *
|
||||
from .providers.lrclib import *
|
||||
from .providers.wikipedia import *
|
||||
from .providers.musicbrainz import *
|
||||
from .providers.genius import *
|
||||
|
||||
@@ -12,155 +12,149 @@ class TestMatchAlbum(unittest.TestCase):
|
||||
return MatcherTestUtils.setup_context()
|
||||
|
||||
def test_get_album(self):
|
||||
[matches, date, type, genres] = match_album(
|
||||
1, "Confessions on a Dancefloor", "Madonna", AlbumType.STUDIO
|
||||
)
|
||||
res = match_album(1, "Confessions on a Dancefloor", "Madonna", AlbumType.STUDIO)
|
||||
# Type
|
||||
self.assertIsNone(type)
|
||||
self.assertIsNone(res.album_type)
|
||||
# Genres
|
||||
self.assertEqual(len(genres), 7)
|
||||
self.assertIn("Pop", genres)
|
||||
self.assertIn("Dance-Pop", genres)
|
||||
self.assertIn("Synth-Pop", genres)
|
||||
self.assertIn("Disco", genres)
|
||||
self.assertIn("House", genres)
|
||||
self.assertIn("Electronic", genres)
|
||||
self.assertIn("Euro House", genres)
|
||||
self.assertEqual(len(res.genres), 7)
|
||||
self.assertIn("Pop", res.genres)
|
||||
self.assertIn("Dance-Pop", res.genres)
|
||||
self.assertIn("Synth-Pop", res.genres)
|
||||
self.assertIn("Disco", res.genres)
|
||||
self.assertIn("House", res.genres)
|
||||
self.assertIn("Electronic", res.genres)
|
||||
self.assertIn("Euro House", res.genres)
|
||||
# Rating
|
||||
self.assertIsNotNone(matches.rating)
|
||||
self.assertEqual(matches.rating, 70)
|
||||
self.assertIsNotNone(res.metadata.rating)
|
||||
self.assertEqual(res.metadata.rating, 70)
|
||||
# Release date
|
||||
self.assertIsNotNone(date)
|
||||
self.assertEqual(date, datetime.date(2005, 11, 11))
|
||||
self.assertIsNotNone(res.release_date)
|
||||
self.assertEqual(res.release_date, datetime.date(2005, 11, 11))
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.album_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.album_id, 1)
|
||||
##Description
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("Confessions" in str(matches.description))
|
||||
self.assertTrue("2005" in str(matches.description))
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("Confessions" in str(res.metadata.description))
|
||||
self.assertTrue("2005" in str(res.metadata.description))
|
||||
##Sources
|
||||
self.assertGreaterEqual(len(matches.sources), 5)
|
||||
self.assertGreaterEqual(len(res.metadata.sources), 5)
|
||||
### Wikipedia
|
||||
[wiki] = [p for p in matches.sources if "wiki" in p.url]
|
||||
[wiki] = [p for p in res.metadata.sources if "wiki" in p.url]
|
||||
self.assertEqual(
|
||||
wiki.url, "https://en.wikipedia.org/wiki/Confessions on a Dance Floor"
|
||||
)
|
||||
### Metacritic
|
||||
[meta] = [p for p in matches.sources if "metacritic" in p.url]
|
||||
[meta] = [p for p in res.metadata.sources if "metacritic" in p.url]
|
||||
self.assertEqual(
|
||||
meta.url,
|
||||
"https://www.metacritic.com/music/confessions-on-a-dance-floor/madonna",
|
||||
)
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/master/34205")
|
||||
### Genius
|
||||
if not MatcherTestUtils.is_ci():
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(
|
||||
genius.url,
|
||||
"https://genius.com/albums/Madonna/Confessions-on-a-dance-floor",
|
||||
)
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/release-group/122677be-e664-362d-95eb-be3ae126ec03",
|
||||
)
|
||||
### Allmusic
|
||||
[allmusic] = [p for p in matches.sources if "allmusic" in p.url]
|
||||
[allmusic] = [p for p in res.metadata.sources if "allmusic" in p.url]
|
||||
self.assertEqual(allmusic.url, "https://www.allmusic.com/album/mw0000356345")
|
||||
|
||||
def test_get_album2(self):
|
||||
[matches, date, type, genres] = match_album(
|
||||
res = match_album(
|
||||
1, "The Tortured Poets Department", "Taylor Swift", AlbumType.STUDIO
|
||||
)
|
||||
# Type
|
||||
self.assertIsNone(type)
|
||||
self.assertIsNone(res.album_type)
|
||||
# Rating
|
||||
self.assertIsNotNone(matches.rating)
|
||||
self.assertEqual(matches.rating, 60)
|
||||
self.assertIsNotNone(res.metadata.rating)
|
||||
self.assertEqual(res.metadata.rating, 60)
|
||||
# Release date
|
||||
self.assertIsNotNone(date)
|
||||
self.assertEqual(date, datetime.date(2024, 4, 19))
|
||||
self.assertIsNotNone(res.release_date)
|
||||
self.assertEqual(res.release_date, datetime.date(2024, 4, 19))
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.album_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.album_id, 1)
|
||||
# ##Description
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("eleventh studio album" in str(matches.description))
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("eleventh studio album" in str(res.metadata.description))
|
||||
### Metacritic
|
||||
[meta] = [p for p in matches.sources if "metacritic" in p.url]
|
||||
[meta] = [p for p in res.metadata.sources if "metacritic" in p.url]
|
||||
self.assertEqual(
|
||||
meta.url,
|
||||
"https://www.metacritic.com/music/the-tortured-poets-department/taylor-swift",
|
||||
)
|
||||
### Wikipedia
|
||||
[wiki] = [p for p in matches.sources if "wiki" in p.url]
|
||||
[wiki] = [p for p in res.metadata.sources if "wiki" in p.url]
|
||||
self.assertEqual(
|
||||
wiki.url, "https://en.wikipedia.org/wiki/The Tortured Poets Department"
|
||||
)
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/master/3461018")
|
||||
### Genius
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(
|
||||
genius.url,
|
||||
"https://genius.com/albums/Taylor-swift/The-tortured-poets-department",
|
||||
)
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/release-group/71c3eed6-466c-4aef-ad12-65a36d19467a",
|
||||
)
|
||||
### Allmusic
|
||||
[allmusic] = [p for p in matches.sources if "allmusic" in p.url]
|
||||
[allmusic] = [p for p in res.metadata.sources if "allmusic" in p.url]
|
||||
self.assertEqual(allmusic.url, "https://www.allmusic.com/album/mw0004210541")
|
||||
|
||||
def test_get_album_no_rating(self):
|
||||
[matches, date, type, genres] = match_album(
|
||||
1, "Aéromusical", "Superbus", AlbumType.STUDIO
|
||||
)
|
||||
res = match_album(1, "Aéromusical", "Superbus", AlbumType.STUDIO)
|
||||
# Rating
|
||||
self.assertIsNone(matches.rating)
|
||||
self.assertIsNone(res.metadata.rating)
|
||||
# Release date
|
||||
self.assertIsNotNone(date)
|
||||
self.assertEqual(date.month, 3)
|
||||
self.assertIsNotNone(res.release_date)
|
||||
self.assertEqual(res.release_date.month, 3)
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
##Description
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("Aéromusical" in matches.description)
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("Aéromusical" in res.metadata.description)
|
||||
### Wikipedia
|
||||
[wiki] = [p for p in matches.sources if "wiki" in p.url]
|
||||
[wiki] = [p for p in res.metadata.sources if "wiki" in p.url]
|
||||
self.assertEqual(wiki.url, "https://en.wikipedia.org/wiki/Aéromusical")
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/master/426003")
|
||||
### Genius
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(genius.url, "https://genius.com/albums/Superbus/Aeromusical")
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/release-group/5666a1be-2156-3e94-847b-7bd5cc8b5b93",
|
||||
)
|
||||
### Allmusic
|
||||
[allmusic] = [p for p in matches.sources if "allmusic" in p.url]
|
||||
[allmusic] = [p for p in res.metadata.sources if "allmusic" in p.url]
|
||||
self.assertEqual(allmusic.url, "https://www.allmusic.com/album/mw0000770491")
|
||||
|
||||
def test_get_album_ignore_genres(self):
|
||||
# Setup
|
||||
context = Context.get()
|
||||
context.settings.push_genres = False
|
||||
[matches, date, type, genres] = match_album(
|
||||
1, "Confessions on a Dancefloor", "Madonna", AlbumType.STUDIO
|
||||
)
|
||||
res = match_album(1, "Confessions on a Dancefloor", "Madonna", AlbumType.STUDIO)
|
||||
# Teardown
|
||||
context.settings.push_genres = True
|
||||
# Genres
|
||||
self.assertEqual(len(genres), 0)
|
||||
self.assertEqual(len(res.genres), 0)
|
||||
@@ -10,101 +10,101 @@ class TestMatchArtist(unittest.TestCase):
|
||||
return MatcherTestUtils.setup_context()
|
||||
|
||||
def test_get_artist(self):
|
||||
[matches, imageUrl] = match_artist(1, "Madonna")
|
||||
res = match_artist(1, "Madonna")
|
||||
# Illustration
|
||||
self.assertIsNotNone(imageUrl)
|
||||
imageUrl = str(imageUrl)
|
||||
self.assertTrue("discogs" in imageUrl)
|
||||
self.assertIsNotNone(res.illustration_url)
|
||||
res.illustration_url = str(res.illustration_url)
|
||||
self.assertTrue("discogs" in res.illustration_url)
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.artist_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.artist_id, 1)
|
||||
##Description
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("Madonna" in str(matches.description))
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("Madonna" in str(res.metadata.description))
|
||||
##Sources
|
||||
self.assertEqual(len(matches.sources), 6)
|
||||
self.assertEqual(len(res.metadata.sources), 6)
|
||||
### Wikipedia
|
||||
[wiki] = [p for p in matches.sources if "wiki" in p.url]
|
||||
[wiki] = [p for p in res.metadata.sources if "wiki" in p.url]
|
||||
self.assertEqual(wiki.url, "https://en.wikipedia.org/wiki/Madonna")
|
||||
### Metacritic
|
||||
[meta] = [p for p in matches.sources if "metacritic" in p.url]
|
||||
[meta] = [p for p in res.metadata.sources if "metacritic" in p.url]
|
||||
self.assertEqual(meta.url, "https://www.metacritic.com/person/madonna")
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/artist/3424607")
|
||||
### Genius
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(genius.url, "https://genius.com/artists/Madonna")
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/artist/79239441-bfd5-4981-a70c-55c3f15c1287",
|
||||
)
|
||||
### Allmusic
|
||||
[allmusic] = [p for p in matches.sources if "allmusic" in p.url]
|
||||
[allmusic] = [p for p in res.metadata.sources if "allmusic" in p.url]
|
||||
self.assertEqual(allmusic.url, "https://www.allmusic.com/artist/mn0000237205")
|
||||
|
||||
def test_get_artist_with_special_char(self):
|
||||
[matches, imageUrl] = match_artist(1, "P!nk")
|
||||
res = match_artist(1, "P!nk")
|
||||
|
||||
# Illustration
|
||||
self.assertIsNotNone(imageUrl)
|
||||
imageUrl = str(imageUrl)
|
||||
self.assertTrue("discogs" in imageUrl)
|
||||
self.assertIsNotNone(res.illustration_url)
|
||||
res.illustration_url = str(res.illustration_url)
|
||||
self.assertTrue("discogs" in res.illustration_url)
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.artist_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.artist_id, 1)
|
||||
##Description
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("American singer" in str(matches.description))
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("American singer" in str(res.metadata.description))
|
||||
##Sources
|
||||
self.assertEqual(len(matches.sources), 6)
|
||||
self.assertEqual(len(res.metadata.sources), 6)
|
||||
### Wikipedia
|
||||
[wiki] = [p for p in matches.sources if "wiki" in p.url]
|
||||
[wiki] = [p for p in res.metadata.sources if "wiki" in p.url]
|
||||
self.assertEqual(wiki.url, "https://en.wikipedia.org/wiki/Pink (singer)")
|
||||
### Metacritic
|
||||
[meta] = [p for p in matches.sources if "metacritic" in p.url]
|
||||
[meta] = [p for p in res.metadata.sources if "metacritic" in p.url]
|
||||
self.assertEqual(meta.url, "https://www.metacritic.com/person/p!nk")
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/artist/1525636")
|
||||
### Genius
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(genius.url, "https://genius.com/artists/P-nk")
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/artist/f4d5cc07-3bc9-4836-9b15-88a08359bc63",
|
||||
)
|
||||
### Allmusic
|
||||
[allmusic] = [p for p in matches.sources if "allmusic" in p.url]
|
||||
[allmusic] = [p for p in res.metadata.sources if "allmusic" in p.url]
|
||||
self.assertEqual(allmusic.url, "https://www.allmusic.com/artist/mn0001878899")
|
||||
|
||||
def test_get_artist_with_placeholder_image(self):
|
||||
[matches, imageUrl] = match_artist(1, "Peplab")
|
||||
res = match_artist(1, "Peplab")
|
||||
# Illustration
|
||||
self.assertIsNotNone(imageUrl)
|
||||
imageUrl = str(imageUrl)
|
||||
self.assertIsNotNone(res.illustration_url)
|
||||
res.illustration_url = str(res.illustration_url)
|
||||
# We want to get Discogs' image, not genius' as it's a placeholder
|
||||
self.assertFalse("genius" in imageUrl)
|
||||
self.assertTrue("discogs" in imageUrl)
|
||||
self.assertFalse("genius" in res.illustration_url)
|
||||
self.assertTrue("discogs" in res.illustration_url)
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.artist_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.artist_id, 1)
|
||||
# ##Sources
|
||||
self.assertTrue(len(matches.sources) >= 2)
|
||||
self.assertTrue(len(res.metadata.sources) >= 2)
|
||||
### Discogs
|
||||
[discogs] = [p for p in matches.sources if "discogs" in p.url]
|
||||
[discogs] = [p for p in res.metadata.sources if "discogs" in p.url]
|
||||
self.assertEqual(discogs.url, "https://www.discogs.com/artist/9663")
|
||||
### Genius
|
||||
### CI fails here (thanks cloudflare) because we do a search for this provider
|
||||
if not MatcherTestUtils.is_ci():
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(genius.url, "https://genius.com/artists/Peplab")
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/artist/ccccf1ba-c503-48d7-8c8c-f7c80c3347d4",
|
||||
|
||||
@@ -9,6 +9,7 @@ from matcher.providers.base import BaseProvider
|
||||
from matcher.providers.factory import ProviderFactory
|
||||
from matcher.settings import (
|
||||
GeniusSettings,
|
||||
LrcLibSettings,
|
||||
Settings,
|
||||
WikipediaSettings,
|
||||
AllMusicSettings,
|
||||
@@ -49,6 +50,7 @@ class MatcherTestUtils:
|
||||
MetacriticSettings(name=MetacriticSettings.name),
|
||||
WikipediaSettings(name=WikipediaSettings.name),
|
||||
AllMusicSettings(name=AllMusicSettings.name),
|
||||
LrcLibSettings(name=LrcLibSettings.name),
|
||||
GeniusSettings(
|
||||
api_key=os.environ[geniusTokenKey], name=GeniusSettings.name
|
||||
),
|
||||
|
||||
@@ -10,42 +10,52 @@ class TestMatchSong(unittest.TestCase):
|
||||
return MatcherTestUtils.setup_context()
|
||||
|
||||
def test_get_song_with_featuring_artist(self):
|
||||
[matches, lyrics, genres] = match_song(
|
||||
1, "It Should Be Easy", "Britney Spears", ["will.i.am"], None
|
||||
res = match_song(
|
||||
1, "It Should Be Easy", "Britney Spears", ["will.i.am"], None, None
|
||||
)
|
||||
# Genres
|
||||
self.assertIn("Pop", genres)
|
||||
self.assertIn("Dance-Pop", genres)
|
||||
self.assertIn("Electronic", genres)
|
||||
self.assertIn("Pop", res.genres)
|
||||
self.assertIn("Dance-Pop", res.genres)
|
||||
self.assertIn("Electronic", res.genres)
|
||||
# Lyrics
|
||||
|
||||
if not MatcherTestUtils.is_ci():
|
||||
self.assertIsNotNone(lyrics)
|
||||
self.assertIsNotNone(res.lyrics)
|
||||
self.assertIsNotNone(res.lyrics.plain)
|
||||
self.assertIsNotNone(res.lyrics.synced)
|
||||
|
||||
self.assertIn("[Verse 1: Britney Spears]", lyrics)
|
||||
self.assertIn("I've been thinking\n", res.lyrics.plain)
|
||||
self.assertIn(
|
||||
"baby, you my right now\nIf there was a scale",
|
||||
lyrics,
|
||||
"baby, you're my right now\n\nIf there was a scale",
|
||||
res.lyrics.plain,
|
||||
)
|
||||
self.assertEqual(
|
||||
15.22,
|
||||
res.lyrics.synced[0][0],
|
||||
)
|
||||
self.assertEqual(
|
||||
"I've been thinking",
|
||||
res.lyrics.synced[0][1],
|
||||
)
|
||||
|
||||
# Matches
|
||||
self.assertIsNotNone(matches)
|
||||
self.assertEqual(matches.song_id, 1)
|
||||
self.assertIsNotNone(res.metadata)
|
||||
self.assertEqual(res.metadata.song_id, 1)
|
||||
##Description
|
||||
if not MatcherTestUtils.is_ci():
|
||||
self.assertIsNotNone(matches.description)
|
||||
self.assertTrue("will.i.am" in str(matches.description))
|
||||
self.assertIsNotNone(res.metadata.description)
|
||||
self.assertTrue("will.i.am" in str(res.metadata.description))
|
||||
##Sources
|
||||
self.assertGreaterEqual(len(matches.sources), 1)
|
||||
self.assertGreaterEqual(len(res.metadata.sources), 1)
|
||||
### Genius
|
||||
if not MatcherTestUtils.is_ci():
|
||||
[genius] = [p for p in matches.sources if "genius" in p.url]
|
||||
[genius] = [p for p in res.metadata.sources if "genius" in p.url]
|
||||
self.assertEqual(
|
||||
genius.url,
|
||||
"https://genius.com/Britney-spears-it-should-be-easy-lyrics",
|
||||
)
|
||||
### Musicbrainz
|
||||
[mb] = [p for p in matches.sources if "musicbrainz" in p.url]
|
||||
[mb] = [p for p in res.metadata.sources if "musicbrainz" in p.url]
|
||||
self.assertEqual(
|
||||
mb.url,
|
||||
"https://musicbrainz.org/recording/b4e91acc-17d6-4e1a-b08e-e06714bab7bd",
|
||||
@@ -55,10 +65,10 @@ class TestMatchSong(unittest.TestCase):
|
||||
# Setup
|
||||
context = Context.get()
|
||||
context.settings.push_genres = False
|
||||
[matches, lyrics, genres] = match_song(
|
||||
1, "It Should Be Easy", "Britney Spears", ["will.i.am"], None
|
||||
res = match_song(
|
||||
1, "It Should Be Easy", "Britney Spears", ["will.i.am"], None, None
|
||||
)
|
||||
# Teardown
|
||||
context.settings.push_genres = True
|
||||
# Genres
|
||||
self.assertEqual(len(genres), 0)
|
||||
self.assertEqual(len(res.genres), 0)
|
||||
@@ -132,7 +132,7 @@ class TestGenius(unittest.TestCase):
|
||||
expected=expected,
|
||||
):
|
||||
search_res: SongSearchResult = provider.search_song(
|
||||
song_name, artist_name, feat
|
||||
song_name, artist_name, feat, None
|
||||
) # pyright: ignore
|
||||
if expected:
|
||||
self.assertIsNotNone(search_res)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import unittest
|
||||
from matcher.context import Context
|
||||
from typing import List, Tuple
|
||||
from matcher.models.match_result import SyncedLyrics
|
||||
from matcher.providers.lrclib import LrcLibProvider
|
||||
from tests.matcher.common import MatcherTestUtils
|
||||
|
||||
|
||||
class TestLrcLib(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
MatcherTestUtils.setup_context()
|
||||
|
||||
def test_search_song(self):
|
||||
scenarios: List[Tuple[str, str, List[str], int, List[int]]] = [
|
||||
("E.T.", "Katy Perry", ["Kanye West"], 230, [960041, 3695008, 1031603]),
|
||||
(
|
||||
"It Should Be Easy",
|
||||
"Britney Spears",
|
||||
["will.i.am"],
|
||||
209,
|
||||
[15055522],
|
||||
),
|
||||
("Get The Party Started", "P!nk", [], 192, [694905, 1334986, 1902620]),
|
||||
("Intro", "Girls Aloud", [], 42, [141026, 141025]),
|
||||
("Silver Strand", "The Corrs", [], 100, []),
|
||||
(
|
||||
"So Young (K-Klass Remix)",
|
||||
"The Corrs",
|
||||
[],
|
||||
254,
|
||||
[16406807, 385738, 10327002],
|
||||
),
|
||||
(
|
||||
"Who's That Chick? (Single Version)",
|
||||
"David Guetta",
|
||||
["Rihanna"],
|
||||
167,
|
||||
[3145103],
|
||||
),
|
||||
(
|
||||
"Morenito",
|
||||
"Stephane Pompougnac",
|
||||
["Clementine"],
|
||||
334,
|
||||
[7379532],
|
||||
),
|
||||
]
|
||||
provider: LrcLibProvider = Context().get().get_provider(LrcLibProvider) # pyright: ignore
|
||||
|
||||
for [song_name, artist_name, feats, duration, expected] in scenarios:
|
||||
with self.subTest(
|
||||
"Search Song", song=song_name, artist=artist_name, feats=feats
|
||||
):
|
||||
song = provider.search_song(song_name, artist_name, feats, duration)
|
||||
if len(expected) == 0:
|
||||
self.assertIsNone(song)
|
||||
else:
|
||||
self.assertIsNotNone(song)
|
||||
self.assertIn(song.id, [str(e) for e in expected]) # pyright: ignore
|
||||
|
||||
def test_get_song(self):
|
||||
provider: LrcLibProvider = Context().get().get_provider(LrcLibProvider) # pyright: ignore
|
||||
song = provider.get_song("45828")
|
||||
self.assertIsNotNone(song)
|
||||
self.assertEqual(song["id"], 45828) # pyright: ignore
|
||||
self.assertIsNotNone(song["plainLyrics"]) # pyright: ignore
|
||||
self.assertIsNotNone(song["syncedLyrics"]) # pyright: ignore
|
||||
|
||||
def test_get_song_plain_lyrics(self):
|
||||
provider: LrcLibProvider = Context().get().get_provider(LrcLibProvider) # pyright: ignore
|
||||
song = provider.get_song("45828")
|
||||
lyrics = provider.get_plain_song_lyrics(song)
|
||||
self.assertIsNotNone(lyrics)
|
||||
lyrics = str(lyrics)
|
||||
self.assertTrue(
|
||||
lyrics.startswith("Time goes by so slowly\nTime goes by so slowly")
|
||||
)
|
||||
self.assertTrue(lyrics.endswith("I'm tired of waiting on you\n"))
|
||||
|
||||
def test_get_song_synced_lyrics(self):
|
||||
provider: LrcLibProvider = Context().get().get_provider(LrcLibProvider) # pyright: ignore
|
||||
song = provider.get_song("45828")
|
||||
lyrics: SyncedLyrics = provider.get_synced_song_lyrics(song) # pyright: ignore
|
||||
|
||||
def get_lyrics_at(ts: float):
|
||||
return [line for (t, line) in lyrics if t == ts][0]
|
||||
|
||||
self.assertIsNotNone(lyrics)
|
||||
self.assertEqual(get_lyrics_at(6.71), "Time goes by so slowly")
|
||||
self.assertEqual(get_lyrics_at(229.33), "")
|
||||
self.assertEqual(get_lyrics_at(330.84), "Waiting for your call")
|
||||
@@ -203,7 +203,7 @@ class TestMusicbrainz(unittest.TestCase):
|
||||
|
||||
for [song_name, artist_name, featuring, expected] in scenarios:
|
||||
with self.subTest("Search Song", song_name=song_name, featuring=featuring):
|
||||
song = provider.search_song(song_name, artist_name, featuring)
|
||||
song = provider.search_song(song_name, artist_name, featuring, None)
|
||||
self.assertIsNotNone(song)
|
||||
self.assertIn(song.id, expected) # pyright: ignore
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.22.6-alpine AS builder
|
||||
FROM golang:1.24.1-alpine AS builder
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.22.6-alpine
|
||||
FROM golang:1.24.1-alpine
|
||||
RUN go install github.com/bokwoon95/wgo@latest
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
|
||||
+9
-6
@@ -1,20 +1,23 @@
|
||||
module github.com/Arthi-chaud/Meelo/scanner
|
||||
|
||||
go 1.22.6
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0
|
||||
github.com/alecthomas/participle/v2 v2.1.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/go-playground/validator/v10 v10.22.0
|
||||
github.com/goccy/go-json v0.10.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/echo-swagger v1.4.1
|
||||
github.com/swaggo/swag v1.16.3
|
||||
github.com/u2takey/ffmpeg-go v0.5.0
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,10 +46,10 @@ require (
|
||||
github.com/u2takey/go-utils v0.3.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -8,6 +8,11 @@ github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9
|
||||
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs=
|
||||
github.com/alecthomas/participle v0.7.1/go.mod h1:HfdmEuwvr12HXQN44HPWXR0lHmVolVYe4dyL6lQ3duY=
|
||||
github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8=
|
||||
github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
|
||||
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||
github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA=
|
||||
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
@@ -157,6 +162,8 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
@@ -169,6 +176,8 @@ golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -190,6 +199,8 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
@@ -204,6 +215,8 @@ golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -76,17 +76,27 @@ func DeleteFiles(config config.Config, fileIds []int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func HasLyrics(config config.Config, songId int) (bool, error) {
|
||||
_, err := request("GET", fmt.Sprintf("/songs/%d/lyrics", songId), nil, config, "")
|
||||
return err == nil, err
|
||||
func HasLyrics(config config.Config, songId int) (bool, bool, error) {
|
||||
res, err := request("GET", fmt.Sprintf("/songs/%d/lyrics", songId), nil, config, JsonContentType)
|
||||
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
var resp = LyricsDto{}
|
||||
err = validate(res, &resp)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return len(resp.Plain) > 0, resp.Synced != nil && len(*resp.Synced) > 0, err
|
||||
}
|
||||
|
||||
type LyricsDto struct {
|
||||
Lyrics string `json:"lyrics"`
|
||||
Plain string `json:"plain"`
|
||||
Synced *(internal.SyncedLyrics) `json:"synced"`
|
||||
}
|
||||
|
||||
func PostLyrics(config config.Config, songId int, lyrics []string) error {
|
||||
dto := LyricsDto{Lyrics: strings.Join(lyrics, "\n")}
|
||||
func PostLyrics(config config.Config, songId int, plainLyrics internal.PlainLyrics, syncedLyrics *internal.SyncedLyrics) error {
|
||||
dto := LyricsDto{Plain: strings.Join(plainLyrics, "\n"), Synced: syncedLyrics}
|
||||
serialized, err := json.Marshal(dto)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -126,6 +136,9 @@ func SaveMetadata(config config.Config, m internal.Metadata, saveMethod SaveMeta
|
||||
if m.DiscIndex > 0 {
|
||||
mp.WriteField("discIndex", strconv.FormatInt(m.DiscIndex, 10))
|
||||
}
|
||||
if len(m.DiscName) > 0 {
|
||||
mp.WriteField("discName", m.DiscName)
|
||||
}
|
||||
if m.Bitrate > 0 {
|
||||
mp.WriteField("bitrate", strconv.FormatInt(m.Bitrate, 10))
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ const (
|
||||
)
|
||||
|
||||
type UserSettings struct {
|
||||
Compilations CompilationSettings `json:"compilations" validate:"required"`
|
||||
TrackRegex []string `json:"trackRegex" validate:"required"`
|
||||
Metadata MetadataSettings `json:"metadata" validate:"required"`
|
||||
Compilations CompilationSettings `json:"compilations" validate:"required"`
|
||||
TrackRegex []string `json:"trackRegex" validate:"required"`
|
||||
Metadata MetadataSettings `json:"metadata" validate:"required"`
|
||||
UseEmbeddedThumbnails bool `json:"useEmbeddedThumbnails"`
|
||||
}
|
||||
|
||||
func GetUserSettings(settingsFilePath string) (UserSettings, []error) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Lyrics interface {
|
||||
isLyrics()
|
||||
}
|
||||
|
||||
type PlainLyrics []string
|
||||
|
||||
func (_ PlainLyrics) isLyrics() {}
|
||||
|
||||
type SyncedLyrics []SyncedLyric
|
||||
type SyncedLyric struct {
|
||||
// In seconds
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (_ SyncedLyrics) isLyrics() {}
|
||||
|
||||
func (l SyncedLyrics) ToPlain() PlainLyrics {
|
||||
res := make([]string, len(l))
|
||||
for i, entry := range l {
|
||||
res[i] = entry.Content
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func ParseLyrics(s string) Lyrics {
|
||||
syncedLyrics := SyncedLyrics{}
|
||||
offset := float64(0) // offset in seconds
|
||||
failed := false
|
||||
lines := strings.Split(s, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parsing offset tag
|
||||
if m := offsetRegex.FindStringSubmatch(line); len(m) != 0 {
|
||||
parsedOffset, err := strconv.ParseInt(m[1], 10, 64)
|
||||
if err == nil {
|
||||
offset = float64(parsedOffset) / 1000
|
||||
continue
|
||||
}
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
|
||||
// Ugly check that it's a timestamp and not anyother tag
|
||||
times, lyric, err := parseTimestampAndLyric(line)
|
||||
|
||||
// Check if it's any other tag
|
||||
if err != nil {
|
||||
if m := tagRegex.FindStringSubmatch(line); len(m) == 0 {
|
||||
failed = true
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, time := range times {
|
||||
syncedLyrics = append(syncedLyrics, SyncedLyric{time, strings.TrimSpace(lyric)})
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return PlainLyrics(lines)
|
||||
}
|
||||
|
||||
if offset != 0 {
|
||||
for i, _ := range syncedLyrics {
|
||||
// a possitive offset makes the lyric come up sooner
|
||||
syncedLyrics[i].Timestamp -= offset
|
||||
}
|
||||
}
|
||||
sort.Slice(syncedLyrics, func(a, b int) bool {
|
||||
return syncedLyrics[a].Timestamp <= syncedLyrics[b].Timestamp
|
||||
})
|
||||
return syncedLyrics
|
||||
}
|
||||
|
||||
func parseTimestampAndLyric(line string) ([]float64, string, error) {
|
||||
time := float64(0)
|
||||
res := timestampRegex.FindStringSubmatch(line)
|
||||
if len(res) != 5 {
|
||||
return nil, "", fmt.Errorf("regex didn't match")
|
||||
}
|
||||
// Convert time into float
|
||||
for i, timeStr := range res[1:4] {
|
||||
parsedTime, err := strconv.ParseInt(timeStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if i == 2 {
|
||||
time += float64(parsedTime) / 100
|
||||
} else {
|
||||
time += float64(parsedTime) * math.Pow(60, float64(1-i))
|
||||
}
|
||||
}
|
||||
lyric := res[4]
|
||||
// check if recursive
|
||||
otherTimes, nestedlyric, err := parseTimestampAndLyric(lyric)
|
||||
if err != nil {
|
||||
return []float64{time}, lyric, nil
|
||||
}
|
||||
return append(otherTimes, time), nestedlyric, nil
|
||||
}
|
||||
|
||||
var (
|
||||
timestampRegex = regexp.MustCompile(`^\[(?P<Min>\d{2}):(?P<Sec>\d{2}).(?P<Mil>\d{2})\](?P<Rest>.*)$`)
|
||||
tagRegex = regexp.MustCompile(`\[([^\]]+):([^\]]+)\]`)
|
||||
offsetRegex = regexp.MustCompile(`\[offset:(?P<offset>[+-]\d+)\]`)
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLyricsParsing(t *testing.T) {
|
||||
|
||||
path := path.Join("..", "testdata", "lyrics.lrc")
|
||||
lyric, _ := os.ReadFile(path)
|
||||
res := ParseLyrics(string(lyric))
|
||||
switch lyrics := res.(type) {
|
||||
case PlainLyrics:
|
||||
assert.Fail(t, "Returned Plainlyrics instead of SyncedLyrics")
|
||||
case SyncedLyrics:
|
||||
assert.Equal(t, 5, len(lyrics))
|
||||
expected := []SyncedLyric{
|
||||
{Timestamp: 2, Content: "Line 1 lyrics"},
|
||||
{Timestamp: 3.20, Content: "Line 2 lyrics"},
|
||||
{Timestamp: 46.10, Content: "Repeating lyrics"},
|
||||
{Timestamp: 64.10, Content: "Repeating lyrics"},
|
||||
{Timestamp: 65.00, Content: "Last lyrics line"},
|
||||
}
|
||||
// For easier log/debug
|
||||
for i, expectedEntry := range expected {
|
||||
assert.Equal(t, expectedEntry, lyrics[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ type Metadata struct {
|
||||
Artist string `validate:"required"`
|
||||
// Name of the artist of the parent album
|
||||
AlbumArtist string
|
||||
// Lyrics of the song. One string == one line. Empty lines are line jumps
|
||||
Lyrics []string
|
||||
// Plain Lyrics of the song. One string == one line. Empty lines are line jumps
|
||||
PlainLyrics PlainLyrics
|
||||
SyncedLyrics SyncedLyrics
|
||||
// Name of the album of the track
|
||||
Album string
|
||||
// Name of the release of the track
|
||||
@@ -30,6 +31,8 @@ type Metadata struct {
|
||||
Index int64
|
||||
// Index of the disc the track is on
|
||||
DiscIndex int64
|
||||
// Name of the disc
|
||||
DiscName string
|
||||
// Bitrate of the file, in kbps
|
||||
Bitrate int64 `validate:"gte=0"`
|
||||
Bpm float64
|
||||
|
||||
@@ -19,14 +19,34 @@ type parseTagFn func(string)
|
||||
// Tries to get each tag by key one after the other. If it success, calls function and returns
|
||||
func ParseTag(t ffprobe.Tags, keys []string, fun parseTagFn) {
|
||||
for _, key := range keys {
|
||||
value, err := t.GetString(strings.ToLower(key))
|
||||
if err == nil && len(value) > 0 {
|
||||
fun(value)
|
||||
value, found := t[key]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
var s string = value.(string)
|
||||
if len(s) > 0 {
|
||||
fun(s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CollectTags(probeData *ffprobe.ProbeData) ffprobe.Tags {
|
||||
var tags ffprobe.Tags = make(ffprobe.Tags)
|
||||
// In some format (e.g. opus)
|
||||
// Tags are attached to the audio stream,
|
||||
// Not the 'Format'
|
||||
for _, s := range probeData.Streams {
|
||||
for k, v := range s.TagList {
|
||||
tags[strings.ToLower(k)] = v
|
||||
}
|
||||
}
|
||||
for k, v := range probeData.Format.TagList {
|
||||
tags[strings.ToLower(k)] = v
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// sources for keys:
|
||||
// https://github.com/Arthi-chaud/Meelo/issues/851
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/c5287178b4dc373e763f7cd49703a6e3192aab3a/libavformat/id3v2.c#L105
|
||||
@@ -49,11 +69,8 @@ func parseMetadataFromEmbeddedTags(filePath string, c config.UserSettings) (inte
|
||||
}
|
||||
metadata.Duration = int64(probeData.Format.DurationSeconds)
|
||||
metadata.Type = getType(*probeData)
|
||||
tags := CollectTags(probeData)
|
||||
|
||||
var tags ffprobe.Tags = make(ffprobe.Tags)
|
||||
for k, v := range probeData.Format.TagList {
|
||||
tags[strings.ToLower(k)] = v
|
||||
}
|
||||
ParseTag(tags, []string{"artist", "tope"}, func(value string) {
|
||||
metadata.Artist = value
|
||||
})
|
||||
@@ -66,6 +83,9 @@ func parseMetadataFromEmbeddedTags(filePath string, c config.UserSettings) (inte
|
||||
ParseTag(tags, []string{"title"}, func(value string) {
|
||||
metadata.Name = value
|
||||
})
|
||||
ParseTag(tags, []string{"discsubtitle"}, func(value string) {
|
||||
metadata.DiscName = value
|
||||
})
|
||||
ParseTag(tags, []string{"genres", "genre", "tcon"}, func(value string) {
|
||||
metadata.Genres = strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ';' || r == '\\' || r == ','
|
||||
@@ -90,14 +110,14 @@ func parseMetadataFromEmbeddedTags(filePath string, c config.UserSettings) (inte
|
||||
metadata.Index = int64(trackValue)
|
||||
})
|
||||
ParseTag(tags, []string{"lyrics", "uslt"}, func(value string) {
|
||||
metadata.Lyrics = strings.Split(
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(value, "\r", "\n"),
|
||||
"\r\n",
|
||||
"\n",
|
||||
),
|
||||
"\n",
|
||||
)
|
||||
parsedLyrics := internal.ParseLyrics(value)
|
||||
switch l := parsedLyrics.(type) {
|
||||
case internal.PlainLyrics:
|
||||
metadata.PlainLyrics = l
|
||||
case internal.SyncedLyrics:
|
||||
metadata.SyncedLyrics = l
|
||||
metadata.PlainLyrics = l.ToPlain()
|
||||
}
|
||||
})
|
||||
ParseTag(tags, []string{"bpm", "tbp"}, func(value string) {
|
||||
bpm, err := strconv.ParseFloat(value, 64)
|
||||
@@ -129,9 +149,12 @@ func parseMetadataFromEmbeddedTags(filePath string, c config.UserSettings) (inte
|
||||
}
|
||||
})
|
||||
}
|
||||
if streamIndex := illustration.GetEmbeddedIllustrationStreamIndex(*probeData); streamIndex >= 0 {
|
||||
metadata.IllustrationLocation = internal.Embedded
|
||||
metadata.IllustrationStreamIndex = streamIndex
|
||||
|
||||
if !c.UseEmbeddedThumbnails || metadata.Type != internal.Video {
|
||||
if streamIndex := illustration.GetEmbeddedIllustrationStreamIndex(*probeData); streamIndex >= 0 {
|
||||
metadata.IllustrationLocation = internal.Embedded
|
||||
metadata.IllustrationStreamIndex = streamIndex
|
||||
}
|
||||
}
|
||||
return metadata, errors
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestEmbedded(t *testing.T) {
|
||||
assert.Equal(t, int64(2), m.DiscIndex)
|
||||
assert.Equal(t, int64(3), m.Index)
|
||||
assert.Equal(t, []string{"Pop", "Rock", "Trip-Hop"}, m.Genres)
|
||||
assert.Equal(t, []string{"A", "B", "C", "", "D", "", "", "E", ""}, m.Lyrics)
|
||||
assert.Equal(t, internal.PlainLyrics{"A", "B", "C", "", "D", "", "", "E", ""}, m.PlainLyrics)
|
||||
assert.Equal(t, "Dreams", m.Name)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,26 @@ func TestEmbeddedFlac(t *testing.T) {
|
||||
path := p.Join("../..", "testdata", "test.flac")
|
||||
m, err := parseMetadataFromEmbeddedTags(path, getTestConfig())
|
||||
|
||||
assert.Len(t, err, 0)
|
||||
assert.Equal(t, "Album Artist", m.AlbumArtist)
|
||||
assert.Equal(t, "Artist", m.Artist)
|
||||
assert.Equal(t, false, m.IsCompilation)
|
||||
assert.Equal(t, "Album", m.Album)
|
||||
assert.Equal(t, "My Disc", m.DiscName)
|
||||
assert.Equal(t, internal.Audio, m.Type)
|
||||
assert.Equal(t, int64(217), m.Duration)
|
||||
assert.Equal(t, "", m.Release)
|
||||
assert.Equal(t, time.Date(1999, 1, 1, 1, 1, 1, 1, time.UTC).Year(), m.ReleaseDate.Year())
|
||||
assert.Equal(t, int64(2), m.DiscIndex)
|
||||
assert.Equal(t, int64(1), m.Index)
|
||||
assert.Empty(t, m.Genres)
|
||||
assert.Equal(t, "Title", m.Name)
|
||||
}
|
||||
|
||||
func TestEmbeddedOpus(t *testing.T) {
|
||||
path := p.Join("../..", "testdata", "test.opus")
|
||||
m, err := parseMetadataFromEmbeddedTags(path, getTestConfig())
|
||||
|
||||
assert.Len(t, err, 0)
|
||||
assert.Equal(t, "Album Artist", m.AlbumArtist)
|
||||
assert.Equal(t, "Artist", m.Artist)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -48,6 +49,10 @@ func ParseMetadata(config c.UserSettings, filePath string) (internal.Metadata, [
|
||||
errors = append(errors, err)
|
||||
}
|
||||
metadata.Checksum = checksum
|
||||
|
||||
if len(metadata.SyncedLyrics) == 0 {
|
||||
getLyricsFromLrc(filePath, config, &metadata)
|
||||
}
|
||||
// Let's save some time by skipping acoustid for unreasonably long media
|
||||
if metadata.Type == internal.Audio || metadata.Duration < 1200 { // 20 minutes
|
||||
fingerprint, err := internal.GetFileAcousticFingerprint(filePath)
|
||||
@@ -61,3 +66,21 @@ func ParseMetadata(config c.UserSettings, filePath string) (internal.Metadata, [
|
||||
}
|
||||
return metadata, append(errors, internal.SanitizeAndValidateMetadata(&metadata)...)
|
||||
}
|
||||
|
||||
func getLyricsFromLrc(filePath string, config c.UserSettings, metadata *internal.Metadata) {
|
||||
ext := path.Ext(filePath)
|
||||
lrcPath := strings.ReplaceAll(filePath, ext, ".lrc")
|
||||
lyric, err := os.ReadFile(lrcPath)
|
||||
if err == nil {
|
||||
parsedLyrics := internal.ParseLyrics(string(lyric))
|
||||
switch res := parsedLyrics.(type) {
|
||||
case internal.PlainLyrics:
|
||||
if config.Metadata.Source == c.Path || config.Metadata.Order == c.Preferred {
|
||||
metadata.PlainLyrics = res
|
||||
}
|
||||
case internal.SyncedLyrics:
|
||||
metadata.SyncedLyrics = res
|
||||
metadata.PlainLyrics = res.ToPlain()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ func getMetadataFromMatches(matches []string, regex *regexp.Regexp) (internal.Me
|
||||
errors = append(errors, fmt.Errorf("invalid year: '%s'", matches[index]))
|
||||
}
|
||||
}
|
||||
if index := regex.SubexpIndex("DiscName"); index != -1 {
|
||||
metadata.DiscName = matches[index]
|
||||
}
|
||||
if index := regex.SubexpIndex("Disc"); index != -1 && len(matches[index]) > 0 {
|
||||
value, err := strconv.Atoi(matches[index])
|
||||
if err == nil {
|
||||
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user