Added the start of the ux to navigate thru artists and music stuff

Anatole Sot 8 months ago
parent 0c2aa49e80
commit b8740cf22b

@ -11,6 +11,14 @@ export enum MediaType {
MUSIC = 'music',
}
export enum SecondaryType {
ARTIST = 'artist',
RELEASE_GROUP = 'release-group',
RELEASE = 'release',
RECORDING = 'recording',
WORK = 'work',
}
export enum MediaStatus {
UNKNOWN = 1,
PENDING,

@ -91,6 +91,9 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ type: 'varchar', nullable: true })
public secondaryType?: string;
@Column({ nullable: true })
@Index()
public tmdbId?: number;

@ -1,5 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
@ -567,7 +567,7 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.mbId = mbId;
newMedia.secondaryType = SecondaryType.ARTIST;
newMedia.status = !processing
? MediaStatus.AVAILABLE
: processing

@ -82,6 +82,7 @@ export interface CollectionResult {
backdropPath?: string;
overview: string;
originalLanguage: string;
mediaInfo?: Media;
}
export interface PersonResult {
@ -92,6 +93,7 @@ export interface PersonResult {
adult: boolean;
mediaType: 'person';
knownFor: (MovieResult | TvResult)[];
mediaInfo?: Media;
}
export interface ReleaseGroupResult {
@ -100,6 +102,7 @@ export interface ReleaseGroupResult {
type: mbReleaseGroupType;
posterPath?: string;
title: string;
releases: ReleaseResult[];
artist: ArtistResult[];
tags: string[];
mediaInfo?: Media;
@ -125,6 +128,7 @@ export interface RecordingResult {
length: number;
firstReleased?: Date;
tags: string[];
mediaInfo?: Media;
}
export interface WorkResult {
@ -133,6 +137,7 @@ export interface WorkResult {
title: string;
artist: ArtistResult[];
tags: string[];
mediaInfo?: Media;
}
export interface ArtistResult {
@ -250,6 +255,9 @@ export const mapReleaseGroupResult = (
type: releaseGroupResult.type,
title: releaseGroupResult.title,
artist: releaseGroupResult.artist.map((artist) => mapArtistResult(artist)),
releases: (releaseGroupResult.releases ?? []).map((release) =>
mapReleaseResult(release)
),
tags: releaseGroupResult.tags,
posterPath: getPosterFromMB(releaseGroupResult),
mediaInfo: media ?? undefined,

@ -2,6 +2,7 @@ import Slider from '@app/components/Slider';
import MusicTitleCard from '@app/components/TitleCard/MusicTitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import { SecondaryType } from '@server/constants/media';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
@ -31,7 +32,7 @@ const RecentlyAddedSlider = () => {
const videoMedias = (media?.results ?? []).filter((item) => ["movie", "tv"].includes(item.mediaType))
const musicMedias = (media?.results ?? []).filter((item) => !["movie", "tv"].includes(item.mediaType))
console.log(musicMedias)
return (
<>
<div className="slider-header">
@ -69,7 +70,7 @@ const RecentlyAddedSlider = () => {
key={`media-slider-item-${item.id}`}
id={item.id}
mbId={item.mbId ?? ''}
mediaType={item.mediaType as 'music'}
//type={item.secondaryType as SecondaryType}
/>
)
)}

@ -6,6 +6,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { RadioGroup } from '@headlessui/react';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
@ -47,8 +48,10 @@ const classNames = (...classes: string[]) => {
};
interface CreateIssueModalProps {
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number;
mbId?: string;
secondaryType?: SecondaryType;
onCancel?: () => void;
}
@ -56,16 +59,18 @@ const CreateIssueModal = ({
onCancel,
mediaType,
tmdbId,
mbId,
secondaryType,
}: CreateIssueModalProps) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>(
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : mbId ? `/api/v1/music/${secondaryType}/${mbId}` : null
);
if (!tmdbId) {
if (!tmdbId && (!mbId || !secondaryType)) {
return null;
}

@ -1,15 +1,18 @@
import CreateIssueModal from '@app/components/IssueModal/CreateIssueModal';
import { Transition } from '@headlessui/react';
import type { SecondaryType } from '@server/constants/media';
interface IssueModalProps {
show?: boolean;
onCancel: () => void;
mediaType: 'movie' | 'tv';
tmdbId: number;
mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number;
mbId?: string;
secondaryType?: SecondaryType;
issueId?: never;
}
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
const IssueModal = ({ show, mediaType, onCancel, tmdbId, mbId, secondaryType }: IssueModalProps) => (
<Transition
as="div"
enter="transition-opacity duration-300"
@ -24,6 +27,8 @@ const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
mediaType={mediaType}
onCancel={onCancel}
tmdbId={tmdbId}
mbId={mbId}
secondaryType={secondaryType}
/>
</Transition>
);

@ -0,0 +1,294 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tooltip from '@app/components/Common/Tooltip';
import IssueModal from '@app/components/IssueModal';
import RequestButton from '@app/components/RequestButton';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type {
ArtistResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
WorkResult,
} from '@server/models/Search';
import 'country-flag-icons/3x2/flags.css';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
originaltitle: 'Original Title',
releasedate:
'{releaseCount, plural, one {Release Date} other {Release Dates}}',
overview: 'Overview',
recommendations: 'Recommendations',
playonplex: 'Play on Plex',
markavailable: 'Mark as Available',
showmore: 'Show More',
showless: 'Show Less',
streamingproviders: 'Currently Streaming On',
productioncountries:
'Production {countryCount, plural, one {Country} other {Countries}}',
digitalrelease: 'Digital Release',
physicalrelease: 'Physical Release',
reportissue: 'Report an Issue',
managemusic: 'Manage Music',
});
interface MusicDetailsProps {
type: SecondaryType;
artist?: ArtistResult;
releaseGroup?: ReleaseGroupResult;
release?: ReleaseResult;
recording?: RecordingResult;
work?: WorkResult;
}
const MusicDetails = ({
type,
artist,
releaseGroup,
release,
recording,
work,
}: MusicDetailsProps) => {
const settings = useSettings();
const { hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
const [showIssueModal, setShowIssueModal] = useState(false);
let data,
to_explore = [];
switch (type) {
case SecondaryType.ARTIST:
data = artist;
to_explore = [
SecondaryType.RELEASE_GROUP,
SecondaryType.RELEASE,
SecondaryType.RECORDING,
SecondaryType.WORK,
];
break;
case SecondaryType.RELEASE_GROUP:
data = releaseGroup;
to_explore = [
SecondaryType.RELEASE,
SecondaryType.RECORDING,
SecondaryType.WORK,
];
break;
case SecondaryType.RELEASE:
data = release;
to_explore = [SecondaryType.RECORDING, SecondaryType.ARTIST];
break;
case SecondaryType.RECORDING:
data = recording;
to_explore = [SecondaryType.ARTIST];
break;
case SecondaryType.WORK:
data = work;
to_explore = [SecondaryType.ARTIST];
break;
}
const {
data: info,
error: errorInfo,
mutate: revalidate,
} = useSWR<
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult
>(`/api/v1/music/${router.query.type}/${router.query.mbId}`, {
fallbackData: data as
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: data?.mediaInfo?.downloadStatus,
downloadStatus4k: data?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { plexUrl } = useDeepLinks({
plexUrl: data?.mediaInfo?.plexUrl,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
});
if (!data) {
return <Error statusCode={404} />;
}
const mediaLinks: PlayButtonLink[] = [];
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: plexUrl,
svg: <PlayIcon />,
});
}
const title =
data.mediaType !== SecondaryType.ARTIST ? data.title : data.name;
const datesDisplay: string =
type === SecondaryType.ARTIST &&
(data as ArtistResult).beginDate &&
(data as ArtistResult).endDate
? `${(data as ArtistResult).beginDate} - ${
(data as ArtistResult).endDate
}`
: type === SecondaryType.ARTIST && (data as ArtistResult).beginDate
? `${(data as ArtistResult).beginDate}`
: type === SecondaryType.RELEASE && (data as ReleaseResult).date
? `${(data as ReleaseResult).date}`
: type === SecondaryType.RECORDING &&
(data as RecordingResult).firstReleased
? `${(data as RecordingResult).firstReleased}`
: '';
const releases: ReleaseResult[] = to_explore.includes(SecondaryType.RELEASE)
? (data as ArtistResult | ReleaseGroupResult).releases
: [];
const tags: string[] = data.tags ?? [];
return (
<div
className="media-page"
style={{
height: 493,
}}
>
<div className="media-page-bg-image">
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
<PageTitle title={title} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="music"
mbId={data.id}
secondaryType={type as SecondaryType}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
src={
data.mediaType === SecondaryType.RELEASE ||
data.mediaType === SecondaryType.RELEASE_GROUP
? (data.posterPath as string)
: ''
}
alt=""
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="music"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
secondaryType={type}
/>
</div>
<h1 data-testid="media-title">
{title}{' '}
{datesDisplay !== '' && (
<span className="media-year">({datesDisplay})</span>
)}
</h1>
<span className="media-attributes">
{tags.length > 0 &&
tags
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev}
<span>|</span>
{curr}
</>
))}
</span>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="movie"
media={data.mediaInfo}
mbId={data.id}
secondaryType={type}
onUpdate={() => revalidate()}
/>
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{
type: 'or',
}
) &&
data.mediaInfo?.status4k === MediaStatus.AVAILABLE)) &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
</div>
</div>
</div>
);
};
export default MusicDetails;

