Browse Source

Add jellyfin playlist browse support

master
jeffvli 3 years ago
committed by Jeff
parent
commit
038f536be9
  1. 4
      src/api/api.ts
  2. 4
      src/api/controller.ts
  3. 115
      src/api/jellyfinApi.ts
  4. 8
      src/api/types.ts
  5. 5
      src/components/playlist/PlaylistList.tsx
  6. 9
      src/components/playlist/PlaylistView.tsx
  7. 12
      src/shared/utils.ts

4
src/api/api.ts

@ -279,8 +279,8 @@ const normalizeFolder = (item: any) => {
}; };
}; };
export const getPlaylist = async (id: string) => { export const getPlaylist = async (options: { id: string }) => {
const { data } = await api.get(`/getPlaylist?id=${id}`); const { data } = await api.get(`/getPlaylist?id=${options.id}`);
return normalizePlaylist(data.playlist); return normalizePlaylist(data.playlist);
}; };

4
src/api/controller.ts

@ -30,12 +30,12 @@ import {
updatePlaylistSongs, updatePlaylistSongs,
updatePlaylistSongsLg, updatePlaylistSongsLg,
} from './api'; } from './api';
import { getPlaylists as jfGetPlaylists } from './jellyfinApi'; import { getPlaylist as jfGetPlaylist, getPlaylists as jfGetPlaylists } from './jellyfinApi';
import { APIEndpoints, ServerType } from './types'; import { APIEndpoints, ServerType } from './types';
// prettier-ignore // prettier-ignore
const endpoints = [ const endpoints = [
{ id: 'getPlaylist', endpoint: { subsonic: getPlaylist, jellyfin: undefined } }, { id: 'getPlaylist', endpoint: { subsonic: getPlaylist, jellyfin: jfGetPlaylist } },
{ id: 'getPlaylists', endpoint: { subsonic: getPlaylists, jellyfin: jfGetPlaylists } }, { id: 'getPlaylists', endpoint: { subsonic: getPlaylists, jellyfin: jfGetPlaylists } },
{ id: 'getStarred', endpoint: { subsonic: getStarred, jellyfin: undefined } }, { id: 'getStarred', endpoint: { subsonic: getStarred, jellyfin: undefined } },
{ id: 'getAlbum', endpoint: { subsonic: getAlbum, jellyfin: undefined } }, { id: 'getAlbum', endpoint: { subsonic: getAlbum, jellyfin: undefined } },

115
src/api/jellyfinApi.ts

@ -1,11 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import _ from 'lodash'; import _ from 'lodash';
import { nanoid } from 'nanoid/non-secure';
import { handleDisconnect } from '../components/settings/DisconnectButton'; import { handleDisconnect } from '../components/settings/DisconnectButton';
import { notifyToast } from '../components/shared/toast'; import { notifyToast } from '../components/shared/toast';
import { Item } from './types';
const getAuth = () => { const getAuth = () => {
return { return {
userId: localStorage.getItem('username') || '', username: localStorage.getItem('username') || '',
token: localStorage.getItem('token') || '', token: localStorage.getItem('token') || '',
server: localStorage.getItem('server') || '', server: localStorage.getItem('server') || '',
deviceId: localStorage.getItem('deviceId') || '', deviceId: localStorage.getItem('deviceId') || '',
@ -47,16 +49,109 @@ jellyfinApi.interceptors.response.use(
} }
); );
export const getPlaylists = async () => { const getStreamUrl = (id: string) => {
const params = { return (
SortBy: 'SortName', `${API_BASE_URL}/Audio` +
SortOrder: 'Ascending', `/${id}` +
IncludeItemTypes: 'Playlist', `/universal` +
Recursive: true, `?UserId=${auth.username}` +
`&DeviceId=${auth.deviceId}` +
`&AudioCodec=aac` +
`&api_key=${auth.token}` +
`&PlaySessionId=${auth.deviceId}` +
`&Container=['opus','mp3','aac','m4a','m4b','flac','wav','ogg']`
);
};
const getCoverArtUrl = (item: any, size?: number) => {
if (!item.ImageTags?.Primary) {
return 'img/placeholder.jpg';
}
return (
`${API_BASE_URL}/Items` +
`/${item.Id}` +
`/Images/Primary` +
`?width=${size}` +
`&height=${size}`
);
};
const normalizeSong = (item: any) => {
return {
id: item.Id,
parent: undefined,
isDir: item.isFolder,
title: item.Name,
album: item.Album,
albumId: item.AlbumId,
artist: item?.ArtistItems[0]?.Name,
artistId: item?.ArtistItems[0]?.Id,
track: item.IndexNumber,
year: item.ProductionYear,
genre: item.GenreItems && item?.GenreItems[0]?.Name,
size: item.MediaSources?.Size,
contentType: undefined,
suffix: undefined,
duration: item.RunTimeTicks / 10000000,
bitRate: item.MediaSources?.MediaStreams?.BitRate,
path: item.Path,
playCount: item.UserData.PlayCount,
discNumber: undefined,
created: item.DateCreated,
streamUrl: getStreamUrl(item.Id),
image: getCoverArtUrl(item, 150),
starred: item.UserData.isFavorite ? 'true' : undefined,
type: Item.Music,
uniqueId: nanoid(),
};
};
const normalizePlaylist = (item: any) => {
return {
id: item.Id,
title: item.Name,
comment: undefined,
owner: undefined,
public: undefined,
songCount: item.SongCount,
duration: item.RunTimeTicks / 10000000,
created: item.DateCreated,
changed: item.DateLastMediaAdded,
image: getCoverArtUrl(item, 350),
type: Item.Playlist,
uniqueId: nanoid(),
song: [],
};
};
export const getPlaylist = async (options: { id: string }) => {
const { data } = await jellyfinApi.get(`/Items`, {
params: { ids: options.id, UserId: auth.username },
});
const { data: songData } = await jellyfinApi.get(`/Playlists/${options.id}/Items`, {
params: { UserId: auth.username },
});
return {
...normalizePlaylist(data.Items[0]),
songCount: songData.Items.length,
song: (songData.Items || []).map((entry: any) => normalizeSong(entry)),
}; };
};
const playlists: any = await jellyfinApi.get(`Users/${auth.userId}/Items`, { params }); export const getPlaylists = async () => {
console.log(`playlists`, playlists); const { data } = await jellyfinApi.get(`/Users/${auth.username}/Items`, {
params: {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Playlist',
Recursive: true,
},
});
return _.filter(playlists.Items, (item) => item.MediaType === 'Audio'); return (_.filter(data.Items, (item) => item.MediaType === 'Audio') || []).map((entry) =>
normalizePlaylist(entry)
);
}; };

8
src/api/types.ts

@ -73,12 +73,12 @@ export interface Playlist {
id: string; id: string;
title: string; title: string;
comment?: string; comment?: string;
owner: string; owner?: string;
public?: boolean; public?: boolean;
songCount: number; songCount?: number;
duration: number; duration: number;
created: string; created?: string;
changed: string; changed?: string;
image: string; image: string;
type: Item.Playlist; type: Item.Playlist;
uniqueId: string; uniqueId: string;

5
src/components/playlist/PlaylistList.tsx

@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom';
import { Form, Whisper } from 'rsuite'; import { Form, Whisper } from 'rsuite';
import settings from 'electron-settings'; import settings from 'electron-settings';
import useSearchQuery from '../../hooks/useSearchQuery'; import useSearchQuery from '../../hooks/useSearchQuery';
import { createPlaylist, getPlaylists } from '../../api/api'; import { createPlaylist } from '../../api/api';
import ListViewType from '../viewtypes/ListViewType'; import ListViewType from '../viewtypes/ListViewType';
import PageLoader from '../loader/PageLoader'; import PageLoader from '../loader/PageLoader';
import GenericPage from '../layout/GenericPage'; import GenericPage from '../layout/GenericPage';
@ -21,6 +21,7 @@ import {
toggleRangeSelected, toggleRangeSelected,
toggleSelected, toggleSelected,
} from '../../redux/multiSelectSlice'; } from '../../redux/multiSelectSlice';
import { apiController } from '../../api/controller';
const PlaylistList = () => { const PlaylistList = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -32,7 +33,7 @@ const PlaylistList = () => {
const [newPlaylistName, setNewPlaylistName] = useState(''); const [newPlaylistName, setNewPlaylistName] = useState('');
const [viewType, setViewType] = useState(settings.getSync('playlistViewType') || 'list'); const [viewType, setViewType] = useState(settings.getSync('playlistViewType') || 'list');
const { isLoading, isError, data: playlists, error }: any = useQuery(['playlists'], () => const { isLoading, isError, data: playlists, error }: any = useQuery(['playlists'], () =>
getPlaylists() apiController({ serverType: config.serverType, endpoint: 'getPlaylists' })
); );
const filteredData = useSearchQuery(misc.searchQuery, playlists, ['title', 'comment', 'owner']); const filteredData = useSearchQuery(misc.searchQuery, playlists, ['title', 'comment', 'owner']);

9
src/components/playlist/PlaylistView.tsx

@ -19,7 +19,6 @@ import {
import { import {
clearPlaylist, clearPlaylist,
deletePlaylist, deletePlaylist,
getPlaylist,
updatePlaylistSongsLg, updatePlaylistSongsLg,
updatePlaylistSongs, updatePlaylistSongs,
updatePlaylist, updatePlaylist,
@ -76,6 +75,7 @@ import {
} from '../../redux/playlistSlice'; } from '../../redux/playlistSlice';
import { PageHeaderSubtitleDataLine } from '../layout/styled'; import { PageHeaderSubtitleDataLine } from '../layout/styled';
import CustomTooltip from '../shared/CustomTooltip'; import CustomTooltip from '../shared/CustomTooltip';
import { apiController } from '../../api/controller';
interface PlaylistParams { interface PlaylistParams {
id: string; id: string;
@ -94,8 +94,13 @@ const PlaylistView = ({ ...rest }) => {
const { id } = useParams<PlaylistParams>(); const { id } = useParams<PlaylistParams>();
const playlistId = rest.id ? rest.id : id; const playlistId = rest.id ? rest.id : id;
const { isLoading, isError, data, error }: any = useQuery(['playlist', playlistId], () => const { isLoading, isError, data, error }: any = useQuery(['playlist', playlistId], () =>
getPlaylist(playlistId) apiController({
serverType: config.serverType,
endpoint: 'getPlaylist',
args: { id: playlistId },
})
); );
const [customPlaylistImage, setCustomPlaylistImage] = useState<string | string[]>( const [customPlaylistImage, setCustomPlaylistImage] = useState<string | string[]>(
'img/placeholder.jpg' 'img/placeholder.jpg'
); );

12
src/shared/utils.ts

@ -112,14 +112,8 @@ const formatBytes = (bytes: number, decimals = 2) => {
export const formatSongDuration = (duration: number) => { export const formatSongDuration = (duration: number) => {
const hours = Math.floor(duration / 60 / 60); const hours = Math.floor(duration / 60 / 60);
const minutes = Math.floor((duration / 60) % 60); const minutes = Math.floor((duration / 60) % 60);
const seconds = String(duration % 60).padStart(2, '0'); const seconds = String(Math.trunc(Number(duration % 60))).padStart(2, '0');
// if (minutes > 60) {
// const hours = Math.floor(minutes / 60);
// const newMinutes = Math.floor(minutes % 60);
// const newSeconds = String(duration % 60).padStart(2, '0');
// return `${hours}:${newMinutes}:${newSeconds}`;
// }
if (hours > 0) { if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${seconds}`; return `${hours}:${String(minutes).padStart(2, '0')}:${seconds}`;
} }
@ -134,7 +128,7 @@ export const formatSongDuration = (duration: number) => {
export const formatDuration = (duration: number) => { export const formatDuration = (duration: number) => {
const hours = Math.floor(duration / 60 / 60); const hours = Math.floor(duration / 60 / 60);
const minutes = Math.floor((duration / 60) % 60); const minutes = Math.floor((duration / 60) % 60);
const seconds = String(duration % 60).padStart(2, '0'); const seconds = String(Math.trunc(Number(duration % 60))).padStart(2, '0');
if (hours > 0) { if (hours > 0) {
return `${hours} hr ${minutes} min ${seconds} sec`; return `${hours} hr ${minutes} min ${seconds} sec`;

Loading…
Cancel
Save