mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Add playlist editing
This commit is contained in:
parent
9a192c7e5c
commit
363171b56d
6 changed files with 143 additions and 27 deletions
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, useCallback, useMemo } from 'react';
|
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
@ -113,6 +113,7 @@ const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
|
||||||
itemId: selectedItemId || itemId || ''
|
itemId: selectedItemId || itemId || ''
|
||||||
});
|
});
|
||||||
const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId;
|
const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId;
|
||||||
|
const [ hasCommands, setHasCommands ] = useState(false);
|
||||||
|
|
||||||
const playlistItem = useMemo(() => {
|
const playlistItem = useMemo(() => {
|
||||||
let PlaylistItemId: string | null = null;
|
let PlaylistItemId: string | null = null;
|
||||||
|
@ -198,10 +199,15 @@ const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
|
||||||
[defaultMenuOptions, item, itemId, items, parentId, queryClient, queryKey]
|
[defaultMenuOptions, item, itemId, items, parentId, queryClient, queryKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
useEffect(() => {
|
||||||
item
|
const getCommands = async () => {
|
||||||
&& itemContextMenu.getCommands(defaultMenuOptions).length
|
const commands = await itemContextMenu.getCommands(defaultMenuOptions);
|
||||||
) {
|
setHasCommands(commands.length > 0);
|
||||||
|
};
|
||||||
|
void getCommands();
|
||||||
|
}, [ defaultMenuOptions ]);
|
||||||
|
|
||||||
|
if (item && hasCommands) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
className='button-flat btnMoreCommands'
|
className='button-flat btnMoreCommands'
|
||||||
|
|
|
@ -5,7 +5,7 @@ import globalize from '../lib/globalize';
|
||||||
import actionsheet from './actionSheet/actionSheet';
|
import actionsheet from './actionSheet/actionSheet';
|
||||||
import { appHost } from './apphost';
|
import { appHost } from './apphost';
|
||||||
import { appRouter } from './router/appRouter';
|
import { appRouter } from './router/appRouter';
|
||||||
import itemHelper from './itemHelper';
|
import itemHelper, { canEditPlaylist } from './itemHelper';
|
||||||
import { playbackManager } from './playback/playbackmanager';
|
import { playbackManager } from './playback/playbackmanager';
|
||||||
import ServerConnections from './ServerConnections';
|
import ServerConnections from './ServerConnections';
|
||||||
import toast from './toast/toast';
|
import toast from './toast/toast';
|
||||||
|
@ -29,7 +29,7 @@ function getDeleteLabel(type) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCommands(options) {
|
export async function getCommands(options) {
|
||||||
const item = options.item;
|
const item = options.item;
|
||||||
const user = options.user;
|
const user = options.user;
|
||||||
|
|
||||||
|
@ -209,6 +209,17 @@ export function getCommands(options) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.Type === BaseItemKind.Playlist) {
|
||||||
|
const _canEditPlaylist = await canEditPlaylist(user, item);
|
||||||
|
if (_canEditPlaylist) {
|
||||||
|
commands.push({
|
||||||
|
name: globalize.translate('Edit'),
|
||||||
|
id: 'editplaylist',
|
||||||
|
icon: 'edit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canEdit = itemHelper.canEdit(user, item);
|
const canEdit = itemHelper.canEdit(user, item);
|
||||||
if (canEdit && options.edit !== false && item.Type !== 'SeriesTimer') {
|
if (canEdit && options.edit !== false && item.Type !== 'SeriesTimer') {
|
||||||
const text = (item.Type === 'Timer' || item.Type === 'SeriesTimer') ? globalize.translate('Edit') : globalize.translate('EditMetadata');
|
const text = (item.Type === 'Timer' || item.Type === 'SeriesTimer') ? globalize.translate('Edit') : globalize.translate('EditMetadata');
|
||||||
|
@ -466,6 +477,15 @@ function executeCommand(item, id, options) {
|
||||||
case 'edit':
|
case 'edit':
|
||||||
editItem(apiClient, item).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id));
|
editItem(apiClient, item).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id));
|
||||||
break;
|
break;
|
||||||
|
case 'editplaylist':
|
||||||
|
import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
|
||||||
|
const playlistEditor = new PlaylistEditor();
|
||||||
|
playlistEditor.show({
|
||||||
|
id: itemId,
|
||||||
|
serverId
|
||||||
|
}).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id));
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'editimages':
|
case 'editimages':
|
||||||
import('./imageeditor/imageeditor').then((imageEditor) => {
|
import('./imageeditor/imageeditor').then((imageEditor) => {
|
||||||
imageEditor.show({
|
imageEditor.show({
|
||||||
|
@ -712,19 +732,19 @@ function refresh(apiClient, item) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function show(options) {
|
export async function show(options) {
|
||||||
const commands = getCommands(options);
|
const commands = await getCommands(options);
|
||||||
if (!commands.length) {
|
if (!commands.length) {
|
||||||
return Promise.reject();
|
throw new Error('No item commands present');
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionsheet.show({
|
const id = await actionsheet.show({
|
||||||
items: commands,
|
items: commands,
|
||||||
positionTo: options.positionTo,
|
positionTo: options.positionTo,
|
||||||
resolveOnClick: ['share']
|
resolveOnClick: ['share']
|
||||||
}).then(function (id) {
|
|
||||||
return executeCommand(options.item, id, options);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return executeCommand(options.item, id, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import { appHost } from './apphost';
|
|
||||||
import globalize from 'lib/globalize';
|
|
||||||
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||||
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
|
import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type';
|
||||||
import { RecordingStatus } from '@jellyfin/sdk/lib/generated-client/models/recording-status';
|
import { RecordingStatus } from '@jellyfin/sdk/lib/generated-client/models/recording-status';
|
||||||
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type';
|
||||||
|
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
||||||
|
|
||||||
|
import { appHost } from './apphost';
|
||||||
|
import globalize from 'lib/globalize';
|
||||||
|
import ServerConnections from './ServerConnections';
|
||||||
|
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||||
|
|
||||||
export function getDisplayName(item, options = {}) {
|
export function getDisplayName(item, options = {}) {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
@ -159,6 +163,25 @@ export function canEditImages (user, item) {
|
||||||
return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item);
|
return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function canEditPlaylist(user, item) {
|
||||||
|
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||||
|
const api = toApi(apiClient);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: permissions } = await getPlaylistsApi(api)
|
||||||
|
.getPlaylistUser({
|
||||||
|
userId: user.Id,
|
||||||
|
playlistId: item.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!permissions.CanEdit;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get playlist permissions', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function canEditSubtitles (user, item) {
|
export function canEditSubtitles (user, item) {
|
||||||
if (item.MediaType !== MediaType.Video) {
|
if (item.MediaType !== MediaType.Video) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite
|
||||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||||
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
|
||||||
|
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api';
|
||||||
import escapeHtml from 'escape-html';
|
import escapeHtml from 'escape-html';
|
||||||
|
|
||||||
import toast from 'components/toast/toast';
|
import toast from 'components/toast/toast';
|
||||||
|
@ -28,11 +29,13 @@ import 'material-design-icons-iconfont';
|
||||||
import '../formdialog.scss';
|
import '../formdialog.scss';
|
||||||
|
|
||||||
interface DialogElement extends HTMLDivElement {
|
interface DialogElement extends HTMLDivElement {
|
||||||
|
playlistId?: string
|
||||||
submitted?: boolean
|
submitted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaylistEditorOptions {
|
interface PlaylistEditorOptions {
|
||||||
items: string[],
|
items: string[],
|
||||||
|
id?: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
enableAddToPlayQueue?: boolean,
|
enableAddToPlayQueue?: boolean,
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
|
@ -56,6 +59,13 @@ function onSubmit(this: HTMLElement, e: Event) {
|
||||||
toast(globalize.translate('PlaylistError.AddFailed'));
|
toast(globalize.translate('PlaylistError.AddFailed'));
|
||||||
})
|
})
|
||||||
.finally(loading.hide);
|
.finally(loading.hide);
|
||||||
|
} else if (panel.playlistId) {
|
||||||
|
updatePlaylist(panel)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[PlaylistEditor] Failed to update to playlist %s', panel.playlistId, err);
|
||||||
|
toast(globalize.translate('PlaylistError.UpdateFailed'));
|
||||||
|
})
|
||||||
|
.finally(loading.hide);
|
||||||
} else {
|
} else {
|
||||||
createPlaylist(panel)
|
createPlaylist(panel)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
@ -99,6 +109,26 @@ function redirectToPlaylist(id: string | undefined) {
|
||||||
appRouter.showItem(id, currentServerId);
|
appRouter.showItem(id, currentServerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePlaylist(dlg: DialogElement) {
|
||||||
|
const apiClient = ServerConnections.getApiClient(currentServerId);
|
||||||
|
const api = toApi(apiClient);
|
||||||
|
|
||||||
|
if (!dlg.playlistId) return Promise.reject(new Error('Missing playlist ID'));
|
||||||
|
|
||||||
|
return getPlaylistsApi(api)
|
||||||
|
.updatePlaylist({
|
||||||
|
playlistId: dlg.playlistId,
|
||||||
|
updatePlaylistDto: {
|
||||||
|
Name: dlg.querySelector<HTMLInputElement>('#txtNewPlaylistName')?.value,
|
||||||
|
IsPublic: dlg.querySelector<HTMLInputElement>('#chkPlaylistPublic')?.checked
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
dlg.submitted = true;
|
||||||
|
dialogHelper.close(dlg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addToPlaylist(dlg: DialogElement, id: string) {
|
function addToPlaylist(dlg: DialogElement, id: string) {
|
||||||
const apiClient = ServerConnections.getApiClient(currentServerId);
|
const apiClient = ServerConnections.getApiClient(currentServerId);
|
||||||
const api = toApi(apiClient);
|
const api = toApi(apiClient);
|
||||||
|
@ -210,7 +240,7 @@ function populatePlaylists(editorOptions: PlaylistEditorOptions, panel: DialogEl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditorHtml(items: string[]) {
|
function getEditorHtml(items: string[], options: PlaylistEditorOptions) {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
|
html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
|
||||||
|
@ -232,7 +262,7 @@ function getEditorHtml(items: string[]) {
|
||||||
html += `
|
html += `
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" is="emby-checkbox" id="chkPlaylistPublic" checked />
|
<input type="checkbox" is="emby-checkbox" id="chkPlaylistPublic" />
|
||||||
<span>${globalize.translate('PlaylistPublic')}</span>
|
<span>${globalize.translate('PlaylistPublic')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="fieldDescription checkboxFieldDescription">
|
<div class="fieldDescription checkboxFieldDescription">
|
||||||
|
@ -244,7 +274,7 @@ function getEditorHtml(items: string[]) {
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += '<div class="formDialogFooter">';
|
html += '<div class="formDialogFooter">';
|
||||||
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('Add')}</button>`;
|
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${options.id ? globalize.translate('Save') : globalize.translate('Add')}</button>`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += '<input type="hidden" class="fldSelectedItemIds" />';
|
html += '<input type="hidden" class="fldSelectedItemIds" />';
|
||||||
|
@ -281,6 +311,34 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item
|
||||||
console.error('[PlaylistEditor] failed to populate playlists', err);
|
console.error('[PlaylistEditor] failed to populate playlists', err);
|
||||||
})
|
})
|
||||||
.finally(loading.hide);
|
.finally(loading.hide);
|
||||||
|
} else if (options.id) {
|
||||||
|
content.querySelector('.fldSelectPlaylist')?.classList.add('hide');
|
||||||
|
const panel = dom.parentWithClass(content, 'dialog') as DialogElement | null;
|
||||||
|
if (!panel) {
|
||||||
|
console.error('[PlaylistEditor] could not find dialog element');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClient = ServerConnections.getApiClient(currentServerId);
|
||||||
|
const api = toApi(apiClient);
|
||||||
|
Promise.all([
|
||||||
|
getUserLibraryApi(api)
|
||||||
|
.getItem({ itemId: options.id }),
|
||||||
|
getPlaylistsApi(api)
|
||||||
|
.getPlaylist({ playlistId: options.id })
|
||||||
|
])
|
||||||
|
.then(([ { data: playlistItem }, { data: playlist } ]) => {
|
||||||
|
panel.playlistId = options.id;
|
||||||
|
|
||||||
|
const nameField = panel.querySelector<HTMLInputElement>('#txtNewPlaylistName');
|
||||||
|
if (nameField) nameField.value = playlistItem.Name || '';
|
||||||
|
|
||||||
|
const publicField = panel.querySelector<HTMLInputElement>('#chkPlaylistPublic');
|
||||||
|
if (publicField) publicField.checked = !!playlist.OpenAccess;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[playlistEditor] failed to get playlist details', err);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
content.querySelector('.fldSelectPlaylist')?.classList.add('hide');
|
content.querySelector('.fldSelectPlaylist')?.classList.add('hide');
|
||||||
|
|
||||||
|
@ -325,17 +383,21 @@ export class PlaylistEditor {
|
||||||
dlg.classList.add('formDialog');
|
dlg.classList.add('formDialog');
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
const title = globalize.translate('HeaderAddToPlaylist');
|
|
||||||
|
|
||||||
html += '<div class="formDialogHeader">';
|
html += '<div class="formDialogHeader">';
|
||||||
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
|
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
|
||||||
html += '<h3 class="formDialogHeaderTitle">';
|
html += '<h3 class="formDialogHeaderTitle">';
|
||||||
html += title;
|
if (items.length) {
|
||||||
|
html += globalize.translate('HeaderAddToPlaylist');
|
||||||
|
} else if (options.id) {
|
||||||
|
html += globalize.translate('HeaderEditPlaylist');
|
||||||
|
} else {
|
||||||
|
html += globalize.translate('HeaderNewPlaylist');
|
||||||
|
}
|
||||||
html += '</h3>';
|
html += '</h3>';
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += getEditorHtml(items);
|
html += getEditorHtml(items, options);
|
||||||
|
|
||||||
dlg.innerHTML = html;
|
dlg.innerHTML = html;
|
||||||
|
|
||||||
|
|
|
@ -582,11 +582,13 @@ function reloadFromItem(instance, page, params, item, user) {
|
||||||
page.querySelector('.btnSplitVersions').classList.add('hide');
|
page.querySelector('.btnSplitVersions').classList.add('hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemContextMenu.getCommands(getContextMenuOptions(item, user)).length) {
|
itemContextMenu.getCommands(getContextMenuOptions(item, user)).then(commands => {
|
||||||
hideAll(page, 'btnMoreCommands', true);
|
if (commands.length) {
|
||||||
} else {
|
hideAll(page, 'btnMoreCommands', true);
|
||||||
hideAll(page, 'btnMoreCommands');
|
} else {
|
||||||
}
|
hideAll(page, 'btnMoreCommands');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const itemBirthday = page.querySelector('#itemBirthday');
|
const itemBirthday = page.querySelector('#itemBirthday');
|
||||||
|
|
||||||
|
|
|
@ -411,6 +411,7 @@
|
||||||
"HeaderDummyChapter": "Chapter Images",
|
"HeaderDummyChapter": "Chapter Images",
|
||||||
"HeaderDVR": "DVR",
|
"HeaderDVR": "DVR",
|
||||||
"HeaderEditImages": "Edit Images",
|
"HeaderEditImages": "Edit Images",
|
||||||
|
"HeaderEditPlaylist": "Edit Playlist",
|
||||||
"HeaderEnabledFields": "Enabled Fields",
|
"HeaderEnabledFields": "Enabled Fields",
|
||||||
"HeaderEnabledFieldsHelp": "Uncheck a field to lock it and prevent its data from being changed.",
|
"HeaderEnabledFieldsHelp": "Uncheck a field to lock it and prevent its data from being changed.",
|
||||||
"HeaderEpisodesStatus": "Episodes Status",
|
"HeaderEpisodesStatus": "Episodes Status",
|
||||||
|
@ -456,6 +457,7 @@
|
||||||
"HeaderNetworking": "IP Protocols",
|
"HeaderNetworking": "IP Protocols",
|
||||||
"HeaderNewApiKey": "New API Key",
|
"HeaderNewApiKey": "New API Key",
|
||||||
"HeaderNewDevices": "New Devices",
|
"HeaderNewDevices": "New Devices",
|
||||||
|
"HeaderNewPlaylist": "New Playlist",
|
||||||
"HeaderNewRepository": "New Repository",
|
"HeaderNewRepository": "New Repository",
|
||||||
"HeaderNextItem": "Next {0}",
|
"HeaderNextItem": "Next {0}",
|
||||||
"HeaderNextItemPlayingInValue": "Next {0} Playing in {1}",
|
"HeaderNextItemPlayingInValue": "Next {0} Playing in {1}",
|
||||||
|
@ -1326,6 +1328,7 @@
|
||||||
"PlayFromBeginning": "Play from beginning",
|
"PlayFromBeginning": "Play from beginning",
|
||||||
"PlaylistError.AddFailed": "Error adding to playlist",
|
"PlaylistError.AddFailed": "Error adding to playlist",
|
||||||
"PlaylistError.CreateFailed": "Error creating playlist",
|
"PlaylistError.CreateFailed": "Error creating playlist",
|
||||||
|
"PlaylistError.UpdateFailed": "Error updating playlist",
|
||||||
"PlaylistPublic": "Allow public access",
|
"PlaylistPublic": "Allow public access",
|
||||||
"PlaylistPublicDescription": "Allow this playlist to be viewed by any logged in user.",
|
"PlaylistPublicDescription": "Allow this playlist to be viewed by any logged in user.",
|
||||||
"Playlists": "Playlists",
|
"Playlists": "Playlists",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue