Skip to content

Commit

Permalink
Add support for music playlists (#500)
Browse files Browse the repository at this point in the history
Closes #498

Also includes some QoL updates:

* Slightly better handling of the image loading state for grid items
* Handling more potential item types for extracting subtitles, child
  counts, etc.
  • Loading branch information
chrisbenincasa authored Jun 11, 2024
1 parent 229e405 commit 8a92aa8
Show file tree
Hide file tree
Showing 10 changed files with 411 additions and 84 deletions.
45 changes: 42 additions & 3 deletions types/src/plex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export const PlexLibraryCollectionSchema = z

export type PlexLibraryCollection = z.infer<typeof PlexLibraryCollectionSchema>;

const basePlexMediaContainerSchema = z.object({
size: z.number(),
totalSize: z.number().optional(), // Present when paging
offset: z.number().optional(), // Present when paging
});

const basePlexCollectionSchema = z.object({
allowSync: z.boolean(),
art: z.string(),
Expand Down Expand Up @@ -146,6 +152,35 @@ export type PlexLibraryCollections = z.infer<
typeof PlexLibraryCollectionsSchema
>;

// /playlists
export const PlexPlaylistSchema = z.object({
ratingKey: z.string(),
key: z.string(),
guid: z.string(),
type: z.literal('playlist'),
title: z.string(),
titleSort: z.string().optional(),
summary: z.string().optional(),
smart: z.boolean().optional(),
playlistType: z.literal('audio').optional(), // Add new known types here
composite: z.string().optional(), // Thumb path
icon: z.string().optional(),
viewCount: z.number().optional(),
lastViewedAt: z.number().optional(),
duration: z.number().optional(),
leafCount: z.number().optional(),
addedAt: z.number().optional(),
updatedAt: z.number().optional(),
});

export type PlexPlaylist = z.infer<typeof PlexPlaylistSchema>;

export const PlexPlaylistsSchema = basePlexMediaContainerSchema.extend({
Metadata: z.array(PlexPlaylistSchema).default([]),
});

export type PlexPlaylists = z.infer<typeof PlexPlaylistsSchema>;

const BasePlexMediaStreamSchema = z.object({
id: z.number().optional(),
default: z.boolean().optional(),
Expand Down Expand Up @@ -583,9 +618,9 @@ export type PlexLibraryListing =
| PlexLibraryMusic;

export function isPlexDirectory(
item: PlexLibrarySection | PlexMedia | undefined,
item: PlexLibrarySection | PlexMedia | PlexPlaylist | undefined,
): item is PlexLibrarySection {
return item?.directory === true;
return item?.type !== 'playlist' && item?.directory === true;
}

export function isPlexMoviesLibrary(
Expand Down Expand Up @@ -621,6 +656,7 @@ export const isPlexCollection =
export const isPlexMusicArtist = isPlexMediaType<PlexMusicArtist>('artist');
export const isPlexMusicAlbum = isPlexMediaType<PlexMusicAlbum>('album');
export const isPlexMusicTrack = isPlexMediaType<PlexMusicTrack>('track');
export const isPlexPlaylist = isPlexMediaType<PlexPlaylist>('playlist');
const funcs = [
isPlexMovie,
isPlexShow,
Expand All @@ -630,6 +666,7 @@ const funcs = [
isPlexMusicArtist,
isPlexMusicAlbum,
isPlexMusicTrack,
isPlexPlaylist,
];
export const isPlexMedia = (
media: PlexLibrarySection | PlexMedia | undefined,
Expand Down Expand Up @@ -704,6 +741,7 @@ export type PlexMedia = Alias<
| PlexMusicArtist
| PlexMusicAlbum
| PlexMusicTrack
| PlexPlaylist
>;
export type PlexTerminalMedia = PlexMovie | PlexEpisode | PlexMusicTrack; // Media that has no children

Expand All @@ -718,7 +756,8 @@ export type PlexParentMediaType =
| PlexTvShow
| PlexTvSeason
| PlexMusicArtist
| PlexMusicAlbum;
| PlexMusicAlbum
| PlexPlaylist;

type PlexMediaApiChildType = [
[PlexTvShow, PlexSeasonView],
Expand Down
1 change: 1 addition & 0 deletions web/src/components/InlineModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function InlineModal(props: InlineModalProps) {
if (children) {
return _.chain(children)
.map((id) => s.knownMediaByServer[s.currentServer!.name][id])
.compact()
.filter(isPlexMedia)
.value();
}
Expand Down
147 changes: 109 additions & 38 deletions web/src/components/channel_config/PlexGridItem.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material';
import {
Box,
Fade,
Unstable_Grid2 as Grid,
IconButton,
ImageListItem,
ImageListItemBar,
Skeleton,
alpha,
useTheme,
} from '@mui/material';
import {
PlexChildMediaApiType,
PlexMedia,
isPlexCollection,
isPlexPlaylist,
isTerminalItem,
} from '@tunarr/types/plex';
import { filter, isNaN, isNull, isUndefined } from 'lodash-es';
import { filter, isNaN, isNil, isUndefined } from 'lodash-es';
import pluralize from 'pluralize';
import React, {
ForwardedRef,
Expand Down Expand Up @@ -50,21 +53,68 @@ export interface PlexGridItemProps<T extends PlexMedia> {
ref?: React.RefObject<HTMLDivElement>;
}

const genPlexChildPath = forPlexMedia({
collection: (collection) =>
`/library/collections/${collection.ratingKey}/children`,
playlist: (playlist) => `/playlists/${playlist.ratingKey}/items`,
default: (item) => `/library/metadata/${item.ratingKey}/children`,
});

const extractChildCount = forPlexMedia({
season: (s) => s.leafCount,
show: (s) => s.childCount,
collection: (s) => parseInt(s.childCount),
playlist: (s) => s.leafCount,
});

const childItemType = forPlexMedia({
season: 'episode',
show: 'season',
collection: (coll) => (coll.subtype === 'movie' ? 'movie' : 'show'),
playlist: 'track',
artist: 'album',
album: 'track',
});

const subtitle = forPlexMedia({
movie: (item) => <span>{prettyItemDuration(item.duration)}</span>,
default: (item) => {
const childCount = extractChildCount(item);
if (isNil(childCount)) {
return null;
}

return (
<span>{`${childCount} ${pluralize(
childItemType(item) ?? 'item',
childCount,
)}`}</span>
);
},
});

export const PlexGridItem = forwardRef(
<T extends PlexMedia>(
props: PlexGridItemProps<T>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const theme = useTheme();
const skeletonBgColor = alpha(
theme.palette.text.primary,
theme.palette.mode === 'light' ? 0.11 : 0.13,
);
const server = useStore((s) => s.currentServer!); // We have to have a server at this point
const darkMode = useStore((state) => state.theme.darkMode);
const [open, setOpen] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const { item, index, style, moveModal } = props;
const hasThumb = isNonEmptyString(
isPlexPlaylist(props.item) ? props.item.composite : props.item.thumb,
);
const [imageLoaded, setImageLoaded] = useState(!hasThumb);
const hasChildren = !isTerminalItem(item);
const childPath = isPlexCollection(item) ? 'collections' : 'metadata';
const { data: children } = usePlexTyped<PlexChildMediaApiType<T>>(
server.name,
`/library/${childPath}/${props.item.ratingKey}/children`,
genPlexChildPath(props.item),
hasChildren && open,
);
const selectedServer = useStore((s) => s.currentServer);
Expand Down Expand Up @@ -118,13 +168,21 @@ export const PlexGridItem = forwardRef(
childCount = null;
}

const isMusicItem = ['artist', 'album', 'track', 'playlist'].includes(
item.type,
);

let thumbSrc: string;
if (isPlexPlaylist(item)) {
thumbSrc = `${server.uri}${item.composite}?X-Plex-Token=${server.accessToken}`;
} else {
// TODO: Use server endpoint for plex metdata
thumbSrc = `${server.uri}${item.thumb}?X-Plex-Token=${server.accessToken}`;
}

return (
<Fade
in={
isInViewport &&
!isUndefined(item) &&
(imageLoaded || !isNonEmptyString(item.thumb))
}
in={isInViewport && !isUndefined(item) && (imageLoaded || hasThumb)}
timeout={500}
ref={imageContainerRef}
>
Expand Down Expand Up @@ -161,42 +219,55 @@ export const PlexGridItem = forwardRef(
ref={ref}
>
{isInViewport && // TODO: Eventually turn this itno isNearViewport so images load before they hit the viewport
(isNonEmptyString(item.thumb) ? (
// TODO: Use server endpoint for plex metdata
<img
src={`${server.uri}${item.thumb}?X-Plex-Token=${server.accessToken}`}
style={{ borderRadius: '5%', height: 'auto' }}
onLoad={() => setImageLoaded(true)}
/>
(hasThumb ? (
<Box
sx={{
position: 'relative',
minHeight: isMusicItem ? 100 : 200,
maxHeight: '100%',
}}
>
<img
src={thumbSrc}
style={{
borderRadius: '5%',
height: 'auto',
width: '100%',
visibility: imageLoaded ? 'visible' : 'hidden',
zIndex: 2,
}}
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(true)}
/>
<Box
component="div"
sx={{
background: skeletonBgColor,
borderRadius: '5%',
position: 'absolute',
top: 0,
left: 0,
aspectRatio: isMusicItem ? '1/1' : '2/3',
width: '100%',
height: 'auto',
zIndex: 1,
opacity: imageLoaded ? 0 : 1,
visibility: imageLoaded ? 'hidden' : 'visible',
minHeight: isMusicItem ? 100 : 200,
}}
></Box>
</Box>
) : (
<Skeleton
variant="rectangular"
animation={false}
variant="rounded"
sx={{ borderRadius: '5%' }}
height={250}
height={isMusicItem ? 144 : 250}
/>
))}
{!imageLoaded && (
<Skeleton
variant="rectangular"
sx={{ borderRadius: '5%' }}
height={250}
/>
)}
<ImageListItemBar
title={item.title}
subtitle={
item.type !== 'movie' ? (
!isNull(childCount) ? (
<span>{`${childCount} ${pluralize(
'item',
childCount,
)}`}</span>
) : null
) : (
<span>{prettyItemDuration(item.duration)}</span>
)
}
subtitle={subtitle(item)}
position="below"
actionIcon={
<IconButton
Expand Down
1 change: 1 addition & 0 deletions web/src/components/channel_config/PlexListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const plexTypeString = forPlexMedia({
track: 'Track',
album: 'Album',
artist: 'Artist',
playlist: 'Playlist',
default: 'All',
});

Expand Down
Loading

0 comments on commit 8a92aa8

Please sign in to comment.