Add canStream user permission to control streaming access

Adds a per-user "Can Stream" permission mirroring the existing "Can Download"
pattern. Server admins can now disable streaming for specific users, encouraging
local downloads instead. Addresses #2572.

Changes:
- User model: stream permission in mapping, defaults, and getter
- ApiKey model: stream permission in defaults
- Controller: 403 enforcement on playback session creation endpoints
- Frontend: permission toggle in admin UI, play button gated by canStream,
  download button shown when streaming disabled, message when neither allowed
- Tests: 11 Mocha tests (model + controller), 1 Cypress test (card UI)
- Localization: English strings for toggle label and fallback message

The getter uses !== false (rather than !!) so existing users without the
stream key in their permissions JSON default to allowed on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zriddle 2026-04-02 14:12:08 -06:00
parent 64cbf59609
commit 464b720d9e
11 changed files with 236 additions and 1 deletions

View file

@ -436,7 +436,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
return this.userCanStream && !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat
@ -476,6 +476,9 @@ export default {
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userCanStream() {
return this.store.getters['user/getUserCanStream']
},
userIsAdminOrUp() {
return this.store.getters['user/getIsAdminOrUp']
},

View file

@ -42,6 +42,15 @@
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="stream-permissions-toggle">{{ $strings.LabelPermissionsStream }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch labeledBy="stream-permissions-toggle" v-model="newUser.permissions.stream" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
@ -354,6 +363,7 @@ export default {
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
stream: true,
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin',

View file

@ -42,6 +42,7 @@ function createMountOptions() {
'user/getUserCanUpdate': true,
'user/getUserCanDelete': true,
'user/getUserCanDownload': true,
'user/getUserCanStream': true,
'user/getIsAdminOrUp': true,
'user/getUserMediaProgress': (id) => null,
'user/getUserSetting': (settingName) => false,
@ -163,6 +164,15 @@ describe('LazyBookCard', () => {
cy.get('&ebookFormat').should('not.exist')
})
it('hides play button on mouseover when user cannot stream', () => {
mountOptions.mocks.$store.getters['user/getUserCanStream'] = false
cy.mount(LazyBookCard, mountOptions)
cy.get('#book-card-0').trigger('mouseover')
cy.get('&overlay').should('be.visible')
cy.get('&playButton').should('be.hidden')
})
it('routes to item page when clicked', () => {
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
cy.mount(LazyBookCard, mountOptions)

View file

@ -86,6 +86,15 @@
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
<ui-btn v-else-if="!userCanStream && userCanDownload && tracks.length" color="bg" :padding-x="4" small class="flex items-center h-9 mr-2" @click="downloadLibraryItem">
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">download</span>
{{ $strings.LabelDownload }}
</ui-btn>
<p v-else-if="!userCanStream && !userCanDownload && tracks.length" class="text-sm text-gray-400 mr-2">
{{ $strings.MessageNoStreamOrDownloadAccess }}
</p>
<ui-btn v-else-if="isMissing || isInvalid" color="bg-error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
@ -229,6 +238,7 @@ export default {
return !!this.mediaMetadata.abridged
},
showPlayButton() {
if (!this.userCanStream) return false
if (this.isMissing || this.isInvalid) return false
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
@ -352,6 +362,9 @@ export default {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanStream() {
return this.$store.getters['user/getUserCanStream']
},
showRssFeedBtn() {
if (!this.rssFeed && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks

View file

@ -53,6 +53,9 @@ export const getters = {
getUserCanDownload: (state) => {
return !!state.user?.permissions?.download
},
getUserCanStream: (state) => {
return state.user?.permissions?.stream !== false
},
getUserCanUpload: (state) => {
return !!state.user?.permissions?.upload
},

View file

@ -512,6 +512,7 @@
"LabelPermissionsCreateEreader": "Can Create Ereader",
"LabelPermissionsDelete": "Can Delete",
"LabelPermissionsDownload": "Can Download",
"LabelPermissionsStream": "Can Stream",
"LabelPermissionsUpdate": "Can Update",
"LabelPermissionsUpload": "Can Upload",
"LabelPersonalYearReview": "Your Year in Review ({0})",
@ -851,6 +852,7 @@
"MessageNoFoldersAvailable": "No Folders Available",
"MessageNoGenres": "No Genres",
"MessageNoIssues": "No Issues",
"MessageNoStreamOrDownloadAccess": "Contact your server admin for access",
"MessageNoItems": "No Items",
"MessageNoItemsFound": "No items found",
"MessageNoListeningSessions": "No Listening Sessions",