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

@ -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