diff --git a/core/playlists.go b/core/playlists.go
index ed90cc23b..58626a510 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -168,6 +168,10 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
+ pls.Global = nsp.Global
+ if nsp.Global {
+ pls.Public = true
+ }
return nil
}
@@ -404,12 +408,18 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
- newPls.Public = pls.Public
+ // Preserve Public from existing playlist, unless the new playlist is Global
+ if !newPls.Global {
+ newPls.Public = pls.Public
+ }
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
- newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ // Only apply default visibility if not a global playlist (which is always public)
+ if !newPls.Global {
+ newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
+ }
}
return s.ds.Playlist(ctx).Put(newPls)
}
@@ -473,6 +483,7 @@ type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
+ Global bool `json:"global"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
@@ -483,5 +494,6 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
+ i.Global, _ = m["global"].(bool)
return json.Unmarshal(data, &i.Criteria)
}
diff --git a/core/playlists_test.go b/core/playlists_test.go
index 6aa8aac9a..11e752b16 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -107,11 +107,20 @@ var _ = Describe("Playlists", func() {
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
+ Expect(pls.Global).To(BeFalse())
+ Expect(pls.Public).To(BeFalse())
})
It("returns an error if the playlist is not well-formed", func() {
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
+ It("parses global attribute and sets playlist to public", func() {
+ pls, err := ps.ImportFile(ctx, folder, "global_smart_playlist.nsp")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.Name).To(Equal("Global Smart Playlist"))
+ Expect(pls.Global).To(BeTrue())
+ Expect(pls.Public).To(BeTrue())
+ })
})
Describe("Cross-library relative paths", func() {
diff --git a/db/migrations/20260116135347_add_global_smart_playlist.sql b/db/migrations/20260116135347_add_global_smart_playlist.sql
new file mode 100644
index 000000000..25fa1393c
--- /dev/null
+++ b/db/migrations/20260116135347_add_global_smart_playlist.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE playlist ADD COLUMN global BOOL DEFAULT FALSE NOT NULL;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+ALTER TABLE playlist DROP COLUMN global;
+-- +goose StatementEnd
diff --git a/model/playlist.go b/model/playlist.go
index a87019ed5..ae67cc258 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -27,6 +27,7 @@ type Playlist struct {
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
+ Global bool `structs:"global" json:"global"`
}
func (pls Playlist) IsSmartPlaylist() bool {
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 3fdd19af2..a19360853 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -227,9 +227,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return false
}
- // Never refresh other users' playlists
+ // Only refresh for owners, unless the playlist is marked as global
usr := loggedUser(r.ctx)
- if pls.OwnerID != usr.ID {
+ if pls.OwnerID != usr.ID && !pls.Global {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 05a36352f..a493d73c2 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -4,6 +4,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@@ -147,6 +148,67 @@ var _ = Describe("PlaylistRepository", func() {
})
})
+ Context("Global smart playlists", func() {
+ var globalPls model.Playlist
+ var otherUserRepo model.PlaylistRepository
+
+ BeforeEach(func() {
+ // Force smart playlist refresh
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
+
+ // Create a global smart playlist owned by the admin user
+ globalPls = model.Playlist{Name: "Global Smart", OwnerID: "userid", Rules: rules, Global: true, Public: true}
+ Expect(repo.Put(&globalPls)).To(Succeed())
+
+ // Create a different user context (using regularUser who has library access)
+ otherCtx := log.NewContext(GinkgoT().Context())
+ otherCtx = request.WithUser(otherCtx, regularUser)
+ otherUserRepo = NewPlaylistRepository(otherCtx, GetDBXBuilder())
+ })
+
+ AfterEach(func() {
+ _ = repo.Delete(globalPls.ID)
+ })
+
+ It("stores and retrieves the Global attribute", func() {
+ savedPls, err := repo.Get(globalPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(savedPls.Global).To(BeTrue())
+ })
+
+ It("allows non-owner to refresh a global smart playlist", func() {
+ // Verify the playlist can be retrieved by non-owner and has Global=true
+ plsCheck, err := otherUserRepo.Get(globalPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(plsCheck.Global).To(BeTrue(), "Global should be true when retrieved by non-owner")
+ Expect(plsCheck.IsSmartPlaylist()).To(BeTrue(), "Should be smart playlist")
+ Expect(plsCheck.EvaluatedAt).To(BeNil(), "Should not be evaluated yet")
+
+ // Non-owner requests the playlist with refresh
+ _, err = otherUserRepo.GetWithTracks(globalPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Re-fetch to verify EvaluatedAt was updated in DB
+ pls, err := otherUserRepo.Get(globalPls.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.EvaluatedAt).ToNot(BeNil(), "Global smart playlist should be refreshed for non-owner")
+ })
+
+ It("does not allow non-owner to refresh a non-global smart playlist", func() {
+ // Create a non-global smart playlist
+ nonGlobalPls := model.Playlist{Name: "Non-Global Smart", OwnerID: "userid", Rules: rules, Global: false, Public: true}
+ Expect(repo.Put(&nonGlobalPls)).To(Succeed())
+ DeferCleanup(func() { _ = repo.Delete(nonGlobalPls.ID) })
+
+ // Non-owner requests the playlist with refresh
+ pls, err := otherUserRepo.GetWithTracks(nonGlobalPls.ID, true, false)
+ Expect(err).ToNot(HaveOccurred())
+ // EvaluatedAt should be nil because the playlist was not refreshed
+ Expect(pls.EvaluatedAt).To(BeNil())
+ })
+ })
+
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 76280c42d..530634aaf 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -200,6 +200,7 @@
"duration": "Duração",
"ownerName": "Dono",
"public": "Pública",
+ "global": "Global",
"updatedAt": "Últ. Atualização",
"createdAt": "Data de Criação",
"songCount": "Músicas",
@@ -222,7 +223,8 @@
"duplicate_song": "Adicionar músicas duplicadas",
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
"noPlaylistsFound": "Nenhuma playlist encontrada",
- "noPlaylists": "Nenhuma playlist disponível"
+ "noPlaylists": "Nenhuma playlist disponível",
+ "globalPlaylistPublicDisabled": "Playlists globais são sempre públicas"
}
},
"radio": {
diff --git a/tests/fixtures/playlists/global_smart_playlist.nsp b/tests/fixtures/playlists/global_smart_playlist.nsp
new file mode 100644
index 000000000..bbc26bb6a
--- /dev/null
+++ b/tests/fixtures/playlists/global_smart_playlist.nsp
@@ -0,0 +1,10 @@
+{
+ "name": "Global Smart Playlist",
+ "comment": "Available for evaluation by any user",
+ "global": true,
+ "all": [
+ {"is": {"loved": true}}
+ ],
+ "sort": "title",
+ "order": "asc"
+}
diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js
index 74a01d47a..c22e6e3ff 100644
--- a/ui/src/common/playlistUtils.js
+++ b/ui/src/common/playlistUtils.js
@@ -9,7 +9,9 @@ export const isReadOnly = (ownerId) => {
return !isWritable(ownerId)
}
-export const isSmartPlaylist = (pls) => !!pls.rules
+export const isSmartPlaylist = (pls) => !!pls?.rules
+
+export const isGlobalPlaylist = (pls) => isSmartPlaylist(pls) && !!pls?.global
export const canChangeTracks = (pls) =>
- isWritable(pls.ownerId) && !isSmartPlaylist(pls)
+ isWritable(pls?.ownerId) && !isSmartPlaylist(pls)
diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js
index 2c671ecf5..068e27f22 100644
--- a/ui/src/common/playlistUtils.test.js
+++ b/ui/src/common/playlistUtils.test.js
@@ -2,6 +2,7 @@ import {
isWritable,
isReadOnly,
isSmartPlaylist,
+ isGlobalPlaylist,
canChangeTracks,
} from './playlistUtils'
@@ -56,6 +57,28 @@ describe('playlistUtils', () => {
})
})
+ describe('isGlobalPlaylist', () => {
+ it('returns true if playlist is smart and global', () => {
+ const playlist = { rules: [], global: true }
+ expect(isGlobalPlaylist(playlist)).toBe(true)
+ })
+
+ it('returns false if playlist is smart but not global', () => {
+ const playlist = { rules: [], global: false }
+ expect(isGlobalPlaylist(playlist)).toBe(false)
+ })
+
+ it('returns false if playlist is not smart even if global is true', () => {
+ const playlist = { global: true }
+ expect(isGlobalPlaylist(playlist)).toBe(false)
+ })
+
+ it('returns false if playlist is not smart and not global', () => {
+ const playlist = {}
+ expect(isGlobalPlaylist(playlist)).toBe(false)
+ })
+ })
+
describe('canChangeTracks', () => {
it('returns true if user is the owner and playlist is not smart', () => {
localStorage.setItem('userId', 'user1')
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 2a04b8127..69a1e25c7 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -200,6 +200,7 @@
"duration": "Duration",
"ownerName": "Owner",
"public": "Public",
+ "global": "Global",
"updatedAt": "Updated at",
"createdAt": "Created at",
"songCount": "Songs",
@@ -222,7 +223,8 @@
"duplicate_song": "Add duplicated songs",
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
"noPlaylistsFound": "No playlists found",
- "noPlaylists": "No playlists available"
+ "noPlaylists": "No playlists available",
+ "globalPlaylistPublicDisabled": "Global playlists are always public"
}
},
"radio": {
diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx
index acccb15f7..b513aaf7a 100644
--- a/ui/src/playlist/PlaylistDetails.jsx
+++ b/ui/src/playlist/PlaylistDetails.jsx
@@ -2,15 +2,22 @@ import {
Card,
CardContent,
CardMedia,
+ Chip,
Typography,
useMediaQuery,
} from '@material-ui/core'
+import PublicIcon from '@material-ui/icons/Public'
import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin'
import { useCallback, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css'
-import { CollapsibleComment, DurationField, SizeField } from '../common'
+import {
+ CollapsibleComment,
+ DurationField,
+ SizeField,
+ isGlobalPlaylist,
+} from '../common'
import subsonic from '../subsonic'
const useStyles = makeStyles(
@@ -77,6 +84,10 @@ const useStyles = makeStyles(
marginTop: '1em',
marginBottom: '0.5em',
},
+ globalChip: {
+ marginLeft: '0.5em',
+ verticalAlign: 'middle',
+ },
}),
{
name: 'NDPlaylistDetails',
@@ -146,6 +157,14 @@ const PlaylistDetails = (props) => {
className={classes.title}
>
{record.name || translate('ra.page.loading')}
+ {isGlobalPlaylist(record) && (
+ }
+ label={translate('resources.playlist.fields.global')}
+ size="small"
+ className={classes.globalChip}
+ />
+ )}
{record.songCount ? (
diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx
index f8cee9b5f..59b448d07 100644
--- a/ui/src/playlist/PlaylistEdit.jsx
+++ b/ui/src/playlist/PlaylistEdit.jsx
@@ -11,7 +11,16 @@ import {
ReferenceInput,
SelectInput,
} from 'react-admin'
-import { isWritable, Title } from '../common'
+import { useForm } from 'react-final-form'
+import Tooltip from '@material-ui/core/Tooltip'
+import { makeStyles } from '@material-ui/core/styles'
+import { isWritable, isSmartPlaylist, Title } from '../common'
+
+const useStyles = makeStyles({
+ tooltipWrapper: {
+ display: 'inline-block',
+ },
+})
const SyncFragment = ({ formData, variant, ...rest }) => {
return (
@@ -28,9 +37,48 @@ const PlaylistTitle = ({ record }) => {
return
}
+const PublicInput = ({ record, formData }) => {
+ const translate = useTranslate()
+ const classes = useStyles()
+ const isGlobal = isSmartPlaylist(record) && formData?.global
+ const disabled = !isWritable(record.ownerId) || isGlobal
+
+ const input =
+
+ if (isGlobal) {
+ return (
+
+ {input}
+
+ )
+ }
+ return input
+}
+
+const GlobalInput = ({ record }) => {
+ const form = useForm()
+ const handleChange = (value) => {
+ if (value) {
+ form.change('public', true)
+ }
+ }
+ return (
+
+ )
+}
+
const PlaylistEditForm = (props) => {
const { record } = props
const { permissions } = usePermissions()
+ const isSmart = isSmartPlaylist(record)
return (
@@ -50,7 +98,10 @@ const PlaylistEditForm = (props) => {
) : (
)}
-
+
+ {({ formData }) => }
+
+ {isSmart && }
{(formDataProps) => }
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index eae9d863f..b9859a0f0 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -14,8 +14,10 @@ import {
useRecordContext,
BulkDeleteButton,
usePermissions,
+ useTranslate,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
+import Tooltip from '@material-ui/core/Tooltip'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import {
@@ -23,6 +25,7 @@ import {
List,
Writable,
isWritable,
+ isGlobalPlaylist,
useSelectedFields,
useResourceRefresh,
} from '../common'
@@ -59,6 +62,7 @@ const PlaylistFilter = (props) => {
const TogglePublicInput = ({ resource, source }) => {
const record = useRecordContext()
const notify = useNotify()
+ const translate = useTranslate()
const [togglePublic] = useUpdate(
resource,
record.id,
@@ -79,13 +83,29 @@ const TogglePublicInput = ({ resource, source }) => {
e.stopPropagation()
}
- return (
+ const isGlobal = isGlobalPlaylist(record)
+ const disabled = !isWritable(record.ownerId) || isGlobal
+
+ const switchElement = (
)
+
+ if (isGlobal) {
+ return (
+
+ {switchElement}
+
+ )
+ }
+ return switchElement
}
const ToggleAutoImport = ({ resource, source }) => {