diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 0695d62c2..c40197277 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -191,6 +191,7 @@
"sync": "Auto-import",
"path": "Import from"
},
+ "byOwner": "by %{name}",
"actions": {
"selectPlaylist": "Select a playlist:",
"addNewPlaylist": "Create \"%{name}\"",
diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx
index 87ca11546..634804d4f 100644
--- a/ui/src/playlist/PlaylistDetails.jsx
+++ b/ui/src/playlist/PlaylistDetails.jsx
@@ -6,7 +6,7 @@ import {
useMediaQuery,
} from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
-import { useTranslate } from 'react-admin'
+import { useTranslate, usePermissions } from 'react-admin'
import { useCallback, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css'
@@ -82,6 +82,7 @@ const useStyles = makeStyles(
const PlaylistDetails = (props) => {
const { record = {} } = props
const translate = useTranslate()
+ const { permissions } = usePermissions()
const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const [isLightboxOpen, setLightboxOpen] = useState(false)
@@ -159,6 +160,14 @@ const PlaylistDetails = (props) => {
)}
+ {(record.public || permissions === 'admin') && (
+
+ {translate('resources.playlist.byOwner', {
+ name: record.ownerName,
+ _: `by ${record.ownerName}`,
+ })}
+
+ )}
diff --git a/ui/src/playlist/PlaylistDetails.test.jsx b/ui/src/playlist/PlaylistDetails.test.jsx
new file mode 100644
index 000000000..a77d46fdd
--- /dev/null
+++ b/ui/src/playlist/PlaylistDetails.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import { render, screen, cleanup } from '@testing-library/react'
+import PlaylistDetails from './PlaylistDetails'
+import { usePermissions } from 'react-admin'
+import { useMediaQuery } from '@material-ui/core'
+
+vi.mock('react-admin', () => ({
+ usePermissions: vi.fn(),
+ useTranslate: () => (key, opts) => {
+ if (key === 'resources.playlist.byOwner') {
+ return `by ${opts.name}`
+ }
+ if (key === 'resources.song.name') {
+ return opts.smart_count === 1 ? 'Song' : 'Songs'
+ }
+ return key
+ },
+ useRecordContext: (props) => props.record || {},
+}))
+
+vi.mock('@material-ui/core', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useMediaQuery: vi.fn() }
+})
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ useMediaQuery.mockReturnValue(false)
+ })
+
+ afterEach(cleanup)
+
+ const baseRecord = {
+ id: 'pl1',
+ name: 'My Playlist',
+ songCount: 1,
+ duration: 60,
+ size: 1024,
+ ownerName: 'Owner',
+ public: false,
+ }
+
+ it('shows owner for admin users', () => {
+ usePermissions.mockReturnValue({ permissions: 'admin' })
+ render()
+ expect(screen.getByText('by Owner')).toBeInTheDocument()
+ })
+
+ it('shows owner for public playlists', () => {
+ usePermissions.mockReturnValue({ permissions: 'user' })
+ render()
+ expect(screen.getByText('by Owner')).toBeInTheDocument()
+ })
+
+ it('hides owner for private playlists when not admin', () => {
+ usePermissions.mockReturnValue({ permissions: 'user' })
+ render()
+ expect(screen.queryByText('by Owner')).toBeNull()
+ })
+})