add lots of features
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,7 +100,8 @@ class LMSMediaPlayer {
|
||||
|
||||
this.#elems.audio.addEventListener("ended", () => {
|
||||
this.#resetTimer();
|
||||
Wt.emit(this.#root, "playbackEnded");
|
||||
if (this.#queueEnabled)
|
||||
Wt.emit(this.#root, "playbackEnded");
|
||||
});
|
||||
|
||||
this.#elems.audio.addEventListener("canplay", () => {
|
||||
@@ -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,12 +287,14 @@ class LMSMediaPlayer {
|
||||
|
||||
#playPrevious() {
|
||||
this.#initAudioCtx();
|
||||
Wt.emit(this.#root, "playPrevious");
|
||||
if (this.#queueEnabled)
|
||||
Wt.emit(this.#root, "playPrevious");
|
||||
}
|
||||
|
||||
#playNext() {
|
||||
this.#initAudioCtx();
|
||||
Wt.emit(this.#root, "playNext");
|
||||
if (this.#queueEnabled)
|
||||
Wt.emit(this.#root, "playNext");
|
||||
}
|
||||
|
||||
#initVolume() {
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
@@ -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++)
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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())
|
||||
n.print(oss, "", pugi::format_raw);
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 += "&"; break; case '<': out += "<"; break; case '>': out += ">"; break; case '"': out += """; break; case '\'': out += "'"; 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>";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()");
|
||||
|
||||
@@ -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
|
||||
|
||||
+167
-23
@@ -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,9 +283,19 @@ namespace lms::ui
|
||||
if (resetNextPlayPos.value())
|
||||
_nextPlayPos.reset();
|
||||
|
||||
const db::Track::pointer track{ queue->getEntry(*_trackPos)->getTrack() };
|
||||
trackId = track->getId();
|
||||
replayGain = getReplayGain(pos, track);
|
||||
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);
|
||||
@@ -285,7 +303,10 @@ namespace lms::ui
|
||||
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)
|
||||
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;
|
||||
|
||||
LmsApp->getDbSession().create<db::TrackListEntry>(track, queue);
|
||||
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() };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,7 +283,8 @@ namespace lms::ui
|
||||
throw UserNotFoundException{};
|
||||
|
||||
const Wt::WString title{ Wt::WString::tr("Lms.Admin.User.user-edit").arg(user->getLoginName()) };
|
||||
LmsApp->setTitle(title);
|
||||
if (!_userId)
|
||||
LmsApp->setTitle(title);
|
||||
|
||||
t->bindString("title", title, Wt::TextFormat::Plain);
|
||||
t->setCondition("if-has-last-login", true);
|
||||
@@ -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,15 +328,35 @@ 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"));
|
||||
LmsApp->setInternalPath("/admin/users", true);
|
||||
if (_userId)
|
||||
saved().emit();
|
||||
else
|
||||
LmsApp->setInternalPath("/admin/users", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) };
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user