@ -9,7 +9,11 @@ import {
InformationCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import {
MediaRequestStatus,
MediaStatus,
SecondaryType,
} from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
@ -43,10 +47,12 @@ interface ButtonOption {
}
interface RequestButtonProps {
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
onUpdate: () => void;
tmdbId: number;
tmdbId?: number;
media?: Media;
mbId?: string;
secondaryType?: SecondaryType;
isShowComplete?: boolean;
is4kShowComplete?: boolean;
}
@ -56,6 +62,8 @@ const RequestButton = ({
onUpdate,
media,
mediaType,
mbId,
secondaryType,
isShowComplete = false,
is4kShowComplete = false,
}: RequestButtonProps) => {

@ -5,6 +5,7 @@ import DownloadBlock from '@app/components/DownloadBlock';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, useIntl } from 'react-intl';
@ -26,7 +27,9 @@ interface StatusBadgeProps {
plexUrl?: string;
serviceUrl?: string;
tmdbId?: number;
mediaType?: 'movie' | 'tv';
secondaryType?: SecondaryType;
mbId?: string;
mediaType?: 'movie' | 'tv' | 'music';
title?: string | string[];
}
@ -38,6 +41,8 @@ const StatusBadge = ({
plexUrl,
serviceUrl,
tmdbId,
mbId,
secondaryType,
mediaType,
title,
}: StatusBadgeProps) => {
@ -52,9 +57,11 @@ const StatusBadge = ({
return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100);
};
if (
mediaType &&
plexUrl &&
if (mediaType && plexUrl) {
if (mediaType === 'music') {
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex);
} else if (
hasPermission(
is4k
? [
@ -80,22 +87,33 @@ const StatusBadge = ({
) {
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex);
} else if (hasPermission(Permission.MANAGE_REQUESTS)) {
if (mediaType && tmdbId) {
}
if (hasPermission(Permission.MANAGE_REQUESTS)) {
if ((mediaType === 'movie' || mediaType === 'tv') && tmdbId) {
mediaLink = `/${mediaType}/${tmdbId}?manage=1`;
mediaLinkDescription = intl.formatMessage(messages.managemedia, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
),
});
} else if (mediaType === 'music' && mbId && secondaryType) {
mediaLink = `/music/${secondaryType}/${mbId}?manage=1`;
mediaLinkDescription = intl.formatMessage(messages.managemedia, {
mediaType: intl.formatMessage(globalMessages.music),
});
} else if (hasPermission(Permission.ADMIN) && serviceUrl) {
mediaLink = serviceUrl;
mediaLinkDescription = intl.formatMessage(messages.openinarr, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
arr:
mediaType === 'movie'
? 'Radarr'
: mediaType === 'tv'
? 'Sonarr'
: 'Lidarr',
});
}
}
}
const tooltipContent = (
<ul>
{downloadItem.map((status, index) => (

@ -1,5 +1,6 @@
import TitleCard from '@app/components/TitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import { SecondaryType } from '@server/constants/media';
import type { ArtistResult,
ReleaseGroupResult,
ReleaseResult,
@ -11,17 +12,15 @@ import useSWR from 'swr';
export interface MusicBrainTitleCardProps {
id: number;
mbId: string;
mediaType: 'music';
type?: 'artist' | 'release-group' | 'release' | 'recording' | 'work';
type?: SecondaryType;
canExpand?: boolean;
}
const TmdbTitleCard = ({
const MusicTitleCard = ({
id,
mbId,
mediaType,
canExpand,
type='artist'
type = SecondaryType.ARTIST,
}: MusicBrainTitleCardProps) => {
const { hasPermission } = useUser();
@ -55,16 +54,16 @@ const TmdbTitleCard = ({
const newData = data as ArtistResult;
return (
<TitleCard
id={id}
id={mbId}
status={newData.mediaInfo?.status}
title={newData.name}
mediaType={mediaType}
mediaType={data.mediaType}
canExpand={canExpand}
/>
);
} else if (data.mediaType === 'release-group' || data.mediaType === 'release') {
return (<TitleCard
id={data.id}
id={mbId}
image={data.posterPath}
status={data.mediaInfo?.status}
title={data.title}
@ -75,4 +74,4 @@ const TmdbTitleCard = ({
return null;
};
export default TmdbTitleCard;
export default MusicTitleCard;

@ -188,7 +188,7 @@ const TitleCard = ({
>
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link
href={`/${mediaType}/${id}`}
href={tmdbOrMbId ? `/${mediaType}/${id}` : `/music/${mediaType}/${id as string}`}
>
<a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"

@ -0,0 +1,73 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import MusicDetails from '@app/components/MusicDetails';
import Error from '@app/pages/_error';
import { SecondaryType } from '@server/constants/media';
import type {
ArtistResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
WorkResult,
} from '@server/models/Search';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface MusicPageProps {
music?:
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult;
}
const MusicPage: NextPage<MusicPageProps> = ({ music }) => {
if (!music) {
return <LoadingSpinner />;
}
switch (music?.mediaType) {
case SecondaryType.ARTIST:
return <MusicDetails type={SecondaryType.ARTIST} artist={music} />;
case SecondaryType.RELEASE_GROUP:
return (
<MusicDetails type={SecondaryType.RELEASE_GROUP} releaseGroup={music} />
);
case SecondaryType.RELEASE:
return <MusicDetails type={SecondaryType.RELEASE} release={music} />;
case SecondaryType.RECORDING:
return <MusicDetails type={SecondaryType.RECORDING} recording={music} />;
case SecondaryType.WORK:
return <MusicDetails type={SecondaryType.WORK} work={music} />;
default:
return <Error statusCode={404} />;
}
};
export const getServerSideProps: GetServerSideProps<MusicPageProps> = async (
ctx
) => {
const response = await axios.get<
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult
>(
`http://localhost:${process.env.PORT || 5055}/api/v1/music/${
ctx.query.type
}/${ctx.query.mbId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return {
props: {
music: response.data,
},
};
};
export default MusicPage;
Loading…
Cancel
Save