Merge branch 'develop' for release v3.18.0

This commit is contained in:
emeric
2020-08-21 10:54:19 +02:00
56 changed files with 923 additions and 405 deletions
+5
View File
@@ -60,6 +60,11 @@ The Subsonic API is enabled by default.
__Note__: since _LMS_ stores hashed and salted passwords, it cannot handle the __token authentication__ method defined from version 1.13.0.
## Keyboard shortcuts
* Play/pause: <kbd>Space</bbd>
* Previous track: <kbd>Ctrl</kbd> + <kbd>Left</kbd>
* Next track: <kbd>Ctrl</kbd> + <kbd>Right</kbd>
## Installation
### Docker
+2 -37
View File
@@ -60,7 +60,7 @@
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${apply-btn class="btn-primary"} ${discard-btn} ${immediate-scan-btn class="btn-info"}
${apply-btn class="btn-primary"} ${discard-btn}
</div>
</div>
</form>
@@ -69,42 +69,7 @@
<br/>
<div class="row">
<div class="col-lg-8">
<div class="panel panel-info">
<div class="panel-heading">${tr:Lms.Admin.Database.Status.status}</div>
<div class="panel-body">
${status}
</div>
</div>
</div>
</div>
</message>
<message id="Lms.Admin.Database.Status.template">
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.Database.Status.status}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${status}
</div>
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.Database.Status.last-scan}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${last-scan}
</div>
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${btn-report class="btn"}
</div>
${scanner-controller}
</div>
</div>
</message>
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions">
<message id="Lms.Admin.ScannerController.template">
<div class="panel panel-info">
<div class="panel-heading">${tr:Lms.Admin.ScannerController.scanner}</div>
<div class="panel-body">
<div class="form-horizontal">
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.ScannerController.status}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${status}
<div>${step-status}</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-lg-3 control-label">
${tr:Lms.Admin.ScannerController.last-scan}
</label>
<div class="col-lg-9">
<div class="well well-sm">
${last-scan}
</div>
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${btn-report class="btn btn-xs"}
</div>
</div>
<div class="form-group">
<div class="col-lg-offset-3 col-lg-9">
${btn-action}
</div>
</div>
</div>
</div>
</div>
</message>
</messages>
+3 -1
View File
@@ -6,18 +6,20 @@
<div class="Lms-header">
<h2><div class="Lms-artistname text-primary">${name}</div></h2>
${clusters}
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
<div class="row">
${releases class="Lms-explore-artist-entries-container"}
</div>
<div class="row">
${<if-has-similar-artists>}
<div class="col-md-4">
<div class="well well-sm">
<h3>${tr:Lms.Explore.Artist.similar-artists}</h3>
${similar-artists}
</div>
</div>
${</if-has-similar-artists>}
${<if-has-mbid>}
<div class="col-md-4">
<div class="well well-sm">
+1 -3
View File
@@ -13,9 +13,7 @@
</div>
</div>
${artists}
<div class="Lms-horizontal-center">
${show-more class="btn-primary Lms-show-more"}
</div>
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
</message>
<message id="Lms.Explore.Artists.template.entry">
+1 -1
View File
@@ -5,7 +5,7 @@
${contents class="Lms-explore-contents"}
</message>
<message id="Lms.Explore.template.add-btn"><i class="fa fa-plus-circle Lms-explore-btn Lms-control-btn"></i></message>
<message id="Lms.Explore.template.more-btn"><i class="fa fa-plus-circle Lms-explore-btn Lms-control-btn"></i></message>
<message id="Lms.Explore.template.play-btn"><i class="fa fa-play-circle Lms-explore-btn Lms-control-btn"></i></message>
<message id="Lms.Explore.template.filters">
+27 -21
View File
@@ -9,6 +9,7 @@
<message id="Lms.discard">Discard</message>
<message id="Lms.cancel">Cancel</message>
<message id="Lms.create">Create</message>
<message id="Lms.loading">Loading...</message>
<message id="Lms.login">Login</message>
<message id="Lms.logout"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> Logout</message>
<message id="Lms.not-a-directory">Not a directory</message>
@@ -53,22 +54,29 @@
<message id="Lms.Admin.Database.update-start-time">Update start time</message>
<message id="Lms.Admin.Database.weekly">Weekly</message>
<message id="Lms.Admin.Database.Status.bad-duration">Cannot get track duration</message>
<message id="Lms.Admin.Database.Status.cannot-parse-file">Cannot parse file</message>
<message id="Lms.Admin.Database.Status.cannot-read-file">Cannot read file</message>
<message id="Lms.Admin.Database.Status.duplicates-header">{1} duplicate files:</message>
<message id="Lms.Admin.Database.Status.errors-header">{1} errors:</message>
<message id="Lms.Admin.Database.Status.get-report">Get report</message>
<message id="Lms.Admin.Database.Status.last-scan">Last scan</message>
<message id="Lms.Admin.Database.Status.last-scan-not-available">Not available</message>
<message id="Lms.Admin.Database.Status.last-scan-status">Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates)</message>
<message id="Lms.Admin.Database.Status.no-audio-track">No audio track</message>
<message id="Lms.Admin.Database.Status.same-hash">Duplicated file hash</message>
<message id="Lms.Admin.Database.Status.same-mbid">Duplicated MBID</message>
<message id="Lms.Admin.Database.Status.status">Status</message>
<message id="Lms.Admin.Database.Status.status-not-scheduled">Not scheduled</message>
<message id="Lms.Admin.Database.Status.status-scheduled">Scheduled on {1}</message>
<message id="Lms.Admin.Database.Status.status-in-progress">Scanning {1} of {2} files ({3} %)</message>
<message id="Lms.Admin.ScannerController.bad-duration">Cannot get track duration</message>
<message id="Lms.Admin.ScannerController.cannot-parse-file">Cannot parse file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Cannot read file</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} duplicate files:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} errors:</message>
<message id="Lms.Admin.ScannerController.force-scan-now">Force scan now</message>
<message id="Lms.Admin.ScannerController.get-report">Get report</message>
<message id="Lms.Admin.ScannerController.last-scan">Last scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Not available</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates)</message>
<message id="Lms.Admin.ScannerController.no-audio-track">No audio track</message>
<message id="Lms.Admin.ScannerController.same-hash">Duplicated file hash</message>
<message id="Lms.Admin.ScannerController.same-mbid">Duplicated MBID</message>
<message id="Lms.Admin.ScannerController.scan-now">Scan now</message>
<message id="Lms.Admin.ScannerController.scanner">Scanner</message>
<message id="Lms.Admin.ScannerController.status">Status</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Not scheduled</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Scheduled on {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Scanning: step {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-checking-for-missing-files">Checking for missing files... {1}%</message>
<message id="Lms.Admin.ScannerController.step-discovering-files">Discovering files: {1} files</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scanning files: {1}/{2} files ({3}%)...</message>
<!--Users-->
<message id="Lms.Admin.Users.add">New user</message>
@@ -113,11 +121,12 @@
<message id="Lms.Explore.most-played">Most played</message>
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz Artist</message>
<message id="Lms.Explore.musicbrainz-release">MusicBrainz Release</message>
<message id="Lms.Explore.play-last">Play last</message>
<message id="Lms.Explore.play-shuffled">Play shuffled</message>
<message id="Lms.Explore.random">Random</message>
<message id="Lms.Explore.recently-added">Recently added</message>
<message id="Lms.Explore.recently-played">Recently played</message>
<message id="Lms.Explore.releases">Albums</message>
<message id="Lms.Explore.show-more">Show more</message>
<message id="Lms.Explore.tracks">Tracks</message>
<message id="Lms.Explore.type">Type</message>
<message id="Lms.Explore.value">Value</message>
@@ -150,11 +159,8 @@
<plural case="0">Added {1} track</plural>
<plural case="1">Added {1} tracks</plural>
</message>
<message id="Lms.PlayQueue.nb-tracks-playing">
<plural case="0">Playing {1} track</plural>
<plural case="1">Playing {1} tracks</plural>
</message>
<message id="Lms.PlayQueue.playqueue">Play Queue</message>
<message id="Lms.PlayQueue.playqueue-full">Play Queue full!</message>
<message id="Lms.PlayQueue.radio-mode">Radio mode</message>
<message id="Lms.PlayQueue.repeat">Repeat</message>
<message id="Lms.PlayQueue.shuffle">Shuffle</message>
+28 -22
View File
@@ -9,6 +9,7 @@
<message id="Lms.discard">Annuler</message>
<message id="Lms.cancel">Annuler</message>
<message id="Lms.create">Créer</message>
<message id="Lms.loading">Chargement...</message>
<message id="Lms.login">Login</message>
<message id="Lms.logout"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> Quitter</message>
<message id="Lms.not-a-directory">N'est pas un répertoire</message>
@@ -53,22 +54,29 @@
<message id="Lms.Admin.Database.update-start-time">Heure de départ de la mise à jour</message>
<message id="Lms.Admin.Database.weekly">Toutes les semaines</message>
<message id="Lms.Admin.Database.Status.bad-duration">Impossible de récupérer la durée de la piste</message>
<message id="Lms.Admin.Database.Status.cannot-parse-file">Impossible d'analyser le fichier</message>
<message id="Lms.Admin.Database.Status.cannot-read-file">Impossible de lire le fichier</message>
<message id="Lms.Admin.Database.Status.duplicates-header">{1} fichiers dupliqués :</message>
<message id="Lms.Admin.Database.Status.errors-header">{1} erreurs :</message>
<message id="Lms.Admin.Database.Status.get-report">Rapport</message>
<message id="Lms.Admin.Database.Status.last-scan">Dernier scan</message>
<message id="Lms.Admin.Database.Status.last-scan-not-available">Non disponible</message>
<message id="Lms.Admin.Database.Status.last-scan-status">{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas)</message>
<message id="Lms.Admin.Database.Status.no-audio-track">Pas de piste audio</message>
<message id="Lms.Admin.Database.Status.same-hash">Hash dupliqué</message>
<message id="Lms.Admin.Database.Status.same-mbid">MBID dupliqué</message>
<message id="Lms.Admin.Database.Status.status">Statut</message>
<message id="Lms.Admin.Database.Status.status-not-scheduled">Non planifié</message>
<message id="Lms.Admin.Database.Status.status-scheduled">Planifié le {1}</message>
<message id="Lms.Admin.Database.Status.status-in-progress">Scan de {1} fichiers sur {2} ({3} %)</message>
<message id="Lms.Admin.ScannerController.bad-duration">Impossible de récupérer la durée de la piste</message>
<message id="Lms.Admin.ScannerController.cannot-parse-file">Impossible d'analyser le fichier</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Impossible de lire le fichier</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} fichiers dupliqués :</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} erreurs :</message>
<message id="Lms.Admin.ScannerController.force-scan-now">Lancer un scan forcé</message>
<message id="Lms.Admin.ScannerController.get-report">Rapport</message>
<message id="Lms.Admin.ScannerController.last-scan">Dernier scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponible</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas)</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Pas de piste audio</message>
<message id="Lms.Admin.ScannerController.same-hash">Hash dupliqué</message>
<message id="Lms.Admin.ScannerController.same-mbid">MBID dupliqué</message>
<message id="Lms.Admin.ScannerController.scan-now">Lancer un scan</message>
<message id="Lms.Admin.ScannerController.scanner">Scanner</message>
<message id="Lms.Admin.ScannerController.status">Statut</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Non planifié</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Planifié le {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">En cours de scan : étape {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-checking-for-missing-files">Vérification des fichiers supprimés... {1}%</message>
<message id="Lms.Admin.ScannerController.step-discovering-files">Découverte des fichiers: {1} fichiers</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Récupération des métadonnées AcousticBrainz: {1}/{2} fichiers ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scan des fichiers: {1}/{2} fichiers ({3}%)...</message>
<!--Users-->
<message id="Lms.Admin.Users.add">Ajouter</message>
@@ -113,11 +121,12 @@
<message id="Lms.Explore.most-played">Plus joués</message>
<message id="Lms.Explore.musicbrainz-artist">MusicBrainz Artist</message>
<message id="Lms.Explore.musicbrainz-release">MusicBrainz Release</message>
<message id="Lms.Explore.play-last">Jouer en dernier</message>
<message id="Lms.Explore.play-shuffled">Jouer aléatoirement</message>
<message id="Lms.Explore.random">Aléatoire</message>
<message id="Lms.Explore.recently-added">Ajoutés récemment</message>
<message id="Lms.Explore.recently-played">Joués récemment</message>
<message id="Lms.Explore.releases">Albums</message>
<message id="Lms.Explore.show-more">Voir plus</message>
<message id="Lms.Explore.tracks">Pistes</message>
<message id="Lms.Explore.type">Type</message>
<message id="Lms.Explore.value">Valeur</message>
@@ -132,7 +141,7 @@
<message id="Lms.Explore.Artists.linktype-releaseartist">Artistes d'album</message>
<!--Explore:Release-->
<message id="Lms.Explore.ReleaseInfo.similar-releases">Albums similaires</message>
<message id="Lms.Explore.Release.similar-releases">Albums similaires</message>
<message id="Lms.Explore.Release.copyright">Copyright</message>
<message id="Lms.Explore.Release.disc">Disque {1}</message>
@@ -150,11 +159,8 @@
<plural case="0">{1} piste ajoutée</plural>
<plural case="1">{1} pistes ajoutées</plural>
</message>
<message id="Lms.PlayQueue.nb-tracks-playing">
<plural case="0">{1} piste ajoutée</plural>
<plural case="1">{1} pistes ajoutées</plural>
</message>
<message id="Lms.PlayQueue.playqueue">Liste de lecture</message>
<message id="Lms.PlayQueue.playqueue-full">Liste de lecture pleine!</message>
<message id="Lms.PlayQueue.radio-mode">Mode radio</message>
<message id="Lms.PlayQueue.repeat">Repéter</message>
<message id="Lms.PlayQueue.shuffle">Mélanger</message>
+1 -3
View File
@@ -16,9 +16,7 @@
<h4><small>${nb-tracks}</small></h4>
</div>
${entries}
<div class="Lms-horizontal-center">
${show-more class="btn-primary Lms-show-more"}
</div>
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
</div>
</div>
</message>
+4 -2
View File
@@ -17,12 +17,13 @@
${<if-has-artist>}<h4>${artist class="Lms-artistname text-muted"}</h4>${</if-has-artist>}
<div class="text-muted"><small>${<if-has-year>}${year}${</if-has-year>}${<if-has-orig-year>} (${orig-year})${</if-has-orig-year>}</small></div>
${clusters}
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
${container}
</div>
</div>
<div class="row">
${<if-has-similar-releases>}
<div class="col-xs-12 col-md-8">
<div class="well well-sm">
<h3>${tr:Lms.Explore.Release.similar-releases}</h3>
@@ -31,6 +32,7 @@
</div>
</div>
</div>
${</if-has-similar-releases>}
${<if-has-copyright>}
<div class="col-xs-12 col-md-4">
<div class="well well-sm">
@@ -66,7 +68,7 @@
</div>
<div class="col-xs-3">
<div class="Lms-explore-release-entry-controls">
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
<div class="Lms-explore-release-entry-track-duration text-muted">
${duration}
+2 -4
View File
@@ -6,14 +6,12 @@
<div class="Lms-header">
<h2>${tr:Lms.Explore.releases}</h2>
${mode class="nav nav-pills Lms-explore-mode-container"}
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
<div class="row">
${releases}
</div>
<div class="Lms-horizontal-center">
${show-more class="btn-primary Lms-show-more"}
</div>
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
</message>
<message id="Lms.Explore.Releases.template.entry-grid">
+5
View File
@@ -56,4 +56,9 @@
${player class="Lms-player"}
</message>
<message id="Lms.LoadingIndicator.template">
<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>
<span class="sr-only">${tr:Lms.loading}</span>
</message>
</messages>
+3 -5
View File
@@ -9,12 +9,10 @@
<div class="Lms-header">
<h2>${tr:Lms.Explore.tracks}</h2>
${mode class="nav nav-pills Lms-explore-mode-container"}
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
${tracks}
<div class="Lms-horizontal-center">
${show-more class="btn-primary Lms-show-more"}
</div>
${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
</div>
</div>
</message>
@@ -35,7 +33,7 @@
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<div class="Lms-explore-tracks-entry-controls">
${play-btn}${add-btn}
${play-btn}${more-btn}
</div>
<div class="Lms-explore-tracks-entry-duration text-muted">
${duration}
+5 -5
View File
@@ -259,6 +259,11 @@ a.Lms-artistname:hover, a.Lms-artistname:focus {
justify-content: center;
}
.Lms-loading-indicator {
margin-top: 8px;
margin-bottom: 8px;
}
.Lms-navbar-search {
max-width: 175px;
padding-top: 7.5px;
@@ -478,9 +483,4 @@ a.Lms-releasename:hover, a.Lms-releasename:focus {
padding-bottom: 100%;
}
.Lms-show-more {
margin-top: 8px;
margin-bottom: 8px;
}
+42 -10
View File
@@ -65,6 +65,25 @@ LMS.mediaplayer = function () {
}
}
var _playPause = function() {
_audioCtx.resume();
if (_elems.audio.paused && _elems.audio.children.length > 0) {
_playTrack();
}
else
_elems.audio.pause();
}
var _playPrevious = function() {
_audioCtx.resume();
_requestPreviousTrack();
}
var _playNext = function() {
_audioCtx.resume();
_requestNextTrack();
}
var _requestPreviousTrack = function() {
Wt.emit(_root, "playPrevious");
}
@@ -146,21 +165,14 @@ LMS.mediaplayer = function () {
_gainNode.connect(_audioCtx.destination);
_elems.playpause.addEventListener("click", function() {
_audioCtx.resume();
if (_elems.audio.paused && _elems.audio.children.length > 0) {
_playTrack();
}
else
_elems.audio.pause();
_playPause();
});
_elems.previous.addEventListener("click", function() {
_audioCtx.resume();
_requestPreviousTrack();
_playPrevious();
});
_elems.next.addEventListener("click", function() {
_audioCtx.resume();
_requestNextTrack();
_playNext();
});
_elems.seek.addEventListener("change", function() {
_audioCtx.resume();
@@ -225,6 +237,26 @@ LMS.mediaplayer = function () {
}
});
document.addEventListener("keydown", function(event) {
let handled = false;
if (event.keyCode == 32 && !(event.target instanceof HTMLInputElement)) {
_playPause();
handled = true;
}
else if (event.ctrlKey && event.keyCode == 37) {
_playPrevious();
handled = true;
}
else if (event.ctrlKey && event.keyCode == 39) {
_playNext();
handled = true;
}
if (handled)
event.preventDefault();
});
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler("previoustrack", function() {
_requestPreviousTrack();
+6 -4
View File
@@ -6,16 +6,15 @@ add_library(lmsav SHARED
impl/AvTypes.cpp
)
# TODO make av libs private
target_include_directories(lmsav INTERFACE
include
${AVCODEC_INCLUDE_DIR}
${AVFORMAT_INCLUDE_DIR}
${AVUTIL_INCLUDE_DIR}
)
target_include_directories(lmsav PRIVATE
include/
${AVCODEC_INCLUDE_DIR}
${AVFORMAT_INCLUDE_DIR}
${AVUTIL_INCLUDE_DIR}
)
target_link_libraries(lmsav PUBLIC
@@ -24,6 +23,9 @@ target_link_libraries(lmsav PUBLIC
avutil
std::filesystem
wt
)
target_link_libraries(lmsav PRIVATE
${AVFORMAT_LIBRARY}
${AVUTIL_LIBRARY}
)
+8
View File
@@ -19,6 +19,14 @@
#include "av/AvInfo.hpp"
extern "C"
{
#define __STDC_CONSTANT_MACROS
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
}
#include <array>
#include "utils/Logger.hpp"
@@ -39,7 +39,6 @@ namespace Av
static constexpr std::size_t _chunkSize {262144};
const std::filesystem::path _trackPath;
Transcoder _transcoder;
};
}
+14 -13
View File
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
@@ -59,17 +59,20 @@ Transcoder::start()
if (!std::filesystem::exists(_filePath))
{
LOG(ERROR) << "File '" << _filePath << "' does not exist!";
_isComplete = true;
return false;
}
else if (!std::filesystem::is_regular_file( _filePath) )
{
LOG(ERROR) << "File '" << _filePath << "' is not regular!";
_isComplete = true;
return false;
}
}
catch (const std::filesystem::filesystem_error& e)
{
LOG(ERROR) << "File error on '" << _filePath.string() << "': " << e.what();
_isComplete = true;
return false;
}
@@ -154,6 +157,7 @@ Transcoder::start()
break;
default:
_isComplete = true;
return false;
}
@@ -178,12 +182,14 @@ Transcoder::start()
if (!_child->is_open())
{
LOG(DEBUG) << "Exec failed!";
_isComplete = true;
return false;
}
if (_child->out().eof())
{
LOG(DEBUG) << "Early end of file!";
_isComplete = true;
return false;
}
@@ -199,16 +205,6 @@ Transcoder::process(std::vector<unsigned char>& output, std::size_t maxSize)
if (!_child || _isComplete)
return;
if (_child->out().fail())
{
LOG(DEBUG) << "Stdout FAILED 2";
}
if (_child->out().eof())
{
LOG(DEBUG) << "Stdout ENDED 2";
}
output.resize(maxSize);
//Read on the output stream
@@ -218,14 +214,19 @@ Transcoder::process(std::vector<unsigned char>& output, std::size_t maxSize)
if (_child->out().fail())
{
LOG(DEBUG) << "Stdout FAILED";
_isComplete = true;
}
if (_child->out().eof())
{
LOG(DEBUG) << "Stdout EOF!";
_child->clear();
_isComplete = true;
}
if (_isComplete)
{
LOG(DEBUG) << "Transcode complete!";
_child->clear();
_child.reset();
}
+2 -8
View File
@@ -21,14 +21,6 @@
#pragma once
extern "C"
{
#define __STDC_CONSTANT_MACROS
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
}
#include <chrono>
#include <filesystem>
#include <map>
@@ -38,6 +30,8 @@ extern "C"
#include "AvTypes.hpp"
struct AVFormatContext;
namespace Av
{
+91 -56
View File
@@ -258,21 +258,13 @@ MediaScanner::MediaScanner(Database::Db& db)
MediaScanner::~MediaScanner()
{
if (_running)
stop();
}
void
MediaScanner::restart(void)
MediaScanner::start()
{
stop();
start();
}
void
MediaScanner::start(void)
{
_running = true;
std::scoped_lock lock {_controlMutex};
scheduleNextScan();
@@ -280,27 +272,47 @@ MediaScanner::start(void)
}
void
MediaScanner::stop(void)
MediaScanner::stop()
{
_running = false;
std::scoped_lock lock {_controlMutex};
_abortScan = true;
_scheduleTimer.cancel();
_ioService.stop();
}
void
MediaScanner::requestImmediateScan()
MediaScanner::abortScan()
{
LMS_LOG(DBUPDATER, DEBUG) << "Aborting scan...";
std::scoped_lock lock {_controlMutex};
LMS_LOG(DBUPDATER, DEBUG) << "Waiting for the scan to abort...";
_abortScan = true;
_scheduleTimer.cancel();
_ioService.stop();
LMS_LOG(DBUPDATER, DEBUG) << "Scan abort done!";
_abortScan = false;
_ioService.start();
}
void
MediaScanner::requestImmediateScan(bool force)
{
abortScan();
_ioService.post([=]()
{
scheduleScan();
scheduleScan(force);
});
}
void
MediaScanner::requestReschedule()
MediaScanner::requestReload()
{
abortScan();
_ioService.post([=]()
{
scheduleNextScan();
@@ -308,7 +320,7 @@ MediaScanner::requestReschedule()
}
MediaScanner::Status
MediaScanner::getStatus()
MediaScanner::getStatus() const
{
Status res;
@@ -317,7 +329,7 @@ MediaScanner::getStatus()
res.currentState = _curState;
res.nextScheduledScan = _nextScheduledScan;
res.lastCompleteScanStats = _lastCompleteScanStats;
res.inProgressScanStats = _inProgressScanStats;
res.currentScanStepStats = _currentScanStepStats;
return res;
}
@@ -365,7 +377,7 @@ MediaScanner::scheduleNextScan()
if (nextScanDate.isValid())
{
nextScanDateTime = Wt::WDateTime {nextScanDate, _startTime};
scheduleScan(nextScanDateTime);
scheduleScan(false, nextScanDateTime);
}
{
@@ -380,17 +392,21 @@ MediaScanner::scheduleNextScan()
void
MediaScanner::countAllFiles(ScanStats& stats)
{
stats.filesToScan = 0;
ScanStepStats stepStats{stats.startTime, ScanProgressStep::DiscoveringFiles};
stats.filesScanned = 0;
notifyInProgress(stepStats);
exploreFilesRecursive(_mediaDirectory, [&](std::error_code ec, const std::filesystem::path& path)
{
if (!_running)
if (_abortScan)
return false;
if (!ec && isFileSupported(path, _fileExtensions))
{
stats.filesToScan++;
notifyInProgressIfNeeded(stats);
stats.filesScanned++;
stepStats.processedFiles++;
notifyInProgressIfNeeded(stepStats);
}
return true;
@@ -398,13 +414,21 @@ MediaScanner::countAllFiles(ScanStats& stats)
}
void
MediaScanner::scheduleScan(const Wt::WDateTime& dateTime)
MediaScanner::scheduleScan(bool force, const Wt::WDateTime& dateTime)
{
auto cb {[=](boost::system::error_code ec)
{
if (ec)
return;
scan(force);
}};
if (dateTime.isNull())
{
LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan right now";
_scheduleTimer.expires_from_now(std::chrono::seconds(0));
_scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1));
_scheduleTimer.expires_from_now(std::chrono::seconds {0});
_scheduleTimer.async_wait(cb);
}
else
{
@@ -413,16 +437,13 @@ MediaScanner::scheduleScan(const Wt::WDateTime& dateTime)
LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan at " << std::string(std::ctime(&t));
_scheduleTimer.expires_at(timePoint);
_scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1));
_scheduleTimer.async_wait(cb);
}
}
void
MediaScanner::scan(boost::system::error_code err)
MediaScanner::scan(bool forceScan)
{
if (err)
return;
scanStarted().emit();
{
@@ -438,13 +459,11 @@ MediaScanner::scan(boost::system::error_code err)
refreshScanSettings();
bool forceScan {false};
removeMissingTracks(stats);
LMS_LOG(DBUPDATER, DEBUG) << "Counting files in media directory '" << _mediaDirectory.string() << "'...";
countAllFiles(stats);
LMS_LOG(DBUPDATER, DEBUG) << "-> Nb files = " << stats.filesToScan;
removeMissingTracks(stats);
LMS_LOG(DBUPDATER, DEBUG) << "-> Nb files = " << stats.filesScanned;
LMS_LOG(UI, INFO) << "Checks complete, force scan = " << forceScan;
@@ -454,36 +473,39 @@ MediaScanner::scan(boost::system::error_code err)
removeOrphanEntries();
if (_running)
if (!_abortScan)
checkDuplicatedAudioFiles(stats);
// Now update all the track features if needed
fetchTrackFeatures(stats);
LMS_LOG(DBUPDATER, INFO) << "Scan " << (_running ? "complete" : "aborted") << ". Changes = " << stats.nbChanges() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errors.size() << "), features fetched = " << stats.featuresFetched << "/" << stats.featuresToFetch <<", duplicates = " << stats.duplicates.size();
LMS_LOG(DBUPDATER, INFO) << "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.nbChanges() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errors.size() << "), features fetched = " << stats.featuresFetched << ", duplicates = " << stats.duplicates.size();
_dbSession.optimize();
if (_running)
if (!_abortScan)
{
stats.stopTime = Wt::WLocalDateTime::currentDateTime().toUTC();
{
std::unique_lock lock {_statusMutex};
_lastCompleteScanStats = std::move(stats);
_inProgressScanStats.reset();
_currentScanStepStats.reset();
}
LMS_LOG(DBUPDATER, DEBUG) << "Scan not aborted, scheduling next scan!";
scheduleNextScan();
scanComplete().emit();
}
else
{
LMS_LOG(DBUPDATER, DEBUG) << "Scan aborted, not scheduling next scan!";
std::unique_lock lock {_statusMutex};
_curState = State::NotScheduled;
_inProgressScanStats.reset();
_currentScanStepStats.reset();
}
}
@@ -519,6 +541,8 @@ MediaScanner::fetchTrackFeatures(ScanStats& stats)
if (_recommendationEngineType != ScanSettings::RecommendationEngineType::Features)
return;
ScanStepStats stepStats{stats.startTime, ScanProgressStep::FetchingTrackFeatures};
LMS_LOG(DBUPDATER, INFO) << "Fetching missing track features...";
struct TrackInfo
@@ -540,18 +564,21 @@ MediaScanner::fetchTrackFeatures(ScanStats& stats)
return res;
}()};
stats.featuresToFetch = tracksToFetch.size();
stepStats.filesToProcess = tracksToFetch.size();
notifyInProgress(stepStats);
LMS_LOG(DBUPDATER, INFO) << "Found " << tracksToFetch.size() << " track(s) to fetch!";
for (const TrackInfo& trackToFetch : tracksToFetch)
{
if (!_running)
if (_abortScan)
return;
if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid))
stats.featuresFetched++;
stepStats.processedFiles++;
notifyInProgressIfNeeded(stepStats);
}
LMS_LOG(DBUPDATER, INFO) << "Track features fetched!";
@@ -591,27 +618,25 @@ MediaScanner::refreshScanSettings()
}
void
MediaScanner::notifyInProgress(const ScanStats& stats)
MediaScanner::notifyInProgress(const ScanStepStats& stepStats)
{
const ScanProgressStats progressStats {stats.toProgressStats()};
{
std::unique_lock lock {_statusMutex};
_inProgressScanStats = progressStats;
_currentScanStepStats = stepStats;
}
const std::chrono::system_clock::time_point now {std::chrono::system_clock::now()};
_sigScanInProgress(progressStats);
_sigScanInProgress(stepStats);
_lastScanInProgressEmit = now;
}
void
MediaScanner::notifyInProgressIfNeeded(const ScanStats& stats)
MediaScanner::notifyInProgressIfNeeded(const ScanStepStats& stepStats)
{
std::chrono::system_clock::time_point now {std::chrono::system_clock::now()};
if (std::chrono::duration_cast<std::chrono::seconds>(now - _lastScanInProgressEmit).count() > 2)
notifyInProgress(stats);
if (std::chrono::duration_cast<std::chrono::seconds>(now - _lastScanInProgressEmit).count() > 1)
notifyInProgress(stepStats);
}
void
@@ -772,9 +797,13 @@ MediaScanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, S
void
MediaScanner::scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats)
{
ScanStepStats stepStats{stats.startTime, ScanProgressStep::ScanningFiles};
stepStats.filesToProcess = stats.filesScanned;
notifyInProgress(stepStats);
exploreFilesRecursive(mediaDirectory, [&](std::error_code ec, const std::filesystem::path& path)
{
if (!_running)
if (_abortScan)
return false;
if (ec)
@@ -786,13 +815,12 @@ MediaScanner::scanMediaDirectory(const std::filesystem::path& mediaDirectory, bo
{
scanAudioFile(path, forceScan, stats );
notifyInProgressIfNeeded(stats);
stepStats.processedFiles++;
notifyInProgressIfNeeded(stepStats);
}
return true;
});
notifyInProgress(stats);
}
// Check if a file exists and is still in a media directory
@@ -837,6 +865,8 @@ MediaScanner::removeMissingTracks(ScanStats& stats)
{
static constexpr std::size_t batchSize {50};
ScanStepStats stepStats{stats.startTime, ScanProgressStep::ChekingForMissingFiles};
LMS_LOG(DBUPDATER, DEBUG) << "Checking tracks to be removed...";
std::size_t trackCount {};
@@ -846,6 +876,9 @@ MediaScanner::removeMissingTracks(ScanStats& stats)
}
LMS_LOG(DBUPDATER, DEBUG) << trackCount << " tracks to be checked...";
stepStats.filesToProcess = trackCount;
notifyInProgress(stepStats);
std::vector<std::pair<Database::IdType, std::filesystem::path>> trackPaths;
std::vector<IdType> tracksToRemove;
@@ -861,11 +894,13 @@ MediaScanner::removeMissingTracks(ScanStats& stats)
for (const auto& [trackId, trackPath] : trackPaths)
{
if (!_running)
if (_abortScan)
return;
if (!checkFile(trackPath, _mediaDirectory, _fileExtensions))
tracksToRemove.push_back(trackId);
stepStats.processedFiles++;
}
if (!tracksToRemove.empty())
@@ -883,7 +918,7 @@ MediaScanner::removeMissingTracks(ScanStats& stats)
}
}
notifyInProgressIfNeeded(stats);
notifyInProgressIfNeeded(stepStats);
if (i == 0)
break;
+15 -14
View File
@@ -52,26 +52,26 @@ class MediaScanner : public IMediaScanner
void start() override;
void stop() override;
void restart() override;
void requestReload() override;
void requestImmediateScan(bool force) override;
void requestImmediateScan() override;
void requestReschedule() override ;
Status getStatus() override;
Status getStatus() const override;
Wt::Signal<>& scanStarted() override { return _sigScanStarted; }
Wt::Signal<>& scanComplete() override { return _sigScanComplete; }
Wt::Signal<ScanProgressStats>& scanInProgress() override { return _sigScanInProgress; }
Wt::Signal<ScanStepStats>& scanInProgress() override { return _sigScanInProgress; }
Wt::Signal<Wt::WDateTime>& scheduled() override { return _sigScheduled; }
private:
// Job handling
void scheduleNextScan();
void scheduleScan(const Wt::WDateTime& dateTime = {});
void scheduleScan(bool force, const Wt::WDateTime& dateTime = {});
void abortScan();
// Update database (scheduled callback)
void scan(boost::system::error_code ec);
void scan(bool force);
void scanMediaDirectory( const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats);
bool fetchTrackFeatures(Database::IdType trackId, const UUID& MBID);
@@ -86,24 +86,25 @@ class MediaScanner : public IMediaScanner
void checkDuplicatedAudioFiles(ScanStats& stats);
void scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanStats& stats);
Database::IdType doScanAudioFile(const std::filesystem::path& file, ScanStats& stats);
void notifyInProgressIfNeeded(const ScanStats& stats);
void notifyInProgress(const ScanStats& stats);
void notifyInProgressIfNeeded(const ScanStepStats& stats);
void notifyInProgress(const ScanStepStats& stats);
bool _running {};
std::mutex _controlMutex;
std::atomic<bool> _abortScan {};
Wt::WIOService _ioService;
boost::asio::system_timer _scheduleTimer {_ioService};
Wt::Signal<> _sigScanStarted;
Wt::Signal<> _sigScanComplete;
Wt::Signal<ScanProgressStats> _sigScanInProgress;
Wt::Signal<ScanStepStats> _sigScanInProgress;
std::chrono::system_clock::time_point _lastScanInProgressEmit {};
Wt::Signal<Wt::WDateTime> _sigScheduled;
Database::Session _dbSession;
std::unique_ptr<MetaData::IParser> _metadataParser;
std::shared_mutex _statusMutex;
mutable std::shared_mutex _statusMutex;
State _curState {State::NotScheduled};
std::optional<ScanStats> _lastCompleteScanStats;
std::optional<ScanProgressStats> _inProgressScanStats;
std::optional<ScanStepStats> _currentScanStepStats;
Wt::WDateTime _nextScheduledScan;
// Current scan settings
+2 -8
View File
@@ -40,16 +40,10 @@ ScanStats::nbChanges() const
return additions + deletions + updates;
}
ScanProgressStats
ScanStats::toProgressStats() const
{
return ScanProgressStats {startTime, filesToScan, nbFiles()};
}
unsigned
ScanProgressStats::progress() const
ScanStepStats::progress() const
{
return (processedFiles / static_cast<float>(filesToScan ? filesToScan : 1)) * 100;
return (processedFiles / static_cast<float>(filesToProcess ? filesToProcess : 1)) * 100;
}
} // namespace Scanner
@@ -40,11 +40,10 @@ class IMediaScanner
virtual void start() = 0;
virtual void stop() = 0;
virtual void restart() = 0;
// Async requests
virtual void requestImmediateScan() = 0;
virtual void requestReschedule() = 0;
virtual void requestReload() = 0;
virtual void requestImmediateScan(bool force) = 0;
enum class State
@@ -59,10 +58,10 @@ class IMediaScanner
State currentState {State::NotScheduled};
Wt::WDateTime nextScheduledScan;
std::optional<ScanStats> lastCompleteScanStats;
std::optional<ScanProgressStats> inProgressScanStats;
std::optional<ScanStepStats> currentScanStepStats;
};
virtual Status getStatus() = 0;
virtual Status getStatus() const = 0;
// Called just after scan start
virtual Wt::Signal<>& scanStarted() = 0;
@@ -71,7 +70,7 @@ class IMediaScanner
virtual Wt::Signal<>& scanComplete() = 0;
// Called during scan in progress
virtual Wt::Signal<ScanProgressStats>& scanInProgress() = 0;
virtual Wt::Signal<ScanStepStats>& scanInProgress() = 0;
// Called after a schedule
virtual Wt::Signal<Wt::WDateTime>& scheduled() = 0;
@@ -56,12 +56,23 @@ namespace Scanner {
};
enum class ScanProgressStep : unsigned
{
ChekingForMissingFiles = 0,
DiscoveringFiles,
ScanningFiles,
FetchingTrackFeatures,
};
static inline constexpr unsigned ScanProgressStepCount {4};
// reduced scan stats
struct ScanProgressStats
struct ScanStepStats
{
Wt::WDateTime startTime;
std::size_t filesToScan {};
ScanProgressStep currentStep;
std::size_t filesToProcess {};
std::size_t processedFiles {};
unsigned progress() const;
@@ -72,7 +83,7 @@ namespace Scanner {
Wt::WDateTime startTime;
Wt::WDateTime stopTime;
std::size_t filesToScan {}; // Total number of files to be scanned (estimated)
std::size_t filesScanned {}; // Total number of files scanned (estimated)
std::size_t skips {}; // no change since last scan
std::size_t scans {}; // actually scanned filed
@@ -82,15 +93,12 @@ namespace Scanner {
std::size_t updates {}; // updated file in DB
std::size_t featuresFetched {}; // features fetched in DB
std::size_t featuresToFetch {}; // features to be fetched in DB
std::vector<ScanError> errors;
std::vector<ScanDuplicate> duplicates;
std::size_t nbFiles() const;
std::size_t nbChanges() const;
ScanProgressStats toProgressStats() const;
};
}
+10 -3
View File
@@ -35,8 +35,15 @@ namespace API::Subsonic::Scan
const IMediaScanner::Status scanStatus {ServiceProvider<IMediaScanner>::get()->getStatus()};
statusResponse.setAttribute("scanning", scanStatus.currentState == IMediaScanner::State::InProgress);
if (scanStatus.currentState == IMediaScanner::State::InProgress && scanStatus.inProgressScanStats)
statusResponse.setAttribute("count", scanStatus.inProgressScanStats->processedFiles);
if (scanStatus.currentState == IMediaScanner::State::InProgress)
{
std::size_t count{};
if (scanStatus.currentScanStepStats && scanStatus.currentScanStepStats->currentStep == ScanProgressStep::ScanningFiles)
count = scanStatus.currentScanStepStats->processedFiles;
statusResponse.setAttribute("count", count);
}
return statusResponse;
}
@@ -54,7 +61,7 @@ namespace API::Subsonic::Scan
Response
handleStartScan(RequestContext& context)
{
ServiceProvider<IMediaScanner>::get()->requestImmediateScan();
ServiceProvider<IMediaScanner>::get()->requestImmediateScan(false);
Response response {Response::createOkResponse(context)};
response.addNode("scanStatus", createStatusResponseNode());
+3 -1
View File
@@ -10,11 +10,12 @@ add_executable(lms
ui/SettingsView.cpp
ui/TrackStringUtils.cpp
ui/admin/DatabaseSettingsView.cpp
ui/admin/DatabaseStatus.cpp
ui/admin/ScannerController.cpp
ui/admin/InitWizardView.cpp
ui/admin/UserView.cpp
ui/admin/UsersView.cpp
ui/common/AuthModeModel.cpp
ui/common/LoadingIndicator.cpp
ui/common/Validators.cpp
ui/explore/ArtistListHelpers.cpp
ui/explore/ArtistView.cpp
@@ -22,6 +23,7 @@ add_executable(lms
ui/explore/Explore.cpp
ui/explore/Filters.cpp
ui/explore/ReleaseListHelpers.cpp
ui/explore/ReleasePopup.cpp
ui/explore/ReleasesView.cpp
ui/explore/ReleaseView.cpp
ui/explore/SearchView.cpp
+12 -2
View File
@@ -36,7 +36,9 @@
#include "utils/Service.hpp"
#include "utils/WtLogger.hpp"
std::vector<std::string> generateWtConfig(std::string execPath)
static
std::vector<std::string>
generateWtConfig(std::string execPath)
{
std::vector<std::string> args;
@@ -92,9 +94,17 @@ std::vector<std::string> generateWtConfig(std::string execPath)
}
std::ofstream oss(wtConfigPath.string().c_str(), std::ios::out);
{
std::ofstream oss {wtConfigPath.string().c_str(), std::ios::out};
if (!oss)
throw LmsException {"Can't open '" + wtConfigPath.string() + "' for writing!"};
boost::property_tree::xml_parser::write_xml(oss, pt);
if (!oss)
throw LmsException {"Can't write in file '" + wtConfigPath.string() + "', no space left?"};
}
return args;
}
+13 -7
View File
@@ -22,6 +22,7 @@
#include <Wt/WAnchor.h>
#include <Wt/WEnvironment.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WServer.h>
#include <Wt/WStackedWidget.h>
#include <Wt/WText.h>
@@ -34,7 +35,6 @@
#include "database/User.hpp"
#include "explore/Explore.hpp"
#include "explore/Filters.hpp"
#include "explore/SearchView.hpp"
#include "utils/Logger.hpp"
#include "utils/Service.hpp"
#include "utils/String.hpp"
@@ -122,6 +122,7 @@ LmsApplication::LmsApplication(const Wt::WEnvironment& env,
// Add a resource bundle
messageResourceBundle().use(appRoot() + "admin-database");
messageResourceBundle().use(appRoot() + "admin-initwizard");
messageResourceBundle().use(appRoot() + "admin-scannercontroller");
messageResourceBundle().use(appRoot() + "admin-user");
messageResourceBundle().use(appRoot() + "admin-users");
messageResourceBundle().use(appRoot() + "artist");
@@ -306,6 +307,13 @@ LmsApplication::createCluster(Database::Cluster::pointer cluster, bool canDelete
return res;
}
Wt::WPopupMenu*
LmsApplication::createPopupMenu()
{
_popupMenu = std::make_unique<Wt::WPopupMenu>();
return _popupMenu.get();
}
void
LmsApplication::handleException(LmsApplicationException& e)
{
@@ -332,7 +340,6 @@ enum IdxRoot
{
IdxExplore = 0,
IdxPlayQueue,
IdxSearch,
IdxSettings,
IdxAdminDatabase,
IdxAdminUsers,
@@ -353,9 +360,9 @@ handlePathChange(Wt::WStackedWidget* stack, bool isAdmin)
{ "/artist", IdxExplore, false },
{ "/releases", IdxExplore, false },
{ "/release", IdxExplore, false },
{ "/search", IdxExplore, false },
{ "/tracks", IdxExplore, false },
{ "/playqueue", IdxPlayQueue, false },
{ "/search", IdxSearch, false },
{ "/settings", IdxSettings, false },
{ "/admin/database", IdxAdminDatabase, true },
{ "/admin/users", IdxAdminUsers, true },
@@ -469,7 +476,6 @@ LmsApplication::createHome()
Explore* explore = mainStack->addNew<Explore>(filters);
_playQueue = mainStack->addNew<PlayQueue>();
auto* search {mainStack->addNew<SearchView>(filters)};
mainStack->addNew<SettingsView>();
searchEdit->enterPressed().connect([=]
@@ -480,7 +486,7 @@ LmsApplication::createHome()
searchEdit->textInput().connect([=]
{
setInternalPath("/search", true);
search->refreshView(searchEdit->text().toUTF8());
explore->search(searchEdit->text());
});
// Admin stuff
@@ -542,11 +548,11 @@ LmsApplication::createHome()
});
});
ServiceProvider<Scanner::IMediaScanner>::get()->scanInProgress().connect(this, [=] (Scanner::ScanProgressStats stats)
ServiceProvider<Scanner::IMediaScanner>::get()->scanInProgress().connect(this, [=] (Scanner::ScanStepStats stepStats)
{
Wt::WServer::instance()->post(sessionId, [=]
{
_events.dbScanInProgress.emit(stats);
_events.dbScanInProgress.emit(stepStats);
triggerUpdate();
});
});
+4 -1
View File
@@ -22,6 +22,7 @@
#include <optional>
#include <Wt/WApplication.h>
#include <Wt/WPopupMenu.h>
#include "database/Db.hpp"
#include "database/Session.hpp"
@@ -57,7 +58,7 @@ struct Events
// Database events
Wt::Signal<> dbScanStarted;
Wt::Signal<> dbScanned;
Wt::Signal<Scanner::ScanProgressStats> dbScanInProgress;
Wt::Signal<Scanner::ScanStepStats> dbScanInProgress;
Wt::Signal<Wt::WDateTime> dbScanScheduled;
};
@@ -101,6 +102,7 @@ class LmsApplication : public Wt::WApplication
static Wt::WLink createReleaseLink(Wt::Dbo::ptr<Database::Release> release);
static std::unique_ptr<Wt::WAnchor> createReleaseAnchor(Wt::Dbo::ptr<Database::Release> release, bool addText = true);
static std::unique_ptr<Wt::WText> createCluster(Wt::Dbo::ptr<Database::Cluster> cluster, bool canDelete = false);
Wt::WPopupMenu* createPopupMenu();
MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; }
PlayQueue& getPlayQueue() const { return *_playQueue; }
@@ -135,6 +137,7 @@ class LmsApplication : public Wt::WApplication
std::shared_ptr<ImageResource> _imageResource;
MediaPlayer* _mediaPlayer {};
PlayQueue* _playQueue {};
std::unique_ptr<Wt::WPopupMenu> _popupMenu {};
};
+72 -25
View File
@@ -32,6 +32,7 @@
#include "utils/Service.hpp"
#include "utils/String.hpp"
#include "common/LoadingIndicator.hpp"
#include "resource/ImageResource.hpp"
#include "LmsApplication.hpp"
#include "MediaPlayer.hpp"
@@ -65,14 +66,7 @@ PlayQueue::PlayQueue()
});
_entriesContainer = bindNew<Wt::WContainerWidget>("entries");
_showMore = bindNew<Wt::WPushButton>("show-more", Wt::WString::tr("Lms.Explore.show-more"));
_showMore->setHidden(true);
_showMore->clicked().connect([=]
{
addSome();
updateCurrentTrack(true);
});
hideLoadingIndicator();
Wt::WText* shuffleBtn = bindNew<Wt::WText>("shuffle-btn", Wt::WString::tr("Lms.PlayQueue.template.shuffle-btn"), Wt::TextFormat::XHTML);
setToolTip(*shuffleBtn, Wt::WString::tr("Lms.PlayQueue.shuffle"));
@@ -188,12 +182,40 @@ PlayQueue::updateRadioBtn()
_radioBtn->toggleStyleClass("text-muted", !_radioMode);
}
void
PlayQueue::displayLoadingIndicator()
{
_loadingIndicator = bindWidget<Wt::WTemplate>("loading-indicator", createLoadingIndicator());
_loadingIndicator->scrollVisibilityChanged().connect([this](bool visible)
{
if (!visible)
return;
addSome();
updateCurrentTrack(true);
});
}
void
PlayQueue::hideLoadingIndicator()
{
_loadingIndicator = nullptr;
bindEmpty("loading-indicator");
}
Database::TrackList::pointer
PlayQueue::getTrackList() const
{
return Database::TrackList::getById(LmsApp->getDbSession(), _tracklistId);
}
bool
PlayQueue::isFull() const
{
auto transaction {LmsApp->getDbSession().createSharedTransaction()};
return getTrackList()->getCount() == _nbMaxEntries;
}
void
PlayQueue::clearTracks()
{
@@ -202,7 +224,7 @@ PlayQueue::clearTracks()
getTrackList().modify()->clear();
}
_showMore->setHidden(true);
hideLoadingIndicator();
_entriesContainer->clear();
updateInfo();
}
@@ -306,55 +328,77 @@ PlayQueue::updateCurrentTrack(bool selected)
entry->bindString("is-selected", selected ? "Lms-playqueue-selected" : "");
}
void
std::size_t
PlayQueue::enqueueTracks(const std::vector<Database::IdType>& trackIds)
{
std::size_t nbTracksQueued {};
{
auto transaction {LmsApp->getDbSession().createUniqueTransaction()};
auto tracklist = getTrackList();
auto tracklist {getTrackList()};
std::size_t nbTracksToEnqueue {tracklist->getCount() + trackIds.size() > _nbMaxEntries ? _nbMaxEntries - tracklist->getCount() : trackIds.size()};
for (Database::IdType trackId : trackIds)
{
Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)};
if (!track)
continue;
if (nbTracksQueued == nbTracksToEnqueue)
break;
Database::TrackListEntry::create(LmsApp->getDbSession(), track, tracklist);
nbTracksQueued++;
}
}
updateInfo();
addSome();
}
void
PlayQueue::enqueueTrack(Database::IdType trackId)
{
enqueueTracks({trackId});
return nbTracksQueued;
}
void
PlayQueue::processTracks(PlayQueueAction action, const std::vector<Database::IdType>& trackIds)
{
std::size_t nbAddedTracks {};
switch (action)
{
case PlayQueueAction::AddLast:
enqueueTracks(trackIds);
LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", trackIds.size()).arg(trackIds.size()), std::chrono::milliseconds(2000));
break;
case PlayQueueAction::AddNext:
case PlayQueueAction::PlayLast:
nbAddedTracks = enqueueTracks(trackIds);
if (!_trackPos)
loadTrack(0, true);
break;
case PlayQueueAction::Play:
clearTracks();
enqueueTracks(trackIds);
nbAddedTracks = enqueueTracks(trackIds);
loadTrack(0, true);
LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-playing", trackIds.size()).arg(trackIds.size()), std::chrono::milliseconds(2000));
break;
case PlayQueueAction::PlayShuffled:
{
clearTracks();
{
std::vector<Database::IdType> shuffledTrackIds {trackIds};
Random::shuffleContainer(shuffledTrackIds);
nbAddedTracks = enqueueTracks(shuffledTrackIds);
}
loadTrack(0, true);
break;
}
}
if (nbAddedTracks > 0)
LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", nbAddedTracks).arg(nbAddedTracks), std::chrono::milliseconds(2000));
if (isFull())
LmsApp->notifyMsg(MsgType::Warning, Wt::WString::tr("Lms.PlayQueue.playqueue-full"), std::chrono::milliseconds(2000));
}
void
@@ -447,7 +491,10 @@ PlayQueue::addSome()
}
_showMore->setHidden(static_cast<std::size_t>(_entriesContainer->count()) >= tracklist->getCount());
if (static_cast<std::size_t>(_entriesContainer->count()) < tracklist->getCount())
displayLoadingIndicator();
else
hideLoadingIndicator();
}
void
+7 -4
View File
@@ -22,7 +22,6 @@
#include <optional>
#include <Wt/WContainerWidget.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include <Wt/WText.h>
@@ -61,16 +60,18 @@ class PlayQueue : public Wt::WTemplate
private:
Wt::Dbo::ptr<Database::TrackList> getTrackList() const;
bool isFull() const;
void clearTracks();
void enqueueTracks(const std::vector<Database::IdType>& trackIds);
void enqueueTrack(Database::IdType trackId);
std::size_t enqueueTracks(const std::vector<Database::IdType>& trackIds);
void addSome();
void enqueueRadioTrack();
void updateInfo();
void updateCurrentTrack(bool selected);
void updateRepeatBtn();
void updateRadioBtn();
void displayLoadingIndicator();
void hideLoadingIndicator();
void loadTrack(std::size_t pos, bool play);
void stop();
@@ -79,12 +80,14 @@ class PlayQueue : public Wt::WTemplate
void addRadioTrackFromClusters();
std::optional<float> getReplayGain(std::size_t pos, const Wt::Dbo::ptr<Database::Track>& track) const;
static inline constexpr std::size_t _nbMaxEntries {1000};
bool _repeatAll {};
bool _radioMode {};
bool _mediaPlayerSettingsLoaded {};
Database::IdType _tracklistId {};
Wt::WContainerWidget* _entriesContainer {};
Wt::WPushButton* _showMore {};
Wt::WTemplate* _loadingIndicator {};
Wt::WText* _nbTracks {};
Wt::WText* _repeatBtn {};
Wt::WText* _radioBtn {};
+9 -2
View File
@@ -19,15 +19,22 @@
#pragma once
#include <vector>
#include <Wt/WSignal.h>
#include "database/Types.hpp"
namespace UserInterface
{
enum class PlayQueueAction
{
Play,
AddNext,
AddLast,
PlayLast,
PlayShuffled,
};
using PlayQueueActionSignal = Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&>;
}
+4 -4
View File
@@ -34,7 +34,7 @@
#include "common/Validators.hpp"
#include "common/ValueStringModel.hpp"
#include "DatabaseStatus.hpp"
#include "ScannerController.hpp"
#include "LmsApplication.hpp"
namespace UserInterface {
@@ -220,7 +220,7 @@ DatabaseSettingsView::refreshView()
Wt::WPushButton *discardBtn = t->bindWidget("discard-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.discard")));
Wt::WPushButton *immScanBtn = t->bindWidget("immediate-scan-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.Admin.Database.immediate-scan")));
t->bindNew<DatabaseStatus>("status");
t->bindNew<ScannerController>("scanner-controller");
saveBtn->clicked().connect([=] ()
{
@@ -230,7 +230,7 @@ DatabaseSettingsView::refreshView()
{
model->saveData();
ServiceProvider<Scanner::IMediaScanner>::get()->requestReschedule();
ServiceProvider<Scanner::IMediaScanner>::get()->requestReload();
LmsApp->notifyMsg(MsgType::Success, Wt::WString::tr("Lms.Admin.Database.settings-saved"));
}
@@ -247,7 +247,7 @@ DatabaseSettingsView::refreshView()
immScanBtn->clicked().connect([=] ()
{
ServiceProvider<Scanner::IMediaScanner>::get()->requestImmediateScan();
ServiceProvider<Scanner::IMediaScanner>::get()->requestImmediateScan(false);
});
t->updateView(model.get());
@@ -17,14 +17,16 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "DatabaseStatus.hpp"
#include "ScannerController.hpp"
#include <iomanip>
#include <Wt/Http/Response.h>
#include <Wt/WDateTime.h>
#include <Wt/WPopupMenu.h>
#include <Wt/WPushButton.h>
#include <Wt/WResource.h>
#include <Wt/WSplitButton.h>
#include "utils/Service.hpp"
#include "LmsApplication.hpp"
@@ -66,7 +68,7 @@ class ReportResource : public Wt::WResource
void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response)
{
response.out() << Wt::WString::tr("Lms.Admin.Database.Status.errors-header").arg(_stats.errors.size()).toUTF8() << std::endl;
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats.errors.size()).toUTF8() << std::endl;
for (const auto& error : _stats.errors)
{
@@ -78,7 +80,7 @@ class ReportResource : public Wt::WResource
response.out() << std::endl;
response.out() << Wt::WString::tr("Lms.Admin.Database.Status.duplicates-header").arg(_stats.duplicates.size()).toUTF8() << std::endl;
response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats.duplicates.size()).toUTF8() << std::endl;
for (const auto& duplicate : _stats.duplicates)
response.out() << duplicate.file.string() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << std::endl;
@@ -90,10 +92,10 @@ class ReportResource : public Wt::WResource
{
switch (error)
{
case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.Database.Status.cannot-read-file");
case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.Database.Status.cannot-parse-file");
case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.Database.Status.no-audio-track");
case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.Database.Status.bad-duration");
case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-file");
case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-parse-file");
case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track");
case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.ScannerController.bad-duration");
}
return "?";
}
@@ -102,8 +104,8 @@ class ReportResource : public Wt::WResource
{
switch (reason)
{
case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.Database.Status.same-hash");
case Scanner::DuplicateReason::SameMBID: return Wt::WString::tr("Lms.Admin.Database.Status.same-mbid");
case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.ScannerController.same-hash");
case Scanner::DuplicateReason::SameMBID: return Wt::WString::tr("Lms.Admin.ScannerController.same-mbid");
}
return "?";
}
@@ -112,8 +114,8 @@ class ReportResource : public Wt::WResource
};
DatabaseStatus::DatabaseStatus()
: WTemplate {Wt::WString::tr("Lms.Admin.Database.Status.template")}
ScannerController::ScannerController()
: WTemplate {Wt::WString::tr("Lms.Admin.ScannerController.template")}
{
addFunction("tr", &Wt::WTemplate::Functions::tr);
@@ -133,16 +135,34 @@ DatabaseStatus::DatabaseStatus()
}
void
DatabaseStatus::refreshContents()
ScannerController::refreshContents()
{
using namespace Scanner;
Wt::WPushButton* reportBtn {bindNew<Wt::WPushButton>("btn-report", Wt::WString::tr("Lms.Admin.Database.Status.get-report"))};
Wt::WPushButton* reportBtn {bindNew<Wt::WPushButton>("btn-report", Wt::WString::tr("Lms.Admin.ScannerController.get-report"))};
Wt::WSplitButton* actionBtn {bindNew<Wt::WSplitButton>("btn-action")};
actionBtn->actionButton()->addStyleClass("btn-primary");
actionBtn->actionButton()->setText(Wt::WString::tr("Lms.Admin.ScannerController.scan-now"));
actionBtn->actionButton()->clicked().connect([]
{
ServiceProvider<Scanner::IMediaScanner>::get()->requestImmediateScan(false);
});
auto popup = std::make_unique<Wt::WPopupMenu>();
popup->addItem(Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now"));
popup->itemSelected().connect([]
{
ServiceProvider<Scanner::IMediaScanner>::get()->requestImmediateScan(true);
});
actionBtn->dropDownButton()->setMenu(std::move(popup));
actionBtn->dropDownButton()->addStyleClass("btn-primary");
const IMediaScanner::Status status {ServiceProvider<IMediaScanner>::get()->getStatus()};
if (status.lastCompleteScanStats)
{
bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-status")
bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status")
.arg(status.lastCompleteScanStats->nbFiles())
.arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime))
.arg(status.lastCompleteScanStats->stopTime.toString())
@@ -156,7 +176,7 @@ DatabaseStatus::refreshContents()
}
else
{
bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-not-available"));
bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available"));
reportBtn->setEnabled(false);
}
@@ -164,19 +184,44 @@ DatabaseStatus::refreshContents()
switch (status.currentState)
{
case IMediaScanner::State::NotScheduled:
bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-not-scheduled"));
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled"));
bindEmpty("step-status");
break;
case IMediaScanner::State::Scheduled:
bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-scheduled")
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled")
.arg(status.nextScheduledScan.toString()));
bindEmpty("step-status");
break;
case IMediaScanner::State::InProgress:
bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress")
.arg(static_cast<int>(status.currentScanStepStats->currentStep) + 1)
.arg(Scanner::ScanProgressStepCount));
switch (status.currentScanStepStats->currentStep)
{
std::ostringstream oss;
bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-in-progress")
.arg(status.inProgressScanStats->processedFiles)
.arg(status.inProgressScanStats->filesToScan)
.arg(status.inProgressScanStats->progress()));
case Scanner::ScanProgressStep::ChekingForMissingFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files")
.arg(status.currentScanStepStats->progress()));
break;
case Scanner::ScanProgressStep::DiscoveringFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files")
.arg(status.currentScanStepStats->processedFiles));
break;
case Scanner::ScanProgressStep::ScanningFiles:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files")
.arg(status.currentScanStepStats->processedFiles)
.arg(status.currentScanStepStats->filesToProcess)
.arg(status.currentScanStepStats->progress()));
break;
case Scanner::ScanProgressStep::FetchingTrackFeatures:
bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features")
.arg(status.currentScanStepStats->processedFiles)
.arg(status.currentScanStepStats->filesToProcess)
.arg(status.currentScanStepStats->progress()));
break;
}
break;
}
@@ -21,16 +21,17 @@
#include <Wt/WTemplate.h>
namespace UserInterface {
class DatabaseStatus : public Wt::WTemplate
namespace UserInterface
{
class ScannerController : public Wt::WTemplate
{
public:
DatabaseStatus();
ScannerController();
private:
void refreshContents();
};
};
} // namespace DatabaseStatus
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "LoadingIndicator.hpp"
namespace UserInterface
{
std::unique_ptr<Wt::WTemplate>
createLoadingIndicator()
{
auto res {std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.LoadingIndicator.template"))};
res->addFunction("tr", &Wt::WTemplate::Functions::tr);
res->setScrollVisibilityMargin(200);
res->setScrollVisibilityEnabled(true);
return res;
}
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <Wt/WTemplate.h>
namespace UserInterface
{
std::unique_ptr<Wt::WTemplate> createLoadingIndicator();
}
+20 -3
View File
@@ -115,11 +115,24 @@ Artist::refreshView()
}
{
Wt::WText* addBtn = bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML);
Wt::WText* moreBtn = bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML);
addBtn->clicked().connect([=]
moreBtn->clicked().connect([=]
{
artistsAction.emit(PlayQueueAction::AddLast, {*artistId});
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled"))
->triggered().connect(this, [=]
{
artistsAction.emit(PlayQueueAction::PlayShuffled, {*artistId});
});
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect(this, [=]
{
artistsAction.emit(PlayQueueAction::PlayLast, {*artistId});
});
popup->exec(moreBtn);
});
}
@@ -135,6 +148,10 @@ Artist::refreshView()
void
Artist::refreshSimilarArtists(const std::vector<Database::IdType>& similarArtistsId)
{
if (similarArtistsId.empty())
return;
setCondition("if-has-similar-artists", true);
Wt::WContainerWidget* similarArtistsContainer {bindNew<Wt::WContainerWidget>("similar-artists")};
for (Database::IdType artistId : similarArtistsId)
+1 -1
View File
@@ -42,7 +42,7 @@ class Artist : public Wt::WTemplate
public:
Artist(Filters* filters);
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> artistsAction;
PlayQueueActionSignal artistsAction;
private:
void refreshView();
+26 -7
View File
@@ -29,6 +29,7 @@
#include "database/TrackList.hpp"
#include "utils/Logger.hpp"
#include "common/LoadingIndicator.hpp"
#include "ArtistListHelpers.hpp"
#include "LmsApplication.hpp"
#include "Filters.hpp"
@@ -75,12 +76,7 @@ Artists::Artists(Filters* filters)
_linkType->changed().connect([this] { refreshView(); });
_container = bindNew<Wt::WContainerWidget>("artists");
_showMore = bindNew<Wt::WPushButton>("show-more", Wt::WString::tr("Lms.Explore.show-more"));
_showMore->clicked().connect([=]
{
addSome();
});
hideLoadingIndicator();
refreshView();
@@ -102,6 +98,26 @@ Artists::refreshView(Mode mode)
refreshView();
}
void
Artists::displayLoadingIndicator()
{
_loadingIndicator = bindWidget<Wt::WTemplate>("loading-indicator", createLoadingIndicator());
_loadingIndicator->scrollVisibilityChanged().connect([this](bool visible)
{
if (!visible)
return;
addSome();
});
}
void
Artists::hideLoadingIndicator()
{
_loadingIndicator = nullptr;
bindEmpty("loading-indicator");
}
std::vector<Artist::pointer>
Artists::getRandomArtists(std::optional<Range> range, bool& moreResults)
{
@@ -203,7 +219,10 @@ Artists::addSome()
_container->addWidget(ArtistListHelpers::createEntry(artist));
}
_showMore->setHidden(!moreResults);
if (moreResults)
displayLoadingIndicator();
else
hideLoadingIndicator();
}
} // namespace UserInterface
+5 -5
View File
@@ -24,9 +24,7 @@
#include <Wt/WComboBox.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include <Wt/WSignal.h>
#include "database/Types.hpp"
@@ -56,6 +54,8 @@ class Artists : public Wt::WTemplate
void refreshView();
void refreshView(Mode mode);
void displayLoadingIndicator();
void hideLoadingIndicator();
void addSome();
std::vector<Wt::Dbo::ptr<Database::Artist>> getArtists(std::optional<Database::Range> range, bool& moreResults);
@@ -67,15 +67,15 @@ class Artists : public Wt::WTemplate
{
{Mode::Random, batchSize * 4},
{Mode::RecentlyPlayed, batchSize * 4},
{Mode::RecentlyAdded, batchSize * 2},
{Mode::MostPlayed, batchSize * 2},
{Mode::RecentlyAdded, batchSize * 4},
{Mode::MostPlayed, batchSize * 4},
{Mode::All, batchSize * 50},
};
Mode _mode {defaultMode};
std::vector<Database::IdType> _randomArtists;
Filters* _filters {};
Wt::WPushButton* _showMore {};
Wt::WTemplate* _loadingIndicator {};
Wt::WContainerWidget* _container {};
Wt::WComboBox* _linkType {};
};
+14
View File
@@ -35,6 +35,7 @@
#include "Filters.hpp"
#include "ReleasesView.hpp"
#include "ReleaseView.hpp"
#include "SearchView.hpp"
#include "TracksView.hpp"
namespace UserInterface {
@@ -50,6 +51,7 @@ handleContentsPathChange(Wt::WStackedWidget* stack)
IdxArtist,
IdxReleases,
IdxRelease,
IdxSearch,
IdxTracks,
};
@@ -59,6 +61,7 @@ handleContentsPathChange(Wt::WStackedWidget* stack)
{ "/artist", IdxArtist },
{ "/releases", IdxReleases },
{ "/release", IdxRelease },
{ "/search", IdxSearch },
{ "/tracks", IdxTracks },
};
@@ -100,6 +103,11 @@ Explore::Explore(Filters* filters)
release->tracksAction.connect(this, &Explore::handleTracksAction);
contentsStack->addWidget(std::move(release));
auto search = std::make_unique<SearchView>(_filters);
search->tracksAction.connect(this, &Explore::handleTracksAction);
_search = search.get();
contentsStack->addWidget(std::move(search));
auto tracks = std::make_unique<Tracks>(_filters);
tracks->tracksAction.connect(this, &Explore::handleTracksAction);
contentsStack->addWidget(std::move(tracks));
@@ -112,6 +120,12 @@ Explore::Explore(Filters* filters)
handleContentsPathChange(contentsStack);
}
void
Explore::search(const Wt::WString& searchText)
{
_search->refreshView(searchText);
}
static
std::vector<Database::IdType>
getArtistsTracks(Database::Session& session, const std::vector<Database::IdType>& artistsId, const std::set<Database::IdType>&)
+5 -1
View File
@@ -27,13 +27,16 @@
namespace UserInterface {
class Filters;
class SearchView;
class Explore : public Wt::WTemplate
{
public:
Explore(Filters* filters);
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> tracksAction;
void search(const Wt::WString& searchText);
PlayQueueActionSignal tracksAction;
private:
@@ -42,6 +45,7 @@ class Explore : public Wt::WTemplate
void handleTracksAction(PlayQueueAction action, const std::vector<Database::IdType>& tracksId);
Filters* _filters {};
SearchView* _search {};
};
} // namespace UserInterface
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ReleasePopup.hpp"
#include "LmsApplication.hpp"
namespace UserInterface
{
void
displayReleasePopupMenu(Wt::WInteractWidget& target,
Database::IdType releaseId,
PlayQueueActionSignal& releasesAction)
{
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled"))
->triggered().connect(&target, [&releasesAction, releaseId]
{
releasesAction.emit(PlayQueueAction::PlayShuffled, {releaseId});
});
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect(&target, [&releasesAction, releaseId]
{
releasesAction.emit(PlayQueueAction::PlayLast, {releaseId});
});
popup->popup(&target);
}
} // namespace UserInterface
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <Wt/WWidget.h>
#include <Wt/WSignal.h>
#include "database/Types.hpp"
#include "PlayQueueAction.hpp"
namespace UserInterface
{
void displayReleasePopupMenu(Wt::WInteractWidget& target,
Database::IdType releaseId,
PlayQueueActionSignal& releasesAction);
} // namespace UserInterface
+19 -7
View File
@@ -22,7 +22,6 @@
#include <Wt/WAnchor.h>
#include <Wt/WApplication.h>
#include <Wt/WImage.h>
#include <Wt/WPopupMenu.h>
#include <Wt/WTemplate.h>
#include <Wt/WText.h>
@@ -39,6 +38,7 @@
#include "LmsApplicationException.hpp"
#include "MediaPlayer.hpp"
#include "ReleaseListHelpers.hpp"
#include "ReleasePopup.hpp"
#include "TrackStringUtils.hpp"
using namespace Database;
@@ -157,10 +157,10 @@ Release::refreshView()
}
{
Wt::WText* addBtn {bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)};
addBtn->clicked().connect([=]
Wt::WText* moreBtn {bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)};
moreBtn->clicked().connect([=]
{
releasesAction.emit(PlayQueueAction::AddLast, {*releaseId});
displayReleasePopupMenu(*moreBtn, *releaseId, releasesAction);
});
}
@@ -237,10 +237,18 @@ Release::refreshView()
tracksAction.emit(PlayQueueAction::Play, {trackId});
});
Wt::WText* addBtn {entry->bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)};
addBtn->clicked().connect([=]()
Wt::WText* moreBtn {entry->bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)};
moreBtn->clicked().connect([=]()
{
tracksAction.emit(PlayQueueAction::AddLast, {trackId});
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect(moreBtn, [=]
{
tracksAction.emit(PlayQueueAction::PlayLast, {trackId});
});
popup->popup(moreBtn);
});
entry->bindString("duration", trackDurationToString(track->getDuration()), Wt::TextFormat::Plain);
@@ -306,6 +314,10 @@ Release::refreshLinks(const Database::Release::pointer& release)
void
Release::refreshSimilarReleases(const std::vector<Database::IdType>& similarReleasesId)
{
if (similarReleasesId.empty())
return;
setCondition("if-has-similar-releases", true);
auto* similarReleasesContainer {bindNew<Wt::WContainerWidget>("similar-releases")};
for (Database::IdType id : similarReleasesId)
+2 -6
View File
@@ -19,7 +19,6 @@
#pragma once
#include <Wt/WSignal.h>
#include <Wt/WTemplate.h>
#include "database/Types.hpp"
@@ -39,11 +38,8 @@ namespace UserInterface
public:
Release(Filters* filters);
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> releasesAction;
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> tracksAction;
Wt::Signal<Wt::WWidget&, Database::IdType> createReleaseActionMenu;
Wt::Signal<Wt::WWidget&, Database::IdType> createTrackActionMenu;
PlayQueueActionSignal releasesAction;
PlayQueueActionSignal tracksAction;
private:
void refreshView();
+42 -10
View File
@@ -32,6 +32,7 @@
#include "utils/Logger.hpp"
#include "utils/String.hpp"
#include "common/LoadingIndicator.hpp"
#include "resource/ImageResource.hpp"
#include "ReleaseListHelpers.hpp"
#include "Filters.hpp"
@@ -71,19 +72,27 @@ _filters {filters}
{
releasesAction.emit(PlayQueueAction::Play, getAllReleases());
});
Wt::WText* addBtn {bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)};
addBtn->clicked().connect([this]
Wt::WText* moreBtn {bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)};
moreBtn->clicked().connect([=]
{
releasesAction.emit(PlayQueueAction::AddLast, getAllReleases());
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled"))
->triggered().connect([this]
{
releasesAction.emit(PlayQueueAction::PlayShuffled, getAllReleases());
});
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect([this]
{
releasesAction.emit(PlayQueueAction::PlayLast, getAllReleases());
});
popup->popup(moreBtn);
});
_container = bindNew<Wt::WContainerWidget>("releases");
_showMore = bindNew<Wt::WPushButton>("show-more", Wt::WString::tr("Lms.Explore.show-more"));
_showMore->clicked().connect([this]
{
addSome();
});
hideLoadingIndicator();
refreshView(defaultMode);
@@ -105,6 +114,26 @@ Releases::refreshView(Mode mode)
refreshView();
}
void
Releases::displayLoadingIndicator()
{
_loadingIndicator = bindWidget<Wt::WTemplate>("loading-indicator", createLoadingIndicator());
_loadingIndicator->scrollVisibilityChanged().connect([this](bool visible)
{
if (!visible)
return;
addSome();
});
}
void
Releases::hideLoadingIndicator()
{
_loadingIndicator = nullptr;
bindEmpty("loading-indicator");
}
void
Releases::addSome()
{
@@ -118,7 +147,10 @@ Releases::addSome()
_container->addWidget(ReleaseListHelpers::createEntry(release));
}
_showMore->setHidden(!moreResults);
if (moreResults)
displayLoadingIndicator();
else
hideLoadingIndicator();
}
std::vector<Database::Release::pointer>
+11 -8
View File
@@ -22,7 +22,6 @@
#include <optional>
#include <Wt/WContainerWidget.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include "database/Types.hpp"
@@ -42,7 +41,7 @@ class Releases : public Wt::WTemplate
public:
Releases(Filters* filters);
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> releasesAction;
PlayQueueActionSignal releasesAction;
private:
@@ -57,19 +56,23 @@ class Releases : public Wt::WTemplate
void refreshView();
void refreshView(Mode mode);
void displayLoadingIndicator();
void hideLoadingIndicator();
void addSome();
std::vector<Wt::Dbo::ptr<Database::Release>> getReleases(std::optional<Database::Range> range, bool& moreResults);
std::vector<Wt::Dbo::ptr<Database::Release>> getRandomReleases(std::optional<Database::Range> range, bool& moreResults);
std::vector<Database::IdType> getAllReleases();
static constexpr Mode defaultMode {Mode::Random};
static constexpr std::size_t batchSize {18};
static constexpr std::size_t maxItemsPerLine {6};
static constexpr std::size_t batchSize {maxItemsPerLine * 3};
static inline std::unordered_map<Mode, std::optional<std::size_t>> maxItemsPerMode
{
{Mode::Random, batchSize * 6},
{Mode::RecentlyPlayed, batchSize * 3},
{Mode::RecentlyAdded, batchSize * 2},
{Mode::MostPlayed, batchSize * 2},
{Mode::Random, batchSize * 10},
{Mode::RecentlyPlayed, batchSize * 10},
{Mode::RecentlyAdded, batchSize * 10},
{Mode::MostPlayed, batchSize * 10},
{Mode::All, batchSize * 30},
};
@@ -77,7 +80,7 @@ class Releases : public Wt::WTemplate
Filters* _filters {};
std::vector<Database::IdType> _randomReleases;
Wt::WContainerWidget* _container {};
Wt::WPushButton* _showMore {};
Wt::WTemplate* _loadingIndicator {};
};
} // namespace UserInterface
+3 -3
View File
@@ -51,9 +51,9 @@ namespace UserInterface
}
void
SearchView::refreshView(const std::string& searchText)
SearchView::refreshView(const Wt::WString& searchText)
{
_keywords = StringUtils::splitString(searchText, " ");
_keywords = StringUtils::splitString(searchText.toUTF8(), " ");
refreshView();
}
@@ -127,7 +127,7 @@ namespace UserInterface
auto* container {bindNew<Wt::WContainerWidget>("tracks")};
for (const Database::Track::pointer& track : tracks)
container->addWidget(TrackListHelpers::createEntry(track));
container->addWidget(TrackListHelpers::createEntry(track, tracksAction));
}
}
+5 -1
View File
@@ -24,6 +24,8 @@
#include <Wt/WTemplate.h>
#include "PlayQueueAction.hpp"
namespace UserInterface {
class Filters;
@@ -33,7 +35,9 @@ class SearchView : public Wt::WTemplate
public:
SearchView(Filters* filters);
void refreshView(const std::string& searchText);
PlayQueueActionSignal tracksAction;
void refreshView(const Wt::WString& searchText);
private:
void refreshView();
+14 -7
View File
@@ -30,7 +30,6 @@
#include "resource/ImageResource.hpp"
#include "LmsApplication.hpp"
#include "MediaPlayer.hpp"
#include "PlayQueue.hpp"
#include "TrackStringUtils.hpp"
using namespace Database;
@@ -38,7 +37,7 @@ using namespace Database;
namespace UserInterface::TrackListHelpers
{
std::unique_ptr<Wt::WTemplate>
createEntry(const Wt::Dbo::ptr<Database::Track>& track)
createEntry(const Wt::Dbo::ptr<Database::Track>& track, PlayQueueActionSignal& tracksAction)
{
auto entry {std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.Explore.Tracks.template.entry"))};
auto* entryPtr {entry.get()};
@@ -89,15 +88,23 @@ namespace UserInterface::TrackListHelpers
entry->bindString("duration", trackDurationToString(track->getDuration()), Wt::TextFormat::Plain);
Wt::WText* playBtn = entry->bindNew<Wt::WText>("play-btn", Wt::WString::tr("Lms.Explore.template.play-btn"), Wt::TextFormat::XHTML);
playBtn->clicked().connect([=]
playBtn->clicked().connect([trackId, &tracksAction]
{
LmsApp->getPlayQueue().processTracks(PlayQueueAction::Play, {trackId});
tracksAction.emit(PlayQueueAction::Play, {trackId});
});
Wt::WText* addBtn = entry->bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML);
addBtn->clicked().connect([=]
Wt::WText* moreBtn = entry->bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML);
moreBtn->clicked().connect([=, &tracksAction]
{
LmsApp->getPlayQueue().processTracks(PlayQueueAction::AddLast, {trackId});
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect(moreBtn, [=, &tracksAction]
{
tracksAction.emit(PlayQueueAction::PlayLast, {trackId});
});
popup->popup(moreBtn);
});
LmsApp->getMediaPlayer().trackLoaded.connect(entryPtr, [=] (Database::IdType loadedTrackId)
+2 -1
View File
@@ -22,6 +22,7 @@
#include <memory>
#include <Wt/WTemplate.h>
#include "PlayQueueAction.hpp"
namespace Database
{
@@ -30,6 +31,6 @@ namespace Database
namespace UserInterface::TrackListHelpers
{
std::unique_ptr<Wt::WTemplate> createEntry(const Wt::Dbo::ptr<Database::Track>& track);
std::unique_ptr<Wt::WTemplate> createEntry(const Wt::Dbo::ptr<Database::Track>& track, PlayQueueActionSignal& tracksAction);
} // namespace UserInterface
+43 -12
View File
@@ -32,8 +32,8 @@
#include "utils/Logger.hpp"
#include "utils/String.hpp"
#include "common/LoadingIndicator.hpp"
#include "resource/ImageResource.hpp"
#include "Filters.hpp"
#include "LmsApplication.hpp"
#include "MediaPlayer.hpp"
@@ -75,19 +75,27 @@ _filters {filters}
tracksAction.emit(PlayQueueAction::Play, getAllTracks());
});
Wt::WText* addBtn = bindNew<Wt::WText>("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML);
addBtn->clicked().connect([=]
Wt::WText* moreBtn = bindNew<Wt::WText>("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML);
moreBtn->clicked().connect([=]
{
tracksAction.emit(PlayQueueAction::AddLast, getAllTracks());
Wt::WPopupMenu* popup {LmsApp->createPopupMenu()};
popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled"))
->triggered().connect(this, [this]
{
tracksAction.emit(PlayQueueAction::PlayShuffled, getAllTracks());
});
popup->addItem(Wt::WString::tr("Lms.Explore.play-last"))
->triggered().connect(this, [this]
{
tracksAction.emit(PlayQueueAction::PlayLast, getAllTracks());
});
popup->popup(moreBtn);
});
_tracksContainer = bindNew<Wt::WContainerWidget>("tracks");
_showMore = bindNew<Wt::WPushButton>("show-more", Wt::WString::tr("Lms.Explore.show-more"));
_showMore->clicked().connect([this]
{
addSome();
});
hideLoadingIndicator();
filters->updated().connect([this]
{
@@ -112,6 +120,26 @@ Tracks::refreshView(Mode mode)
refreshView();
}
void
Tracks::displayLoadingIndicator()
{
_loadingIndicator = bindWidget<Wt::WTemplate>("loading-indicator", createLoadingIndicator());
_loadingIndicator->scrollVisibilityChanged().connect([this](bool visible)
{
if (!visible)
return;
addSome();
});
}
void
Tracks::hideLoadingIndicator()
{
_loadingIndicator = nullptr;
bindEmpty("loading-indicator");
}
std::vector<Database::Track::pointer>
Tracks::getRandomTracks(std::optional<Range> range, bool& moreResults)
{
@@ -203,10 +231,13 @@ Tracks::addSome()
bool moreResults;
for (const Track::pointer& track : getTracks(Range {static_cast<std::size_t>(_tracksContainer->count()), batchSize}, moreResults))
{
_tracksContainer->addWidget(TrackListHelpers::createEntry(track));
_tracksContainer->addWidget(TrackListHelpers::createEntry(track, tracksAction));
}
_showMore->setHidden(!moreResults);
if (moreResults)
displayLoadingIndicator();
else
hideLoadingIndicator();
}
} // namespace UserInterface
+4 -5
View File
@@ -22,8 +22,6 @@
#include <optional>
#include <Wt/WContainerWidget.h>
#include <Wt/WLineEdit.h>
#include <Wt/WPushButton.h>
#include <Wt/WTemplate.h>
#include "database/Types.hpp"
@@ -42,7 +40,7 @@ class Tracks : public Wt::WTemplate
public:
Tracks(Filters* filters);
Wt::Signal<PlayQueueAction, const std::vector<Database::IdType>&> tracksAction;
PlayQueueActionSignal tracksAction;
private:
@@ -57,6 +55,8 @@ class Tracks : public Wt::WTemplate
void refreshView();
void refreshView(Mode mode);
void displayLoadingIndicator();
void hideLoadingIndicator();
void addSome();
std::vector<Wt::Dbo::ptr<Database::Track>> getRandomTracks(std::optional<Database::Range> range, bool& moreResults);
@@ -78,8 +78,7 @@ class Tracks : public Wt::WTemplate
Filters* _filters {};
std::vector<Database::IdType> _randomTracks;
Wt::WContainerWidget* _tracksContainer {};
Wt::WPushButton* _showMore {};
Wt::WLineEdit* _search {};
Wt::WTemplate* _loadingIndicator {};
};
} // namespace UserInterface