Compare commits

...
15 Commits
Author SHA1 Message Date
Arthur Jamet a3095b4dfa Front: Rewrite Infinite Views' Controls
* Front: Start new Controls + Actio  Group

* Front: New Controls: Layout Button

* Front: New Controls: Split into subcomponents

* Front: New Controls: Layout Hook

* Front: New Controls: Sort Hook

* Front: New Controls: Filter Hook

* Front: TMP

* Front: New Controls: Fixes for Sorting

* Front: New Controls: Specialize filter for single/multiple choices

* Front: New Controls: Fix typing for song groups

* Front: New Controls: Use in Album View

* Front: New Controls: Use in Video View

* Front: New Controls: Use in Track View

* Front: New Controls: Use in Playlist View

* Front: New Controls: Fix opeing actions' dialog

* Front: New Controls: Use in Artist View

* Front: Remove Old controls

* Front: New Controls: Rename files, shorten paths

* Front: New Controls: Fix initial state of layout when SSR

* Front: New Controls: Filters and Sort have dedicated button groups

* Front: New Controls: Fix 'selected' state when filter has multiple choices

* Front: New Controls: Remove 'defaultSortingKey' from Sort Control
2025-03-28 10:29:19 +00:00
Arthur Jamet 385ca37848 Front: Bump Nextjs 2025-03-24 17:47:16 +00:00
Arthur Jamet ec02d780ea Scanner: LRC-formatted Lyrics (#960)
* Scanner: Defined plain/synced lyrics type

* Scanner: Parse LRC

* Scanner: Look for LRC files

* Scanner: Look for LRC files

* README: Mention LRC support
2025-03-17 11:34:21 +00:00
Arthur Jamet ecdb82782f Front: Remove Dependency to spinner library (#959) 2025-03-16 10:32:45 +00:00
Matthieu LAURENT ace1d78505 Capture "/" key event to prevent quick find from opening (#957) 2025-03-16 10:07:56 +00:00
Matthieu LAURENT 87dc8a3f66 Only skip using the embedded thumbnail as illustrations for videos (#958) 2025-03-16 10:00:44 +00:00
Arthur Jamet e8b6db6ed5 README: Add Synced Lyrics GIF 2025-03-15 10:06:00 +00:00
Matthieu LAURENT 7aa484b1e4 Use the embeded video illustration as thumbnail if one exists (#949)
* Use the embeded video illustration as thumbnail if one exists.
This falls back to the existing implementation of using a frame from the video if no embeded illustration is present.

* Add aspect ratio check

* Revert "Add aspect ratio check"

This reverts commit 73a0c737156feda2c9a1a05e6736cb8d07d4c5d4.

* Add "useEmbeddedThumbnails" option

* Format code
2025-03-15 09:55:19 +00:00
Arthur Jamet 988a2e38f0 Support Disc Names (#954)
* Scanner: Get Disc Name

* Prisma: Define Disc View

* Server: Persist Disc Name

* API: Get Disc from release

* Front: Release Page: Show Disc Names
2025-03-14 15:16:48 +00:00
Arthur Jamet af359358d5 Front: Fix padding around song-type icons (#953) 2025-03-13 16:38:05 +00:00
Matthieu LAURENT fd3e5c9e5f Add an icon with the song type (#948)
* Add song type icons next to the name

* Use translated name for song type tooltip

* Move icons to song info page and track info modal

* Change icons for cut and edit song types

* Remove leftover code edits

* Change demo song type icon

* Improve songType french translation

* Remove unnecessary casts

* Make the track info song type icon fit the font size
This also makes it smaller overall

* Remove song type icon tooltip

* Handle the case where a track has no song during song type display

* Show a skeleton for song type while the data is loading
This also makes the icon a bit smaller

* Fix type check, lint & format
2025-03-13 16:26:06 +00:00
Arthur Jamet bae28fb94a Scanner: Bump Version of Go + Bump dep 2025-03-13 14:37:36 +00:00
Arthur Jamet 8964086a61 Server: Bump Axios 2025-03-13 13:05:21 +00:00
Arthur Jamet e0c2711fac Synced Lyrics (#944) 2025-03-10 19:04:54 +00:00
Arthur Jamet 27146f2bb0 Scanner: Get embedded tags from streams 2025-03-09 20:07:17 +00:00
133 changed files with 3448 additions and 2383 deletions

No files matched your search

+2 -1
View File
@@ -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'
+2 -1
View File
@@ -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
![Artist Page](./assets/examples/artist-page.png)
![Player Page](./assets/examples/player.png)
![Release Page](./assets/examples/releases.png)
![Synced Lyrics](./assets/examples/synced-lyrics.gif)
More screenshots [here](./assets/examples/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

+1 -2
View File
@@ -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
View File
@@ -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
-432
View File
@@ -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;
-36
View File
@@ -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;
+14 -1
View File
@@ -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;
@@ -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) => (
@@ -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 ?? []),
]}
/>
);
};
@@ -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) => (
@@ -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;
-72
View File
@@ -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>
);
@@ -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;
+2 -1
View File
@@ -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();
+34 -11
View File
@@ -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
+70
View File
@@ -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;
+20 -2
View File
@@ -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(".")
+4
View File
@@ -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],
+6 -2
View File
@@ -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"
}
+6 -2
View File
@@ -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>;
+10 -5
View File
@@ -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;
+3 -1
View File
@@ -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),
);
+1 -1
View File
@@ -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";
+13 -26
View File
@@ -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;
+8 -20
View File
@@ -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"],
+1 -1
View File
@@ -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"],
);
+46 -40
View File
@@ -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>
);
+10 -12
View File
@@ -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"],
+12 -20
View File
@@ -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",
])
}
/>
</>
+11 -12
View File
@@ -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"],
+1 -1
View File
@@ -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",
+9 -19
View File
@@ -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"])}
/>
</>
);
+1 -1
View File
@@ -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,
+20 -17
View File
@@ -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"],
+1 -2
View File
@@ -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 />;
+48 -12
View File
@@ -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",
+34 -33
View File
@@ -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;
+10 -15
View File
@@ -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"],
)
-31
View File
@@ -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;
};
-12
View File
@@ -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,
});
};
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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})
+22 -17
View File
@@ -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,
+9 -10
View File
@@ -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,
+54 -32
View File
@@ -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,
)
+1 -1
View File
@@ -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
+35
View File
@@ -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
+15 -10
View File
@@ -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:
+4
View File
@@ -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}")
+13 -2
View File
@@ -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
+3 -3
View File
@@ -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"):
+117
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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(
+1
View File
@@ -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 *
+56 -62
View File
@@ -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)
+41 -41
View File
@@ -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",
+2
View File
@@ -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
),
+29 -19
View File
@@ -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)
+1 -1
View File
@@ -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)
+92
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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=
+19 -6
View File
@@ -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))
}
+4 -3
View File
@@ -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) {
+121
View File
@@ -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+)\]`)
)
+34
View File
@@ -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
}
}
+5 -2
View File
@@ -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
+41 -18
View File
@@ -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
}
+21 -1
View File
@@ -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)
+23
View File
@@ -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()
}
}
}
+3
View File
@@ -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