add lots of features

This commit is contained in:
2026-07-29 03:49:50 -05:00
parent 69e3dcb729
commit 39cd3e4cfe
101 changed files with 3320 additions and 224 deletions
+5
View File
@@ -7,6 +7,11 @@
<div class="col-12">
${export-query-profiling-btn class="btn btn-primary"}
</div>
<div class="col-12">
${musicbrainz-resync-btn class="btn btn-warning"}
<span class="ms-2">${musicbrainz-resync-status}</span>
<div class="form-text">${tr:Lms.Admin.DebugTools.Db.musicbrainz-resync-help}</div>
</div>
</div>
</form>
</message>
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.Admin.InternetRadio.template">
<legend>${tr:Lms.Admin.InternetRadio.title}</legend>
<p class="text-body-secondary">${tr:Lms.Admin.InternetRadio.help}</p>
<div class="card mb-4"><div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.name}</label>${name class="form-control"}</div>
<div class="col-12 col-lg-4"><label class="form-label">${tr:Lms.Admin.InternetRadio.stream-url}</label>${stream-url class="form-control"}</div>
<div class="col-12 col-lg-4"><label class="form-label">${tr:Lms.Admin.InternetRadio.homepage-url}</label>${homepage-url class="form-control"}</div>
<div class="col-12 col-lg-1 d-grid">${add-btn class="btn btn-primary"}</div>
</div>
</div></div>
${stations class="d-grid gap-1 mb-3 Lms-row-container"}
</message>
<message id="Lms.Admin.InternetRadio.template.entry">
<div class="d-flex align-items-center Lms-bg-dark-hover">
<div class="row align-items-center flex-fill overflow-hidden">
<div class="col-12 col-md-3"><div class="p-2 fw-semibold text-truncate">${name}</div></div>
<div class="col-12 col-md-5"><div class="p-2 text-truncate">${stream-url}</div></div>
<div class="col-12 col-md-4"><div class="p-2 text-body-secondary text-truncate">${homepage-url}</div></div>
</div>
<div class="p-2 d-flex gap-1">${edit-btn class="btn btn-sm btn-outline-secondary border-0"}${delete-btn class="btn btn-sm btn-outline-danger border-0"}</div>
</div>
</message>
<message id="Lms.Admin.InternetRadio.template.edit">
<div class="modal fade" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">${tr:Lms.Admin.InternetRadio.edit}</h5></div>
<div class="modal-body">
<div class="mb-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.name}</label>${name class="form-control"}</div>
<div class="mb-3"><label class="form-label">${tr:Lms.Admin.InternetRadio.stream-url}</label>${stream-url class="form-control"}</div>
<div><label class="form-label">${tr:Lms.Admin.InternetRadio.homepage-url}</label>${homepage-url class="form-control"}</div>
</div>
<div class="modal-footer">${save-btn class="btn btn-primary"}${cancel-btn class="btn btn-secondary"}</div>
</div></div></div>
</message>
<message id="Lms.Admin.InternetRadio.template.delete">
<div class="modal fade" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">${tr:Lms.Admin.InternetRadio.delete-confirm}</h5></div>
<div class="modal-body">${tr:Lms.Admin.InternetRadio.delete-help}</div>
<div class="modal-footer">${delete-btn class="btn btn-danger"}${cancel-btn class="btn btn-secondary"}</div>
</div></div></div>
</message>
</messages>
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.Admin.Podcasts.template">
<legend>${tr:Lms.Admin.Podcasts.podcasts}</legend>
<p class="text-body-secondary">${tr:Lms.Admin.Podcasts.help}</p>
<div class="input-group mb-3">
${url class="form-control"}
${add-btn class="btn btn-primary"}
</div>
<div class="d-flex justify-content-end mb-3">
${refresh-btn class="btn btn-secondary"}
</div>
${podcasts class="d-grid gap-1 mb-3 Lms-row-container"}
</message>
<message id="Lms.Admin.Podcasts.template.entry">
<div class="d-flex align-items-center Lms-bg-dark-hover">
<div class="row align-items-center flex-fill">
<div class="col-12 col-md-4"><div class="p-2">${title class="d-block text-truncate"}</div></div>
<div class="col-12 col-md-8"><div class="p-2 d-none d-md-block">${url class="d-block text-truncate"}</div></div>
</div>
<div class="p-2 d-flex">${delete-btn class="btn btn-sm btn-outline-danger border-0"}</div>
</div>
</message>
<message id="Lms.Admin.Podcasts.template.delete">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">${tr:Lms.Admin.Podcasts.delete-confirm}</h5></div>
<div class="modal-body">${tr:Lms.Admin.Podcasts.delete-help}</div>
<div class="modal-footer">${delete-btn class="btn btn-danger me-1"}${cancel-btn class="btn btn-secondary"}</div>
</div></div>
</div>
</message>
<message id="Lms.Admin.Podcasts.template.episodes">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
${close-x class="btn-close" aria-label="Close"}
</div>
<div class="modal-body">
${episodes class="d-grid gap-1 Lms-row-container"}
</div>
<div class="modal-footer">
${download-all-btn class="btn btn-primary"}
${close-btn class="btn btn-secondary"}
</div>
</div></div>
</div>
</message>
<message id="Lms.Admin.Podcasts.template.episode">
<div class="d-flex align-items-center Lms-bg-dark-hover p-2 gap-2">
<div class="flex-fill overflow-hidden">
<div class="text-truncate">${title}</div>
<small class="text-body-secondary">${date}</small>
</div>
${status}
${download-btn class="btn btn-sm btn-outline-primary"}
</div>
</message>
</messages>
+13
View File
@@ -23,6 +23,15 @@
</div>
</div>
${</if-has-login>}
<div class="col-lg-6">
<label class="form-label" for="${id:display-name}">
${tr:Lms.Admin.User.display-name}
</label>
${display-name class="form-control"}
<div class="invalid-feedback">
${display-name-info}
</div>
</div>
${<if-has-password>}
<div class="col-lg-6">
<label class="form-label" for="${id:password}">
@@ -47,6 +56,10 @@
</div>
</div>
${</if-demo>}
<div class="col-12">
<label class="form-label">${tr:Lms.Admin.User.media-libraries}</label>
${media-libraries class="d-grid gap-1"}
</div>
<div class="col-12">
${save-btn class="btn btn-primary"}
</div>
+14
View File
@@ -23,6 +23,20 @@
</div>
</message>
<message id="Lms.Admin.Users.template.edit-user">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${tr:Lms.Admin.Users.edit-user}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">${content}</div>
</div>
</div>
</div>
</message>
<message id="Lms.Admin.Users.template.delete-user">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog">
+5
View File
@@ -31,6 +31,9 @@
<li class="nav-item">
${tracklists class="nav-link"}
</li>
<li class="nav-item">
${podcasts class="nav-link"}
</li>
</ul>
${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"}
<div class="navbar-nav align-items-md-center">
@@ -41,6 +44,8 @@
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
<li>${media-libraries class="dropdown-item"}</li>
<li>${admin-podcasts class="dropdown-item"}</li>
<li>${admin-internet-radio class="dropdown-item"}</li>
<li>${scan-settings class="dropdown-item"}</li>
<li>${scanner class="dropdown-item"}</li>
<li>${users class="dropdown-item"}</li>
+5
View File
@@ -45,6 +45,7 @@
</div>
<div class="py-2 ps-2">
${playqueue-btn class="btn btn-outline-primary border-0"}
${share-btn class="btn btn-outline-primary border-0"}
</div>
</div>
</div>
@@ -56,4 +57,8 @@
</span>
</message>
<message id="Lms.MediaPlayer.template.share-btn">
<i class="fa fa-fw fa-share-alt" aria-hidden="true"></i>
</message>
</messages>
+58
View File
@@ -170,6 +170,9 @@
<!--Db-->
<message id="Lms.Admin.DebugTools.Db.export-query-profiling">Export query profiling data</message>
<message id="Lms.Admin.DebugTools.Db.db">Database</message>
<message id="Lms.Admin.DebugTools.Db.musicbrainz-resync">Resync albums from MusicBrainz</message>
<message id="Lms.Admin.DebugTools.Db.musicbrainz-resync-help">Updates MusicBrainz-tagged albums at one request per second. Albums without a MusicBrainz release ID are skipped.</message>
<message id="Lms.Admin.DebugTools.Db.musicbrainz-resync-progress">{1} / {2} processed — {3} updated, {4} skipped, {5} failed</message>
<!--PcmDecodingStats-->
<message id="Lms.Admin.DebugTools.PcmDecodingStats.pcm-decoding-stats">PCM decoding stats</message>
@@ -187,12 +190,15 @@
<message id="Lms.Admin.Users.admin">Admin</message>
<message id="Lms.Admin.Users.del-user-confirm">Delete user?</message>
<message id="Lms.Admin.Users.demo">Demo</message>
<message id="Lms.Admin.Users.edit-user">Edit user</message>
<message id="Lms.Admin.Users.users">Users</message>
<!--User-->
<message id="Lms.Admin.User.demo-account">Demo account</message>
<message id="Lms.Admin.User.demo-account-already-exists">Demo account already exists!</message>
<message id="Lms.Admin.User.display-name">Display name</message>
<message id="Lms.Admin.User.last-login">Last login</message>
<message id="Lms.Admin.User.media-libraries">Visible libraries</message>
<message id="Lms.Admin.User.user-already-exists">User already exists!</message>
<message id="Lms.Admin.User.user-create">New user</message>
<message id="Lms.Admin.User.user-created">New user created!</message>
@@ -363,7 +369,59 @@
<!--PlayHistory-->
<message id="Lms.PlayHistory.playhistory">Play History</message>
<!--Podcasts-->
<message id="Lms.Podcasts.podcasts">Podcasts</message>
<message id="Lms.Podcasts.empty">No podcast feeds have been added yet.</message>
<message id="Lms.Podcasts.back">← All podcasts</message>
<message id="Lms.Podcasts.no-episodes">No episodes have been discovered yet.</message>
<message id="Lms.Podcasts.play"><i class="fa fa-play" aria-hidden="true"></i> Play</message>
<message id="Lms.Podcasts.download"><i class="fa fa-download" aria-hidden="true"></i> Download</message>
<message id="Lms.Podcasts.download-requested">Episode download requested.</message>
<message id="Lms.Podcasts.status.downloaded">Downloaded</message>
<message id="Lms.Podcasts.status.downloading">Downloading</message>
<message id="Lms.Podcasts.status.deleting">Deleting</message>
<message id="Lms.Podcasts.status.not-downloaded">Not downloaded</message>
<!--Settings-->
<message id="Lms.Admin.menu-podcasts"><i class="fa fa-fw fa-podcast" aria-hidden="true"></i> Podcasts</message>
<message id="Lms.Admin.Podcasts.podcasts">Podcasts</message>
<message id="Lms.Admin.Podcasts.help">Add an RSS feed to check it daily and automatically download new episodes. Podcast clients can manage the same feeds through the Subsonic podcast API.</message>
<message id="Lms.Admin.Podcasts.feed-url-placeholder">https://example.com/podcast.rss</message>
<message id="Lms.Admin.Podcasts.add-feed">Add RSS feed</message>
<message id="Lms.Admin.Podcasts.refresh-now">Scan for new episodes</message>
<message id="Lms.Admin.Podcasts.refresh-started">Podcast scan started.</message>
<message id="Lms.Admin.Podcasts.invalid-url">Enter a valid HTTP or HTTPS RSS feed URL.</message>
<message id="Lms.Admin.Podcasts.feed-added">Podcast feed added. Its episodes are being refreshed.</message>
<message id="Lms.Admin.Podcasts.feed-deleted">Podcast feed deleted.</message>
<message id="Lms.Admin.Podcasts.pending-title">Waiting for feed metadata…</message>
<message id="Lms.Admin.Podcasts.episodes">Podcast episodes</message>
<message id="Lms.Admin.Podcasts.download-all">Download all</message>
<message id="Lms.Admin.Podcasts.download-all-requested">All available podcast episodes were queued for download.</message>
<message id="Lms.Admin.Podcasts.close">Close</message>
<message id="Lms.Admin.Podcasts.delete-confirm">Delete podcast feed?</message>
<message id="Lms.Admin.Podcasts.delete-help">The feed, downloaded episodes, and cached artwork will be removed.</message>
<!-- Internet radio -->
<message id="Lms.Admin.menu-internet-radio"><i class="fa fa-fw fa-broadcast-tower" aria-hidden="true"></i> Internet radio</message>
<message id="Lms.Admin.InternetRadio.title">Internet radio</message>
<message id="Lms.Admin.InternetRadio.help">Add live audio streams here to make them available to Subsonic-compatible players, including Supersonic.</message>
<message id="Lms.Admin.InternetRadio.name">Station name</message>
<message id="Lms.Admin.InternetRadio.stream-url">Stream URL</message>
<message id="Lms.Admin.InternetRadio.homepage-url">Homepage URL (optional)</message>
<message id="Lms.Admin.InternetRadio.name-placeholder">Station name</message>
<message id="Lms.Admin.InternetRadio.stream-url-placeholder">https://radio.example.com/live.mp3</message>
<message id="Lms.Admin.InternetRadio.homepage-url-placeholder">https://radio.example.com</message>
<message id="Lms.Admin.InternetRadio.add">Add</message>
<message id="Lms.Admin.InternetRadio.added">Radio station added.</message>
<message id="Lms.Admin.InternetRadio.saved">Radio station saved.</message>
<message id="Lms.Admin.InternetRadio.deleted">Radio station deleted.</message>
<message id="Lms.Admin.InternetRadio.edit">Edit radio station</message>
<message id="Lms.Admin.InternetRadio.empty">No internet radio stations have been added yet.</message>
<message id="Lms.Admin.InternetRadio.no-homepage">No homepage</message>
<message id="Lms.Admin.InternetRadio.invalid-name">Enter a station name.</message>
<message id="Lms.Admin.InternetRadio.invalid-url">Enter valid HTTP or HTTPS URLs.</message>
<message id="Lms.Admin.InternetRadio.delete-confirm">Delete radio station?</message>
<message id="Lms.Admin.InternetRadio.delete-help">The station will no longer appear in connected Subsonic players.</message>
<message id="Lms.Settings.artist-release-sort-method">Method to sort artist's albums</message>
<message id="Lms.Settings.audio">Audio</message>
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser</message>
+2 -1
View File
@@ -65,7 +65,8 @@
${more-btn data-bs-toggle="dropdown" aria-expanded="false" class="btn btn-sm btn-outline-secondary border-0"}
<ul class="dropdown-menu" aria-labelledby="${id:more-btn}">
<li>${play class="dropdown-item"}</li>
<li>${star class="dropdown-item"}</li>
${<if-has-star>}<li>${star class="dropdown-item"}</li>${</if-has-star>}
<li>${share class="dropdown-item"}</li>
${<if-has-download>}
<li>${download class="dropdown-item"}</li>
${</if-has-download>}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages>
<message id="Lms.Podcasts.template">
<legend class="mb-3">${tr:Lms.Podcasts.podcasts}</legend>
<div class="clearfix"></div>
${content class="d-block w-100"}
</message>
<message id="Lms.Podcasts.template.channel">
<div class="card w-100">
<div class="row g-0 align-items-center">
<div class="col-auto p-2">
<div class="Lms-cover-container" style="width: 8rem; height: 8rem;">
${cover class="shadow-sm rounded w-100 h-100" style="object-fit: cover;"}
</div>
</div>
<div class="col min-width-0">
<div class="card-body">
<h5 class="card-title">${title}</h5>
<p class="card-text text-body-secondary mb-0 text-break">${description}</p>
</div>
</div>
<div class="col-auto p-2">${share-btn class="btn btn-sm btn-outline-secondary"}</div>
</div>
</div>
</message>
<message id="Lms.Podcasts.template.channel-header">
<div class="mb-3 clearfix">
${back class="btn btn-sm btn-outline-secondary mb-3"}
<div class="d-flex gap-3 align-items-center">
<div class="Lms-cover-container flex-shrink-0" style="width: 8rem; height: 8rem;">
${cover class="shadow-sm rounded w-100 h-100" style="object-fit: cover;"}
</div>
<div class="min-width-0 flex-fill">
<h2>${title}</h2>
<p class="text-body-secondary mb-0">${description}</p>
</div>
${share-btn class="btn btn-sm btn-outline-secondary"}
</div>
</div>
</message>
<message id="Lms.Podcasts.template.episode">
<div class="d-flex align-items-center Lms-bg-dark-hover p-2 gap-2 ${row-class}">
<div class="flex-fill overflow-hidden">
<div class="text-truncate">${title}</div>
<small class="text-body-secondary">${date}</small>
</div>
${action-btn class="btn btn-sm btn-outline-primary"}
${share-btn class="btn btn-sm btn-outline-secondary"}
</div>
</message>
</messages>
+2
View File
@@ -24,6 +24,7 @@
<li>${play-last class="dropdown-item"}</li>
<li>${play-shuffled class="dropdown-item"}</li>
<li>${star class="dropdown-item"}</li>
<li>${share class="dropdown-item"}</li>
${<if-has-mbid>}
<li><a href="${mbid-link}" target="_blank" class="dropdown-item">${tr:Lms.Explore.musicbrainz-release}</a></li>
${</if-has-mbid>}
@@ -118,6 +119,7 @@
<li>${play-next class="dropdown-item"}</li>
<li>${play-last class="dropdown-item"}</li>
<li>${star class="dropdown-item"}</li>
<li>${share class="dropdown-item"}</li>
${<if-has-download>}
<li>${download class="dropdown-item"}</li>
${</if-has-download>}
+1
View File
@@ -26,6 +26,7 @@
<div class="p-2">
<div class="Lms-responsive-square Lms-cover-container mb-1">
${cover class="shadow-sm"}
${share-btn class="btn btn-sm btn-primary position-absolute top-0 end-0 m-1"}
</div>
${release-name class="Lms-multiline-clamp text-decoration-none link-success"}
${<if-has-artist>}${artist-name class="Lms-multiline-clamp"}${</if-has-artist>}
+1
View File
@@ -60,6 +60,7 @@
<li>${play-next class="dropdown-item"}</li>
<li>${play-last class="dropdown-item"}</li>
<li>${star class="dropdown-item"}</li>
<li>${share class="dropdown-item"}</li>
${<if-has-download>}
<li>${download class="dropdown-item"}</li>
${</if-has-download>}
+5 -1
View File
@@ -84,6 +84,10 @@ login-throttler-max-entries = 10000;
# API
api-subsonic = true;
# Log Subsonic request paths and parameters at info level. Authentication
# secrets are redacted. Useful for diagnosing client compatibility issues.
api-subsonic-log-requests = false;
# List of clients for whom open subsonic extensions and extra fields are disabled
api-open-subsonic-disabled-clients = ("DSub");
@@ -134,7 +138,7 @@ musicnn-model-path = "/usr/share/lms/models/MSD_musicnn_embedding.onnx";
musicnn-max-patch-count-per-track = 20;
# Refresh period for podcast feeds in hours (must be greater or equal than 1)
podcast-refresh-period-hours = 2;
podcast-refresh-period-hours = 24;
# Automatically download new episodes
podcast-auto-download-episodes = true;
+19 -5
View File
@@ -24,6 +24,9 @@ class LMSMediaPlayer {
#elems;
#offset;
#trackId;
#scrobbleEnabled;
#queueEnabled;
#transcodingEnabled;
#duration;
#audioNativeSrc;
#audioTranscodingSrc;
@@ -40,6 +43,9 @@ class LMSMediaPlayer {
this.#elems = {};
this.#offset = 0;
this.#trackId = null;
this.#scrobbleEnabled = true;
this.#queueEnabled = true;
this.#transcodingEnabled = true;
this.#duration = 0;
this.#settings = {};
this.#playedDuration = 0;
@@ -94,6 +100,7 @@ class LMSMediaPlayer {
this.#elems.audio.addEventListener("ended", () => {
this.#resetTimer();
if (this.#queueEnabled)
Wt.emit(this.#root, "playbackEnded");
});
@@ -224,7 +231,7 @@ class LMSMediaPlayer {
}
#startTimer() {
if (this.#lastStartPlaying == null)
if (this.#lastStartPlaying == null && this.#scrobbleEnabled)
Wt.emit(this.#root, "scrobbleListenNow", this.#trackId);
this.#lastStartPlaying = Date.now();
}
@@ -240,10 +247,10 @@ class LMSMediaPlayer {
if (this.#lastStartPlaying != null)
this.#pauseTimer();
if (this.#playedDuration > 0) {
if (this.#playedDuration > 0 && this.#scrobbleEnabled) {
Wt.emit(this.#root, "scrobbleListenFinished", this.#trackId, this.#playedDuration);
this.#playedDuration = 0;
}
this.#playedDuration = 0;
}
#durationToString(duration) {
@@ -280,11 +287,13 @@ class LMSMediaPlayer {
#playPrevious() {
this.#initAudioCtx();
if (this.#queueEnabled)
Wt.emit(this.#root, "playPrevious");
}
#playNext() {
this.#initAudioCtx();
if (this.#queueEnabled)
Wt.emit(this.#root, "playNext");
}
@@ -446,6 +455,11 @@ class LMSMediaPlayer {
this.#resetTimer();
this.#trackId = params.trackId;
this.#scrobbleEnabled = params.scrobbleEnabled ?? true;
this.#queueEnabled = params.queueEnabled ?? true;
this.#transcodingEnabled = params.transcodingEnabled ?? true;
this.#elems.previous.disabled = !this.#queueEnabled;
this.#elems.next.disabled = !this.#queueEnabled;
this.#offset = 0;
this.#duration = params.duration;
this.#audioNativeSrc = params.nativeResource;
@@ -455,10 +469,10 @@ class LMSMediaPlayer {
this.#removeAudioSources();
// ! order is important
if (this.#settings.transcoding.mode == LMSTranscodingMode.Never || this.#settings.transcoding.mode == LMSTranscodingMode.IfFormatNotSupported) {
if (!this.#transcodingEnabled || this.#settings.transcoding.mode == LMSTranscodingMode.Never || this.#settings.transcoding.mode == LMSTranscodingMode.IfFormatNotSupported) {
this.#addAudioSource(this.#audioNativeSrc);
}
if (this.#settings.transcoding.mode == LMSTranscodingMode.Always || this.#settings.transcoding.mode == LMSTranscodingMode.IfFormatNotSupported) {
if (this.#transcodingEnabled && (this.#settings.transcoding.mode == LMSTranscodingMode.Always || this.#settings.transcoding.mode == LMSTranscodingMode.IfFormatNotSupported)) {
this.#addAudioSource(this.#audioTranscodingSrc);
}
this.#elems.audio.load();
+2
View File
@@ -9,6 +9,7 @@ add_library(lmsdatabase STATIC
impl/objects/Genre.cpp
impl/objects/Grouping.cpp
impl/objects/Image.cpp
impl/objects/InternetRadioStation.cpp
impl/objects/Language.cpp
impl/objects/Listen.cpp
impl/objects/MediaLibrary.cpp
@@ -24,6 +25,7 @@ add_library(lmsdatabase STATIC
impl/objects/RatedTrack.cpp
impl/objects/Release.cpp
impl/objects/ScanSettings.cpp
impl/objects/Share.cpp
impl/objects/ServerInfo.cpp
impl/objects/StarredArtist.cpp
impl/objects/StarredRelease.cpp
+73 -1
View File
@@ -36,7 +36,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 110 };
static constexpr Version LMS_DATABASE_VERSION{ 117 };
}
VersionInfo::VersionInfo()
@@ -1966,6 +1966,71 @@ CREATE TABLE IF NOT EXISTS "server_info" (
))");
}
void migrateFromV110(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE tracklist_entry ADD COLUMN podcast_episode_id bigint REFERENCES podcast_episode(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX tracklist_entry_podcast_episode_idx ON tracklist_entry(podcast_episode_id))");
}
void migrateFromV111(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "share" (
"id" integer primary key autoincrement,
"version" integer not null,
"token" text not null,
"media_ids" text not null,
"description" text not null,
"created" text not null,
"expires" text,
"last_visited" text,
"visit_count" integer not null,
"user_id" bigint not null,
constraint "fk_share_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred
))");
}
void migrateFromV112(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "user_media_library" (
"user_id" bigint,
"media_library_id" bigint,
primary key ("user_id", "media_library_id"),
constraint "fk_user_media_library_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred,
constraint "fk_user_media_library_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete cascade deferrable initially deferred
))");
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO user_media_library (user_id, media_library_id) SELECT u.id, ml.id FROM "user" u CROSS JOIN media_library ml)");
}
void migrateFromV113(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "internet_radio_station" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null,
"stream_url" text not null,
"homepage_url" text not null
))");
}
void migrateFromV114(Session& session)
{
// Album-less tracks are now assigned to an "Unknown Album" release.
// Force existing tracks through the audio metadata scanner again.
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1");
}
void migrateFromV115(Session& session)
{
// Reserved migration version. MusicBrainz public metadata needs no stored credentials.
(void)session;
}
void migrateFromV116(Session& session)
{
utils::executeCommand(*session.getDboSession(), R"(ALTER TABLE "user" ADD COLUMN display_name text NOT NULL DEFAULT '')");
utils::executeCommand(*session.getDboSession(), R"(UPDATE "user" SET display_name = login_name)");
}
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -2052,6 +2117,13 @@ CREATE TABLE IF NOT EXISTS "server_info" (
{ 107, migrateFromV107 },
{ 108, migrateFromV108 },
{ 109, migrateFromV109 },
{ 110, migrateFromV110 },
{ 111, migrateFromV111 },
{ 112, migrateFromV112 },
{ 113, migrateFromV113 },
{ 114, migrateFromV114 },
{ 115, migrateFromV115 },
{ 116, migrateFromV116 },
};
LMS_SCOPED_TRACE_OVERVIEW("Database", "Migration");
+6
View File
@@ -42,6 +42,7 @@
#include "database/objects/PlayListFile.hpp"
#include "database/objects/PlayQueue.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/InternetRadioStation.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/RatedArtist.hpp"
#include "database/objects/RatedRelease.hpp"
@@ -49,6 +50,7 @@
#include "database/objects/Release.hpp"
#include "database/objects/ReleaseArtistLink.hpp"
#include "database/objects/ScanSettings.hpp"
#include "database/objects/Share.hpp"
#include "database/objects/ServerInfo.hpp"
#include "database/objects/StarredArtist.hpp"
#include "database/objects/StarredRelease.hpp"
@@ -95,6 +97,7 @@ namespace lms::db
_session.mapClass<Country>("country");
_session.mapClass<Directory>("directory");
_session.mapClass<Image>("image");
_session.mapClass<InternetRadioStation>("internet_radio_station");
_session.mapClass<Label>("label");
_session.mapClass<Listen>("listen");
_session.mapClass<MediaLibrary>("media_library");
@@ -110,6 +113,7 @@ namespace lms::db
_session.mapClass<ReleaseArtistLink>("release_artist_link");
_session.mapClass<ReleaseType>("release_type");
_session.mapClass<ScanSettings>("scan_settings");
_session.mapClass<Share>("share");
_session.mapClass<StarredArtist>("starred_artist");
_session.mapClass<StarredRelease>("starred_release");
_session.mapClass<StarredTrack>("starred_track");
@@ -223,6 +227,8 @@ namespace lms::db
"CREATE INDEX IF NOT EXISTS auth_token_user_domain_idx ON auth_token(user_id, domain)",
"CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)",
"CREATE INDEX IF NOT EXISTS auth_token_domain_value_idx ON auth_token(domain, value)",
"CREATE UNIQUE INDEX IF NOT EXISTS share_token_idx ON share(token)",
"CREATE INDEX IF NOT EXISTS share_user_idx ON share(user_id)",
"CREATE INDEX IF NOT EXISTS cluster_id_idx ON cluster(id)",
"CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)",
+19
View File
@@ -111,6 +111,25 @@ namespace lms::db
.bind(params.filters.mediaLibrary)
.bind(params.filters.mediaLibrary);
}
else if (params.filters.mediaLibraries)
{
if (params.filters.mediaLibraries->empty())
query.where("1 = 0");
else
{
std::string placeholders{ "?" };
for (std::size_t i{ 1 }; i < params.filters.mediaLibraries->size(); ++i)
placeholders += ",?";
query.where(
"EXISTS (SELECT 1 FROM track_artist_link scoped_tal JOIN track scoped_t ON scoped_t.id = scoped_tal.track_id WHERE scoped_tal.artist_id = a.id AND scoped_t.media_library_id IN (" + placeholders + "))"
" OR EXISTS (SELECT 1 FROM release_artist_link scoped_ral JOIN release scoped_r ON scoped_r.id = scoped_ral.release_id JOIN track scoped_t ON scoped_t.release_id = scoped_r.id WHERE scoped_ral.artist_id = a.id AND scoped_t.media_library_id IN (" + placeholders + "))");
for (int pass{}; pass < 2; ++pass)
{
for (db::MediaLibraryId libraryId : *params.filters.mediaLibraries)
query.bind(libraryId);
}
}
}
if (params.trackArtistLinkType.has_value())
query.where("+t_a_l.type = ?").bind(*params.trackArtistLinkType); // Exclude this since the query planner does not do a good job when db is not analyzed
@@ -0,0 +1,35 @@
#include "database/objects/InternetRadioStation.hpp"
#include <Wt/Dbo/Impl.h>
#include "database/Session.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
#include "traits/StringViewTraits.hpp"
DBO_INSTANTIATE_TEMPLATES(lms::db::InternetRadioStation)
namespace lms::db
{
InternetRadioStation::InternetRadioStation(std::string_view name, std::string_view streamUrl, std::string_view homepageUrl)
: _name{ name }, _streamUrl{ streamUrl }, _homepageUrl{ homepageUrl }
{
}
InternetRadioStation::pointer InternetRadioStation::create(Session& session, std::string_view name, std::string_view streamUrl, std::string_view homepageUrl)
{
return session.getDboSession()->add(std::unique_ptr<InternetRadioStation>{ new InternetRadioStation{ name, streamUrl, homepageUrl } });
}
InternetRadioStation::pointer InternetRadioStation::find(Session& session, InternetRadioStationId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<InternetRadioStation>().where("id = ?").bind(id));
}
void InternetRadioStation::find(Session& session, const std::function<void(const pointer&)>& visitor)
{
session.checkReadTransaction();
utils::forEachQueryResult(session.getDboSession()->find<InternetRadioStation>().orderBy("name COLLATE NOCASE"), visitor);
}
} // namespace lms::db
@@ -24,11 +24,14 @@
#include "core/String.hpp"
#include "database/Session.hpp"
#include "database/objects/AuthToken.hpp"
#include "database/objects/Genre.hpp"
#include "database/objects/Grouping.hpp"
#include "database/objects/Language.hpp"
#include "database/objects/Mood.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "database/objects/UIState.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
@@ -89,6 +89,7 @@ namespace lms::db
|| params.filters.grouping.isValid()
|| params.filters.language.isValid()
|| params.filters.mediaLibrary.isValid()
|| params.filters.mediaLibraries.has_value()
|| params.filters.mood.isValid()
|| params.filters.codec.has_value()
|| params.directory.isValid()
@@ -108,6 +109,21 @@ namespace lms::db
if (params.filters.mediaLibrary.isValid())
query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary);
else if (params.filters.mediaLibraries)
{
if (params.filters.mediaLibraries->empty())
query.where("1 = 0");
else
{
std::string clause{ "t.media_library_id IN (?" };
for (std::size_t i{ 1 }; i < params.filters.mediaLibraries->size(); ++i)
clause += ",?";
clause += ")";
query.where(clause);
for (db::MediaLibraryId libraryId : *params.filters.mediaLibraries)
query.bind(libraryId);
}
}
if (params.filters.label.isValid())
{
+48
View File
@@ -0,0 +1,48 @@
#include "database/objects/Share.hpp"
#include <Wt/Dbo/Impl.h>
#include <Wt/Dbo/WtSqlTraits.h>
#include "database/Session.hpp"
#include "database/objects/User.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
#include "traits/StringViewTraits.hpp"
DBO_INSTANTIATE_TEMPLATES(lms::db::Share)
namespace lms::db
{
Share::Share(std::string_view token, std::string_view mediaIds, ObjectPtr<User> user)
: _token{ token }, _mediaIds{ mediaIds }, _created{ Wt::WDateTime::currentDateTime() }, _user{ getDboPtr(user) }
{
}
Share::pointer Share::create(Session& session, std::string_view token, std::string_view mediaIds, ObjectPtr<User> user)
{
return session.getDboSession()->add(std::unique_ptr<Share>{ new Share{ token, mediaIds, user } });
}
Share::pointer Share::find(Session& session, ShareId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<Share>>("SELECT s from share s").where("s.id = ?").bind(id));
}
Share::pointer Share::find(Session& session, std::string_view token)
{
session.checkReadTransaction();
pointer result;
utils::forEachQueryResult(session.getDboSession()->find<Share>(), [&](const Wt::Dbo::ptr<Share>& share) {
if (!result && share->getToken() == token)
result = share;
});
return result;
}
void Share::find(Session& session, UserId userId, const std::function<void(const pointer&)>& visitor)
{
session.checkReadTransaction();
utils::forEachQueryResult(session.getDboSession()->find<Share>().where("user_id = ?").bind(userId).orderBy("created DESC"), visitor);
}
}
+15
View File
@@ -206,6 +206,21 @@ namespace lms::db
if (params.filters.mediaLibrary.isValid())
query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary);
else if (params.filters.mediaLibraries)
{
if (params.filters.mediaLibraries->empty())
query.where("1 = 0");
else
{
std::string clause{ "t.media_library_id IN (?" };
for (std::size_t i{ 1 }; i < params.filters.mediaLibraries->size(); ++i)
clause += ",?";
clause += ")";
query.where(clause);
for (db::MediaLibraryId libraryId : *params.filters.mediaLibraries)
query.bind(libraryId);
}
}
if (params.filters.label.isValid())
{
@@ -31,6 +31,7 @@
#include "database/objects/Mood.hpp"
#include "database/objects/Movement.hpp"
#include "database/objects/PlayListFile.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "database/objects/Work.hpp"
@@ -351,11 +352,24 @@ namespace lms::db
assert(tracklist);
}
TrackListEntry::TrackListEntry(ObjectPtr<PodcastEpisode> episode, ObjectPtr<TrackList> tracklist)
: _podcastEpisode{ getDboPtr(episode) }
, _tracklist{ getDboPtr(tracklist) }
{
assert(episode);
assert(tracklist);
}
TrackListEntry::pointer TrackListEntry::create(Session& session, ObjectPtr<Track> track, ObjectPtr<TrackList> tracklist, const Wt::WDateTime& dateTime)
{
return session.getDboSession()->add(std::unique_ptr<TrackListEntry>{ new TrackListEntry{ track, tracklist, dateTime } });
}
TrackListEntry::pointer TrackListEntry::create(Session& session, ObjectPtr<PodcastEpisode> episode, ObjectPtr<TrackList> tracklist)
{
return session.getDboSession()->add(std::unique_ptr<TrackListEntry>{ new TrackListEntry{ episode, tracklist } });
}
TrackListEntry::pointer TrackListEntry::getById(Session& session, TrackListEntryId id)
{
session.checkReadTransaction();
+28
View File
@@ -30,6 +30,7 @@
#include "database/objects/Genre.hpp"
#include "database/objects/Grouping.hpp"
#include "database/objects/Language.hpp"
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/Mood.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
@@ -46,6 +47,7 @@ namespace lms::db
{
User::User(std::string_view loginName)
: _loginName{ loginName }
, _displayName{ loginName }
{
}
@@ -109,4 +111,30 @@ namespace lms::db
assert(isAudioBitrateAllowed(bitrate));
_subsonicDefaultTranscodingOutputBitrate = bitrate;
}
std::vector<ObjectPtr<MediaLibrary>> User::getMediaLibraries() const
{
std::vector<ObjectPtr<MediaLibrary>> mediaLibraries;
mediaLibraries.reserve(_mediaLibraries.size());
for (const auto& mediaLibrary : _mediaLibraries)
mediaLibraries.emplace_back(mediaLibrary);
return mediaLibraries;
}
bool User::hasMediaLibrary(MediaLibraryId mediaLibraryId) const
{
for (const auto& mediaLibrary : _mediaLibraries)
{
if (mediaLibrary.id() == mediaLibraryId)
return true;
}
return false;
}
void User::setMediaLibraries(const std::vector<ObjectPtr<MediaLibrary>>& mediaLibraries)
{
_mediaLibraries.clear();
for (const auto& mediaLibrary : mediaLibraries)
_mediaLibraries.insert(getDboPtr(mediaLibrary));
}
} // namespace lms::db
@@ -39,6 +39,7 @@ namespace lms::db
struct Filters
{
MediaLibraryId mediaLibrary; // tracks that belongs to this library
std::optional<std::vector<MediaLibraryId>> mediaLibraries; // tracks in any of these authorized libraries; empty means none
std::vector<ClusterId> clusters; // tracks that belong to *all* these clusters
GenreId genre; // tracks that belong to this genre
GroupingId grouping; // tracks that belong to this grouping
@@ -78,6 +79,11 @@ namespace lms::db
mediaLibrary = _mediaLibrary;
return *this;
}
Filters& setMediaLibraries(std::vector<MediaLibraryId> _mediaLibraries)
{
mediaLibraries = std::move(_mediaLibraries);
return *this;
}
Filters& setLabel(LabelId _label)
{
label = _label;
@@ -0,0 +1,49 @@
#pragma once
#include <functional>
#include <string>
#include <string_view>
#include <Wt/Dbo/Field.h>
#include "database/Object.hpp"
#include "database/objects/InternetRadioStationId.hpp"
namespace lms::db
{
class Session;
class InternetRadioStation final : public Object<InternetRadioStation, InternetRadioStationId>
{
public:
InternetRadioStation() = default;
static pointer find(Session& session, InternetRadioStationId id);
static void find(Session& session, const std::function<void(const pointer&)>& visitor);
std::string_view getName() const { return _name; }
std::string_view getStreamUrl() const { return _streamUrl; }
std::string_view getHomepageUrl() const { return _homepageUrl; }
void setName(std::string_view value) { _name = value; }
void setStreamUrl(std::string_view value) { _streamUrl = value; }
void setHomepageUrl(std::string_view value) { _homepageUrl = value; }
template<class Action>
void persist(Action& a)
{
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::field(a, _streamUrl, "stream_url");
Wt::Dbo::field(a, _homepageUrl, "homepage_url");
}
private:
friend class Session;
InternetRadioStation(std::string_view name, std::string_view streamUrl, std::string_view homepageUrl);
static pointer create(Session& session, std::string_view name, std::string_view streamUrl, std::string_view homepageUrl);
std::string _name;
std::string _streamUrl;
std::string _homepageUrl;
};
} // namespace lms::db
@@ -0,0 +1,5 @@
#pragma once
#include "database/IdType.hpp"
LMS_DECLARE_IDTYPE(InternetRadioStationId)
@@ -25,6 +25,7 @@
#include <string_view>
#include <Wt/Dbo/Field.h>
#include <Wt/Dbo/collection.h>
#include "database/Object.hpp"
#include "database/objects/MediaLibraryId.hpp"
@@ -32,6 +33,7 @@
namespace lms::db
{
class Session;
class User;
class MediaLibrary final : public Object<MediaLibrary, MediaLibraryId>
{
@@ -62,6 +64,7 @@ namespace lms::db
{
Wt::Dbo::field(a, _path, "path");
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::hasMany(a, _users, Wt::Dbo::ManyToMany, "user_media_library", "media_library", Wt::Dbo::OnDeleteCascade);
}
private:
@@ -71,5 +74,6 @@ namespace lms::db
std::filesystem::path _path;
std::string _name;
Wt::Dbo::collection<Wt::Dbo::ptr<User>> _users;
};
} // namespace lms::db
@@ -0,0 +1,70 @@
#pragma once
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <Wt/Dbo/Field.h>
#include <Wt/WDateTime.h>
#include "database/Object.hpp"
#include "database/objects/ShareId.hpp"
#include "database/objects/UserId.hpp"
namespace lms::db
{
class Session;
class User;
class Share final : public Object<Share, ShareId>
{
public:
Share() = default;
static pointer find(Session& session, ShareId id);
static pointer find(Session& session, std::string_view token);
static void find(Session& session, UserId userId, const std::function<void(const pointer&)>& visitor);
std::string_view getToken() const { return _token; }
std::string_view getMediaIds() const { return _mediaIds; }
std::string_view getDescription() const { return _description; }
const Wt::WDateTime& getCreated() const { return _created; }
const Wt::WDateTime& getExpires() const { return _expires; }
const Wt::WDateTime& getLastVisited() const { return _lastVisited; }
long getVisitCount() const { return _visitCount; }
ObjectPtr<User> getUser() const { return _user; }
void setDescription(std::string_view value) { _description = value; }
void setExpires(const Wt::WDateTime& value) { _expires = value; }
void setLastVisited(const Wt::WDateTime& value) { _lastVisited = value; }
void incrementVisitCount() { ++_visitCount; }
template<class Action>
void persist(Action& a)
{
Wt::Dbo::field(a, _token, "token");
Wt::Dbo::field(a, _mediaIds, "media_ids");
Wt::Dbo::field(a, _description, "description");
Wt::Dbo::field(a, _created, "created");
Wt::Dbo::field(a, _expires, "expires");
Wt::Dbo::field(a, _lastVisited, "last_visited");
Wt::Dbo::field(a, _visitCount, "visit_count");
Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade);
}
private:
friend class Session;
Share(std::string_view token, std::string_view mediaIds, ObjectPtr<User> user);
static pointer create(Session& session, std::string_view token, std::string_view mediaIds, ObjectPtr<User> user);
std::string _token;
std::string _mediaIds;
std::string _description;
Wt::WDateTime _created;
Wt::WDateTime _expires;
Wt::WDateTime _lastVisited;
long _visitCount{};
Wt::Dbo::ptr<User> _user;
};
}
@@ -0,0 +1,5 @@
#pragma once
#include "database/IdType.hpp"
LMS_DECLARE_IDTYPE(ShareId)
@@ -33,6 +33,8 @@
#include "database/objects/Filters.hpp"
#include "database/objects/TrackId.hpp"
#include "database/objects/TrackListId.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Types.hpp"
#include "database/objects/UserId.hpp"
@@ -214,6 +216,8 @@ namespace lms::db
// Accessors
TrackId getTrackId() const { return _track.id(); }
ObjectPtr<Track> getTrack() const { return _track; }
PodcastEpisodeId getPodcastEpisodeId() const { return _podcastEpisode.id(); }
ObjectPtr<PodcastEpisode> getPodcastEpisode() const { return _podcastEpisode; }
const Wt::WDateTime& getDateTime() const { return _dateTime; }
template<class Action>
@@ -222,6 +226,7 @@ namespace lms::db
Wt::Dbo::field(a, _dateTime, "date_time");
Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _podcastEpisode, "podcast_episode", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _tracklist, "tracklist", Wt::Dbo::OnDeleteCascade);
}
@@ -229,10 +234,13 @@ namespace lms::db
friend class Session;
TrackListEntry(ObjectPtr<Track> track, ObjectPtr<TrackList> tracklist, const Wt::WDateTime& dateTime);
TrackListEntry(ObjectPtr<Track> track, ObjectPtr<TrackList> tracklist);
TrackListEntry(ObjectPtr<PodcastEpisode> episode, ObjectPtr<TrackList> tracklist);
static pointer create(Session& session, ObjectPtr<Track> track, ObjectPtr<TrackList> tracklist, const Wt::WDateTime& dateTime = {});
static pointer create(Session& session, ObjectPtr<PodcastEpisode> episode, ObjectPtr<TrackList> tracklist);
Wt::WDateTime _dateTime; // optional date time
Wt::Dbo::ptr<Track> _track;
Wt::Dbo::ptr<PodcastEpisode> _podcastEpisode;
Wt::Dbo::ptr<TrackList> _tracklist;
};
@@ -31,11 +31,13 @@
#include "database/Object.hpp"
#include "database/Types.hpp"
#include "database/objects/Types.hpp"
#include "database/objects/MediaLibraryId.hpp"
#include "database/objects/UserId.hpp"
namespace lms::db
{
class AuthToken;
class MediaLibrary;
class Session;
class UIState;
@@ -94,12 +96,17 @@ namespace lms::db
// accessors
const std::string& getLoginName() const { return _loginName; }
const std::string& getDisplayName() const { return _displayName; }
PasswordHash getPasswordHash() const { return PasswordHash{ .bcryptRoundCount = static_cast<std::size_t>(_bcryptRoundCount), .salt = _passwordSalt, .hash = _passwordHash }; }
const Wt::WDateTime& getLastLogin() const { return _lastLogin; }
std::size_t getAuthTokensCount() const { return _authTokens.size(); }
std::vector<ObjectPtr<MediaLibrary>> getMediaLibraries() const;
bool hasMediaLibrary(MediaLibraryId mediaLibraryId) const;
// write
void setLastLogin(const Wt::WDateTime& dateTime) { _lastLogin = dateTime; }
void setLoginName(std::string_view loginName) { _loginName = loginName; }
void setDisplayName(std::string_view displayName) { _displayName = displayName; }
void setPasswordHash(const PasswordHash& passwordHash)
{
_bcryptRoundCount = passwordHash.bcryptRoundCount;
@@ -121,6 +128,7 @@ namespace lms::db
void setLastFmApiKey(std::string_view key) { _lastFmApiKey = key; }
void setLastFmApiSecret(std::string_view secret) { _lastFmApiSecret = secret; }
void setLastFmSessionKey(std::string_view key) { _lastFmSessionKey = key; }
void setMediaLibraries(const std::vector<ObjectPtr<MediaLibrary>>& mediaLibraries);
// read
bool isAdmin() const { return _type == UserType::ADMIN; }
@@ -146,6 +154,7 @@ namespace lms::db
{
Wt::Dbo::field(a, _type, "type");
Wt::Dbo::field(a, _loginName, "login_name");
Wt::Dbo::field(a, _displayName, "display_name");
Wt::Dbo::field(a, _bcryptRoundCount, "bcrypt_round_count");
Wt::Dbo::field(a, _passwordSalt, "password_salt");
Wt::Dbo::field(a, _passwordHash, "password_hash");
@@ -167,6 +176,7 @@ namespace lms::db
Wt::Dbo::hasMany(a, _authTokens, Wt::Dbo::ManyToOne, "user");
Wt::Dbo::hasMany(a, _uiStates, Wt::Dbo::ManyToOne, "user");
Wt::Dbo::hasMany(a, _mediaLibraries, Wt::Dbo::ManyToMany, "user_media_library", "user", Wt::Dbo::OnDeleteCascade);
}
private:
@@ -175,6 +185,7 @@ namespace lms::db
static pointer create(Session& session, std::string_view loginName);
std::string _loginName;
std::string _displayName;
int _bcryptRoundCount{};
std::string _passwordSalt;
std::string _passwordHash;
@@ -201,5 +212,6 @@ namespace lms::db
Wt::Dbo::collection<Wt::Dbo::ptr<AuthToken>> _authTokens;
Wt::Dbo::collection<Wt::Dbo::ptr<UIState>> _uiStates;
Wt::Dbo::collection<Wt::Dbo::ptr<MediaLibrary>> _mediaLibraries;
};
} // namespace lms::db
+26
View File
@@ -301,6 +301,32 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, Track_MediaLibraries)
{
ScopedTrack track1{ session };
ScopedTrack track2{ session };
ScopedMediaLibrary library1{ session, "Library1", "/library1" };
ScopedMediaLibrary library2{ session, "Library2", "/library2" };
{
auto transaction{ session.createWriteTransaction() };
track1.get().modify()->setMediaLibrary(library1.get());
track2.get().modify()->setMediaLibrary(library2.get());
}
{
auto transaction{ session.createReadTransaction() };
const auto tracks{ Track::findIds(session, Track::FindParameters{}.setFilters(Filters{}.setMediaLibraries({ library1.getId() }))) };
ASSERT_EQ(tracks.size(), 1);
EXPECT_EQ(tracks.front(), track1.getId());
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_TRUE(Track::findIds(session, Track::FindParameters{}.setFilters(Filters{}.setMediaLibraries({}))).empty());
}
}
TEST_F(DatabaseFixture, Track_noMediaLibrary)
{
ScopedTrack track{ session };
+27
View File
@@ -20,6 +20,8 @@
#include <list>
#include "database/objects/Release.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/TrackList.hpp"
#include "Common.hpp"
@@ -27,6 +29,7 @@
namespace lms::db::tests
{
using ScopedReleaseType = ScopedEntity<db::ReleaseType>;
using ScopedPodcast = ScopedEntity<db::Podcast>;
TEST_F(DatabaseFixture, SingleTrackList)
{
@@ -77,6 +80,30 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, TrackListPodcastEpisode)
{
ScopedTrackList trackList{ session, "Podcast queue", TrackListType::Internal };
ScopedPodcast podcast{ session, "https://example.com/feed.xml" };
PodcastEpisodeId episodeId;
{
auto transaction{ session.createWriteTransaction() };
const PodcastEpisode::pointer episode{ session.create<PodcastEpisode>(podcast.get()) };
episodeId = episode->getId();
session.create<TrackListEntry>(episode, trackList.get());
}
{
auto transaction{ session.createReadTransaction() };
const TrackList::pointer queue{ TrackList::find(session, trackList.getId()) };
ASSERT_EQ(queue->getCount(), 1);
const TrackListEntry::pointer entry{ queue->getEntry(0) };
EXPECT_FALSE(entry->getTrack());
ASSERT_TRUE(entry->getPodcastEpisode());
EXPECT_EQ(entry->getPodcastEpisodeId(), episodeId);
}
}
TEST_F(DatabaseFixture, TrackList_SortMethod)
{
ScopedTrackList trackList2{ session, "MyTrackList2", TrackListType::PlayList };
+57
View File
@@ -48,4 +48,61 @@ namespace lms::db::tests
EXPECT_EQ(visitedUsers[1], user2->getId());
}
}
TEST_F(DatabaseFixture, UserMediaLibraries)
{
ScopedUser user{ session, "MyUser" };
ScopedMediaLibrary library1{ session, "Library1", "/library1" };
ScopedMediaLibrary library2{ session, "Library2", "/library2" };
{
auto transaction{ session.createWriteTransaction() };
auto userPtr{ user.get() };
userPtr.modify()->setMediaLibraries({ library1.get() });
}
{
auto transaction{ session.createReadTransaction() };
const auto userPtr{ user.get() };
ASSERT_EQ(userPtr->getMediaLibraries().size(), 1);
EXPECT_TRUE(userPtr->hasMediaLibrary(library1.getId()));
EXPECT_FALSE(userPtr->hasMediaLibrary(library2.getId()));
}
{
auto transaction{ session.createWriteTransaction() };
user.get().modify()->setMediaLibraries({ library2.get() });
}
{
auto transaction{ session.createReadTransaction() };
const auto userPtr{ user.get() };
ASSERT_EQ(userPtr->getMediaLibraries().size(), 1);
EXPECT_FALSE(userPtr->hasMediaLibrary(library1.getId()));
EXPECT_TRUE(userPtr->hasMediaLibrary(library2.getId()));
}
}
TEST_F(DatabaseFixture, UserNames)
{
ScopedUser user{ session, "MyUser" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(user->getLoginName(), "MyUser");
EXPECT_EQ(user->getDisplayName(), "MyUser");
}
{
auto transaction{ session.createWriteTransaction() };
user.get().modify()->setLoginName("new-login");
user.get().modify()->setDisplayName("New Display Name");
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(user->getLoginName(), "new-login");
EXPECT_EQ(user->getDisplayName(), "New Display Name");
}
}
} // namespace lms::db::tests
+2
View File
@@ -21,6 +21,7 @@ set_property(CACHE LMS_IMAGE_BACKEND PROPERTY STRINGS "stb" "graphicsmagick")
if (${LMS_IMAGE_BACKEND} STREQUAL "stb")
find_package(StbImage REQUIRED)
pkg_check_modules(WebP REQUIRED IMPORTED_TARGET libwebp)
message(STATUS "Using stb (resize version ${STB_IMAGE_RESIZE_VERSION})")
target_sources(lmsimage PRIVATE
@@ -38,6 +39,7 @@ if (${LMS_IMAGE_BACKEND} STREQUAL "stb")
target_compile_options(lmsimage PRIVATE "-DSTB_IMAGE_RESIZE_VERSION=${STB_IMAGE_RESIZE_VERSION}")
target_include_directories(lmsimage PRIVATE ${STB_IMAGE_INCLUDE_DIR})
target_link_libraries(lmsimage PRIVATE PkgConfig::WebP)
elseif (${LMS_IMAGE_BACKEND} STREQUAL "graphicsmagick")
pkg_check_modules(GraphicsMagick++ REQUIRED IMPORTED_TARGET GraphicsMagick++)
+13 -13
View File
@@ -20,6 +20,9 @@
#include "image/Image.hpp"
#include <array>
#include <fstream>
#include <webp/decode.h>
#include "Exception.hpp"
#include "StbImage.hpp"
@@ -39,7 +42,7 @@ namespace lms::image
std::span<const std::filesystem::path> getSupportedFileExtensions()
{
static const std::array<std::filesystem::path, 4> fileExtensions{ ".jpg", ".jpeg", ".png", ".bmp" };
static const std::array<std::filesystem::path, 5> fileExtensions{ ".jpg", ".jpeg", ".png", ".bmp", ".webp" };
return fileExtensions;
}
@@ -47,24 +50,21 @@ namespace lms::image
{
LMS_SCOPED_TRACE_DETAILED("Image", "ProbeFile");
int x{};
int y{};
int comp{};
if (::stbi_info(path.c_str(), &x, &y, &comp) == 0)
throw StbiException{ "Probe failed" };
ImageProperties properties;
properties.width = x;
properties.height = y;
return properties;
std::ifstream file{ path, std::ios::binary };
if (!file) throw StbiException{ "Probe failed" };
const std::string data{ std::istreambuf_iterator<char>{ file }, std::istreambuf_iterator<char>{} };
return probeImage(std::as_bytes(std::span{ data.data(), data.size() }));
}
ImageProperties probeImage(std::span<const std::byte> encodedData)
{
LMS_SCOPED_TRACE_DETAILED("Image", "ProbeBuffer");
int webpWidth{};
int webpHeight{};
if (::WebPGetInfo(reinterpret_cast<const uint8_t*>(encodedData.data()), encodedData.size(), &webpWidth, &webpHeight))
return ImageProperties{ .width = static_cast<ImageSize>(webpWidth), .height = static_cast<ImageSize>(webpHeight) };
int x{};
int y{};
int comp{};
+28 -5
View File
@@ -23,6 +23,9 @@
#include "StbImage.hpp"
#include "StbImageResize.hpp"
#include <fstream>
#include <webp/decode.h>
#include "core/ITraceLogger.hpp"
#include "image/Exception.hpp"
@@ -30,11 +33,34 @@ namespace lms::image::STB
{
namespace
{
std::vector<std::byte> readFile(const std::filesystem::path& path)
{
std::ifstream file{ path, std::ios::binary };
if (!file) throw StbiException{ "Cannot load image from file" };
const std::string contents{ std::istreambuf_iterator<char>{ file }, std::istreambuf_iterator<char>{} };
const auto bytes{ std::as_bytes(std::span{ contents.data(), contents.size() }) };
return { bytes.begin(), bytes.end() };
}
} // namespace
RawImage::RawImage(std::span<const std::byte> encodedData)
{
int webpWidth{};
int webpHeight{};
if (::WebPGetInfo(reinterpret_cast<const uint8_t*>(encodedData.data()), encodedData.size(), &webpWidth, &webpHeight))
{
uint8_t* decoded{ ::WebPDecodeRGB(reinterpret_cast<const uint8_t*>(encodedData.data()), encodedData.size(), &webpWidth, &webpHeight) };
if (!decoded) throw StbiException{ "Cannot load WebP image from memory" };
const std::size_t size{ static_cast<std::size_t>(webpWidth) * static_cast<std::size_t>(webpHeight) * 3 };
_data.reset(static_cast<unsigned char*>(std::malloc(size)));
if (!_data) { ::WebPFree(decoded); throw StbiException{ "Cannot allocate WebP image" }; }
std::copy(decoded, decoded + size, static_cast<unsigned char*>(_data.get()));
::WebPFree(decoded);
_width = webpWidth;
_height = webpHeight;
return;
}
int n{};
_data = UniquePtrFree{ ::stbi_load_from_memory(reinterpret_cast<const stbi_uc*>(encodedData.data()), encodedData.size(), &_width, &_height, &n, 3), std::free };
if (!_data)
@@ -42,11 +68,8 @@ namespace lms::image::STB
}
RawImage::RawImage(const std::filesystem::path& p)
: RawImage{ readFile(p) }
{
int n{};
_data = UniquePtrFree{ stbi_load(p.c_str(), &_width, &_height, &n, 3), std::free };
if (!_data)
throw StbiException{ "Cannot load image from file" };
}
void RawImage::resize(ImageSize width)
@@ -107,7 +107,12 @@ namespace lms::podcast
{
std::ostringstream oss;
for (const pugi::xml_node& n : child.children())
{
if (n.type() == pugi::node_pcdata || n.type() == pugi::node_cdata)
oss << n.value();
else
n.print(oss, "", pugi::format_raw);
}
res = oss.str();
}
@@ -55,7 +55,7 @@ namespace lms::podcast
, _refreshTimer(ioContext)
, _httpClient{ core::http::createClient(ioContext, "") }
, _refreshContext{ _executor, db, *_httpClient, cachePath }
, _refreshPeriod{ core::Service<core::IConfig>::get()->getULong("podcast-refresh-period-hours", 2) }
, _refreshPeriod{ core::Service<core::IConfig>::get()->getULong("podcast-refresh-period-hours", 24) }
, _refreshInProgress{ false }
, _abortRequested{ false }
, _refreshStepIndex{ 0 }
@@ -120,7 +120,7 @@ namespace lms::podcast
dbPodcast.modify()->setSubtitle(podcast.subtitle);
dbPodcast.modify()->setSummary(podcast.summary);
dbPodcast.modify()->setTitle(podcast.title);
if (std::string previousUrl{ dbPodcast->getImageUrl() }; !previousUrl.empty() && previousUrl != podcast.imageUrl)
if (std::string previousUrl{ dbPodcast->getImageUrl() }; previousUrl != podcast.imageUrl && !podcast.imageUrl.empty())
{
if (isAllowedPodcastUrl(podcast.imageUrl))
{
@@ -33,7 +33,7 @@ namespace lms::podcast::tests
<channel>
<title>Affaires sensibles</title>
<link>https://www.franceinter.fr/emission-affaires-sensibles</link>
<description>Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur <a href="https://www.franceinter.fr/emission-affaires-sensibles?at_campaign=desc_podcast&at_medium=lien_RSS">Radio France</a>.</description>
<description><![CDATA[Les grandes affaires, les aventures et les procès qui ont marqué les cinquante dernières années. Vous aimez ce podcast ? Pour écouter tous les épisodes sans limite, rendez-vous sur ]]><a href="https://www.franceinter.fr/emission-affaires-sensibles?at_campaign=desc_podcast&at_medium=lien_RSS">Radio France</a>.</description>
<language>fr</language>
<copyright>Radio France</copyright>
<lastBuildDate>Sat, 09 Aug 2025 21:34:32 +0200</lastBuildDate>
@@ -460,14 +460,9 @@ namespace lms::scanner
{
using namespace audio;
std::optional<Release> release;
std::optional<Release> release{ std::in_place };
auto releaseName{ getTagValueAs<std::string>(tagReader, TagType::Album) };
if (!releaseName)
return release;
release.emplace();
release->name = std::move(*releaseName);
release->name = getTagValueAs<std::string>(tagReader, TagType::Album).value_or("Unknown Album");
release->sortName = getTagValueAs<std::string>(tagReader, TagType::AlbumSortOrder).value_or(release->name);
release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _params);
release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs<std::string>(tagReader, TagType::AlbumArtist), _params.artistTagDelimiters);
@@ -29,6 +29,18 @@
namespace lms::scanner::tests
{
TEST(TrackMetadataParser, missingAlbumUsesUnknownAlbum)
{
const TestTagReader testTags{ TestTagReader::Tags{} };
const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) };
ASSERT_TRUE(track.medium.has_value());
ASSERT_TRUE(track.medium->release.has_value());
EXPECT_EQ(track.medium->release->name, "Unknown Album");
EXPECT_EQ(track.medium->release->sortName, "Unknown Album");
}
TEST(TrackMetadataParser, generalTest)
{
TrackMetadataParser::Parameters params;
+3
View File
@@ -1,6 +1,7 @@
add_library(lmssubsonic STATIC
impl/AuthUtils.cpp
impl/MusicBrainzArtistMetadata.cpp
impl/endpoints/transcoding/AudioFileInfo.cpp
impl/endpoints/transcoding/TranscodeDecision.cpp
impl/endpoints/transcoding/TranscodeDecisionTracker.cpp
@@ -8,12 +9,14 @@ add_library(lmssubsonic STATIC
impl/endpoints/Bookmarks.cpp
impl/endpoints/Browsing.cpp
impl/endpoints/Jukebox.cpp
impl/endpoints/InternetRadio.cpp
impl/endpoints/MediaAnnotation.cpp
impl/endpoints/MediaLibraryScanning.cpp
impl/endpoints/MediaRetrieval.cpp
impl/endpoints/Playlists.cpp
impl/endpoints/Podcast.cpp
impl/endpoints/Searching.cpp
impl/endpoints/Sharing.cpp
impl/endpoints/System.cpp
impl/endpoints/Transcoding.cpp
impl/endpoints/UserManagement.cpp
@@ -0,0 +1,253 @@
#include "subsonic/MusicBrainzArtistMetadata.hpp"
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <Wt/Json/Array.h>
#include <Wt/Json/Object.h>
#include <Wt/Json/Parser.h>
#include "core/IConfig.hpp"
#include "core/ILogger.hpp"
#include "core/Service.hpp"
#include "core/String.hpp"
#include "core/http/IClient.hpp"
#include "core/UUID.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Release.hpp"
namespace lms::api::subsonic
{
namespace
{
std::string stringValue(const Wt::Json::Object& object, std::string_view key)
{
const std::string keyStr{ key };
return object.type(keyStr) == Wt::Json::Type::String ? static_cast<std::string>(object.get(keyStr)) : std::string{};
}
class Service final : public MusicBrainzArtistMetadataService
{
public:
explicit Service(boost::asio::io_context& ioContext, db::IDb& database)
: _client{ core::http::createClient(ioContext, core::Service<core::IConfig>::get()->getString("musicbrainz-api-base-url", "https://musicbrainz.org")) }
, _database{ database }
{
}
bool startAlbumResync() override
{
std::scoped_lock lock{ _resyncMutex };
if (_resyncStatus.running)
return false;
if (_resyncThread.joinable())
_resyncThread.join();
_resyncStatus = { .running = true };
_resyncThread = std::jthread{ [this](std::stop_token stopToken) { resyncAlbums(stopToken); } };
return true;
}
ResyncStatus getAlbumResyncStatus() const override
{
std::scoped_lock lock{ _resyncMutex };
return _resyncStatus;
}
private:
struct ReleaseMetadata
{
std::string id;
std::string title;
std::string barcode;
std::string comment;
std::string groupId;
std::vector<std::string> labels;
std::vector<std::string> countries;
std::vector<std::string> types;
std::optional<int> mediumCount;
};
std::optional<ReleaseMetadata> getReleaseMetadata(std::string_view mbid)
{
const auto body{ request(*_client, "MusicBrainz", "/ws/2/release/" + core::stringUtils::urlEncode(mbid) + "?inc=labels+release-groups+media&fmt=json") };
if (!body) return std::nullopt;
try
{
Wt::Json::Object release;
Wt::Json::parse(*body, release);
ReleaseMetadata result;
result.id = stringValue(release, "id");
result.title = stringValue(release, "title");
result.barcode = stringValue(release, "barcode");
result.comment = stringValue(release, "disambiguation");
result.countries.push_back(stringValue(release, "country"));
if (release.type("media") == Wt::Json::Type::Array)
result.mediumCount = static_cast<int>(static_cast<const Wt::Json::Array&>(release.get("media")).size());
if (release.type("release-group") == Wt::Json::Type::Object)
{
const Wt::Json::Object& group{ release.get("release-group") };
result.groupId = stringValue(group, "id");
const std::string primaryType{ stringValue(group, "primary-type") };
if (!primaryType.empty()) result.types.push_back(primaryType);
if (group.type("secondary-types") == Wt::Json::Type::Array)
for (const Wt::Json::Value& value : static_cast<const Wt::Json::Array&>(group.get("secondary-types")))
if (value.type() == Wt::Json::Type::String) result.types.push_back(static_cast<std::string>(value));
}
if (release.type("label-info") == Wt::Json::Type::Array)
for (const Wt::Json::Value& value : static_cast<const Wt::Json::Array&>(release.get("label-info")))
if (value.type() == Wt::Json::Type::Object)
{
const Wt::Json::Object& info{ value };
if (info.type("label") == Wt::Json::Type::Object)
{
const std::string name{ stringValue(static_cast<const Wt::Json::Object&>(info.get("label")), "name") };
if (!name.empty()) result.labels.push_back(name);
}
}
return result.id.empty() ? std::nullopt : std::optional{ std::move(result) };
}
catch (const std::exception& e)
{
LMS_LOG(API_SUBSONIC, WARNING, "Cannot parse MusicBrainz release metadata: " << e.what());
return std::nullopt;
}
}
void resyncAlbums(std::stop_token stopToken)
{
db::Session& session{ _database.getTLSSession() };
std::vector<db::ReleaseId> ids;
{
auto transaction{ session.createReadTransaction() };
ids = db::Release::findIds(session, {});
}
{
std::scoped_lock lock{ _resyncMutex };
_resyncStatus.total = ids.size();
}
for (const db::ReleaseId id : ids)
{
if (stopToken.stop_requested()) break;
std::string mbid;
{
auto transaction{ session.createReadTransaction() };
const db::Release::pointer release{ db::Release::find(session, id) };
if (release && release->getMBID()) mbid = release->getMBID()->toString();
}
if (mbid.empty())
{
std::scoped_lock lock{ _resyncMutex };
++_resyncStatus.processed; ++_resyncStatus.skipped;
continue;
}
const auto metadata{ getReleaseMetadata(mbid) };
if (!metadata)
{
std::scoped_lock lock{ _resyncMutex };
++_resyncStatus.processed; ++_resyncStatus.failed;
continue;
}
try
{
auto transaction{ session.createWriteTransaction() };
db::Release::pointer release{ db::Release::find(session, id) };
if (release)
{
auto writable{ release.modify() };
if (!metadata->title.empty()) writable->setName(metadata->title);
writable->setBarcode(metadata->barcode);
writable->setComment(metadata->comment);
writable->setTotalDisc(metadata->mediumCount);
if (const auto groupId{ core::UUID::fromString(metadata->groupId) }) writable->setGroupMBID(*groupId);
writable->clearLabels();
for (const std::string& name : metadata->labels)
{
db::Label::pointer label{ db::Label::find(session, name) };
if (!label) label = session.create<db::Label>(name);
writable->addLabel(label);
}
writable->clearCountries();
for (const std::string& name : metadata->countries)
if (!name.empty())
{
db::Country::pointer country{ db::Country::find(session, name) };
if (!country) country = session.create<db::Country>(name);
writable->addCountry(country);
}
writable->clearReleaseTypes();
for (const std::string& name : metadata->types)
{
db::ReleaseType::pointer type{ db::ReleaseType::find(session, name) };
if (!type) type = session.create<db::ReleaseType>(name);
writable->addReleaseType(type);
}
}
std::scoped_lock lock{ _resyncMutex };
++_resyncStatus.processed; ++_resyncStatus.updated;
}
catch (const std::exception& e)
{
LMS_LOG(API_SUBSONIC, ERROR, "Cannot update release " << id.getValue() << " from MusicBrainz: " << e.what());
std::scoped_lock lock{ _resyncMutex };
++_resyncStatus.processed; ++_resyncStatus.failed;
}
}
std::scoped_lock lock{ _resyncMutex };
_resyncStatus.running = false;
}
std::optional<std::string> request(core::http::IClient& client, std::string_view serviceName, std::string relativeUrl)
{
struct State { std::mutex mutex; std::condition_variable cv; bool complete{}; std::optional<std::string> body; };
const auto state{ std::make_shared<State>() };
const std::string requestUrl{ relativeUrl };
{
std::unique_lock lock{ _rateLimitMutex };
const auto nextRequest{ _lastRequest + std::chrono::seconds{ 1 } };
if (const auto now{ std::chrono::steady_clock::now() }; now < nextRequest)
std::this_thread::sleep_until(nextRequest);
_lastRequest = std::chrono::steady_clock::now();
}
core::http::ClientGETRequestParameters request;
request.relativeUrl = std::move(relativeUrl);
request.responseBufferSize = 2 * 1024 * 1024;
request.headers.push_back({ "User-Agent", "LMS/3.79 (https://github.com/epoupon/lms)" });
request.headers.push_back({ "Accept", "application/json" });
request.onSuccessFunc = [state](const Wt::Http::Message& message) { std::scoped_lock lock{ state->mutex }; state->body = message.body(); state->complete = true; state->cv.notify_one(); };
request.onFailureFunc = [state, requestUrl, serviceName = std::string{ serviceName }] {
LMS_LOG(API_SUBSONIC, WARNING, serviceName << " request failed: " << requestUrl);
std::scoped_lock lock{ state->mutex };
state->complete = true;
state->cv.notify_one();
};
request.onAbortFunc = request.onFailureFunc;
client.sendGETRequest(std::move(request));
std::unique_lock lock{ state->mutex };
if (!state->cv.wait_for(lock, std::chrono::seconds{ 10 }, [&] { return state->complete; }))
LMS_LOG(API_SUBSONIC, WARNING, serviceName << " request timed out: " << requestUrl);
return state->body;
}
std::unique_ptr<core::http::IClient> _client;
std::mutex _rateLimitMutex;
std::chrono::steady_clock::time_point _lastRequest{};
db::IDb& _database;
mutable std::mutex _resyncMutex;
ResyncStatus _resyncStatus;
std::jthread _resyncThread;
};
} // namespace
std::unique_ptr<MusicBrainzArtistMetadataService> createMusicBrainzArtistMetadataService(boost::asio::io_context& ioContext, db::IDb& database)
{
return std::make_unique<Service>(ioContext, database);
}
} // namespace lms::api::subsonic
+40
View File
@@ -22,6 +22,8 @@
#include "ParameterParsing.hpp"
#include "SubsonicResourceConfig.hpp"
#include "SubsonicResponse.hpp"
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/User.hpp"
namespace lms::api::subsonic
{
@@ -55,6 +57,20 @@ namespace lms::api::subsonic
checkProtocolVersion(_clientProtocolVersion, serverProtocolVersion);
}
std::string RequestContext::getPublicBaseUrl() const
{
std::string scheme{ _request.headerValue("X-Forwarded-Proto") };
if (scheme.empty())
scheme = _request.urlScheme();
std::string host{ _request.headerValue("X-Forwarded-Host") };
if (host.empty())
host = _request.hostName();
if (host.find(':') == std::string::npos && !_request.serverPort().empty()
&& !((scheme == "http" && _request.serverPort() == "80") || (scheme == "https" && _request.serverPort() == "443")))
host += ":" + _request.serverPort();
return scheme + "://" + host;
}
RequestContext::~RequestContext() = default;
const RequestContext::ParameterMap& RequestContext::getParameters() const
@@ -82,6 +98,30 @@ namespace lms::api::subsonic
return _user;
}
bool RequestContext::isMediaLibraryAllowed(db::MediaLibraryId libraryId) const
{
return _user && libraryId.isValid() && _user->hasMediaLibrary(libraryId);
}
void RequestContext::applyUserLibraryFilter(db::Filters& filters, db::MediaLibraryId requestedLibrary) const
{
if (requestedLibrary.isValid())
{
if (!isMediaLibraryAllowed(requestedLibrary))
throw RequestedDataNotFoundError{};
filters.setMediaLibrary(requestedLibrary);
return;
}
std::vector<db::MediaLibraryId> libraryIds;
if (_user)
{
for (const db::MediaLibrary::pointer& library : _user->getMediaLibraries())
libraryIds.push_back(library->getId());
}
filters.setMediaLibraries(std::move(libraryIds));
}
std::string RequestContext::getClientIpAddr() const
{
return _request.clientAddress();
@@ -26,6 +26,7 @@
#include <Wt/Http/Request.h>
#include "database/Object.hpp"
#include "database/objects/Filters.hpp"
#include "ProtocolVersion.hpp"
#include "ResponseFormat.hpp"
@@ -56,12 +57,15 @@ namespace lms::api::subsonic
void setUser(const db::ObjectPtr<db::User>& user);
db::ObjectPtr<db::User> getUser() const;
void applyUserLibraryFilter(db::Filters& filters, db::MediaLibraryId requestedLibrary = {}) const;
bool isMediaLibraryAllowed(db::MediaLibraryId libraryId) const;
std::string getClientIpAddr() const;
std::string_view getClientName() const;
ResponseFormat getResponseFormat() const;
bool isOpenSubsonicEnabled() const;
std::string getPublicBaseUrl() const;
private:
const Wt::Http::Request& _request;
+14 -10
View File
@@ -41,12 +41,14 @@
#include "endpoints/Bookmarks.hpp"
#include "endpoints/Browsing.hpp"
#include "endpoints/Jukebox.hpp"
#include "endpoints/InternetRadio.hpp"
#include "endpoints/MediaAnnotation.hpp"
#include "endpoints/MediaLibraryScanning.hpp"
#include "endpoints/MediaRetrieval.hpp"
#include "endpoints/Playlists.hpp"
#include "endpoints/Podcast.hpp"
#include "endpoints/Searching.hpp"
#include "endpoints/Sharing.hpp"
#include "endpoints/System.hpp"
#include "endpoints/Transcoding.hpp"
#include "endpoints/UserManagement.hpp"
@@ -64,7 +66,7 @@ namespace lms::api::subsonic
{
constexpr std::string_view redactedStr{ "*REDACTED*" };
auto redactValueIfNeeded = [redactedStr](const std::string& type, const std::string& value) -> std::string_view {
if (type == "p" || type == "password" || type == "apiKey")
if (type == "p" || type == "password" || type == "apiKey" || type == "t" || type == "s")
return redactedStr;
return value;
@@ -147,7 +149,7 @@ namespace lms::api::subsonic
{ "/getSong", { handleGetSongRequest } },
{ "/getVideos", { handleNotImplemented } },
{ "/getArtistInfo", { handleNotImplemented } },
{ "/getArtistInfo2", { handleGetArtistInfo2Request } },
{ "/getArtistInfo2", { handleNotImplemented } },
{ "/getAlbumInfo", { handleGetAlbumInfo } },
{ "/getAlbumInfo2", { handleGetAlbumInfo2 } },
{ "/getSimilarSongs", { handleGetSimilarSongsRequest } },
@@ -194,10 +196,11 @@ namespace lms::api::subsonic
{ "/scrobble", { handleScrobble } },
// Sharing
{ "/getShares", { handleNotImplemented } },
{ "/createShares", { handleNotImplemented } },
{ "/updateShare", { handleNotImplemented } },
{ "/deleteShare", { handleNotImplemented } },
{ "/getShares", { handleGetShares, AuthenticationMode::Authenticated, { db::UserType::REGULAR, db::UserType::ADMIN } } },
{ "/createShare", { handleCreateShare, AuthenticationMode::Authenticated, { db::UserType::REGULAR, db::UserType::ADMIN } } },
{ "/createShares", { handleCreateShare, AuthenticationMode::Authenticated, { db::UserType::REGULAR, db::UserType::ADMIN } } },
{ "/updateShare", { handleUpdateShare, AuthenticationMode::Authenticated, { db::UserType::REGULAR, db::UserType::ADMIN } } },
{ "/deleteShare", { handleDeleteShare, AuthenticationMode::Authenticated, { db::UserType::REGULAR, db::UserType::ADMIN } } },
// Podcast
{ "/getPodcasts", { handleGetPodcasts } },
@@ -213,10 +216,10 @@ namespace lms::api::subsonic
{ "/jukeboxControl", { handleJukeboxControl } },
// Internet radio
{ "/getInternetRadioStations", { handleNotImplemented } },
{ "/createInternetRadioStation", { handleNotImplemented } },
{ "/updateInternetRadioStation", { handleNotImplemented } },
{ "/deleteInternetRadioStation", { handleNotImplemented } },
{ "/getInternetRadioStations", { handleGetInternetRadioStations } },
{ "/createInternetRadioStation", { handleCreateInternetRadioStation, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } },
{ "/updateInternetRadioStation", { handleUpdateInternetRadioStation, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } },
{ "/deleteInternetRadioStation", { handleDeleteInternetRadioStation, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } },
// Chat
{ "/getChatMessages", { handleNotImplemented } },
@@ -289,6 +292,7 @@ namespace lms::api::subsonic
requestPath.resize(requestPath.length() - optionalSuffix.size());
LMS_LOG(API_SUBSONIC, DEBUG, "Handling request " << requestId << " to '" << requestPath << " with params = " << parameterMapToDebugString(request.getParameterMap()) << "', continuation = " << (request.continuation() ? "true" : "false"));
LMS_LOG_IF(API_SUBSONIC, INFO, _config.logRequests, "Subsonic request " << requestId << " to '" << requestPath << "' with params = " << parameterMapToDebugString(request.getParameterMap()) << ", continuation = " << (request.continuation() ? "true" : "false"));
try
{
@@ -43,6 +43,7 @@ namespace lms::api::subsonic
{
return SubsonicResourceConfig{
.openSubsonicDisabledClients = readOpenSubsonicDisabledClients(config),
.logRequests = config.getBool("api-subsonic-log-requests", false),
};
}
} // namespace lms::api::subsonic
@@ -32,6 +32,7 @@ namespace lms::api::subsonic
struct SubsonicResourceConfig
{
std::unordered_set<std::string> openSubsonicDisabledClients;
bool logRequests{};
};
SubsonicResourceConfig readSubsonicResourceConfig(core::IConfig& _config);
@@ -69,7 +69,7 @@ namespace lms::api::subsonic
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::Name);
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = Release::findIds(context.getDbSession(), params);
}
@@ -78,7 +78,7 @@ namespace lms::api::subsonic
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::ArtistNameThenName);
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = Release::findIds(context.getDbSession(), params);
}
@@ -90,7 +90,7 @@ namespace lms::api::subsonic
if (const Genre::pointer genreObj{ Genre::find(context.getDbSession(), genre) })
{
Release::FindParameters params;
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
params.filters.setGenre(genreObj->getId());
params.setSortMethod(ReleaseSortMethod::Name);
params.setRange(range);
@@ -107,7 +107,7 @@ namespace lms::api::subsonic
params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::OriginalDateDesc : ReleaseSortMethod::OriginalDate);
params.setRange(range);
params.setOriginalDateRange(YearRange{ std::min(fromYear, toYear), std::max(fromYear, toYear) });
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = Release::findIds(context.getDbSession(), params);
}
@@ -116,7 +116,7 @@ namespace lms::api::subsonic
scrobbling::IScrobblingService::FindParameters params;
params.setUser(context.getUser()->getId());
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = scrobblingService.getTopReleases(params);
}
@@ -125,7 +125,7 @@ namespace lms::api::subsonic
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::AddedDesc);
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = Release::findIds(context.getDbSession(), params);
}
@@ -136,7 +136,7 @@ namespace lms::api::subsonic
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::Random);
params.setRange(Range{ 0, size });
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = Release::findIds(context.getDbSession(), params);
}
@@ -145,7 +145,7 @@ namespace lms::api::subsonic
scrobbling::IScrobblingService::FindParameters params;
params.setUser(context.getUser()->getId());
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = scrobblingService.getRecentReleases(params);
}
@@ -154,7 +154,7 @@ namespace lms::api::subsonic
feedback::IFeedbackService::FindParameters params;
params.setUser(context.getUser()->getId());
params.setRange(range);
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
releases = feedbackService.findStarredReleases(params);
}
@@ -202,7 +202,7 @@ namespace lms::api::subsonic
feedback::IFeedbackService::FindParameters findParameters;
findParameters.setUser(context.getUser()->getId());
findParameters.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(findParameters.filters, mediaLibrary);
for (const ReleaseId releaseId : feedbackService.findStarredReleases(findParameters))
{
@@ -246,7 +246,7 @@ namespace lms::api::subsonic
Track::FindParameters params;
params.setSortMethod(TrackSortMethod::Random);
params.setRange(Range{ 0, size });
params.filters.setMediaLibrary(mediaLibraryId);
context.applyUserLibraryFilter(params.filters, mediaLibraryId);
Track::find(context.getDbSession(), params, [&](const Track::pointer& track) {
randomSongsNode.addArrayChild("song", createSongNode(context, track, context.getUser()));
@@ -279,7 +279,7 @@ namespace lms::api::subsonic
Track::FindParameters params;
params.filters.setGenre(genreObj->getId());
params.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(params.filters, mediaLibrary);
params.setRange(Range{ offset, count });
Track::find(context.getDbSession(), params, [&](const Track::pointer& track) {
+48 -74
View File
@@ -27,7 +27,6 @@
#include "core/Service.hpp"
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/ArtistInfo.hpp"
#include "database/objects/Cluster.hpp"
#include "database/objects/Directory.hpp"
#include "database/objects/Genre.hpp"
@@ -248,12 +247,13 @@ namespace lms::api::subsonic
Response::Node& musicFoldersNode{ response.createNode("musicFolders") };
auto transaction{ context.getDbSession().createReadTransaction() };
MediaLibrary::find(context.getDbSession(), [&](const MediaLibrary::pointer& library) {
for (const MediaLibrary::pointer& library : context.getUser()->getMediaLibraries())
{
Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") };
musicFolderNode.setAttribute("id", library->getId().getValue());
musicFolderNode.setAttribute("name", library->getName());
});
}
return response;
}
@@ -270,13 +270,28 @@ namespace lms::api::subsonic
auto transaction{ context.getDbSession().createReadTransaction() };
const std::vector<Directory::pointer> rootDirectories{ getRootDirectories(context.getDbSession(), mediaLibrary) };
std::vector<Directory::pointer> rootDirectories;
if (mediaLibrary.isValid())
{
if (!context.isMediaLibraryAllowed(mediaLibrary))
throw RequestedDataNotFoundError{};
rootDirectories = getRootDirectories(context.getDbSession(), mediaLibrary);
}
else
{
for (const MediaLibrary::pointer& library : context.getUser()->getMediaLibraries())
{
auto libraryRoots{ getRootDirectories(context.getDbSession(), library->getId()) };
rootDirectories.insert(rootDirectories.end(), libraryRoots.begin(), libraryRoots.end());
}
}
IndexMap indexedDirectories;
for (const Directory::pointer& rootdirectory : rootDirectories)
{
Track::FindParameters params;
params.setDirectory(rootdirectory->getId());
context.applyUserLibraryFilter(params.filters, mediaLibrary);
Track::find(context.getDbSession(), params, [&](const Track::pointer& track) {
indexesNode.addArrayChild("child", createSongNode(context, track, context.getUser()));
@@ -415,7 +430,7 @@ namespace lms::api::subsonic
break;
}
}
parameters.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(parameters.filters, mediaLibrary);
// This endpoint does not scale: make short lived transactions in order not to block the whole application
@@ -480,11 +495,17 @@ namespace lms::api::subsonic
artistNode.addArrayChild("album", createAlbumNode(context, release, true /* id3 */));
} };
Release::find(context.getDbSession(), Release::FindParameters{}.setArtist(artist->getId()), [&](const db::Release::pointer& release) {
Release::FindParameters artistReleaseParams;
artistReleaseParams.setArtist(artist->getId());
context.applyUserLibraryFilter(artistReleaseParams.filters);
Release::find(context.getDbSession(), artistReleaseParams, [&](const db::Release::pointer& release) {
addRelease(release);
});
Release::find(context.getDbSession(), Release::FindParameters{}.setTrackArtist(artist->getId()), [&](const db::Release::pointer& release) {
Release::FindParameters trackArtistReleaseParams;
trackArtistReleaseParams.setTrackArtist(artist->getId());
context.applyUserLibraryFilter(trackArtistReleaseParams.filters);
Release::find(context.getDbSession(), trackArtistReleaseParams, [&](const db::Release::pointer& release) {
if (!release->hasArtist(id))
addRelease(release);
});
@@ -508,7 +529,12 @@ namespace lms::api::subsonic
Response response{ Response::createOkResponse() };
Response::Node albumNode{ createAlbumNode(context, release, true /* id3 */) };
const auto tracks{ Track::find(context.getDbSession(), Track::FindParameters{}.setRelease(id).setSortMethod(TrackSortMethod::Release)) };
Track::FindParameters trackParams;
trackParams.setRelease(id).setSortMethod(TrackSortMethod::Release);
context.applyUserLibraryFilter(trackParams.filters);
const auto tracks{ Track::find(context.getDbSession(), trackParams) };
if (tracks.empty())
throw RequestedDataNotFoundError{};
for (const Track::pointer& track : tracks)
albumNode.addArrayChild("song", createSongNode(context, track, true /* id3 */));
@@ -534,79 +560,27 @@ namespace lms::api::subsonic
return response;
}
Response handleGetArtistInfo2Request(RequestContext& context)
{
// Mandatory params
ArtistId id{ getMandatoryParameterAs<ArtistId>(context.getParameters(), "id") };
// Optional params
std::size_t count{ getParameterAs<std::size_t>(context.getParameters(), "count").value_or(20) };
Response response{ Response::createOkResponse() };
Response::Node& artistInfoNode{ response.createNode(Response::Node::Key{ "artistInfo2" }) };
{
auto transaction{ context.getDbSession().createReadTransaction() };
const Artist::pointer artist{ Artist::find(context.getDbSession(), id) };
if (!artist)
throw RequestedDataNotFoundError{};
if (const std::optional<core::UUID> artistMBID{ artist->getMBID() })
{
switch (context.getResponseFormat())
{
case ResponseFormat::json:
artistInfoNode.setAttribute("musicBrainzId", artistMBID->toString());
break;
case ResponseFormat::xml:
artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->toString());
break;
}
}
ArtistInfo::find(context.getDbSession(), id, Range{ .offset = 0, .size = 1 }, [&](const ArtistInfo::pointer& artistInfo) {
if (!artistInfo->getBiography().empty())
{
switch (context.getResponseFormat())
{
case ResponseFormat::json:
artistInfoNode.setAttribute("biography", artistInfo->getBiography());
break;
case ResponseFormat::xml:
artistInfoNode.createChild("biography").setValue(artistInfo->getBiography());
break;
}
}
});
}
auto similarArtists{ core::Service<recommendation::IRecommendationService>::get()->findSimilarArtists(id, { TrackArtistLinkType::Artist }, count) };
{
auto transaction{ context.getDbSession().createReadTransaction() };
for (const auto& similarArtist : similarArtists)
{
const Artist::pointer artist{ Artist::find(context.getDbSession(), similarArtist.id) };
if (artist)
artistInfoNode.addArrayChild("similarArtist", createArtistNode(context, artist));
}
}
return response;
}
Response handleGetAlbumInfo(RequestContext& context)
{
const db::DirectoryId directoryId{ getMandatoryParameterAs<db::DirectoryId>(context.getParameters(), "id") };
Response response{ Response::createOkResponse() };
{
auto transaction{ context.getDbSession().createReadTransaction() };
if (db::Release::pointer release{ getReleaseFromDirectory(context.getDbSession(), directoryId) })
db::Release::pointer release;
if (const auto directoryId{ getParameterAs<db::DirectoryId>(context.getParameters(), "id") })
release = getReleaseFromDirectory(context.getDbSession(), *directoryId);
else if (const auto releaseId{ getParameterAs<db::ReleaseId>(context.getParameters(), "id") })
release = db::Release::find(context.getDbSession(), *releaseId);
else if (const auto trackId{ getParameterAs<db::TrackId>(context.getParameters(), "id") })
{
if (const db::Track::pointer track{ db::Track::find(context.getDbSession(), *trackId) })
release = track->getRelease();
}
else
throw BadParameterGenericError{ "id" };
if (release)
response.addNode("albumInfo", createAlbumInfoNode(context, release));
}
return response;
@@ -32,7 +32,6 @@ namespace lms::api::subsonic
Response handleGetArtistRequest(RequestContext& context);
Response handleGetAlbumRequest(RequestContext& context);
Response handleGetSongRequest(RequestContext& context);
Response handleGetArtistInfo2Request(RequestContext& context);
Response handleGetAlbumInfo(RequestContext& context);
Response handleGetAlbumInfo2(RequestContext& context);
Response handleGetSimilarSongsRequest(RequestContext& context);
@@ -0,0 +1,101 @@
#include "InternetRadio.hpp"
#include "core/String.hpp"
#include "core/http/UrlValidation.hpp"
#include "database/Session.hpp"
#include "database/objects/InternetRadioStation.hpp"
#include "ParameterParsing.hpp"
namespace lms::api::subsonic
{
namespace
{
db::InternetRadioStationId parseId(RequestContext& context)
{
const auto rawId{ getMandatoryParameterAs<std::string>(context.getParameters(), "id") };
const auto value{ core::stringUtils::readAs<db::InternetRadioStationId::ValueType>(rawId) };
if (!value)
throw RequestedDataNotFoundError{};
return db::InternetRadioStationId{ *value };
}
db::InternetRadioStation::pointer findStation(RequestContext& context)
{
auto station{ db::InternetRadioStation::find(context.getDbSession(), parseId(context)) };
if (!station)
throw RequestedDataNotFoundError{};
return station;
}
void validateUrl(std::string_view value, std::string_view parameter, bool allowEmpty = false)
{
if ((value.empty() && !allowEmpty) || (!value.empty() && !core::http::isValidUrl(value)))
throw BadParameterGenericError{ parameter, "must be a valid absolute HTTP or HTTPS URL" };
}
} // namespace
Response handleGetInternetRadioStations(RequestContext& context)
{
Response response{ Response::createOkResponse() };
auto& stationsNode{ response.createNode("internetRadioStations") };
stationsNode.createEmptyArrayChild("internetRadioStation");
auto transaction{ context.getDbSession().createReadTransaction() };
db::InternetRadioStation::find(context.getDbSession(), [&](const auto& station) {
Response::Node node;
node.setAttribute("id", station->getId().toString());
node.setAttribute("name", station->getName());
node.setAttribute("streamUrl", station->getStreamUrl());
if (!station->getHomepageUrl().empty())
node.setAttribute("homepageUrl", station->getHomepageUrl());
stationsNode.addArrayChild("internetRadioStation", std::move(node));
});
return response;
}
Response handleCreateInternetRadioStation(RequestContext& context)
{
const auto name{ getMandatoryParameterAs<std::string>(context.getParameters(), "name") };
const auto streamUrl{ getMandatoryParameterAs<std::string>(context.getParameters(), "streamUrl") };
const auto homepageUrl{ getParameterAs<std::string>(context.getParameters(), "homepageUrl").value_or("") };
if (name.empty())
throw BadParameterGenericError{ "name", "must not be empty" };
validateUrl(streamUrl, "streamUrl");
validateUrl(homepageUrl, "homepageUrl", true);
auto transaction{ context.getDbSession().createWriteTransaction() };
context.getDbSession().create<db::InternetRadioStation>(name, streamUrl, homepageUrl);
return Response::createOkResponse();
}
Response handleUpdateInternetRadioStation(RequestContext& context)
{
auto transaction{ context.getDbSession().createWriteTransaction() };
auto station{ findStation(context) };
if (const auto name{ getParameterAs<std::string>(context.getParameters(), "name") })
{
if (name->empty())
throw BadParameterGenericError{ "name", "must not be empty" };
station.modify()->setName(*name);
}
if (const auto streamUrl{ getParameterAs<std::string>(context.getParameters(), "streamUrl") })
{
validateUrl(*streamUrl, "streamUrl");
station.modify()->setStreamUrl(*streamUrl);
}
if (const auto homepageUrl{ getParameterAs<std::string>(context.getParameters(), "homepageUrl") })
{
validateUrl(*homepageUrl, "homepageUrl", true);
station.modify()->setHomepageUrl(*homepageUrl);
}
return Response::createOkResponse();
}
Response handleDeleteInternetRadioStation(RequestContext& context)
{
auto transaction{ context.getDbSession().createWriteTransaction() };
findStation(context).remove();
return Response::createOkResponse();
}
} // namespace lms::api::subsonic
@@ -0,0 +1,12 @@
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleGetInternetRadioStations(RequestContext& context);
Response handleCreateInternetRadioStation(RequestContext& context);
Response handleUpdateInternetRadioStation(RequestContext& context);
Response handleDeleteInternetRadioStation(RequestContext& context);
} // namespace lms::api::subsonic
@@ -30,6 +30,7 @@
#include "core/media/MimeType.hpp"
#include "database/Session.hpp"
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackLyrics.hpp"
@@ -123,6 +124,15 @@ namespace lms::api::subsonic
const AudioFileId audioId{ trackId ? AudioFileId{ *trackId } : AudioFileId{ *podcastEpisodeId } };
if (trackId)
{
auto transaction{ context.getDbSession().createReadTransaction() };
const db::Track::pointer track{ db::Track::find(context.getDbSession(), *trackId) };
const db::MediaLibrary::pointer library{ track ? track->getMediaLibrary() : db::MediaLibrary::pointer{} };
if (!library || !context.isMediaLibraryAllowed(library->getId()))
throw RequestedDataNotFoundError{};
}
// Optional params
const std::size_t maxBitRate{ getParameterAs<std::size_t>(context.getParameters(), "maxBitRate").value_or(0) * 1000 }; // "If set to zero, no limit is imposed", given in kpbs
const std::string format{ getParameterAs<std::string>(context.getParameters(), "format").value_or("") };
@@ -316,6 +326,9 @@ namespace lms::api::subsonic
auto track{ db::Track::find(context.getDbSession(), id) };
if (!track)
throw RequestedDataNotFoundError{};
const db::MediaLibrary::pointer library{ track->getMediaLibrary() };
if (!library || !context.isMediaLibraryAllowed(library->getId()))
throw RequestedDataNotFoundError{};
trackPath = track->getAbsoluteFilePath();
}
+3 -2
View File
@@ -23,6 +23,7 @@
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "core/http/UrlValidation.hpp"
#include "ParameterParsing.hpp"
#include "RequestContext.hpp"
@@ -96,8 +97,8 @@ namespace lms::api::subsonic
// Mandatory parameters
const std::string url{ getMandatoryParameterAs<std::string>(context.getParameters(), "url") };
if (url.empty() || !(url.starts_with("http://") || url.starts_with("https://")))
throw BadParameterGenericError{ "url", "must start by http:// or https://" };
if (!core::http::isValidUrl(url))
throw BadParameterGenericError{ "url", "must be a valid absolute HTTP or HTTPS URL" };
// no effect if podcast already exists
core::Service<podcast::IPodcastService>::get()->addPodcast(url);
@@ -160,7 +160,7 @@ namespace lms::api::subsonic
ArtistId lastRetrievedId;
auto findArtists{ [&] {
Artist::FindParameters params;
params.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(params.filters, mediaLibrary);
params.setKeywords(keywords);
params.setRange(Range{ artistOffset, artistCount });
params.setSortMethod(ArtistSortMethod::Id); // must be consistent with both methods
@@ -237,7 +237,7 @@ namespace lms::api::subsonic
Release::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ albumOffset, albumCount });
params.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(params.filters, mediaLibrary);
params.setSortMethod(ReleaseSortMethod::Id); // must be consistent with both methods
Release::find(context.getDbSession(), params, [&](const Release::pointer& release) {
@@ -312,7 +312,7 @@ namespace lms::api::subsonic
Track::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ songOffset, songCount });
params.filters.setMediaLibrary(mediaLibrary);
context.applyUserLibraryFilter(params.filters, mediaLibrary);
params.setSortMethod(TrackSortMethod::Id); // must be consistent with both methods
Track::find(context.getDbSession(), params, [&](const Track::pointer& track) {
@@ -0,0 +1,183 @@
#include "Sharing.hpp"
#include <ctime>
#include "core/String.hpp"
#include "core/UUID.hpp"
#include "database/Session.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Share.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackList.hpp"
#include "database/objects/User.hpp"
#include "ParameterParsing.hpp"
#include "RequestContext.hpp"
#include "SubsonicId.hpp"
#include "responses/Song.hpp"
#include "responses/Podcast.hpp"
#include "core/Service.hpp"
#include "services/podcast/IPodcastService.hpp"
namespace lms::api::subsonic
{
namespace
{
std::vector<std::string> mediaIds(const db::Share::pointer& share)
{
std::vector<std::string> result;
for (auto value : core::stringUtils::splitString(share->getMediaIds(), '\n'))
if (!value.empty()) result.emplace_back(value);
return result;
}
std::vector<db::Track::pointer> resolveTracks(RequestContext& context, const std::vector<std::string>& ids)
{
std::vector<db::Track::pointer> tracks;
for (const std::string& id : ids)
{
if (auto trackId = core::stringUtils::readAs<db::TrackId>(id))
{
auto track = db::Track::find(context.getDbSession(), *trackId);
if (!track) throw RequestedDataNotFoundError{};
tracks.push_back(track);
}
else if (auto releaseId = core::stringUtils::readAs<db::ReleaseId>(id))
{
if (!db::Release::find(context.getDbSession(), *releaseId)) throw RequestedDataNotFoundError{};
auto values = db::Track::find(context.getDbSession(), db::Track::FindParameters{}.setRelease(*releaseId));
tracks.insert(tracks.end(), values.begin(), values.end());
}
else if (auto listId = core::stringUtils::readAs<db::TrackListId>(id))
{
auto list = db::TrackList::find(context.getDbSession(), *listId);
if (!list || (list->getVisibility() == db::TrackList::Visibility::Private && list->getUserId() != context.getUser()->getId()))
throw UserNotAuthorizedError{};
for (auto trackId : list->getTrackIds())
if (auto track = db::Track::find(context.getDbSession(), trackId)) tracks.push_back(track);
}
else if (auto episodeId = core::stringUtils::readAs<db::PodcastEpisodeId>(id))
{
auto episode = db::PodcastEpisode::find(context.getDbSession(), *episodeId);
if (!episode || episode->getAudioRelativeFilePath().empty())
throw RequestedDataNotFoundError{};
const auto path = core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
if (!std::filesystem::is_regular_file(path))
throw RequestedDataNotFoundError{};
}
else if (auto podcastId = core::stringUtils::readAs<db::PodcastId>(id))
{
if (!db::Podcast::find(context.getDbSession(), *podcastId)) throw RequestedDataNotFoundError{};
bool hasDownloadedEpisode{};
db::PodcastEpisode::find(context.getDbSession(), db::PodcastEpisode::FindParameters{}.setPodcast(*podcastId), [&](const auto& episode) {
if (episode->getAudioRelativeFilePath().empty()) return;
const auto path = core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
if (std::filesystem::is_regular_file(path)) hasDownloadedEpisode = true;
});
if (!hasDownloadedEpisode) throw RequestedDataNotFoundError{};
}
else
throw RequestedDataNotFoundError{};
}
return tracks;
}
std::optional<db::ShareId> parseShareId(std::string_view value)
{
auto parts = core::stringUtils::splitString(value, '-');
if (parts.size() != 2 || parts[0] != "sh") return {};
auto raw = core::stringUtils::readAs<db::ShareId::ValueType>(parts[1]);
return raw ? std::optional<db::ShareId>{ db::ShareId{ *raw } } : std::nullopt;
}
db::Share::pointer ownedShare(RequestContext& context, std::string_view value)
{
auto id = parseShareId(value);
if (!id) throw RequestedDataNotFoundError{};
auto share = db::Share::find(context.getDbSession(), *id);
if (!share) throw RequestedDataNotFoundError{};
if (share->getUser()->getId() != context.getUser()->getId()) throw UserNotAuthorizedError{};
return share;
}
Response::Node shareNode(RequestContext& context, const db::Share::pointer& share)
{
Response::Node node;
node.setAttribute("id", "sh-" + share->getId().toString());
node.setAttribute("url", context.getPublicBaseUrl() + "/share/" + std::string{ share->getToken() });
node.setAttribute("username", share->getUser()->getLoginName());
node.setAttribute("created", core::stringUtils::toISO8601String(share->getCreated()));
node.setAttribute("visitCount", share->getVisitCount());
if (!share->getDescription().empty()) node.setAttribute("description", share->getDescription());
if (share->getExpires().isValid()) node.setAttribute("expires", core::stringUtils::toISO8601String(share->getExpires()));
if (share->getLastVisited().isValid()) node.setAttribute("lastVisited", core::stringUtils::toISO8601String(share->getLastVisited()));
for (const auto& track : resolveTracks(context, mediaIds(share)))
node.addArrayChild("entry", createSongNode(context, track, true));
for (const auto& id : mediaIds(share))
if (auto episodeId = core::stringUtils::readAs<db::PodcastEpisodeId>(id))
{
auto episode = db::PodcastEpisode::find(context.getDbSession(), *episodeId);
if (!episode || episode->getAudioRelativeFilePath().empty())
throw RequestedDataNotFoundError{};
node.addArrayChild("entry", createPodcastEpisodeNode(episode));
}
else if (auto podcastId = core::stringUtils::readAs<db::PodcastId>(id))
db::PodcastEpisode::find(context.getDbSession(), db::PodcastEpisode::FindParameters{}.setPodcast(*podcastId), [&](const auto& episode) {
if (episode->getAudioRelativeFilePath().empty()) return;
const auto path = core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
if (std::filesystem::is_regular_file(path)) node.addArrayChild("entry", createPodcastEpisodeNode(episode));
});
return node;
}
void setOptionalFields(RequestContext& context, db::Share::pointer share)
{
if (auto description = getParameterAs<std::string>(context.getParameters(), "description"))
share.modify()->setDescription(*description);
if (auto expires = getParameterAs<long long>(context.getParameters(), "expires"))
share.modify()->setExpires(*expires > 0 ? Wt::WDateTime::fromTime_t(static_cast<std::time_t>(*expires / 1000)) : Wt::WDateTime{});
}
}
Response handleCreateShare(RequestContext& context)
{
const auto ids = getMandatoryMultiParametersAs<std::string>(context.getParameters(), "id");
std::string stored;
for (const auto& id : ids) { if (!stored.empty()) stored += '\n'; stored += id; }
auto transaction = context.getDbSession().createWriteTransaction();
resolveTracks(context, ids);
const std::string token = core::UUID::generate().toString() + core::UUID::generate().toString();
auto share = context.getDbSession().create<db::Share>(token, stored, context.getUser());
setOptionalFields(context, share);
Response response = Response::createOkResponse();
response.createNode("shares").addArrayChild("share", shareNode(context, share));
return response;
}
Response handleGetShares(RequestContext& context)
{
auto transaction = context.getDbSession().createReadTransaction();
Response response = Response::createOkResponse();
auto& shares = response.createNode("shares");
db::Share::find(context.getDbSession(), context.getUser()->getId(), [&](const auto& share) { shares.addArrayChild("share", shareNode(context, share)); });
return response;
}
Response handleUpdateShare(RequestContext& context)
{
const auto id = getMandatoryParameterAs<std::string>(context.getParameters(), "id");
auto transaction = context.getDbSession().createWriteTransaction();
auto share = ownedShare(context, id);
setOptionalFields(context, share);
return Response::createOkResponse();
}
Response handleDeleteShare(RequestContext& context)
{
const auto id = getMandatoryParameterAs<std::string>(context.getParameters(), "id");
auto transaction = context.getDbSession().createWriteTransaction();
ownedShare(context, id).remove();
return Response::createOkResponse();
}
}
@@ -0,0 +1,12 @@
#pragma once
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
class RequestContext;
Response handleCreateShare(RequestContext& context);
Response handleGetShares(RequestContext& context);
Response handleUpdateShare(RequestContext& context);
Response handleDeleteShare(RequestContext& context);
}
+2 -2
View File
@@ -96,7 +96,7 @@ namespace lms::api::subsonic
Response::Node podcastNode;
podcastNode.setAttribute("id", idToString(podcast->getId()));
podcastNode.setAttribute("url", podcast->getLink()); // TODO
podcastNode.setAttribute("url", podcast->getUrl());
if (!podcast->getTitle().empty())
podcastNode.setAttribute("title", podcast->getTitle());
if (!podcast->getDescription().empty())
@@ -111,7 +111,7 @@ namespace lms::api::subsonic
if (includeEpisodes)
{
podcastNode.createEmptyArrayChild("episode ");
podcastNode.createEmptyArrayChild("episode");
db::PodcastEpisode::FindParameters params;
params.setPodcast(podcast->getId());
@@ -77,6 +77,10 @@ namespace lms::api::subsonic
{
LMS_SCOPED_TRACE_DETAILED("Subsonic", "CreateSong");
const db::MediaLibrary::pointer mediaLibrary{ track->getMediaLibrary() };
if (!mediaLibrary || !context.isMediaLibraryAllowed(mediaLibrary->getId()))
throw RequestedDataNotFoundError{};
const auto medium{ track->getMedium() };
Response::Node trackResponse;
+5 -5
View File
@@ -27,7 +27,7 @@
namespace lms::api::subsonic
{
Response::Node createUserNode(RequestContext& context, const db::User::pointer& user)
Response::Node createUserNode(RequestContext&, const db::User::pointer& user)
{
Response::Node userNode;
@@ -43,12 +43,12 @@ namespace lms::api::subsonic
userNode.setAttribute("podcastRole", user->isAdmin()); // Whether the user is allowed to administrate Podcasts
userNode.setAttribute("streamRole", true); // Whether the user is allowed to play files
userNode.setAttribute("jukeboxRole", user->isAdmin()); // Whether the user is allowed to control the jukebox
userNode.setAttribute("shareRole", false); // not supported
userNode.setAttribute("shareRole", user->getType() != db::UserType::DEMO);
// users can access all libraries
db::MediaLibrary::find(context.getDbSession(), [&](const db::MediaLibrary::pointer& library) {
for (const db::MediaLibrary::pointer& library : user->getMediaLibraries())
{
userNode.addArrayValue("folder", library->getId().getValue());
});
}
return userNode;
}
@@ -0,0 +1,31 @@
#pragma once
#include <memory>
#include <string>
#include <boost/asio/io_context.hpp>
namespace lms::db { class IDb; }
namespace lms::api::subsonic
{
class MusicBrainzArtistMetadataService
{
public:
struct ResyncStatus
{
bool running{};
std::size_t total{};
std::size_t processed{};
std::size_t updated{};
std::size_t skipped{};
std::size_t failed{};
};
virtual ~MusicBrainzArtistMetadataService() = default;
virtual bool startAlbumResync() = 0;
virtual ResyncStatus getAlbumResyncStatus() const = 0;
};
std::unique_ptr<MusicBrainzArtistMetadataService> createMusicBrainzArtistMetadataService(boost::asio::io_context& ioContext, lms::db::IDb& database);
} // namespace lms::api::subsonic
+5
View File
@@ -1,6 +1,7 @@
add_executable(lms
main.cpp
PublicShareResource.cpp
ui/Auth.cpp
ui/LmsApplication.cpp
ui/LmsApplicationManager.cpp
@@ -10,6 +11,7 @@ add_executable(lms
ui/ModalManager.cpp
ui/NotificationContainer.cpp
ui/PlayQueue.cpp
ui/PodcastsView.cpp
ui/settings/AudioSettingsView.cpp
ui/settings/PasswordSettingsView.cpp
ui/settings/ServicesSettingsView.cpp
@@ -18,6 +20,7 @@ add_executable(lms
ui/settings/SubsonicSettingsView.cpp
ui/settings/UISettingsView.cpp
ui/State.cpp
ui/ShareUtils.cpp
ui/Tooltip.cpp
ui/Utils.cpp
ui/admin/AdminView.cpp
@@ -28,8 +31,10 @@ add_executable(lms
ui/admin/About.cpp
ui/admin/DebugToolsView.cpp
ui/admin/InitWizardView.cpp
ui/admin/InternetRadioView.cpp
ui/admin/MediaLibrariesView.cpp
ui/admin/MediaLibraryModal.cpp
ui/admin/PodcastsView.cpp
ui/admin/ScannerController.cpp
ui/admin/ScannerReportResource.cpp
ui/admin/ScanSettingsView.cpp
+284
View File
@@ -0,0 +1,284 @@
#include "PublicShareResource.hpp"
#include <algorithm>
#include <filesystem>
#include <memory>
#include <sstream>
#include <Wt/Http/Request.h>
#include <Wt/Http/Response.h>
#include "core/FileResourceHandlerCreator.hpp"
#include "core/IResourceHandler.hpp"
#include "core/IZipper.hpp"
#include "core/String.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Share.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackList.hpp"
#include "core/Service.hpp"
#include "services/artwork/IArtworkService.hpp"
#include "services/podcast/IPodcastService.hpp"
namespace lms
{
namespace
{
template<typename Id>
std::optional<Id> parseId(std::string_view value, std::string_view prefix)
{
auto parts = core::stringUtils::splitString(value, '-');
if (parts.size() != 2 || parts[0] != prefix) return {};
auto raw = core::stringUtils::readAs<typename Id::ValueType>(parts[1]);
return raw ? std::optional<Id>{ Id{ *raw } } : std::nullopt;
}
std::string escape(std::string_view value)
{
std::string out;
for (char c : value)
switch (c) { case '&': out += "&amp;"; break; case '<': out += "&lt;"; break; case '>': out += "&gt;"; break; case '"': out += "&quot;"; break; case '\'': out += "&#39;"; break; default: out += c; }
return out;
}
struct PublicItem
{
std::string id;
std::string title;
std::string artist;
std::string album;
db::ArtworkId artworkId;
std::filesystem::path path;
std::string mimeType;
std::string downloadName;
};
std::string safeFileName(std::string value)
{
for (char& c : value)
if (c == '/' || c == '\\' || c == '"' || c == '\r' || c == '\n') c = '_';
return value.empty() ? "shared-audio" : value;
}
std::string podcastExtension(const db::PodcastEpisode::pointer& episode)
{
std::string enclosure{ episode->getEnclosureUrl() };
if (const auto suffixPos{ enclosure.find_first_of("?#") }; suffixPos != std::string::npos)
enclosure.resize(suffixPos);
const std::string extension{ std::filesystem::path{ enclosure }.extension().string() };
if (!extension.empty() && extension.size() <= 10) return extension;
const std::string_view mime{ episode->getEnclosureContentType() };
if (mime == "audio/mpeg" || mime == "audio/mp3") return ".mp3";
if (mime == "audio/ogg" || mime == "application/ogg") return ".ogg";
if (mime == "audio/opus") return ".opus";
if (mime == "audio/mp4" || mime == "audio/x-m4a") return ".m4a";
if (mime == "audio/flac" || mime == "audio/x-flac") return ".flac";
if (mime == "audio/wav" || mime == "audio/x-wav") return ".wav";
return ".audio";
}
std::vector<PublicItem> resolveItems(db::Session& session, std::string_view stored)
{
std::vector<PublicItem> items;
auto addTrack = [&](const db::Track::pointer& track) {
const auto release{ track->getRelease() };
db::ArtworkId artworkId{ track->getPreferredMediaArtworkId() };
if (!artworkId.isValid()) artworkId = track->getPreferredArtworkId();
items.push_back({ "tr-" + track->getId().toString(), std::string{ track->getName() }, std::string{ track->getArtistDisplayName() }, release ? std::string{ release->getName() } : std::string{}, artworkId, track->getAbsoluteFilePath(), {}, safeFileName(track->getAbsoluteFilePath().filename().string()) });
};
for (auto id : core::stringUtils::splitString(stored, '\n'))
{
if (auto trackId = parseId<db::TrackId>(id, "tr"))
{
if (auto track = db::Track::find(session, *trackId)) addTrack(track); else return {};
}
else if (auto releaseId = parseId<db::ReleaseId>(id, "al"))
{
if (!db::Release::find(session, *releaseId)) return {};
auto values = db::Track::find(session, db::Track::FindParameters{}.setRelease(*releaseId));
for (const auto& track : values) addTrack(track);
}
else if (auto listId = parseId<db::TrackListId>(id, "pl"))
{
auto list = db::TrackList::find(session, *listId);
if (!list) return {};
for (auto trackId : list->getTrackIds())
if (auto track = db::Track::find(session, trackId)) addTrack(track);
}
else if (auto episodeId = parseId<db::PodcastEpisodeId>(id, "podep"))
{
auto episode = db::PodcastEpisode::find(session, *episodeId);
if (!episode || episode->getAudioRelativeFilePath().empty()) return {};
const auto path = core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
if (!std::filesystem::is_regular_file(path)) return {};
const auto podcast{ episode->getPodcast() };
db::ArtworkId artworkId{ episode->getArtworkId() };
if (!artworkId.isValid() && podcast) artworkId = podcast->getArtworkId();
const std::string artist{ !episode->getAuthor().empty() ? std::string{ episode->getAuthor() } : (podcast ? std::string{ podcast->getAuthor() } : std::string{}) };
items.push_back({ "podep-" + episode->getId().toString(), std::string{ episode->getTitle() }, artist, podcast ? std::string{ podcast->getTitle() } : std::string{}, artworkId, path, std::string{ episode->getEnclosureContentType() }, safeFileName(std::string{ episode->getTitle() }) + podcastExtension(episode) });
}
else if (auto podcastId = parseId<db::PodcastId>(id, "pod"))
{
if (!db::Podcast::find(session, *podcastId)) return {};
db::PodcastEpisode::find(session, db::PodcastEpisode::FindParameters{}.setPodcast(*podcastId), [&](const auto& episode) {
if (episode->getAudioRelativeFilePath().empty()) return;
const auto path = core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
if (!std::filesystem::is_regular_file(path)) return;
const auto podcast{ episode->getPodcast() };
db::ArtworkId artworkId{ episode->getArtworkId() };
if (!artworkId.isValid() && podcast) artworkId = podcast->getArtworkId();
const std::string artist{ !episode->getAuthor().empty() ? std::string{ episode->getAuthor() } : (podcast ? std::string{ podcast->getAuthor() } : std::string{}) };
items.push_back({ "podep-" + episode->getId().toString(), std::string{ episode->getTitle() }, artist, podcast ? std::string{ podcast->getTitle() } : std::string{}, artworkId, path, std::string{ episode->getEnclosureContentType() }, safeFileName(std::string{ episode->getTitle() }) + podcastExtension(episode) });
});
}
else return {};
}
return items;
}
bool expired(const db::Share::pointer& share)
{
return share->getExpires().isValid() && share->getExpires() <= Wt::WDateTime::currentDateTime();
}
}
void PublicShareResource::handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response)
{
const std::string requestPath{ request.pathInfo() };
auto parts = core::stringUtils::splitString(requestPath, '/');
parts.erase(std::remove_if(parts.begin(), parts.end(), [](auto part) { return part.empty(); }), parts.end());
if (!parts.empty() && parts.front() == "share")
parts.erase(parts.begin());
if (parts.empty()) { response.setStatus(404); return; }
std::vector<PublicItem> items;
db::ShareId shareId;
std::string description;
{
auto transaction = _db.getTLSSession().createReadTransaction();
auto share = db::Share::find(_db.getTLSSession(), parts[0]);
if (!share || expired(share)) { response.setStatus(404); return; }
shareId = share->getId();
description = share->getDescription();
items = resolveItems(_db.getTLSSession(), share->getMediaIds());
if (items.empty()) { response.setStatus(404); return; }
}
if (parts.size() >= 2 && parts[1] == "stream")
{
if (parts.size() != 3) { response.setStatus(404); return; }
auto it = std::find_if(items.begin(), items.end(), [&](const auto& item) { return item.id == parts[2]; });
if (it == items.end()) { response.setStatus(404); return; }
std::shared_ptr<core::IResourceHandler> handler;
if (request.continuation()) handler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(request.continuation()->data());
else handler = core::createFileResourceHandler(it->path, it->mimeType);
if (auto* continuation = handler->processRequest(request, response)) continuation->setData(handler);
return;
}
if (parts.size() >= 2 && parts[1] == "artwork")
{
if (parts.size() != 3) { response.setStatus(404); return; }
const auto item{ std::find_if(items.begin(), items.end(), [&](const auto& value) { return value.id == parts[2]; }) };
if (item == items.end()) { response.setStatus(404); return; }
auto artworkService{ core::Service<artwork::IArtworkService>::get() };
auto image{ item->artworkId.isValid() ? artworkService->getImage(item->artworkId, 256) : nullptr };
if (!image) image = artworkService->getDefaultReleaseArtwork();
if (!image) { response.setStatus(404); return; }
response.setMimeType(std::string{ image->getMimeType() });
response.out().write(reinterpret_cast<const char*>(image->getData().data()), image->getData().size());
return;
}
if (parts.size() == 2 && parts[1] == "download")
{
if (items.size() == 1)
{
std::shared_ptr<core::IResourceHandler> handler;
if (request.continuation())
handler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(request.continuation()->data());
else
{
handler = core::createFileResourceHandler(items.front().path, items.front().mimeType);
response.addHeader("Content-Disposition", "attachment; filename=\"" + items.front().downloadName + "\"");
}
if (auto* continuation = handler->processRequest(request, response)) continuation->setData(handler);
return;
}
std::shared_ptr<zip::IZipper> zipper;
if (request.continuation()) zipper = Wt::cpp17::any_cast<std::shared_ptr<zip::IZipper>>(request.continuation()->data());
else
{
zip::EntryContainer entries;
std::size_t n{};
for (const auto& item : items)
entries.push_back({ std::to_string(++n) + " - " + item.downloadName, item.path });
zipper = zip::createArchiveZipper(entries);
response.setMimeType("application/zip");
response.addHeader("Content-Disposition", "attachment; filename=shared-music.zip");
}
zipper->writeSome(response.out());
if (!zipper->isComplete()) response.createContinuation()->setData(zipper);
return;
}
if (parts.size() != 1) { response.setStatus(404); return; }
{
auto transaction = _db.getTLSSession().createWriteTransaction();
if (auto share = db::Share::find(_db.getTLSSession(), shareId))
{
share.modify()->incrementVisitCount();
share.modify()->setLastVisited(Wt::WDateTime::currentDateTime());
}
}
response.setMimeType("text/html; charset=utf-8");
auto& out = response.out();
const std::string shareBaseUrl{ "/share/" + std::string{ parts[0] } + "/" };
out << "<!doctype html><html><head><base href=\"" << shareBaseUrl << "\"><meta name=viewport content=\"width=device-width,initial-scale=1\"><title>Shared music</title>"
"<style>"
":root{color-scheme:dark;--bg:#102f36;--panel:#173b43;--line:#34545a;--text:#d8e2e3;--muted:#91a9ad;--accent:#41b3ad;--accent2:#65cbc5}"
"*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}"
".page{max-width:900px;margin:auto;padding:3rem 1.25rem 8rem}header{display:flex;align-items:flex-start;justify-content:space-between;gap:2rem;margin-bottom:2rem}"
"h1{font-size:clamp(1.8rem,5vw,2.7rem);line-height:1.1;margin:0 0 .55rem}.description{color:var(--muted);margin:0;max-width:38rem;line-height:1.55}"
"a{color:var(--accent)}.download{flex:none;text-decoration:none;color:var(--accent2);border:1px solid var(--accent);border-radius:.35rem;padding:.65rem .9rem;font-weight:600}"
".queue{border-top:1px solid var(--line)}.track{display:grid;grid-template-columns:2rem 3.25rem minmax(0,1fr) auto;align-items:center;gap:.75rem;width:100%;padding:.65rem .6rem;border:0;border-bottom:1px solid var(--line);background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}"
".track:hover,.track.active{background:rgba(65,179,173,.09)}.track.active{color:var(--accent2)}.track-number{color:var(--muted);text-align:center}.track.active .track-number{font-size:0}.track.active .track-number:after{content:'';display:inline-block;border-left:8px solid var(--accent);border-top:5px solid transparent;border-bottom:5px solid transparent}.track-title{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-action{color:var(--muted);font-size:.8rem}"
".track-art{width:3.25rem;height:3.25rem;border-radius:.25rem;object-fit:cover;background:var(--line)}.track-info{min-width:0}.track-meta{color:var(--muted);font-size:.82rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:.2rem}.track.active .track-meta{color:var(--muted)}"
".player{position:fixed;z-index:10;left:0;right:0;bottom:0;min-height:74px;background:var(--panel);box-shadow:0 -8px 28px rgba(0,0,0,.22)}"
".seek-wrap{position:absolute;left:0;right:0;top:0;height:7px}.seek-track{position:absolute;inset:0;background:var(--line)}.seek-progress{height:100%;width:0;background:var(--accent)}"
"input[type=range]{accent-color:var(--accent)}#seek{position:absolute;inset:-7px 0 0;width:100%;height:20px;margin:0;opacity:0;cursor:pointer}"
".player-inner{max-width:1050px;min-height:74px;margin:auto;padding:.5rem 1rem;display:grid;grid-template-columns:auto 3.5rem minmax(0,1fr) auto auto;align-items:center;gap:1rem}.now-art{width:3.5rem;height:3.5rem;border-radius:.3rem;object-fit:cover;background:var(--line)}"
".controls{display:flex;align-items:center}.control{border:0;background:none;color:var(--accent2);width:2.35rem;height:2.35rem;border-radius:50%;font-size:1rem;cursor:pointer}.control:hover{background:rgba(65,179,173,.12)}#play{font-size:1.15rem;border:1px solid var(--accent);margin:0 .15rem}"
".control svg,.volume svg{display:block;margin:auto;fill:currentColor}.play-symbol{display:block;position:relative;width:13px;height:16px;margin:auto}.play-symbol:before{content:'';position:absolute;inset:0;border-left:13px solid currentColor;border-top:8px solid transparent;border-bottom:8px solid transparent}.playing .play-symbol:before{border:0;background:linear-gradient(90deg,currentColor 0 35%,transparent 35% 65%,currentColor 65% 100%)}"
".now-playing{text-align:center;min-width:0}.now-title{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.now-subtitle{color:var(--muted);font-size:.82rem;margin-top:.2rem}.time{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap;font-size:.9rem}.volume{display:flex;align-items:center;gap:.45rem;color:var(--muted)}#volume{width:88px}audio{display:none}"
"@media(max-width:620px){.page{padding-top:2rem}.player-inner{grid-template-columns:auto 3rem minmax(0,1fr) auto;gap:.4rem}.now-art{width:3rem;height:3rem}.volume{display:none}.time{font-size:.78rem}header{display:block}.download{display:inline-block;margin-top:1.25rem}.control{width:2rem}.now-playing{text-align:left}.track{grid-template-columns:1.5rem 3rem minmax(0,1fr)}.track-art{width:3rem;height:3rem}.track-action{display:none}}"
"</style></head><body><main class=page><header><div><h1>Shared music</h1>";
if (!description.empty()) out << "<p class=description>" << escape(description) << "</p>";
out << "</div><a class=download href=\"download\">Download" << (items.size() > 1 ? " all" : "") << "</a></header><section class=queue aria-label=\"Shared tracks\">";
std::size_t trackNumber{};
for (const auto& item : items)
{
const std::string metadata{ item.artist + (!item.artist.empty() && !item.album.empty() ? " · " : "") + item.album };
out << "<button class=track type=button data-src=\"stream/" << item.id << "\" data-art=\"artwork/" << item.id << "\" data-title=\"" << escape(item.title) << "\" data-meta=\"" << escape(metadata) << "\"><span class=track-number>" << ++trackNumber << "</span><img class=track-art src=\"artwork/" << item.id << "\" alt=\"\"><span class=track-info><strong class=track-title>" << escape(item.title) << "</strong><span class=track-meta>" << escape(metadata) << "</span></span><span class=track-action>Play</span></button>";
}
out << "</section></main>"
"<div class=player role=region aria-label=\"Now playing\"><audio id=audio preload=metadata></audio><div class=seek-wrap><div class=seek-track><div class=seek-progress id=progress></div></div><input id=seek type=range min=0 max=1000 value=0 aria-label=\"Seek\"></div>"
"<div class=player-inner><div class=controls><button class=control id=previous type=button aria-label=\"Previous track\"><svg width=17 height=17 viewBox=\"0 0 17 17\" aria-hidden=true><path d=\"M2 2h2v13H2zm3 6.5L15 2v13z\"/></svg></button><button class=control id=play type=button aria-label=\"Play\"><span class=play-symbol></span></button><button class=control id=next type=button aria-label=\"Next track\"><svg width=17 height=17 viewBox=\"0 0 17 17\" aria-hidden=true><path d=\"M13 2h2v13h-2zM2 2l10 6.5L2 15z\"/></svg></button></div><img class=now-art id=now-art alt=\"\">"
"<div class=now-playing><div class=now-title id=now-title>Choose a track</div><div class=now-subtitle id=now-subtitle>Shared music</div></div><div class=time><span id=current>0:00</span> / <span id=duration>--:--</span></div>"
"<label class=volume aria-label=\"Volume\"><svg width=18 height=18 viewBox=\"0 0 18 18\" aria-hidden=true><path d=\"M2 7v4h3l4 4V3L5 7zm9.3-1.3a4.6 4.6 0 010 6.6l1.2 1.2a6.3 6.3 0 000-9z\"/></svg><input id=volume type=range min=0 max=1 step=.01 value=.8></label></div></div>"
"<script>(()=>{const audio=document.getElementById('audio'),tracks=[...document.querySelectorAll('.track')],play=document.getElementById('play'),seek=document.getElementById('seek'),progress=document.getElementById('progress'),current=document.getElementById('current'),duration=document.getElementById('duration'),title=document.getElementById('now-title'),subtitle=document.getElementById('now-subtitle'),art=document.getElementById('now-art');let selected=-1;"
"const time=n=>Number.isFinite(n)?Math.floor(n/60)+':'+String(Math.floor(n%60)).padStart(2,'0'):'--:--';"
"function select(i,autoplay=true){if(!tracks.length)return;i=(i+tracks.length)%tracks.length;selected=i;tracks.forEach((t,n)=>{t.classList.toggle('active',n===i);t.querySelector('.track-action').textContent=n===i?'Selected':'Play'});audio.src=tracks[i].dataset.src;title.textContent=tracks[i].dataset.title;subtitle.textContent=tracks[i].dataset.meta||'Shared music';art.src=tracks[i].dataset.art;audio.load();if(autoplay)audio.play().catch(()=>{});}"
"function update(){const ratio=audio.duration?audio.currentTime/audio.duration:0;seek.value=ratio*1000;progress.style.width=(ratio*100)+'%';current.textContent=time(audio.currentTime);duration.textContent=time(audio.duration)}"
"tracks.forEach((t,i)=>t.addEventListener('click',()=>{if(i===selected){audio.paused?audio.play():audio.pause()}else select(i)}));"
"play.addEventListener('click',()=>{if(selected<0)select(0);else audio.paused?audio.play():audio.pause()});document.getElementById('previous').addEventListener('click',()=>select(selected<0?0:selected-1));document.getElementById('next').addEventListener('click',()=>select(selected<0?0:selected+1));"
"seek.addEventListener('input',()=>{if(audio.duration)audio.currentTime=audio.duration*seek.value/1000});document.getElementById('volume').addEventListener('input',e=>audio.volume=e.target.value);audio.volume=.8;audio.addEventListener('timeupdate',update);audio.addEventListener('durationchange',update);audio.addEventListener('play',()=>{play.classList.add('playing');play.setAttribute('aria-label','Pause');if(selected>=0)tracks[selected].querySelector('.track-action').textContent='Playing'});audio.addEventListener('pause',()=>{play.classList.remove('playing');play.setAttribute('aria-label','Play');if(selected>=0)tracks[selected].querySelector('.track-action').textContent='Paused'});audio.addEventListener('ended',()=>{if(selected<tracks.length-1)select(selected+1);else{audio.currentTime=0;update()}});if(tracks.length)select(0,false)})();</script></body></html>";
}
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <Wt/WResource.h>
namespace lms::db { class IDb; }
namespace lms
{
class PublicShareResource final : public Wt::WResource
{
public:
explicit PublicShareResource(db::IDb& db) : _db{ db } {}
~PublicShareResource() override { beingDeleted(); }
private:
void handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response) override;
db::IDb& _db;
};
}
+5
View File
@@ -55,6 +55,8 @@
#include "services/scrobbling/IScrobblingService.hpp"
#include "services/transcoding/ITranscodeService.hpp"
#include "subsonic/SubsonicResource.hpp"
#include "subsonic/MusicBrainzArtistMetadata.hpp"
#include "PublicShareResource.hpp"
#include "ui/Auth.hpp"
#include "ui/LmsApplication.hpp"
#include "ui/LmsApplicationManager.hpp"
@@ -499,6 +501,7 @@ namespace lms
core::Service<scanner::IScannerService> scannerService{ scanner::createScannerService(*database, cachePath) };
core::Service<transcoding::ITranscodeService> transcodingService{ transcoding::createTranscodeService() };
core::Service<podcast::IPodcastService> podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") };
core::Service<api::subsonic::MusicBrainzArtistMetadataService> musicBrainzArtistMetadataService{ api::subsonic::createMusicBrainzArtistMetadataService(ioContext, *database) };
const auto jukeboxAudioBackend{ getJukeboxAudioOutputBackend() };
core::Service<jukebox::IJukeboxService> jukeboxService{ jukeboxAudioBackend ? jukebox::createJukeboxService(*database, *jukeboxAudioBackend) : nullptr };
@@ -520,6 +523,8 @@ namespace lms
server.removeEntryPoint("");
std::unique_ptr<Wt::WResource> subsonicResource;
auto publicShareResource = std::make_unique<PublicShareResource>(*database);
server.addResource(publicShareResource.get(), "/share");
// bind API resources
if (config->getBool("api-subsonic", true))
{
+11
View File
@@ -54,6 +54,7 @@
#include "ModalManager.hpp"
#include "NotificationContainer.hpp"
#include "PlayQueue.hpp"
#include "PodcastsView.hpp"
#include "admin/About.hpp"
#include "admin/AdminView.hpp"
#include "admin/InitWizardView.hpp"
@@ -80,8 +81,10 @@ namespace lms::ui
res->use(appRoot + "admin-db");
res->use(appRoot + "admin-debugtools");
res->use(appRoot + "admin-initwizard");
res->use(appRoot + "admin-internet-radio");
res->use(appRoot + "admin-medialibraries");
res->use(appRoot + "admin-medialibrary");
res->use(appRoot + "admin-podcasts");
res->use(appRoot + "admin-scannercontroller");
res->use(appRoot + "admin-scansettings");
res->use(appRoot + "admin-tracing");
@@ -98,6 +101,7 @@ namespace lms::ui
res->use(appRoot + "misc");
res->use(appRoot + "notifications");
res->use(appRoot + "playqueue");
res->use(appRoot + "podcasts");
res->use(appRoot + "release");
res->use(appRoot + "releases");
res->use(appRoot + "settings-audio");
@@ -412,6 +416,7 @@ namespace lms::ui
navbar->bindNew<Wt::WAnchor>("releases", Wt::WLink{ Wt::LinkType::InternalPath, "/releases" }, Wt::WString::tr("Lms.Explore.releases"));
navbar->bindNew<Wt::WAnchor>("tracks", Wt::WLink{ Wt::LinkType::InternalPath, "/tracks" }, Wt::WString::tr("Lms.Explore.tracks"));
navbar->bindNew<Wt::WAnchor>("tracklists", Wt::WLink{ Wt::LinkType::InternalPath, "/tracklists" }, Wt::WString::tr("Lms.Explore.tracklists"));
navbar->bindNew<Wt::WAnchor>("podcasts", Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts" }, Wt::WString::tr("Lms.Podcasts.podcasts"));
Filters* filters{ navbar->bindNew<Filters>("filters") };
navbar->bindString("username", std::string{ getUserLoginName() }, Wt::TextFormat::Plain);
@@ -445,6 +450,8 @@ namespace lms::ui
showAboutModal();
});
navbar->bindNew<Wt::WAnchor>("media-libraries", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/libraries" }, Wt::WString::tr("Lms.Admin.menu-media-libraries"));
navbar->bindNew<Wt::WAnchor>("admin-podcasts", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/podcasts" }, Wt::WString::tr("Lms.Admin.menu-podcasts"));
navbar->bindNew<Wt::WAnchor>("admin-internet-radio", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/internet-radio" }, Wt::WString::tr("Lms.Admin.menu-internet-radio"));
navbar->bindNew<Wt::WAnchor>("scan-settings", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scan-settings" }, Wt::WString::tr("Lms.Admin.menu-scan-settings"));
navbar->bindNew<Wt::WAnchor>("scanner", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scanner" }, Wt::WString::tr("Lms.Admin.menu-scanner"));
navbar->bindNew<Wt::WAnchor>("users", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/users" }, Wt::WString::tr("Lms.Admin.menu-users"));
@@ -464,6 +471,7 @@ namespace lms::ui
mainRouter->addRoute("/tracklist", std::nullopt, explore);
mainRouter->add<SettingsView>("/settings", std::nullopt);
mainRouter->add<PodcastsView>("/podcasts", Wt::WString::tr("Lms.Podcasts.podcasts"));
if (getUserType() == db::UserType::ADMIN)
mainRouter->add<AdminView>("/admin", std::nullopt);
@@ -500,6 +508,9 @@ namespace lms::ui
_playQueue->trackSelected.connect([this](db::TrackId trackId, bool play, float replayGain) {
_mediaPlayer->loadTrack(trackId, play, replayGain);
});
_playQueue->podcastEpisodeSelected.connect([this](db::PodcastEpisodeId episodeId, bool play) {
_mediaPlayer->loadPodcastEpisode(episodeId, play);
});
_playQueue->trackUnselected.connect([this] {
_mediaPlayer->stop();
+84
View File
@@ -32,6 +32,8 @@
#include "database/Session.hpp"
#include "database/Types.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackList.hpp"
@@ -40,6 +42,7 @@
#include "LmsApplication.hpp"
#include "Utils.hpp"
#include "ShareUtils.hpp"
#include "resource/ArtworkResource.hpp"
#include "resource/AudioFileResource.hpp"
#include "resource/AudioTranscodingResource.hpp"
@@ -199,6 +202,13 @@ namespace lms::ui
_release = bindNew<Wt::WAnchor>("release");
_separator = bindNew<Wt::WText>("separator");
_playQueue = bindNew<Wt::WPushButton>("playqueue-btn", Wt::WString::tr("Lms.MediaPlayer.template.playqueue-btn").arg(0), Wt::TextFormat::XHTML);
_share = bindNew<Wt::WPushButton>("share-btn", Wt::WString::tr("Lms.MediaPlayer.template.share-btn"), Wt::TextFormat::XHTML);
_share->setToolTip("Share current item");
_share->setAttributeValue("aria-label", "Share current item");
_share->clicked().connect([this] {
if (_podcastEpisodeIdLoaded) shareUtils::share(*_podcastEpisodeIdLoaded);
else if (_trackIdLoaded) shareUtils::share(*_trackIdLoaded);
});
_playQueue->setLink(Wt::WLink{ Wt::LinkType::InternalPath, "/playqueue" });
_playQueue->setToolTip(tr("Lms.PlayQueue.playqueue"));
@@ -313,9 +323,83 @@ namespace lms::ui
doJavaScript(oss.str());
_trackIdLoaded = trackId;
_podcastEpisodeIdLoaded.reset();
trackLoaded.emit(*_trackIdLoaded);
}
void MediaPlayer::loadPodcastEpisode(db::PodcastEpisodeId episodeId, bool play)
{
LMS_LOG(UI, DEBUG, "Playing podcast episode ID = " << episodeId.toString());
std::ostringstream oss;
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(LmsApp->getDbSession(), episodeId) };
if (!episode || episode->getAudioRelativeFilePath().empty())
return;
const db::Podcast::pointer podcast{ episode->getPodcast() };
const std::string title{ episode->getTitle() };
const std::string podcastTitle{ podcast ? std::string{ podcast->getTitle() } : std::string{} };
const std::string author{ !episode->getAuthor().empty() ? std::string{ episode->getAuthor() } : (podcast ? std::string{ podcast->getAuthor() } : std::string{}) };
const std::string nativeResource{ _audioFileResource->getUrl(episodeId) };
oss
<< "var params = {"
<< " trackId: \"podcast-" << episodeId.toString() << "\","
<< " nativeResource: \"" << nativeResource << "\","
<< " transcodingResource: \"\","
<< " duration: " << std::chrono::duration_cast<std::chrono::duration<float>>(episode->getDuration()).count() << ","
<< " replayGain: 0,"
<< " scrobbleEnabled: false,"
<< " queueEnabled: true,"
<< " transcodingEnabled: false,"
<< " title: \"" << core::stringUtils::jsEscape(title) << "\","
<< " artist: \"" << core::stringUtils::jsEscape(author) << "\","
<< " release: \"" << core::stringUtils::jsEscape(podcastTitle) << "\",";
db::ArtworkId artworkId{ episode->getArtworkId() };
if (!artworkId.isValid() && podcast)
artworkId = podcast->getArtworkId();
if (artworkId.isValid())
{
oss << " artwork: ["
<< " { src: \"" << LmsApp->getArtworkResource()->getArtworkUrl(artworkId, ArtworkResource::DefaultArtworkType::Release, ArtworkResource::Size::Small) << "\", sizes: \"128x128\" },"
<< " { src: \"" << LmsApp->getArtworkResource()->getArtworkUrl(artworkId, ArtworkResource::DefaultArtworkType::Release, ArtworkResource::Size::Large) << "\", sizes: \"512x512\" },"
<< " ]";
}
else
{
oss << " artwork: ["
<< " { src: \"" << LmsApp->getArtworkResource()->getDefaultArtworkUrl(ArtworkResource::DefaultArtworkType::Release) << "\", type: \"image/svg+xml\" },"
<< " ]";
}
oss << "};";
oss << jsRef() + ".mediaplayer.loadTrack(params, " << (play ? "true" : "false") << ")";
_title->setTextFormat(Wt::TextFormat::Plain);
_title->setText(Wt::WString::fromUTF8(title));
_artists->clear();
if (!author.empty())
_artists->addNew<Wt::WText>(Wt::WString::fromUTF8(author), Wt::TextFormat::Plain);
_release->setTextFormat(Wt::TextFormat::Plain);
_release->setText(Wt::WString::fromUTF8(podcastTitle));
if (podcast)
_release->setLink(Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts/" + podcast->getId().toString() });
else
_release->setLink({});
_separator->setText(!author.empty() && !podcastTitle.empty() ? "" : "");
}
LMS_LOG(UI, DEBUG, "Running js = '" << oss.str() << "'");
doJavaScript(oss.str());
_trackIdLoaded.reset();
_podcastEpisodeIdLoaded = episodeId;
}
void MediaPlayer::stop()
{
doJavaScript(jsRef() + ".mediaplayer.stop()");
+4
View File
@@ -29,6 +29,7 @@
#include <Wt/WText.h>
#include "database/objects/TrackId.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/Types.hpp"
namespace lms::ui
@@ -97,6 +98,7 @@ namespace lms::ui
std::optional<db::TrackId> getTrackLoaded() const { return _trackIdLoaded; }
void loadTrack(db::TrackId trackId, bool play, float replayGain);
void loadPodcastEpisode(db::PodcastEpisodeId episodeId, bool play = true);
void stop();
std::optional<Settings> getSettings() const { return _settings; }
@@ -120,6 +122,7 @@ namespace lms::ui
std::unique_ptr<AudioTranscodingResource> _audioTranscodingResource;
std::optional<db::TrackId> _trackIdLoaded;
std::optional<db::PodcastEpisodeId> _podcastEpisodeIdLoaded;
std::optional<Settings> _settings;
Wt::JSignal<std::string> _settingsLoaded;
@@ -129,5 +132,6 @@ namespace lms::ui
Wt::WText* _separator{};
Wt::WContainerWidget* _artists{};
Wt::WPushButton* _playQueue{};
Wt::WPushButton* _share{};
};
} // namespace lms::ui
+166 -22
View File
@@ -35,6 +35,8 @@
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/Medium.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackList.hpp"
@@ -45,6 +47,7 @@
#include "LmsApplication.hpp"
#include "MediaPlayer.hpp"
#include "ModalManager.hpp"
#include "ShareUtils.hpp"
#include "State.hpp"
#include "Utils.hpp"
#include "common/InfiniteScrollingContainer.hpp"
@@ -149,7 +152,12 @@ namespace lms::ui
queue.modify()->clear();
for (const db::TrackListEntry::pointer& entry : entries)
LmsApp->getDbSession().create<db::TrackListEntry>(entry->getTrack(), queue);
{
if (const db::Track::pointer track{ entry->getTrack() })
LmsApp->getDbSession().create<db::TrackListEntry>(track, queue);
else if (const db::PodcastEpisode::pointer episode{ entry->getPodcastEpisode() })
LmsApp->getDbSession().create<db::TrackListEntry>(episode, queue);
}
}
_entriesContainer->reset();
_nextPlayPos.reset();
@@ -252,7 +260,7 @@ namespace lms::ui
{
updateCurrentTrack(false);
db::TrackId trackId{};
MediaId mediaId;
std::optional<float> replayGain{};
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
@@ -275,17 +283,30 @@ namespace lms::ui
if (resetNextPlayPos.value())
_nextPlayPos.reset();
const db::Track::pointer track{ queue->getEntry(*_trackPos)->getTrack() };
trackId = track->getId();
const db::TrackListEntry::pointer entry{ queue->getEntry(*_trackPos) };
if (const db::Track::pointer track{ entry->getTrack() })
{
mediaId = track->getId();
replayGain = getReplayGain(pos, track);
}
else if (const db::PodcastEpisode::pointer episode{ entry->getPodcastEpisode() })
mediaId = episode->getId();
else
{
stop();
return;
}
}
state::writeValue<size_t>("player_cur_playing_track_pos", pos);
enqueueRadioTracksIfNeeded();
updateCurrentTrack(true);
_isTrackSelected = true;
trackSelected.emit(trackId, play, replayGain ? *replayGain : 0);
if (const auto* trackId{ std::get_if<db::TrackId>(&mediaId) })
trackSelected.emit(*trackId, play, replayGain ? *replayGain : 0);
else
podcastEpisodeSelected.emit(std::get<db::PodcastEpisodeId>(mediaId), play);
}
void PlayQueue::playPrevious()
@@ -362,7 +383,15 @@ namespace lms::ui
const db::TrackList::pointer queue{ getQueue() };
const std::size_t trackCount{ queue->getCount() };
_nbTracks->setText(Wt::WString::trn("Lms.track-count", trackCount).arg(trackCount));
_duration->setText(utils::durationToString(queue->getDuration()));
std::chrono::milliseconds duration{};
for (const db::TrackListEntry::pointer& entry : queue->getEntries())
{
if (const db::Track::pointer track{ entry->getTrack() })
duration += track->getDuration();
else if (const db::PodcastEpisode::pointer episode{ entry->getPodcastEpisode() })
duration += episode->getDuration();
}
_duration->setText(utils::durationToString(duration));
trackCountChanged.emit(trackCount);
}
@@ -378,7 +407,7 @@ namespace lms::ui
entry->toggleStyleClass("Lms-entry-playing", selected);
}
void PlayQueue::enqueueTracks(std::span<const db::TrackId> trackIds)
void PlayQueue::enqueueMedia(std::span<const MediaId> mediaIds)
{
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
@@ -386,17 +415,28 @@ namespace lms::ui
db::TrackList::pointer queue{ getQueue() };
const std::size_t queueSize{ queue->getCount() };
std::size_t nbTracksToEnqueue{ queueSize + trackIds.size() > getCapacity() ? getCapacity() - queueSize : trackIds.size() };
for (const db::TrackId trackId : trackIds)
std::size_t nbTracksToEnqueue{ queueSize + mediaIds.size() > getCapacity() ? getCapacity() - queueSize : mediaIds.size() };
for (const MediaId& mediaId : mediaIds)
{
if (nbTracksToEnqueue == 0)
break;
db::Track::pointer track{ db::Track::find(LmsApp->getDbSession(), trackId) };
if (!track)
continue;
bool added{};
if (const auto* trackId{ std::get_if<db::TrackId>(&mediaId) })
{
if (db::Track::pointer track{ db::Track::find(LmsApp->getDbSession(), *trackId) })
{
LmsApp->getDbSession().create<db::TrackListEntry>(track, queue);
added = true;
}
}
else if (db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(LmsApp->getDbSession(), std::get<db::PodcastEpisodeId>(mediaId)) }; episode && !episode->getAudioRelativeFilePath().empty())
{
LmsApp->getDbSession().create<db::TrackListEntry>(episode, queue);
added = true;
}
if (!added)
continue;
nbTracksToEnqueue--;
}
}
@@ -406,18 +446,30 @@ namespace lms::ui
_entriesContainer->setHasMore();
}
std::vector<db::TrackId> PlayQueue::getAndClearTracksFrom(std::size_t pos)
void PlayQueue::enqueueTracks(std::span<const db::TrackId> trackIds)
{
std::vector<db::TrackId> tracks;
std::vector<MediaId> mediaIds;
mediaIds.reserve(trackIds.size());
for (const db::TrackId trackId : trackIds)
mediaIds.emplace_back(trackId);
enqueueMedia(mediaIds);
}
std::vector<PlayQueue::MediaId> PlayQueue::getAndClearMediaFrom(std::size_t pos)
{
std::vector<MediaId> media;
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
db::TrackList::pointer queue{ getQueue() };
auto entries{ queue->getEntries(db::Range{ pos, getCapacity() }) };
tracks.reserve(entries.size());
media.reserve(entries.size());
for (db::TrackListEntry::pointer& entry : entries)
{
tracks.push_back(entry->getTrack()->getId());
if (const db::Track::pointer track{ entry->getTrack() })
media.emplace_back(track->getId());
else if (const db::PodcastEpisode::pointer episode{ entry->getPodcastEpisode() })
media.emplace_back(episode->getId());
entry.remove();
}
@@ -431,7 +483,7 @@ namespace lms::ui
_entriesContainer->remove(pos, _entriesContainer->getCount() - 1);
}
return tracks;
return media;
}
void PlayQueue::play(std::span<const db::TrackId> trackIds)
@@ -447,10 +499,11 @@ namespace lms::ui
const std::size_t insertPos{ *_nextPlayPos };
std::vector<db::TrackId> tracksToInsert{ getAndClearTracksFrom(insertPos) };
tracksToInsert.insert(std::cbegin(tracksToInsert), std::cbegin(trackIds), std::cend(trackIds));
playOrAddLast(tracksToInsert);
std::vector<MediaId> mediaToInsert{ getAndClearMediaFrom(insertPos) };
mediaToInsert.insert(std::cbegin(mediaToInsert), std::cbegin(trackIds), std::cend(trackIds));
enqueueMedia(mediaToInsert);
if (!_isTrackSelected)
loadTrack(0, true);
// set after playOrAddLast: it may call loadTrack() (queue was empty), which resets _nextPlayPos
_nextPlayPos = insertPos + trackIds.size();
@@ -479,6 +532,17 @@ namespace lms::ui
loadTrack(index, true);
}
void PlayQueue::playPodcastEpisodes(std::span<const db::PodcastEpisodeId> episodeIds, std::size_t index)
{
clearTracks();
std::vector<MediaId> mediaIds;
mediaIds.reserve(episodeIds.size());
for (const db::PodcastEpisodeId episodeId : episodeIds)
mediaIds.emplace_back(episodeId);
enqueueMedia(mediaIds);
loadTrack(index, true);
}
void PlayQueue::addSome()
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
@@ -494,6 +558,83 @@ namespace lms::ui
void PlayQueue::addEntry(const db::TrackListEntry::pointer& tracklistEntry)
{
const db::TrackListEntryId tracklistEntryId{ tracklistEntry->getId() };
if (const db::PodcastEpisode::pointer episode{ tracklistEntry->getPodcastEpisode() })
{
const db::PodcastEpisodeId episodeId{ episode->getId() };
const db::Podcast::pointer podcast{ episode->getPodcast() };
const std::string title{ episode->getTitle() };
Template* entry{ _entriesContainer->addNew<Template>(Wt::WString::tr("Lms.PlayQueue.template.entry")) };
entry->addFunction("id", &Wt::WTemplate::Functions::id);
entry->bindString("name", Wt::WString::fromUTF8(title), Wt::TextFormat::Plain);
const std::string author{ !episode->getAuthor().empty() ? std::string{ episode->getAuthor() } : (podcast ? std::string{ podcast->getAuthor() } : std::string{}) };
if (!author.empty())
{
entry->setCondition("if-has-artists", true);
entry->bindNew<Wt::WText>("artists", Wt::WString::fromUTF8(author), Wt::TextFormat::Plain);
entry->bindNew<Wt::WText>("artists-md", Wt::WString::fromUTF8(author), Wt::TextFormat::Plain);
}
db::ArtworkId artworkId{ episode->getArtworkId() };
if (!artworkId.isValid() && podcast)
artworkId = podcast->getArtworkId();
std::unique_ptr<Wt::WImage> image{ artworkId.isValid()
? utils::createArtworkImage(artworkId, ArtworkResource::DefaultArtworkType::Release, ArtworkResource::Size::Small)
: utils::createDefaultArtworkImage(ArtworkResource::DefaultArtworkType::Release) };
image->addStyleClass("Lms-cover-track rounded");
if (podcast)
{
entry->setCondition("if-has-release", true);
const Wt::WLink podcastLink{ Wt::LinkType::InternalPath, "/podcasts/" + podcast->getId().toString() };
entry->bindNew<Wt::WAnchor>("release", podcastLink, Wt::WString::fromUTF8(std::string{ podcast->getTitle() }));
auto* coverAnchor{ entry->bindNew<Wt::WAnchor>("cover", podcastLink) };
image->addStyleClass("Lms-cover-anchor");
coverAnchor->setImage(std::move(image));
}
else
entry->bindWidget("cover", std::move(image));
entry->bindString("duration", utils::durationToString(episode->getDuration()), Wt::TextFormat::Plain);
auto playEpisode{ [this, entry] {
if (const std::optional<std::size_t> pos{ _entriesContainer->getIndexOf(*entry) })
loadTrack(*pos, true);
} };
auto* playBtn{ entry->bindNew<Wt::WPushButton>("play-btn", Wt::WString::tr("Lms.template.play-btn"), Wt::TextFormat::XHTML) };
playBtn->setAttributeValue("aria-label", Wt::WString::tr("Lms.play-item").arg(title));
playBtn->clicked().connect(playEpisode);
entry->bindNew<Wt::WPushButton>("play", Wt::WString::tr("Lms.Explore.play"))->clicked().connect(playEpisode);
auto* delBtn{ entry->bindNew<Wt::WPushButton>("del-btn", Wt::WString::tr("Lms.template.delete-btn"), Wt::TextFormat::XHTML) };
delBtn->setAttributeValue("aria-label", Wt::WString::tr("Lms.delete-item").arg(title));
delBtn->setToolTip(Wt::WString::tr("Lms.delete"));
delBtn->clicked().connect([this, tracklistEntryId, entry] {
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
db::TrackListEntry::pointer entryToRemove{ db::TrackListEntry::getById(LmsApp->getDbSession(), tracklistEntryId) };
entryToRemove.remove();
}
if (_trackPos)
{
const std::optional<std::size_t> pos{ _entriesContainer->getIndexOf(*entry) };
if (pos && *_trackPos >= *pos)
(*_trackPos)--;
else if (*_trackPos >= _entriesContainer->getCount())
_trackPos.reset();
}
_nextPlayPos.reset();
_entriesContainer->remove(*entry);
updateInfo();
});
entry->bindNew<Wt::WPushButton>("more-btn", Wt::WString::tr("Lms.template.more-btn"), Wt::TextFormat::XHTML);
entry->bindNew<Wt::WPushButton>("share", "Share")
->clicked().connect([episodeId] { shareUtils::share(episodeId); });
return;
}
const auto track{ tracklistEntry->getTrack() };
const db::TrackId trackId{ track->getId() };
@@ -581,6 +722,8 @@ namespace lms::ui
});
entry->bindNew<Wt::WPushButton>("more-btn", Wt::WString::tr("Lms.template.more-btn"), Wt::TextFormat::XHTML);
entry->bindNew<Wt::WPushButton>("share", "Share")
->clicked().connect([trackId] { shareUtils::share(trackId); });
entry->bindNew<Wt::WPushButton>("play", Wt::WString::tr("Lms.Explore.play"))
->clicked()
.connect([this, entry] {
@@ -591,6 +734,7 @@ namespace lms::ui
auto isStarred{ [=] { return core::Service<feedback::IFeedbackService>::get()->isStarred(LmsApp->getUserId(), trackId); } };
entry->setCondition("if-has-star", true);
Wt::WPushButton* starBtn{ entry->bindNew<Wt::WPushButton>("star", Wt::WString::tr(isStarred() ? "Lms.Explore.unstar" : "Lms.Explore.star")) };
starBtn->clicked().connect([=] {
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
+7 -1
View File
@@ -21,6 +21,7 @@
#include <optional>
#include <span>
#include <variant>
#include <Wt/WCheckBox.h>
#include <Wt/WContainerWidget.h>
@@ -31,6 +32,7 @@
#include "database/Object.hpp"
#include "database/objects/TrackId.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/TrackListId.hpp"
#include "common/Template.hpp"
@@ -59,6 +61,7 @@ namespace lms::ui
void playShuffled(std::span<const db::TrackId> trackIds);
void playOrAddLast(std::span<const db::TrackId> trackIds); // play if queue empty, otherwise just add last
void playAtIndex(std::span<const db::TrackId> trackIds, std::size_t index);
void playPodcastEpisodes(std::span<const db::PodcastEpisodeId> episodeIds, std::size_t index);
// play the next track in the queue
void playNext();
@@ -68,6 +71,7 @@ namespace lms::ui
// Signal emitted when a track is to be load(and optionally played)
Wt::Signal<db::TrackId, bool /*play*/, float /* replayGain */> trackSelected;
Wt::Signal<db::PodcastEpisodeId, bool /*play*/> podcastEpisodeSelected;
// Signal emitted when track is unselected (has to be stopped)
Wt::Signal<> trackUnselected;
@@ -88,8 +92,10 @@ namespace lms::ui
bool isFull() const;
void clearTracks();
using MediaId = std::variant<db::TrackId, db::PodcastEpisodeId>;
void enqueueMedia(std::span<const MediaId> mediaIds);
void enqueueTracks(std::span<const db::TrackId> trackIds);
std::vector<db::TrackId> getAndClearTracksFrom(std::size_t pos);
std::vector<MediaId> getAndClearMediaFrom(std::size_t pos);
void advanceTrack(ResetNextPlayPos resetNextPlayPos);
void addSome();
void addEntry(const db::ObjectPtr<db::TrackListEntry>& entry);
+179
View File
@@ -0,0 +1,179 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*/
#include "PodcastsView.hpp"
#include <algorithm>
#include <chrono>
#include <vector>
#include <Wt/WAnchor.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include <Wt/WText.h>
#include "core/Service.hpp"
#include "core/String.hpp"
#include "database/Session.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "LmsApplication.hpp"
#include "Utils.hpp"
#include "MediaPlayer.hpp"
#include "PlayQueue.hpp"
#include "ShareUtils.hpp"
namespace lms::ui
{
namespace
{
std::string cleanLegacyDescription(std::string_view description)
{
constexpr std::string_view cdataPrefix{ "<![CDATA[" };
constexpr std::string_view cdataSuffix{ "]]>" };
if (description.starts_with(cdataPrefix) && description.ends_with(cdataSuffix))
description = description.substr(cdataPrefix.size(), description.size() - cdataPrefix.size() - cdataSuffix.size());
return std::string{ description };
}
} // namespace
PodcastsView::PodcastsView()
{
auto* page{ addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Podcasts.template")) };
page->addFunction("tr", &Wt::WTemplate::Functions::tr);
_content = page->bindNew<Wt::WContainerWidget>("content");
wApp->internalPathChanged().connect(this, [this] { refreshView(); });
refreshView();
}
void PodcastsView::refreshView()
{
if (!wApp->internalPathMatches("/podcasts"))
return;
constexpr std::string_view prefix{ "/podcasts/" };
const std::string path{ wApp->internalPath() };
if (path.starts_with(prefix))
{
const auto value{ core::stringUtils::readAs<db::PodcastId::ValueType>(std::string_view{ path }.substr(prefix.size())) };
if (value)
{
showPodcast(db::PodcastId{ *value });
return;
}
}
showChannels();
}
void PodcastsView::showChannels()
{
_content->clear();
auto* channels{ _content->addNew<Wt::WContainerWidget>() };
channels->addStyleClass("d-grid gap-2");
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::Podcast::find(LmsApp->getDbSession(), [channels](const db::Podcast::pointer& podcast) {
if (podcast->isDeleteRequested())
return;
auto* entry{ channels->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Podcasts.template.channel")) };
entry->addFunction("tr", &Wt::WTemplate::Functions::tr);
if (const db::ArtworkId artworkId{ podcast->getArtworkId() }; artworkId.isValid())
entry->bindWidget("cover", utils::createArtworkImage(artworkId, ArtworkResource::DefaultArtworkType::Release, ArtworkResource::Size::Small));
else
entry->bindWidget("cover", utils::createDefaultArtworkImage(ArtworkResource::DefaultArtworkType::Release));
const Wt::WString title{ podcast->getTitle().empty() ? Wt::WString::tr("Lms.Admin.Podcasts.pending-title") : Wt::WString::fromUTF8(std::string{ podcast->getTitle() }) };
entry->bindNew<Wt::WAnchor>("title", Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts/" + podcast->getId().toString() }, title);
entry->bindString("description", cleanLegacyDescription(podcast->getDescription()), Wt::TextFormat::Plain);
const db::PodcastId podcastId{ podcast->getId() };
auto* shareBtn{ entry->bindNew<Wt::WPushButton>("share-btn", "<i class=\"fa fa-share-alt\" aria-hidden=\"true\"></i>", Wt::TextFormat::XHTML) };
shareBtn->setToolTip("Share");
shareBtn->setAttributeValue("aria-label", "Share " + title);
shareBtn->clicked().connect([podcastId] { shareUtils::share(podcastId); });
});
if (channels->count() == 0)
channels->addNew<Wt::WText>(Wt::WString::tr("Lms.Podcasts.empty"));
}
void PodcastsView::showPodcast(db::PodcastId podcastId)
{
_content->clear();
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::Podcast::pointer podcast{ db::Podcast::find(LmsApp->getDbSession(), podcastId) };
if (!podcast || podcast->isDeleteRequested())
{
showChannels();
return;
}
auto* header{ _content->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Podcasts.template.channel-header")) };
header->addFunction("tr", &Wt::WTemplate::Functions::tr);
if (const db::ArtworkId artworkId{ podcast->getArtworkId() }; artworkId.isValid())
header->bindWidget("cover", utils::createArtworkImage(artworkId, ArtworkResource::DefaultArtworkType::Release, ArtworkResource::Size::Small));
else
header->bindWidget("cover", utils::createDefaultArtworkImage(ArtworkResource::DefaultArtworkType::Release));
header->bindNew<Wt::WAnchor>("back", Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts" }, Wt::WString::tr("Lms.Podcasts.back"));
header->bindString("title", std::string{ podcast->getTitle() }, Wt::TextFormat::Plain);
header->bindString("description", cleanLegacyDescription(podcast->getDescription()), Wt::TextFormat::Plain);
auto* shareBtn{ header->bindNew<Wt::WPushButton>("share-btn", "<i class=\"fa fa-share-alt\" aria-hidden=\"true\"></i> Share", Wt::TextFormat::XHTML) };
shareBtn->clicked().connect([podcastId] { shareUtils::share(podcastId); });
auto* episodes{ _content->addNew<Wt::WContainerWidget>() };
episodes->addStyleClass("d-grid gap-1 Lms-row-container");
db::PodcastEpisode::FindParameters params;
params.setPodcast(podcastId).setSortMode(db::PodcastEpisodeSortMode::PubDateDesc);
db::PodcastEpisode::find(LmsApp->getDbSession(), params, [this, episodes, podcastId](const db::PodcastEpisode::pointer& episode) {
auto* entry{ episodes->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Podcasts.template.episode")) };
entry->addFunction("tr", &Wt::WTemplate::Functions::tr);
entry->bindString("title", std::string{ episode->getTitle() }, Wt::TextFormat::Plain);
entry->bindString("date", episode->getPubDate().isValid() ? episode->getPubDate().date().toString("yyyy-MM-dd") : Wt::WString{}, Wt::TextFormat::Plain);
entry->bindString("row-class", episode->getAudioRelativeFilePath().empty() ? "opacity-50" : "", Wt::TextFormat::Plain);
const db::PodcastEpisodeId episodeId{ episode->getId() };
if (!episode->getAudioRelativeFilePath().empty())
{
auto* playBtn{ entry->bindNew<Wt::WPushButton>("action-btn", Wt::WString::tr("Lms.Podcasts.play"), Wt::TextFormat::XHTML) };
playBtn->clicked().connect([episodeId, podcastId] {
std::vector<db::PodcastEpisodeId> episodeIds;
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::PodcastEpisode::FindParameters params;
params.setPodcast(podcastId).setSortMode(db::PodcastEpisodeSortMode::PubDateDesc);
db::PodcastEpisode::find(LmsApp->getDbSession(), params, [&episodeIds](const db::PodcastEpisode::pointer& queuedEpisode) {
if (!queuedEpisode->getAudioRelativeFilePath().empty())
episodeIds.push_back(queuedEpisode->getId());
});
}
const auto it{ std::find(episodeIds.cbegin(), episodeIds.cend(), episodeId) };
if (it != episodeIds.cend())
LmsApp->getPlayQueue().playPodcastEpisodes(episodeIds, static_cast<std::size_t>(std::distance(episodeIds.cbegin(), it)));
});
auto* shareBtn{ entry->bindNew<Wt::WPushButton>("share-btn", "<i class=\"fa fa-share-alt\" aria-hidden=\"true\"></i>", Wt::TextFormat::XHTML) };
shareBtn->setToolTip("Share");
shareBtn->setAttributeValue("aria-label", "Share " + std::string{ episode->getTitle() });
shareBtn->clicked().connect([episodeId] { shareUtils::share(episodeId); });
}
else
{
entry->bindEmpty("action-btn");
entry->bindEmpty("share-btn");
}
});
if (episodes->count() == 0)
episodes->addNew<Wt::WText>(Wt::WString::tr("Lms.Podcasts.no-episodes"));
}
} // namespace lms::ui
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*/
#pragma once
#include <Wt/WContainerWidget.h>
#include "database/objects/PodcastId.hpp"
namespace Wt
{
class WContainerWidget;
}
namespace lms::ui
{
class PodcastsView final : public Wt::WContainerWidget
{
public:
PodcastsView();
private:
void refreshView();
void showChannels();
void showPodcast(db::PodcastId podcastId);
Wt::WContainerWidget* _content{};
};
} // namespace lms::ui
+106
View File
@@ -0,0 +1,106 @@
#include "ShareUtils.hpp"
#include <Wt/WAnchor.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include "core/String.hpp"
#include "core/UUID.hpp"
#include "database/Session.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Share.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "core/Service.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
#include "Utils.hpp"
namespace lms::ui::shareUtils
{
namespace
{
void showShareModal(std::string mediaId)
{
std::string token{ core::UUID::generate().toString() + core::UUID::generate().toString() };
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
const db::User::pointer user{ db::User::find(LmsApp->getDbSession(), LmsApp->getUserId()) };
if (!user || user->getType() == db::UserType::DEMO)
return;
LmsApp->getDbSession().create<db::Share>(token, mediaId, user);
}
const auto& env{ LmsApp->environment() };
const std::string url{ env.urlScheme() + "://" + env.hostName() + "/share/" + token };
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::fromUTF8(R"(
<div class="modal fade" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">Share</h5>${close-x class="btn-close" data-bs-dismiss="modal" aria-label="Close"}</div>
<div class="modal-body"><p>Anyone with this link can listen or download without signing in.</p><div class="input-group">${url class="form-control" readonly="readonly"}${copy class="btn btn-outline-primary"}</div></div>
<div class="modal-footer">${open class="btn btn-outline-primary" target="_blank" rel="noopener noreferrer"}${close class="btn btn-primary"}</div>
</div></div></div>)")) };
Wt::WWidget* modalPtr{ modal.get() };
auto close{ [modalPtr] { LmsApp->getModalManager().dispose(modalPtr); } };
modal->bindNew<Wt::WPushButton>("close-x")->clicked().connect(close);
modal->bindNew<Wt::WLineEdit>("url", url);
auto* copy{ modal->bindNew<Wt::WPushButton>("copy", "Copy link") };
copy->clicked().connect([url] {
utils::copyToClipboard(url);
});
modal->bindNew<Wt::WAnchor>("open", Wt::WLink{ url }, "Open link");
modal->bindNew<Wt::WPushButton>("close", "Close")->clicked().connect(close);
LmsApp->getModalManager().show(std::move(modal));
}
}
void share(db::TrackId trackId)
{
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
if (!db::Track::find(LmsApp->getDbSession(), trackId)) return;
}
showShareModal("tr-" + trackId.toString());
}
void share(db::PodcastEpisodeId episodeId)
{
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const auto episode{ db::PodcastEpisode::find(LmsApp->getDbSession(), episodeId) };
if (!episode || episode->getAudioRelativeFilePath().empty()) return;
const auto path{ core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath() };
if (!std::filesystem::is_regular_file(path)) return;
}
showShareModal("podep-" + episodeId.toString());
}
void share(db::ReleaseId releaseId)
{
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
if (!db::Release::find(LmsApp->getDbSession(), releaseId)) return;
}
showShareModal("al-" + releaseId.toString());
}
void share(db::PodcastId podcastId)
{
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
if (!db::Podcast::find(LmsApp->getDbSession(), podcastId)) return;
bool hasDownloadedEpisode{};
db::PodcastEpisode::find(LmsApp->getDbSession(), db::PodcastEpisode::FindParameters{}.setPodcast(podcastId), [&](const auto& episode) {
if (episode->getAudioRelativeFilePath().empty()) return;
const auto path{ core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath() };
if (std::filesystem::is_regular_file(path)) hasDownloadedEpisode = true;
});
if (!hasDownloadedEpisode) return;
}
showShareModal("pod-" + podcastId.toString());
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/PodcastId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
namespace lms::ui::shareUtils
{
void share(db::TrackId trackId);
void share(db::ReleaseId releaseId);
void share(db::PodcastId podcastId);
void share(db::PodcastEpisodeId episodeId);
}
+19 -1
View File
@@ -560,7 +560,25 @@ namespace lms::ui::utils
void copyToClipboard(std::string_view text)
{
LmsApp->doJavaScript("navigator.clipboard.writeText('" + core::stringUtils::jsEscape(text) + "').catch(function(){})");
LmsApp->doJavaScript(R"(
(function(text) {
function fallback() {
var textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(textArea);
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function')
navigator.clipboard.writeText(text).catch(fallback);
else
fallback();
})(')" + core::stringUtils::jsEscape(text) + "');");
}
TrackDisplayInfo computeTrackDisplayInfo(const db::ObjectPtr<db::Track>& track)
+4
View File
@@ -21,6 +21,8 @@
#include "DebugToolsView.hpp"
#include "MediaLibrariesView.hpp"
#include "InternetRadioView.hpp"
#include "PodcastsView.hpp"
#include "ScanSettingsView.hpp"
#include "ScannerController.hpp"
#include "UserView.hpp"
@@ -34,6 +36,8 @@ namespace lms::ui
auto* router{ addNew<PathRouter>() };
router->add<MediaLibrariesView>("/admin/libraries", Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"));
router->add<PodcastSettingsView>("/admin/podcasts", Wt::WString::tr("Lms.Admin.Podcasts.podcasts"));
router->add<InternetRadioView>("/admin/internet-radio", Wt::WString::tr("Lms.Admin.InternetRadio.title"));
router->add<ScanSettingsView>("/admin/scan-settings", Wt::WString::tr("Lms.Admin.Database.scan-settings"));
router->add<ScannerController>("/admin/scanner", Wt::WString::tr("Lms.Admin.ScannerController.scanner"));
router->add<UsersView>("/admin/users", Wt::WString::tr("Lms.Admin.Users.users"));
+155
View File
@@ -0,0 +1,155 @@
#include "InternetRadioView.hpp"
#include <Wt/WContainerWidget.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WText.h>
#include "core/http/UrlValidation.hpp"
#include "database/Session.hpp"
#include "database/objects/InternetRadioStation.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
namespace lms::ui
{
namespace
{
bool validateStation(std::string_view name, std::string_view streamUrl, std::string_view homepageUrl)
{
if (name.empty())
{
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Admin.InternetRadio.invalid-name"));
return false;
}
if (!core::http::isValidUrl(streamUrl) || (!homepageUrl.empty() && !core::http::isValidUrl(homepageUrl)))
{
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Admin.InternetRadio.invalid-url"));
return false;
}
return true;
}
} // namespace
InternetRadioView::InternetRadioView()
: Wt::WTemplate{ Wt::WString::tr("Lms.Admin.InternetRadio.template") }
{
addFunction("tr", &Wt::WTemplate::Functions::tr);
_nameEdit = bindNew<Wt::WLineEdit>("name");
_streamUrlEdit = bindNew<Wt::WLineEdit>("stream-url");
_homepageUrlEdit = bindNew<Wt::WLineEdit>("homepage-url");
_nameEdit->setPlaceholderText(Wt::WString::tr("Lms.Admin.InternetRadio.name-placeholder"));
_streamUrlEdit->setPlaceholderText(Wt::WString::tr("Lms.Admin.InternetRadio.stream-url-placeholder"));
_homepageUrlEdit->setPlaceholderText(Wt::WString::tr("Lms.Admin.InternetRadio.homepage-url-placeholder"));
auto* addBtn{ bindNew<Wt::WPushButton>("add-btn", Wt::WString::tr("Lms.Admin.InternetRadio.add")) };
addBtn->clicked().connect(this, &InternetRadioView::addStation);
_homepageUrlEdit->enterPressed().connect(this, &InternetRadioView::addStation);
_stations = bindNew<Wt::WContainerWidget>("stations");
wApp->internalPathChanged().connect(this, [this] { refreshView(); });
refreshView();
}
void InternetRadioView::addStation()
{
const std::string name{ _nameEdit->text().toUTF8() };
const std::string streamUrl{ _streamUrlEdit->text().toUTF8() };
const std::string homepageUrl{ _homepageUrlEdit->text().toUTF8() };
if (!validateStation(name, streamUrl, homepageUrl))
return;
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
LmsApp->getDbSession().create<db::InternetRadioStation>(name, streamUrl, homepageUrl);
}
_nameEdit->setText({});
_streamUrlEdit->setText({});
_homepageUrlEdit->setText({});
refreshView();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.InternetRadio.added"));
}
void InternetRadioView::refreshView()
{
if (!wApp->internalPathMatches("/admin/internet-radio"))
return;
_stations->clear();
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::InternetRadioStation::find(LmsApp->getDbSession(), [this](const auto& station) {
auto* entry{ _stations->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.InternetRadio.template.entry")) };
entry->bindString("name", std::string{ station->getName() }, Wt::TextFormat::Plain);
entry->bindString("stream-url", std::string{ station->getStreamUrl() }, Wt::TextFormat::Plain);
entry->bindString("homepage-url", station->getHomepageUrl().empty() ? Wt::WString::tr("Lms.Admin.InternetRadio.no-homepage") : Wt::WString::fromUTF8(std::string{ station->getHomepageUrl() }), Wt::TextFormat::Plain);
const auto id{ station->getId() };
entry->bindNew<Wt::WPushButton>("edit-btn", Wt::WString::tr("Lms.template.edit-btn"), Wt::TextFormat::XHTML)
->clicked().connect(this, [this, id] { showEditModal(id); });
entry->bindNew<Wt::WPushButton>("delete-btn", Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML)
->clicked().connect(this, [this, id] { showDeleteModal(id); });
});
if (_stations->count() == 0)
_stations->addNew<Wt::WText>(Wt::WString::tr("Lms.Admin.InternetRadio.empty"))->setStyleClass("text-body-secondary py-3");
}
void InternetRadioView::showEditModal(db::InternetRadioStationId stationId)
{
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.InternetRadio.template.edit")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
auto* nameEdit{ modal->bindNew<Wt::WLineEdit>("name") };
auto* streamEdit{ modal->bindNew<Wt::WLineEdit>("stream-url") };
auto* homepageEdit{ modal->bindNew<Wt::WLineEdit>("homepage-url") };
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const auto station{ db::InternetRadioStation::find(LmsApp->getDbSession(), stationId) };
if (!station)
return;
nameEdit->setText(Wt::WString::fromUTF8(std::string{ station->getName() }));
streamEdit->setText(Wt::WString::fromUTF8(std::string{ station->getStreamUrl() }));
homepageEdit->setText(Wt::WString::fromUTF8(std::string{ station->getHomepageUrl() }));
}
auto close{ [modalPtr] { LmsApp->getModalManager().dispose(modalPtr); } };
modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel"))->clicked().connect(close);
modal->bindNew<Wt::WPushButton>("save-btn", Wt::WString::tr("Lms.save"))->clicked().connect(this, [this, stationId, nameEdit, streamEdit, homepageEdit, modalPtr] {
const std::string name{ nameEdit->text().toUTF8() };
const std::string streamUrl{ streamEdit->text().toUTF8() };
const std::string homepageUrl{ homepageEdit->text().toUTF8() };
if (!validateStation(name, streamUrl, homepageUrl))
return;
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
auto station{ db::InternetRadioStation::find(LmsApp->getDbSession(), stationId) };
if (!station)
return;
station.modify()->setName(name);
station.modify()->setStreamUrl(streamUrl);
station.modify()->setHomepageUrl(homepageUrl);
}
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.InternetRadio.saved"));
});
LmsApp->getModalManager().show(std::move(modal));
}
void InternetRadioView::showDeleteModal(db::InternetRadioStationId stationId)
{
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.InternetRadio.template.delete")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel"))->clicked().connect([modalPtr] { LmsApp->getModalManager().dispose(modalPtr); });
modal->bindNew<Wt::WPushButton>("delete-btn", Wt::WString::tr("Lms.delete"))->clicked().connect(this, [this, stationId, modalPtr] {
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
auto station{ db::InternetRadioStation::find(LmsApp->getDbSession(), stationId) };
if (station)
station.remove();
}
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.InternetRadio.deleted"));
});
LmsApp->getModalManager().show(std::move(modal));
}
} // namespace lms::ui
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <Wt/WTemplate.h>
#include "database/objects/InternetRadioStationId.hpp"
namespace Wt
{
class WContainerWidget;
class WLineEdit;
}
namespace lms::ui
{
class InternetRadioView final : public Wt::WTemplate
{
public:
InternetRadioView();
private:
void addStation();
void refreshView();
void showEditModal(db::InternetRadioStationId stationId);
void showDeleteModal(db::InternetRadioStationId stationId);
Wt::WLineEdit* _nameEdit{};
Wt::WLineEdit* _streamUrlEdit{};
Wt::WLineEdit* _homepageUrlEdit{};
Wt::WContainerWidget* _stations{};
};
} // namespace lms::ui
+218
View File
@@ -0,0 +1,218 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*/
#include "PodcastsView.hpp"
#include <Wt/WContainerWidget.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WText.h>
#include <vector>
#include "core/Service.hpp"
#include "core/http/UrlValidation.hpp"
#include "database/Session.hpp"
#include "database/objects/Podcast.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
namespace lms::ui
{
namespace
{
Wt::WString getEpisodeStatus(const db::PodcastEpisode::pointer& episode)
{
if (episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DeleteRequested)
return Wt::WString::tr("Lms.Podcasts.status.deleting");
if (!episode->getAudioRelativeFilePath().empty())
return Wt::WString::tr("Lms.Podcasts.status.downloaded");
if (episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DownloadRequested)
return Wt::WString::tr("Lms.Podcasts.status.downloading");
return Wt::WString::tr("Lms.Podcasts.status.not-downloaded");
}
std::string getEpisodeBadgeClass(const db::PodcastEpisode::pointer& episode)
{
if (!episode->getAudioRelativeFilePath().empty())
return "badge text-bg-success";
if (episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DownloadRequested)
return "badge text-bg-info";
if (episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DeleteRequested)
return "badge text-bg-warning";
return "badge text-bg-secondary";
}
} // namespace
PodcastSettingsView::PodcastSettingsView()
: Wt::WTemplate{ Wt::WString::tr("Lms.Admin.Podcasts.template") }
{
addFunction("tr", &Wt::WTemplate::Functions::tr);
_urlEdit = bindNew<Wt::WLineEdit>("url");
_urlEdit->setPlaceholderText(Wt::WString::tr("Lms.Admin.Podcasts.feed-url-placeholder"));
_urlEdit->enterPressed().connect(this, &PodcastSettingsView::addPodcast);
auto* addBtn{ bindNew<Wt::WPushButton>("add-btn", Wt::WString::tr("Lms.Admin.Podcasts.add-feed")) };
addBtn->clicked().connect(this, &PodcastSettingsView::addPodcast);
auto* refreshBtn{ bindNew<Wt::WPushButton>("refresh-btn", Wt::WString::tr("Lms.Admin.Podcasts.refresh-now")) };
refreshBtn->clicked().connect(this, [] {
core::Service<podcast::IPodcastService>::get()->refreshPodcasts();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Podcasts.refresh-started"));
});
_podcasts = bindNew<Wt::WContainerWidget>("podcasts");
wApp->internalPathChanged().connect(this, [this] { refreshView(); });
refreshView();
}
void PodcastSettingsView::addPodcast()
{
const std::string url{ _urlEdit->text().toUTF8() };
if (!core::http::isValidUrl(url))
{
LmsApp->notifyMsg(Notification::Type::Warning, Wt::WString::tr("Lms.Admin.Podcasts.invalid-url"));
return;
}
core::Service<podcast::IPodcastService>::get()->addPodcast(url);
_urlEdit->setText({});
refreshView();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Podcasts.feed-added"));
}
void PodcastSettingsView::refreshView()
{
if (!wApp->internalPathMatches("/admin/podcasts"))
return;
_podcasts->clear();
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::Podcast::find(LmsApp->getDbSession(), [this](const db::Podcast::pointer& podcast) {
if (podcast->isDeleteRequested())
return;
auto* entry{ _podcasts->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Podcasts.template.entry")) };
entry->addFunction("tr", &Wt::WTemplate::Functions::tr);
const db::PodcastId podcastId{ podcast->getId() };
auto* titleBtn{ entry->bindNew<Wt::WPushButton>("title", podcast->getTitle().empty() ? Wt::WString::tr("Lms.Admin.Podcasts.pending-title") : Wt::WString::fromUTF8(std::string{ podcast->getTitle() })) };
titleBtn->setStyleClass("btn btn-link p-0 text-start text-truncate");
titleBtn->clicked().connect(this, [this, podcastId] { showEpisodesModal(podcastId); });
entry->bindString("url", std::string{ podcast->getUrl() }, Wt::TextFormat::Plain);
auto* deleteBtn{ entry->bindNew<Wt::WPushButton>("delete-btn", Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML) };
deleteBtn->setToolTip(Wt::WString::tr("Lms.delete"));
deleteBtn->clicked().connect(this, [this, podcastId] { showDeletePodcastModal(podcastId); });
});
}
void PodcastSettingsView::showEpisodesModal(db::PodcastId podcastId)
{
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Podcasts.template.episodes")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::Podcast::pointer podcast{ db::Podcast::find(LmsApp->getDbSession(), podcastId) };
modal->bindString("title", podcast ? Wt::WString::fromUTF8(std::string{ podcast->getTitle() }) : Wt::WString::tr("Lms.Admin.Podcasts.episodes"), Wt::TextFormat::Plain);
}
auto* episodes{ modal->bindNew<Wt::WContainerWidget>("episodes") };
populateEpisodes(*episodes, podcastId);
auto closeModal{ [modalPtr] { LmsApp->getModalManager().dispose(modalPtr); } };
modal->bindNew<Wt::WPushButton>("close-x")->clicked().connect(closeModal);
modal->bindNew<Wt::WPushButton>("close-btn", Wt::WString::tr("Lms.Admin.Podcasts.close"))->clicked().connect(closeModal);
modal->bindNew<Wt::WPushButton>("download-all-btn", Wt::WString::tr("Lms.Admin.Podcasts.download-all"))
->clicked()
.connect(this, [this, episodes, podcastId] {
std::vector<db::PodcastEpisodeId> episodeIds;
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::PodcastEpisode::FindParameters params;
params.setPodcast(podcastId).setSortMode(db::PodcastEpisodeSortMode::PubDateDesc);
db::PodcastEpisode::find(LmsApp->getDbSession(), params, [&episodeIds](const db::PodcastEpisode::pointer& episode) {
if (episode->getAudioRelativeFilePath().empty()
&& episode->getManualDownloadState() != db::PodcastEpisode::ManualDownloadState::DownloadRequested
&& episode->getManualDownloadState() != db::PodcastEpisode::ManualDownloadState::DeleteRequested)
episodeIds.push_back(episode->getId());
});
}
for (const db::PodcastEpisodeId episodeId : episodeIds)
core::Service<podcast::IPodcastService>::get()->downloadPodcastEpisode(episodeId);
populateEpisodes(*episodes, podcastId);
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Podcasts.download-all-requested"));
});
LmsApp->getModalManager().show(std::move(modal));
}
void PodcastSettingsView::populateEpisodes(Wt::WContainerWidget& container, db::PodcastId podcastId)
{
container.clear();
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
db::PodcastEpisode::FindParameters params;
params.setPodcast(podcastId).setSortMode(db::PodcastEpisodeSortMode::PubDateDesc);
db::PodcastEpisode::find(LmsApp->getDbSession(), params, [this, &container, podcastId](const db::PodcastEpisode::pointer& episode) {
auto* entry{ container.addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Podcasts.template.episode")) };
entry->bindString("title", std::string{ episode->getTitle() }, Wt::TextFormat::Plain);
entry->bindString("date", episode->getPubDate().isValid() ? episode->getPubDate().date().toString("yyyy-MM-dd") : Wt::WString{}, Wt::TextFormat::Plain);
auto* status{ entry->bindNew<Wt::WText>("status", getEpisodeStatus(episode)) };
status->setStyleClass(getEpisodeBadgeClass(episode));
if (!episode->getAudioRelativeFilePath().empty()
|| episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DownloadRequested
|| episode->getManualDownloadState() == db::PodcastEpisode::ManualDownloadState::DeleteRequested)
{
entry->bindEmpty("download-btn");
return;
}
const db::PodcastEpisodeId episodeId{ episode->getId() };
entry->bindNew<Wt::WPushButton>("download-btn", Wt::WString::tr("Lms.Podcasts.download"), Wt::TextFormat::XHTML)
->clicked()
.connect(this, [this, &container, podcastId, episodeId] {
core::Service<podcast::IPodcastService>::get()->downloadPodcastEpisode(episodeId);
populateEpisodes(container, podcastId);
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Podcasts.download-requested"));
});
});
if (container.count() == 0)
container.addNew<Wt::WText>(Wt::WString::tr("Lms.Podcasts.no-episodes"));
}
void PodcastSettingsView::showDeletePodcastModal(db::PodcastId podcastId)
{
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Podcasts.template.delete")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WTemplate* modalPtr{ modal.get() };
modal->bindNew<Wt::WPushButton>("delete-btn", Wt::WString::tr("Lms.delete"))
->clicked()
.connect(this, [this, podcastId, modalPtr] {
core::Service<podcast::IPodcastService>::get()->removePodcast(podcastId);
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Podcasts.feed-deleted"));
});
modal->bindNew<Wt::WPushButton>("cancel-btn", Wt::WString::tr("Lms.cancel"))
->clicked()
.connect([modalPtr] { LmsApp->getModalManager().dispose(modalPtr); });
LmsApp->getModalManager().show(std::move(modal));
}
} // namespace lms::ui
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*/
#pragma once
#include <Wt/WTemplate.h>
#include "database/objects/PodcastId.hpp"
namespace Wt
{
class WContainerWidget;
class WLineEdit;
}
namespace lms::ui
{
class PodcastSettingsView final : public Wt::WTemplate
{
public:
PodcastSettingsView();
private:
void addPodcast();
void refreshView();
void showEpisodesModal(db::PodcastId podcastId);
void populateEpisodes(Wt::WContainerWidget& container, db::PodcastId podcastId);
void showDeletePodcastModal(db::PodcastId podcastId);
Wt::WLineEdit* _urlEdit{};
Wt::WContainerWidget* _podcasts{};
};
} // namespace lms::ui
+95 -9
View File
@@ -22,6 +22,7 @@
#include <Wt/WCheckBox.h>
#include <Wt/WComboBox.h>
#include <Wt/WLineEdit.h>
#include <Wt/WLengthValidator.h>
#include <Wt/WLocalDateTime.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplateFormView.h>
@@ -34,6 +35,7 @@
#include "core/UUID.hpp"
#include "database/Session.hpp"
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/User.hpp"
#include "services/auth/IAuthTokenService.hpp"
#include "services/auth/IPasswordService.hpp"
@@ -49,6 +51,7 @@ namespace lms::ui
{
public:
static inline const Field LoginField{ "login" };
static inline const Field DisplayNameField{ "display-name" };
static inline const Field PasswordField{ "password" };
static inline const Field DemoField{ "demo" };
@@ -57,12 +60,17 @@ namespace lms::ui
, _authPasswordService{ authPasswordService }
, _authTokenService{ authTokenService }
{
if (!_userId)
if (!_userId || authPasswordService)
{
addField(LoginField);
setValidator(LoginField, createLoginNameValidator());
}
addField(DisplayNameField);
auto displayNameValidator{ std::make_shared<Wt::WLengthValidator>(db::User::minNameLength, db::User::maxNameLength) };
displayNameValidator->setMandatory(true);
setValidator(DisplayNameField, displayNameValidator);
if (authPasswordService)
{
addField(PasswordField);
@@ -72,17 +80,36 @@ namespace lms::ui
}
addField(DemoField);
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::User::pointer user{ _userId ? db::User::find(LmsApp->getDbSession(), *_userId) : db::User::pointer{} };
db::MediaLibrary::find(LmsApp->getDbSession(), [&](const db::MediaLibrary::pointer& library) {
_mediaLibraries.emplace_back(library->getId(), !user || user->hasMediaLibrary(library->getId()));
});
}
loadData();
}
const std::vector<std::pair<db::MediaLibraryId, bool>>& getMediaLibraries() const { return _mediaLibraries; }
void setMediaLibrary(db::MediaLibraryId mediaLibraryId, bool enabled)
{
for (auto& [id, value] : _mediaLibraries)
{
if (id == mediaLibraryId)
value = enabled;
}
}
void saveData()
{
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
db::User::pointer user;
if (_userId)
{
// Update user
db::User::pointer user{ db::User::find(LmsApp->getDbSession(), *_userId) };
user = db::User::find(LmsApp->getDbSession(), *_userId);
if (!user)
throw UserNotFoundException{};
@@ -95,7 +122,7 @@ namespace lms::ui
else
{
// Check races with other endpoints (subsonic API...)
db::User::pointer user{ db::User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8()) };
user = db::User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8());
if (user)
throw UserNotAllowedException{};
@@ -113,9 +140,28 @@ namespace lms::ui
if (_authPasswordService)
_authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8());
}
std::vector<db::ObjectPtr<db::MediaLibrary>> mediaLibraries;
for (const auto& [mediaLibraryId, enabled] : _mediaLibraries)
{
if (enabled)
{
if (const auto library{ db::MediaLibrary::find(LmsApp->getDbSession(), mediaLibraryId) })
mediaLibraries.push_back(library);
}
}
if (hasLoginField())
user.modify()->setLoginName(valueText(LoginField).toUTF8());
user.modify()->setDisplayName(valueText(DisplayNameField).toUTF8());
user.modify()->setMediaLibraries(mediaLibraries);
}
private:
bool hasLoginField() const
{
return !_userId || _authPasswordService;
}
void loadData()
{
if (!_userId)
@@ -126,8 +172,9 @@ namespace lms::ui
const db::User::pointer user{ db::User::find(LmsApp->getDbSession(), *_userId) };
if (!user)
throw UserNotFoundException{};
if (user == LmsApp->getUser())
throw UserNotAllowedException{};
if (hasLoginField())
setValue(LoginField, user->getLoginName());
setValue(DisplayNameField, user->getDisplayName());
}
db::UserType getUserType() const
@@ -145,6 +192,9 @@ namespace lms::ui
std::string getLoginName() const
{
if (hasLoginField())
return valueText(LoginField).toUTF8();
if (_userId)
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
@@ -165,7 +215,7 @@ namespace lms::ui
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::User::pointer user{ db::User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8()) };
if (user)
if (user && (!_userId || user->getId() != *_userId))
error = Wt::WString::tr("Lms.Admin.User.user-already-exists");
}
else if (field == DemoField)
@@ -187,6 +237,7 @@ namespace lms::ui
std::optional<db::UserId> _userId;
auth::IPasswordService* _authPasswordService{};
auth::IAuthTokenService& _authTokenService;
std::vector<std::pair<db::MediaLibraryId, bool>> _mediaLibraries;
};
UserView::UserView()
@@ -198,12 +249,18 @@ namespace lms::ui
refreshView();
}
UserView::UserView(db::UserId userId)
: _userId{ userId }
{
refreshView();
}
void UserView::refreshView()
{
if (!wApp->internalPathMatches("/admin/user"))
if (!_userId && !wApp->internalPathMatches("/admin/user"))
return;
const std::optional<db::UserId> userId{ core::stringUtils::readAs<db::UserId::ValueType>(wApp->internalPathNextPart("/admin/user/")) };
const std::optional<db::UserId> userId{ _userId ? _userId : core::stringUtils::readAs<db::UserId::ValueType>(wApp->internalPathNextPart("/admin/user/")) };
clear();
@@ -226,6 +283,7 @@ namespace lms::ui
throw UserNotFoundException{};
const Wt::WString title{ Wt::WString::tr("Lms.Admin.User.user-edit").arg(user->getLoginName()) };
if (!_userId)
LmsApp->setTitle(title);
t->bindString("title", title, Wt::TextFormat::Plain);
@@ -234,6 +292,12 @@ namespace lms::ui
const Wt::WDateTime lastLogin{ user->getLastLogin() };
const Wt::WString lastLoginStr{ locale.timeZone() ? lastLogin.toLocalTime().toString() : lastLogin.toString(locale.dateTimeFormat()) + " (UTC)" };
t->bindString("last-login", lastLoginStr, Wt::TextFormat::Plain);
if (authPasswordService)
{
t->setCondition("if-has-login", true);
t->setFormWidget(UserModel::LoginField, std::make_unique<Wt::WLineEdit>());
}
}
else
{
@@ -246,6 +310,8 @@ namespace lms::ui
t->bindString("title", title);
}
t->setFormWidget(UserModel::DisplayNameField, std::make_unique<Wt::WLineEdit>());
if (authPasswordService)
{
t->setCondition("if-has-password", true);
@@ -262,14 +328,34 @@ namespace lms::ui
if (!userId && core::Service<core::IConfig>::get()->getBool("demo", false))
t->setCondition("if-demo", true);
auto* mediaLibrariesContainer{ t->bindNew<Wt::WContainerWidget>("media-libraries") };
std::vector<std::pair<db::MediaLibraryId, Wt::WCheckBox*>> mediaLibraryCheckBoxes;
for (const auto& [mediaLibraryId, enabled] : model->getMediaLibraries())
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const auto library{ db::MediaLibrary::find(LmsApp->getDbSession(), mediaLibraryId) };
if (!library)
continue;
auto checkBox{ std::make_unique<Wt::WCheckBox>(Wt::WString::fromUTF8(std::string{ library->getName() })) };
checkBox->setChecked(enabled);
Wt::WCheckBox* checkBoxPtr{ checkBox.get() };
mediaLibrariesContainer->addWidget(std::move(checkBox));
mediaLibraryCheckBoxes.emplace_back(mediaLibraryId, checkBoxPtr);
}
Wt::WPushButton* saveBtn{ t->bindNew<Wt::WPushButton>("save-btn", Wt::WString::tr(userId ? "Lms.save" : "Lms.create")) };
saveBtn->clicked().connect([=]() {
saveBtn->clicked().connect([=, this]() {
t->updateModel(model.get());
for (const auto& [mediaLibraryId, checkBox] : mediaLibraryCheckBoxes)
model->setMediaLibrary(mediaLibraryId, checkBox->isChecked());
if (model->validate())
{
model->saveData();
LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created"));
if (_userId)
saved().emit();
else
LmsApp->setInternalPath("/admin/users", true);
}
else
+11
View File
@@ -19,7 +19,12 @@
#pragma once
#include <optional>
#include <Wt/WContainerWidget.h>
#include <Wt/WSignal.h>
#include "database/objects/UserId.hpp"
namespace lms::ui
{
@@ -28,9 +33,15 @@ namespace lms::ui
{
public:
UserView();
explicit UserView(db::UserId userId);
Wt::Signal<>& saved() { return _saved; }
private:
void refreshView();
std::optional<db::UserId> _userId;
Wt::Signal<> _saved;
};
} // namespace lms::ui
+17 -8
View File
@@ -28,6 +28,7 @@
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
#include "UserView.hpp"
namespace lms::ui
{
@@ -70,7 +71,21 @@ namespace lms::ui
Wt::WTemplate* entry{ _container->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Users.template.entry")) };
entry->bindString("name", user->getLoginName(), Wt::TextFormat::Plain);
auto* nameBtn{ entry->bindNew<Wt::WPushButton>("name", user->getDisplayName()) };
nameBtn->addStyleClass("btn btn-link text-start p-0 text-decoration-none");
nameBtn->setToolTip(user->getLoginName());
nameBtn->clicked().connect([this, userId] {
auto modal{ std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Admin.Users.template.edit-user")) };
modal->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WWidget* modalPtr{ modal.get() };
auto* content{ modal->bindNew<Wt::WContainerWidget>("content") };
auto* userView{ content->addNew<UserView>(userId) };
userView->saved().connect([this, modalPtr] {
LmsApp->getModalManager().dispose(modalPtr);
refreshView();
});
LmsApp->getModalManager().show(std::move(modal));
});
// Create tag
if (user->isAdmin() || user->isDemo())
@@ -79,17 +94,11 @@ namespace lms::ui
entry->bindString("tag", Wt::WString::tr(user->isAdmin() ? "Lms.Admin.Users.admin" : "Lms.Admin.Users.demo"));
}
// Don't edit ourself this way
// Don't delete ourself
if (user->getId() == currentUserId)
return;
entry->setCondition("if-edit", true);
Wt::WPushButton* editBtn = entry->bindNew<Wt::WPushButton>("edit-btn", Wt::WString::tr("Lms.template.edit-btn"), Wt::TextFormat::XHTML);
editBtn->setToolTip(Wt::WString::tr("Lms.edit"));
editBtn->clicked().connect([userId]() {
LmsApp->setInternalPath("/admin/user/" + userId.toString(), true);
});
Wt::WPushButton* delBtn = entry->bindNew<Wt::WPushButton>("del-btn", Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML);
delBtn->setToolTip(Wt::WString::tr("Lms.delete"));
delBtn->clicked().connect([this, userId, entry] {
+23
View File
@@ -27,11 +27,14 @@
#include <Wt/WDateTime.h>
#include <Wt/WPushButton.h>
#include <Wt/WResource.h>
#include <Wt/WText.h>
#include <Wt/WTimer.h>
#include "core/Service.hpp"
#include "core/String.hpp"
#include "database/profiling/IQueryProfiler.hpp"
#include "subsonic/MusicBrainzArtistMetadata.hpp"
namespace lms::ui
{
@@ -104,6 +107,26 @@ namespace lms::ui
}
else
dumpBtn->setEnabled(false);
_musicBrainzResyncBtn = bindNew<Wt::WPushButton>("musicbrainz-resync-btn", Wt::WString::tr("Lms.Admin.DebugTools.Db.musicbrainz-resync"));
_musicBrainzResyncStatus = bindNew<Wt::WText>("musicbrainz-resync-status");
_statusTimer = addChild(std::make_unique<Wt::WTimer>());
_statusTimer->setInterval(std::chrono::seconds{ 1 });
_statusTimer->timeout().connect(this, &Database::updateMusicBrainzResyncStatus);
_musicBrainzResyncBtn->clicked().connect([] {
core::Service<api::subsonic::MusicBrainzArtistMetadataService>::get()->startAlbumResync();
});
updateMusicBrainzResyncStatus();
}
void Database::updateMusicBrainzResyncStatus()
{
const auto status{ core::Service<api::subsonic::MusicBrainzArtistMetadataService>::get()->getAlbumResyncStatus() };
_musicBrainzResyncBtn->setEnabled(!status.running);
_musicBrainzResyncStatus->setText(Wt::WString::tr("Lms.Admin.DebugTools.Db.musicbrainz-resync-progress")
.arg(status.processed).arg(status.total).arg(status.updated).arg(status.skipped).arg(status.failed));
if (status.running) _statusTimer->start();
else _statusTimer->stop();
}
} // namespace lms::ui
+9
View File
@@ -21,11 +21,20 @@
#include <Wt/WTemplate.h>
namespace Wt { class WPushButton; class WText; class WTimer; }
namespace lms::ui
{
class Database : public Wt::WTemplate
{
public:
Database();
private:
void updateMusicBrainzResyncStatus();
Wt::WPushButton* _musicBrainzResyncBtn{};
Wt::WText* _musicBrainzResyncStatus{};
Wt::WTimer* _statusTimer{};
};
} // namespace lms::ui
+2 -1
View File
@@ -125,7 +125,7 @@ namespace lms::ui
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::Artist::pointer artist{ db::Artist::find(LmsApp->getDbSession(), *artistId) };
if (!artist)
if (!artist || !_filters.isArtistAllowed(*artistId))
throw ArtistNotFoundException{};
LmsApp->setTitle(artist->getName());
@@ -462,6 +462,7 @@ namespace lms::ui
const db::Range range{ static_cast<std::size_t>(_trackContainer->getCount()), _tracksBatchSize };
db::Track::FindParameters params;
params.setFilters(_filters.getDbFilters());
params.setArtist(_artistId);
params.setRange(range);
params.setSortMethod(db::TrackSortMethod::Name);
+55 -3
View File
@@ -39,6 +39,8 @@
#include "database/objects/Mood.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/ReleaseTypeId.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "LmsApplication.hpp"
#include "ModalManager.hpp"
@@ -122,9 +124,10 @@ namespace lms::ui
std::visit(core::utils::overloads{
[&](const MediaLibraryTag&) {
db::MediaLibrary::find(session, [&](const db::MediaLibrary::pointer& library) {
for (const db::MediaLibrary::pointer& library : LmsApp->getUser()->getMediaLibraries())
{
valueModel->add(Wt::WString::fromUTF8(std::string{ library->getName() }), library->getId());
});
}
},
[&](const LabelTag&) {
db::Label::find(session, db::LabelSortMethod::Name, [&](const db::Label::pointer& label) {
@@ -257,6 +260,14 @@ namespace lms::ui
_filters = bindNew<Wt::WContainerWidget>("clusters");
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
std::vector<db::MediaLibraryId> mediaLibraryIds;
for (const db::MediaLibrary::pointer& library : LmsApp->getUser()->getMediaLibraries())
mediaLibraryIds.push_back(library->getId());
_dbFilters.setMediaLibraries(std::move(mediaLibraryIds));
}
if (const std::optional<db::MediaLibraryId::ValueType> mediaLibraryId{ state::readValue<db::MediaLibraryId::ValueType>("filters_media_library_id") })
set(db::MediaLibraryId{ *mediaLibraryId });
if (const std::optional<db::LabelId::ValueType> labelId{ state::readValue<db::LabelId::ValueType>("filters_label_id") })
@@ -451,7 +462,7 @@ namespace lms::ui
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const auto library{ db::MediaLibrary::find(LmsApp->getDbSession(), mediaLibraryId) };
if (!library)
if (!library || !isMediaLibraryAllowed(mediaLibraryId))
return;
libraryName = library->getName();
@@ -472,6 +483,47 @@ namespace lms::ui
emitFilterAddedNotification();
}
bool Filters::isMediaLibraryAllowed(db::MediaLibraryId mediaLibraryId) const
{
if (!mediaLibraryId.isValid() || !_dbFilters.mediaLibraries)
return false;
const auto& allowed{ *_dbFilters.mediaLibraries };
return std::find(allowed.cbegin(), allowed.cend(), mediaLibraryId) != allowed.cend();
}
bool Filters::isArtistAllowed(db::ArtistId artistId) const
{
db::Release::FindParameters releaseParams;
releaseParams.setFilters(_dbFilters);
releaseParams.setArtist(artistId);
releaseParams.setRange(db::Range{ 0, 1 });
if (!db::Release::find(LmsApp->getDbSession(), releaseParams).empty())
return true;
db::Track::FindParameters trackParams;
trackParams.setFilters(_dbFilters);
trackParams.setArtist(artistId);
trackParams.setRange(db::Range{ 0, 1 });
return !db::Track::find(LmsApp->getDbSession(), trackParams).empty();
}
bool Filters::isReleaseAllowed(db::ReleaseId releaseId) const
{
db::Track::FindParameters params;
params.setFilters(_dbFilters);
params.setRelease(releaseId);
params.setRange(db::Range{ 0, 1 });
return !db::Track::find(LmsApp->getDbSession(), params).empty();
}
bool Filters::isTrackAllowed(db::TrackId trackId) const
{
const db::Track::pointer track{ db::Track::find(LmsApp->getDbSession(), trackId) };
const db::MediaLibrary::pointer mediaLibrary{ track ? track->getMediaLibrary() : db::MediaLibrary::pointer{} };
return mediaLibrary && isMediaLibraryAllowed(mediaLibrary->getId());
}
void Filters::set(db::LabelId labelId)
{
if (_labelFilter)
+7
View File
@@ -26,10 +26,13 @@
#include "core/media/Codec.hpp"
#include "database/objects/Filters.hpp"
#include "database/objects/ArtistId.hpp"
#include "database/objects/GenreId.hpp"
#include "database/objects/GroupingId.hpp"
#include "database/objects/LanguageId.hpp"
#include "database/objects/MoodId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
namespace lms::ui
{
@@ -39,6 +42,10 @@ namespace lms::ui
Filters();
const db::Filters& getDbFilters() const { return _dbFilters; }
bool isMediaLibraryAllowed(db::MediaLibraryId mediaLibraryId) const;
bool isArtistAllowed(db::ArtistId artistId) const;
bool isReleaseAllowed(db::ReleaseId releaseId) const;
bool isTrackAllowed(db::TrackId trackId) const;
void add(db::ClusterId clusterId);
void set(db::GenreId genreId);
+7
View File
@@ -22,6 +22,7 @@
#include <Wt/WAnchor.h>
#include <Wt/WImage.h>
#include <Wt/WText.h>
#include <Wt/WPushButton.h>
#include "core/EnumSet.hpp"
#include "core/Utils.hpp"
@@ -30,6 +31,7 @@
#include "database/objects/Release.hpp"
#include "Utils.hpp"
#include "ShareUtils.hpp"
#include "explore/ReleaseTypes.hpp"
namespace lms::ui::releaseListHelpers
@@ -40,6 +42,11 @@ namespace lms::ui::releaseListHelpers
entry->bindWidget("release-name", utils::createReleaseAnchor(release));
entry->addFunction("tr", &Wt::WTemplate::Functions::tr);
const db::ReleaseId releaseId{ release->getId() };
auto* shareBtn{ entry->bindNew<Wt::WPushButton>("share-btn", "<i class=\"fa fa-share-alt\" aria-hidden=\"true\"></i>", Wt::TextFormat::XHTML) };
shareBtn->setToolTip("Share");
shareBtn->setAttributeValue("aria-label", "Share " + std::string{ release->getName() });
shareBtn->clicked().connect([releaseId] { shareUtils::share(releaseId); });
{
Wt::WAnchor* anchor{ entry->bindWidget("cover", utils::createReleaseAnchor(release, false)) };
+9 -1
View File
@@ -58,6 +58,7 @@
#include "LmsApplicationException.hpp"
#include "MediaPlayer.hpp"
#include "ModalManager.hpp"
#include "ShareUtils.hpp"
#include "Utils.hpp"
#include "common/Template.hpp"
#include "explore/Filters.hpp"
@@ -262,7 +263,7 @@ namespace lms::ui
auto transaction{ session.createReadTransaction() };
const db::Release::pointer release{ db::Release::find(session, *releaseId) };
if (!release)
if (!release || !_filters.isReleaseAllowed(*releaseId))
throw ReleaseNotFoundException{};
LmsApp->setTitle(std::string{ release->getName() });
@@ -386,6 +387,9 @@ namespace lms::ui
showReleaseInfoModal(_releaseId);
});
bindNew<Wt::WPushButton>("share", "Share")
->clicked().connect([this] { shareUtils::share(_releaseId); });
{
auto isStarred{ [this] { return core::Service<feedback::IFeedbackService>::get()->isStarred(LmsApp->getUserId(), _releaseId); } };
@@ -560,6 +564,7 @@ namespace lms::ui
Release::TrackInfoList Release::collectMediumTrackInfoList(const db::Medium::pointer& medium)
{
db::Track::FindParameters params;
params.setFilters(_filters.getDbFilters());
params.setMedium(medium->getId());
params.setSortMethod(db::TrackSortMethod::TrackNumber);
@@ -691,6 +696,9 @@ namespace lms::ui
->setLink(Wt::WLink{ std::make_unique<DownloadTrackResource>(trackId) });
}
entry->bindNew<Wt::WPushButton>("share", "Share")
->clicked().connect([trackId] { shareUtils::share(trackId); });
entry->bindNew<Wt::WPushButton>("track-info", Wt::WString::tr("Lms.Explore.track-info"))
->clicked()
.connect([this, trackId] { TrackListHelpers::showTrackInfoModal(trackId, _filters); });
+3 -3
View File
@@ -79,8 +79,8 @@ namespace lms::ui
std::vector<Segment> segments;
};
void refreshDiscs(const db::ObjectPtr<db::Release>& release, std::span<const DiscInfo> discoInfoList);
static std::vector<DiscInfo> createDiscInfoList(const db::ObjectPtr<db::Release>& release);
static DiscInfo createDiscInfo(const db::ObjectPtr<db::Medium>& medium);
std::vector<DiscInfo> createDiscInfoList(const db::ObjectPtr<db::Release>& release);
DiscInfo createDiscInfo(const db::ObjectPtr<db::Medium>& medium);
static void appendTrackIds(std::vector<db::TrackId>& trackIds, const DiscInfo& disc);
static void appendTrackIds(std::vector<db::TrackId>& trackIds, const TrackInfoList& tracks);
@@ -92,7 +92,7 @@ namespace lms::ui
};
void addDisc(Wt::WContainerWidget* container, const DiscInfo& discInfo, DisplayOptions displayOptions);
static TrackInfoList collectMediumTrackInfoList(const db::ObjectPtr<db::Medium>& medium);
TrackInfoList collectMediumTrackInfoList(const db::ObjectPtr<db::Medium>& medium);
Wt::WContainerWidget* addSegment(Wt::WContainerWidget* container, const Wt::WString& title, db::ArtworkId artworkId, std::span<const db::TrackId> trackIds, const char* segmentTemplate);
void addTrackEntries(Wt::WContainerWidget* container, std::span<const TrackInfo> tracks, bool displayTrackArtists);
+5 -1
View File
@@ -49,6 +49,7 @@
#include "explore/PlayQueueController.hpp"
#include "resource/ArtworkResource.hpp"
#include "resource/DownloadResource.hpp"
#include "ShareUtils.hpp"
namespace lms::ui::TrackListHelpers
{
@@ -57,7 +58,7 @@ namespace lms::ui::TrackListHelpers
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::Track::pointer track{ db::Track::find(LmsApp->getDbSession(), trackId) };
if (!track)
if (!track || !filters.isTrackAllowed(trackId))
return;
auto trackInfo{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.track-info")) };
@@ -341,6 +342,9 @@ namespace lms::ui::TrackListHelpers
->setLink(Wt::WLink{ std::make_unique<DownloadTrackResource>(trackId) });
}
entry->bindNew<Wt::WPushButton>("share", "Share")
->clicked().connect([trackId] { shareUtils::share(trackId); });
entry->bindNew<Wt::WPushButton>("track-info", Wt::WString::tr("Lms.Explore.track-info"))
->clicked()
.connect([trackId, &filters] { showTrackInfoModal(trackId, filters); });
+28
View File
@@ -27,6 +27,9 @@
#include "core/String.hpp"
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "core/Service.hpp"
#include "LmsApplication.hpp"
@@ -52,6 +55,26 @@ namespace lms::ui
std::optional<std::filesystem::path> getTrackPathFromURLArgs(const Wt::Http::Request& request)
{
if (const std::string* episodeIdParameter{ request.getParameter("episodeid") })
{
const std::optional<db::PodcastEpisodeId::ValueType> value{ core::stringUtils::readAs<db::PodcastEpisodeId::ValueType>(*episodeIdParameter) };
if (!value)
{
AUDIO_RESOURCE_LOG(ERROR, "Bad episodeid URL parameter!");
return std::nullopt;
}
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(LmsApp->getDbSession(), db::PodcastEpisodeId{ *value }) };
if (!episode || episode->getAudioRelativeFilePath().empty())
{
AUDIO_RESOURCE_LOG(ERROR, "Missing or undownloaded podcast episode");
return std::nullopt;
}
return core::Service<podcast::IPodcastService>::get()->getCachePath() / episode->getAudioRelativeFilePath();
}
const std::string* trackIdParameter{ request.getParameter("trackid") };
if (!trackIdParameter)
{
@@ -81,6 +104,11 @@ namespace lms::ui
return url() + "&trackid=" + trackId.toString();
}
std::string AudioFileResource::getUrl(db::PodcastEpisodeId episodeId) const
{
return url() + "&episodeid=" + episodeId.toString();
}
void AudioFileResource::handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response)
{
LMS_SCOPED_TRACE_OVERVIEW("UI", "HandleAudioFileRequest");
@@ -22,6 +22,7 @@
#include <Wt/WResource.h>
#include "database/objects/TrackId.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
namespace lms::ui
{
@@ -31,6 +32,7 @@ namespace lms::ui
~AudioFileResource() override;
std::string getUrl(db::TrackId trackId) const;
std::string getUrl(db::PodcastEpisodeId episodeId) const;
private:
void handleRequest(const Wt::Http::Request& request,