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 = <BooleanInput source="public" disabled={disabled} /> + + if (isGlobal) { + return ( + <Tooltip + title={translate( + 'resources.playlist.message.globalPlaylistPublicDisabled', + )} + > + <div className={classes.tooltipWrapper}>{input}</div> + </Tooltip> + ) + } + return input +} + +const GlobalInput = ({ record }) => { + const form = useForm() + const handleChange = (value) => { + if (value) { + form.change('public', true) + } + } + return ( + <BooleanInput + source="global" + disabled={!isWritable(record.ownerId)} + onChange={handleChange} + /> + ) +} + const PlaylistEditForm = (props) => { const { record } = props const { permissions } = usePermissions() + const isSmart = isSmartPlaylist(record) return ( <SimpleForm redirect="list" variant={'outlined'} {...props}> <TextInput source="name" validate={required()} /> @@ -50,7 +98,10 @@ const PlaylistEditForm = (props) => { ) : ( <TextField source="ownerName" /> )} - <BooleanInput source="public" disabled={!isWritable(record.ownerId)} /> + <FormDataConsumer> + {({ formData }) => <PublicInput record={record} formData={formData} />} + </FormDataConsumer> + {isSmart && <GlobalInput record={record} />} <FormDataConsumer> {(formDataProps) => <SyncFragment {...formDataProps} />} </FormDataConsumer> 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 = ( <Switch checked={record[source]} onClick={handleClick} - disabled={!isWritable(record.ownerId)} + disabled={disabled} /> ) + + if (isGlobal) { + return ( + <Tooltip + title={translate( + 'resources.playlist.message.globalPlaylistPublicDisabled', + )} + > + <span>{switchElement}</span> + </Tooltip> + ) + } + return switchElement } const ToggleAutoImport = ({ resource, source }) => {