Compare commits

..
12 Commits
Author SHA1 Message Date
Arthur Jamet 2be1a5b240 Front: Fix skeleton on release tile when release date is null 2024-07-27 17:15:36 +02:00
Arthur Jamet bb7d5490da Front: Add Links to Github + doc in settings 2024-07-26 19:01:51 +02:00
Arthur Jamet 9cd7d1e641 Server: Fix tests 2024-07-13 13:03:43 +02:00
Arthur Jamet 6e0c250666 Servier: Single push for song sorting parameters 2024-07-13 13:03:43 +02:00
Arthur Jamet c4c1a8072e Front: rare songs ordered by release date in artist page 2024-07-13 13:03:43 +02:00
Arthur Jamet 050cc507fb Song: Sort by release date 2024-07-13 13:03:43 +02:00
Arthur Jamet 6692fff5ce front: Add page for rare artist songs 2024-07-13 11:01:43 +02:00
Arthur Jamet 2a199b8b12 Update README.md 2024-07-09 18:32:25 +02:00
Arthur Jamet e27db49826 Get Rare Songs (#691)
* wip

* Song Controller: Get Rare songs by an artist

* Front: Artist Page: Get Rare songs

* Rare Songs: Only consider songs that have only one version AND are demos or acoustic versions

* B-Sides: apply some rules from rare songs

* Server: Prettier
2024-07-09 18:02:22 +02:00
Arthur Jamet 8368dacc5c Front: Better skeletons for release tracklist 2024-07-08 19:16:03 +02:00
Arthur Jamet 93bfd39ce6 Documentation fixes (#687)
* Documentation: Settings example: Remove genius provider, as it need an api key

* docker compose: set postgres host here, instead of .env

* Doc: env: remove postgres-host + better explaination of the CONFIG_DIR variable

* Doc: env: name section
2024-07-07 19:26:21 +02:00
Arthur Jamet 0ef38851bb Front: remove artist from Skeleton in release tracklist 2024-07-07 15:29:40 +02:00
24 changed files with 369 additions and 70 deletions

No files matched your search

+8 -9
View File
@@ -1,3 +1,4 @@
#################### Database
# The port on the host where meelo will be accessible
PORT=
# Username to access database
@@ -6,24 +7,22 @@ POSTGRES_USER=
POSTGRES_PASSWORD=
# Name of Meelo's database
POSTGRES_DB=
# Hostname from which Meelo can access the database
# If dockerized, this must be the container_name
POSTGRES_HOST=
####################
# The path to the `settings.json` file, and the illustration/metadata folder (on the host machine)
#################### Config
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
CONFIG_DIR=
# The root path of your libraries (on the host machine)
DATA_DIR=
####################
# Server URL that would be accessible to the front app's server or any client
#################### Web app
# URL of the server/api that would be accessible to the front app's server or any client
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
PUBLIC_SERVER_URL=
####################
#################### Security
# Random String used to sign JWT Tokens
JWT_SIGNATURE=
# Key used to authenticate the Meilisearch Instance
# Should be a random string, must be at least 16 bytes
MEILI_MASTER_KEY=
####################
#################### Internal
# Do not change this
INTERNAL_DATA_DIR=/data
INTERNAL_CONFIG_DIR=/config
+1
View File
@@ -25,6 +25,7 @@ Get started! Look over [here](https://arthi-chaud.github.io/Meelo/) for setup in
- Access, stream, and download songs from your server
- Music Videos
- B-Sides
- Identifies 'Rare' Tracks in your collection
- Playlists
- Metadata scan using:
- Embedded tags (including bitrate)
+1
View File
@@ -24,6 +24,7 @@ services:
- .env
environment:
- TRANSCODER_URL=http://transcoder:7666
- POSTGRES_HOST=db
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
+1
View File
@@ -19,6 +19,7 @@ services:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
+1
View File
@@ -20,6 +20,7 @@ services:
- TRANSCODER_URL=http://transcoder:7666
- MEILI_HOST=http://meilisearch:7700
- REDIS_HOST=redis
- POSTGRES_HOST=db
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
healthcheck:
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
+1
View File
@@ -666,6 +666,7 @@ export default class API {
genre?: Identifier;
artist?: Identifier;
versionsOf?: Identifier;
rare?: Identifier;
query?: string;
bsides?: Identifier;
random?: number;
@@ -158,15 +158,9 @@ const InfiniteSongView = <
]}
disableSorting={props.disableSorting}
onChange={setOptions}
sortingKeys={SongSortingKeys.filter(
(key) => key !== "userPlayCount",
)}
sortingKeys={SongSortingKeys}
defaultSortingOrder={props.initialSortingOrder}
defaultSortingKey={
props.initialSortingField == "userPlayCount"
? "totalPlayCount"
: props.initialSortingField
}
defaultSortingKey={props.initialSortingField}
router={props.light == true ? undefined : router}
disableLayoutToggle
defaultLayout={"list"}
+22 -13
View File
@@ -20,6 +20,7 @@ import {
Box,
Divider,
Icon,
IconButton,
List,
ListItem,
ListItemButton,
@@ -37,7 +38,7 @@ import Artist from "../models/artist";
import formatDuration from "../utils/formatDuration";
import ReleaseTrackContextualMenu from "./contextual-menu/release-track-contextual-menu";
import { SongWithRelations } from "../models/song";
import { VideoIcon } from "./icons";
import { ContextualMenuIcon, VideoIcon } from "./icons";
import formatArtists from "../utils/formatArtists";
import { useTranslation } from "react-i18next";
import { Fragment, useMemo } from "react";
@@ -125,19 +126,25 @@ const ReleaseTrackList = ({
<Fragment key={index}>
<ListItem
dense={
currentTrack
? currentTrack.song.artistId !=
mainArtist?.id
: false
mainArtist === undefined
? false
: currentTrack
? currentTrack.song.artistId !=
mainArtist?.id
: false
}
disablePadding
disableGutters
secondaryAction={
currentTrack && (
currentTrack ? (
<ReleaseTrackContextualMenu
track={currentTrack}
artist={currentTrack.song.artist}
/>
) : (
<IconButton disabled>
<ContextualMenuIcon />
</IconButton>
)
}
>
@@ -180,7 +187,7 @@ const ReleaseTrackList = ({
)
) : (
<Skeleton
width="30px"
width="20px"
sx={{ color: "text.disabled" }}
/>
)}
@@ -188,18 +195,20 @@ const ReleaseTrackList = ({
<ListItemText
primary={
currentTrack?.name ?? (
<Skeleton width="100%" />
<Skeleton width="120px" />
)
}
primaryTypographyProps={{
fontSize: "medium",
}}
secondary={
currentTrack
? formatTracksubtitle(
currentTrack.song,
)
: undefined
mainArtist === undefined
? null
: currentTrack
? formatTracksubtitle(
currentTrack.song,
)
: undefined
}
secondaryTypographyProps={{
fontSize: "small",
@@ -23,6 +23,8 @@ import { Language, Languages, persistLanguage } from "../../i18n/i18n";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "usehooks-ts";
import Link from "next/link";
import { Book1, Star, Warning2 } from "iconsax-react";
const SettingGroupStyle = {
paddingTop: 1,
@@ -35,6 +37,9 @@ const InputContainerStyle = {
display: "flex",
} as const;
const RepositoryUrl = "https://github.com/Arthi-chaud/meelo";
const LinkIconStyle = { marginBottom: -5, marginRight: 5 };
const UserSettings = () => {
const { t, i18n } = useTranslation();
const colorScheme = useColorScheme();
@@ -153,6 +158,36 @@ const UserSettings = () => {
/>
</Grid>
</Grid>
<SectionHeader heading={t("project")} />
<p>
<Star style={LinkIconStyle} />
{t("enjoyingTheProject")}{" "}
<Link
style={{ textDecoration: "underline" }}
href={RepositoryUrl}
>
{t("starOnGithub")}
</Link>
</p>
<p>
<Warning2 style={LinkIconStyle} />
{t("encounteredABug")}{" "}
<Link
style={{ textDecoration: "underline" }}
href={`${RepositoryUrl}/issues`}
>
{t("openAnIssue")}
</Link>
</p>
<p>
<Book1 style={LinkIconStyle} />
<Link
style={{ textDecoration: "underline" }}
href="https://arthi-chaud.github.io/Meelo"
>
{t("readTheDoc")}
</Link>
</p>
</NoSsr>
);
};
+1 -1
View File
@@ -27,7 +27,7 @@ const ReleaseTile = (props: {
formatSubtitle?: (release: ReleaseWithRelations<"album">) => string;
}) => {
const yearFormat = props.release
? getYear(props.release.releaseDate)?.toString()
? getYear(props.release.releaseDate)?.toString() ?? ""
: "";
return (
<Tile
+8 -1
View File
@@ -228,14 +228,21 @@
"useSystemeTheme": "Use System Theme",
"useDarkTheme": "Use Dark Theme",
"language": "Language",
"project": "Project",
"useSystemeLanguage": "Use System Language",
"notifications": "Notifications",
"permissions": "Permissions",
"notifyOnTrackChange": "Notify On Track Change",
"enjoyingTheProject": "Enjoying the project?",
"starOnGithub": "Star Meelo on GitHub!",
"encounteredABug": "You encountered a bug?",
"openAnIssue": "Open an issue on GitHub!",
"readTheDoc": "Read the user documentation",
"en": "English",
"fr": "French",
"yes": "Yes",
"no": "No",
"featuredAlbums": "Featured Albums"
"featuredAlbums": "Featured Albums",
"rareSongs": "Rare Songs"
}
+8 -1
View File
@@ -217,14 +217,21 @@
"useSystemeTheme": "Utiliser le thème système",
"useDarkTheme": "Utiliser le mode sombre",
"language": "Langue",
"project": "Projet",
"useSystemeLanguage": "Utiliser la langue du système",
"notifications": "Notifications",
"permissions": "Permissions",
"notifyOnTrackChange": "Notifier quand le morceau change",
"enjoyingTheProject": "Vous aimez le projet?",
"starOnGithub": "Donner une étoile à Meelo sur GitHub!",
"encounteredABug": "Vous avez rencontré un problème?",
"openAnIssue": "Ouvrir une issue sur GitHub!",
"readTheDoc": "Accéder à la documentation utilisateur",
"en": "Anglais",
"fr": "Français",
"yes": "Oui",
"no": "Non",
"featuredAlbums": "En Vedette"
"featuredAlbums": "En Vedette",
"rareSongs": "Pistes Inédites"
}
+3 -2
View File
@@ -97,9 +97,10 @@ type SongWithRelations<Selection extends SongInclude | never = never> =
export default Song;
export const SongSortingKeys = [
"name",
"artistName",
"releaseDate",
"addDate",
"userPlayCount",
"totalPlayCount",
"artistName",
"addDate",
] as const;
export { type SongInclude, SongWithRelations, SongRelations };
@@ -71,6 +71,13 @@ const topSongsQuery = (artistSlugOrId: string | number) =>
["artist", "featuring", "master", "illustration"],
);
const rareSongsQuery = (artistSlugOrId: string | number) =>
API.getSongs(
{ rare: artistSlugOrId },
{ sortBy: "releaseDate", order: "desc" },
["artist", "featuring", "master", "illustration"],
);
const artistQuery = (artistSlugOrId: string | number) =>
API.getArtist(artistSlugOrId, ["externalIds", "illustration"]);
@@ -88,6 +95,7 @@ const prepareSSR = (context: NextPageContext) => {
additionalProps: { artistIdentifier },
queries: [artistQuery(artistIdentifier)],
infiniteQueries: [
rareSongsQuery(artistIdentifier),
videosQuery(artistIdentifier),
topSongsQuery(artistIdentifier),
appearanceQuery(artistIdentifier),
@@ -110,6 +118,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
}));
const videos = useInfiniteQuery(videosQuery, artistIdentifier);
const topSongs = useInfiniteQuery(topSongsQuery, artistIdentifier);
const rareSongs = useInfiniteQuery(rareSongsQuery, artistIdentifier);
const appearances = useInfiniteQuery(appearanceQuery, artistIdentifier);
const externalIdWithDescription = artist.data?.externalIds
.filter(({ provider }) => provider.name.toLowerCase() !== "discogs")
@@ -262,6 +271,48 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
</Grid>
</Fragment>
))}
{(rareSongs.data?.pages?.at(0)?.items.length ?? 0) != 0 && (
<>
<SectionHeader
heading={t("rareSongs")}
trailing={
<Fade
in={
(rareSongs.data?.pages.at(0)?.items
.length ?? 0) > songListSize
}
>
<Link
href={`/artists/${artistIdentifier}/rare-songs`}
>
<Button
variant="contained"
color="secondary"
endIcon={<MoreIcon />}
sx={{
textTransform: "none",
fontWeight: "bold",
}}
>
{t("seeAll")}
</Button>
</Link>
</Fade>
}
/>
<Grid item sx={{ overflowX: "clip", width: "100%" }}>
<SongGrid
parentArtistName={artist.data?.name}
songs={
rareSongs.data?.pages
?.at(0)
?.items.slice(0, songListSize) ??
generateArray(songListSize)
}
/>
</Grid>
</>
)}
{[
{ label: "topVideos", items: musicVideos } as const,
{ label: "extras", items: extras } as const,
@@ -269,6 +320,7 @@ const ArtistPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
({ label, items }) =>
items.length != 0 && (
<Fragment key={`videos-${label}`}>
<Box sx={{ paddingBottom: sectionPadding }} />
<SectionHeader
heading={t(label)}
trailing={
@@ -0,0 +1,21 @@
/*
* 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 ArtistSongPage from "./songs";
// routing checks are done there
export default ArtistSongPage;
+13 -7
View File
@@ -38,6 +38,9 @@ import { useGradientBackground } from "../../../utils/gradient-background";
const artistQuery = (identifier: string | number) =>
API.getArtist(identifier, ["illustration"]);
const isRareSongsPage = ({ asPath }: { asPath?: string }) =>
asPath?.includes("/rare-songs") ?? false;
const prepareSSR = async (
context: NextPageContext,
queryClient: QueryClient,
@@ -47,12 +50,14 @@ const prepareSSR = async (
const sortBy = getSortingFieldParams(context.query.sortBy, SongSortingKeys);
const songs = await queryClient.fetchInfiniteQuery(
prepareMeeloInfiniteQuery(() =>
API.getSongs({ artist: artistIdentifier }, { sortBy, order }, [
"artist",
"featuring",
"master",
"illustration",
]),
API.getSongs(
{
[isRareSongsPage(context) ? "rare" : "artist"]:
artistIdentifier,
},
{ sortBy, order },
["artist", "featuring", "master", "illustration"],
),
),
);
@@ -96,7 +101,8 @@ const ArtistSongPage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({
query={({ library, sortBy, order, type, random }) =>
API.getSongs(
{
artist: artistIdentifier,
[isRareSongsPage(router) ? "rare" : "artist"]:
artistIdentifier,
type,
random,
library: library ?? undefined,
+3 -11
View File
@@ -100,7 +100,7 @@ const releaseTracklistQuery = (releaseIdentifier: number | string) => {
};
};
const albumQuery = (albumId: number) =>
API.getAlbum(albumId, ["externalIds", "genres"]);
API.getAlbum(albumId, ["externalIds", "genres", "artist"]);
const artistsOnAlbumQuery = (albumId: number) => {
const query = API.getArtists({ album: albumId }, undefined, [
"illustration",
@@ -216,15 +216,7 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
relatedPlaylistsQuery,
release.data?.albumId,
);
const albumArtist = useMemo(
() =>
album.data?.artistId === null
? null
: artists.data
? artists.data.find((artist) => artist.id === artistId)
: undefined,
[artistId, artists, album.data],
);
const albumArtist = useMemo(() => album.data?.artist, [album.data]);
const featurings = useMemo(
() => artists.data?.filter((artist) => artist.id !== artistId),
[artistId, artists],
@@ -608,7 +600,7 @@ const ReleasePage: Page<GetPropsTypesFrom<typeof prepareSSR>> = ({ props }) => {
<ReleaseTrackList
mainArtist={albumArtist}
tracklist={
trackList
trackList && album.data // need to wait for main artist
? Object.fromEntries(
Array.from(
Object.entries(trackList),
@@ -36,11 +36,7 @@ describe("Wikipedia Provider", () => {
expect(value).toBe("Siobhán Donaghy");
expect(description).not.toBeNull();
expect(
description!.startsWith(
"Siobhán Emma Donaghy (born 14 June 1984)",
),
).toBeTruthy();
expect(description!.includes("Siobhán Emma Donaghy")).toBeTruthy();
expect(
description!.endsWith(
"rights to the Sugababes name again in 2019.",
+7 -2
View File
@@ -579,6 +579,11 @@ describe("Parser Service", () => {
parserService.getSongType("My Song (Original Version)"),
).toBe(SongType.Original);
});
it("Original Version (Original Mix)", () => {
expect(parserService.getSongType("My Song (Original Mix)")).toBe(
SongType.Original,
);
});
it("Original Version (Feat Group)", () => {
expect(parserService.getSongType("My Song (feat. A)")).toBe(
SongType.Original,
@@ -852,12 +857,12 @@ describe("Parser Service", () => {
it("Demo (Rough Mix)", () => {
expect(parserService.getSongType("Fever (Rough Mix)")).toBe(
SongType.Original,
SongType.Demo,
);
});
it("Demo (Rough Mix Edit)", () => {
expect(parserService.getSongType("Fever (Rough Mix Edit)")).toBe(
SongType.Original,
SongType.Demo,
);
});
+3
View File
@@ -409,6 +409,9 @@ export default class ParserService {
return SongType.Clean;
}
if (jointExtensionWords.includes("rough mix")) {
return SongType.Demo;
}
if (jointExtensionWords == "original mix") {
return SongType.Original;
}
if (containsWord("mix") && containsWord("edit")) {
@@ -128,6 +128,7 @@ namespace SongQueryParameters {
"userPlayCount",
"artistName",
"addDate",
"releaseDate",
] as const;
export type SortingKeys = typeof SortingKeys;
export class SortingParameter extends ModelSortingParameter(SortingKeys) {}
+15 -1
View File
@@ -130,6 +130,13 @@ export class Selector {
@TransformIdentifier(ReleaseService)
bsides: ReleaseQueryParameters.WhereInput;
@IsOptional()
@ApiPropertyOptional({
description: "Filter songs that would be considered to be 'rare'",
})
@TransformIdentifier(ReleaseService)
rare: ArtistQueryParameters.WhereInput;
@IsOptional()
@ApiPropertyOptional({
description: "The Seed to Sort the items",
@@ -188,6 +195,13 @@ export class SongController {
include,
sort,
);
} else if (selector.rare) {
return this.songService.getRareSongsByArtist(
selector.rare,
paginationParameters,
include,
sort,
);
}
return this.songService.getMany(
selector,
@@ -212,7 +226,7 @@ export class SongController {
}
@ApiOperation({
summary: "Upate a song",
summary: "Update a song",
})
@Response({ handler: SongResponseBuilder })
@Post(":idOrSlug")
+161 -8
View File
@@ -53,6 +53,7 @@ import {
getRandomIds,
sortItemsUsingOrderedIdList,
} from "src/repository/repository.utils";
import ArtistQueryParameters from "src/artist/models/artist.query-parameters";
@Injectable()
export default class SongService extends SearchableRepositoryService {
@@ -362,6 +363,34 @@ export default class SongService extends SearchableRepositoryService {
},
});
break;
case "releaseDate":
sort.push(
{
master: {
release: {
album: {
releaseDate: sortingParameter.order,
},
},
},
},
{
master: {
release: {
releaseDate: sortingParameter.order,
},
},
},
{
name: "asc",
},
{
artist: {
name: "asc",
},
},
);
break;
default:
sort.push({
[sortingParameter.sortBy ?? "id"]: sortingParameter.order,
@@ -676,14 +705,138 @@ export default class SongService extends SearchableRepositoryService {
// We only want songs that have at least one audtio tracks
{ tracks: { some: { type: TrackType.Audio } } },
{
type: {
in: [
SongType.Original,
SongType.Acoustic,
SongType.Demo,
SongType.NonMusic,
],
},
OR: [
// We take original songs or extras
{
type: { in: ["Original", "NonMusic"] },
},
// Or songs that are only available as demos/acoustic versions
{
group: {
versions: {
every: {
type: { in: ["Demo", "Acoustic"] },
},
},
},
},
],
},
],
},
orderBy: sort ? this.formatSortingInput(sort) : undefined,
include: include ?? ({} as I),
...formatPaginationParameters(pagination),
});
}
async getRareSongsByArtist<I extends SongQueryParameters.RelationInclude>(
where: ArtistQueryParameters.WhereInput,
pagination?: PaginationParameters,
include?: I,
sort?: SongQueryParameters.SortingParameter,
) {
const artist = await this.artistService
.get(where, { albums: true })
.catch(() => null);
if (!artist || artist.albums.length == 0) {
// if the artist does not have albums, lets skip this
return [];
}
return this.prismaService.song.findMany({
where: {
// Take the tracks that have at least one audio track
tracks: {
some: { type: "Audio" },
},
AND: [
{
OR: [
{
type: { in: ["Original"] },
},
{
group: {
versions: {
every: {
type: { in: ["Demo", "Acoustic"] },
},
},
},
},
],
},
{
OR: [
{ artistId: artist.id },
{ featuring: { some: { id: artist.id } } },
],
},
{
OR: [
// Take songs that only appears on other artist's album
{
// In that case, we only want song with artist being the main one
artistId: artist.id,
tracks: {
every: {
release: {
album: {
artistId: { not: artist.id },
},
},
},
},
},
// Take all tracks that only appear on non-master albums
{
tracks: {
every: {
release: {
album: {
type: "StudioRecording",
},
masterOf: null,
},
},
},
},
// Take all tracks that appear only on singles AND non master albums
{
tracks: {
every: {
OR: [
{
release: {
album: {
type: "Single",
},
},
trackIndex: {
notIn: [0, 1],
},
},
{
release: {
album: {
type: "StudioRecording",
},
masterOf: null,
},
},
],
},
},
},
{
tracks: {
some: {
isBonus: true,
},
},
},
],
},
],
},
-1
View File
@@ -8,7 +8,6 @@
"useExternalProviderGenres": true
},
"providers": {
"genius": {},
"musicbrainz": {}
},
"compilations": {