diff --git a/Dockerfile-build-alpine b/Dockerfile-build-alpine index e4a29b8b..6580b786 100644 --- a/Dockerfile-build-alpine +++ b/Dockerfile-build-alpine @@ -1,5 +1,5 @@ FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx -FROM --platform=$BUILDPLATFORM alpine:3.19 +FROM --platform=$BUILDPLATFORM alpine:3.21 ARG BUILD_PACKAGES="\ clang \ diff --git a/Dockerfile-release b/Dockerfile-release index 7a7c0485..2d1a29c8 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -1,4 +1,4 @@ -FROM alpine:3.20 AS build +FROM alpine:3.21.3 AS build WORKDIR /tmp/workdir @@ -37,7 +37,7 @@ ARG BUILD_PACKAGES=" \ RUN apk add --no-cache --update ${BUILD_PACKAGES} # ffmpeg -ARG FFMPEG_VERSION=6.1.1 +ARG FFMPEG_VERSION=6.1.2 RUN \ DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \ @@ -56,8 +56,8 @@ RUN \ --disable-postproc \ --disable-pixelutils \ --disable-network \ - --disable-shared \ - --enable-static \ + --enable-shared \ + --disable-static \ --enable-gpl \ --enable-small \ --enable-version3 \ @@ -75,12 +75,11 @@ RUN \ --enable-filter=aresample \ --enable-lto \ --extra-libs=-ldl && \ - make -j$(nproc) && \ - make install && \ - make distclean + make -j$(nproc) install # WT -ARG WT_VERSION=4.11.1 +ARG WT_VERSION=4.11.3 +ARG WT_DEBUG=OFF RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://github.com/emweb/wt/archive/${WT_VERSION}.tar.gz && \ @@ -88,9 +87,8 @@ RUN \ RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ - cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DSHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF -DUSE_SYSTEM_SQLITE3=ON && \ - make -j$(nproc) && \ - make install + cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_CXX_STANDARD=17 -DSHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF -DUSE_SYSTEM_SQLITE3=ON -DDEBUG=${WT_DEBUG} && \ + make -j$(nproc) install # STB ARG STB_VERSION=5c205738c191bcb0abc65c4febfa9bd25ff35234 @@ -110,18 +108,16 @@ RUN \ RUN \ DIR=/tmp/taglib && mkdir -p ${DIR} && cd ${DIR} && \ - CXXFLAGS="-I/usr/include/utf8cpp" cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_SHARED_LIBS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_BINDINGS=OFF -DBUILD_TESTING=OFF -DTRACE_IN_RELEASE=OFF -DWITH_ZLIB=ON && \ - make -j$(nproc) && \ - make install + CXXFLAGS="-I/usr/include/utf8cpp" cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_SHARED_LIBS=ON -DBUILD_EXAMPLES=OFF -DBUILD_BINDINGS=OFF -DBUILD_TESTING=OFF -DTRACE_IN_RELEASE=OFF -DWITH_ZLIB=ON && \ + make -j$(nproc) install # LMS COPY . /tmp/lms/ RUN \ DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \ PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \ - LD_LIBRARY_PATH=${PREFIX}/lib make -j$(nproc) && \ + LD_LIBRARY_PATH=${PREFIX}/lib make -j$(nproc) install && \ LD_LIBRARY_PATH=${PREFIX}/lib make test && \ - make install && \ mkdir -p ${PREFIX}/etc/ && \ cp /tmp/lms/conf/lms.conf ${PREFIX}/etc @@ -133,6 +129,11 @@ RUN \ strip --strip-all $bin && \ cp $bin /tmp/fakeroot/bin/; \ done && \ + for lib in ${PREFIX}/lib/*.so; \ + do \ + strip --strip-all $lib; \ + done && \ + cp -r ${PREFIX}/lib /tmp/fakeroot/lib && \ cp -r ${PREFIX}/share /tmp/fakeroot/share && \ rm -rf /tmp/fakeroot/share/doc && \ rm -rf /tmp/fakeroot/share/man @@ -143,7 +144,7 @@ RUN \ rm -rf /tmp/fakeroot/share/Wt/resources/themes ## Release Stage -FROM alpine:3.20 AS release +FROM alpine:3.21.3 AS release LABEL maintainer="Emeric Poupon " ARG RUNTIME_PACKAGES=" \ @@ -159,7 +160,6 @@ ARG RUNTIME_PACKAGES=" \ boost-filesystem \ boost-iostreams \ boost-program_options \ - boost-system \ boost-thread \ libarchive \ libconfig++ \ diff --git a/README.md b/README.md index a6dbca37..d9978ff6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat * Multi-valued tags: `genre`, `mood`, `artists`, ... * Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer` * [Release types](https://musicbrainz.org/doc/Release_Group/Type): `album`, `single`, `EP`, `compilation`, `live`, ... +* [Release groups](https://musicbrainz.org/doc/Release_Group) support to show different versions of albums, such as remasters, reissues, etc. * [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names * [ListenBrainz](https://listenbrainz.org) support for: * Scrobbling and synchronizing listens @@ -51,7 +52,7 @@ __Note__: You can use the `lms-metadata` tool to get an idea of the tags parsed _LMS_ works best when using the default [Picard](https://picard.musicbrainz.org/) settings, where the `artist` tag contains a single display-friendly value, and the `artists` tag holds the actual artist names. This ensures a cleaner, more organized representation of artist names, when multiple artists are involved. ### Multiple album artists -While LMS can manage multiple album artists using the `albumartist` tag, it works better when using the custom `albumartists` and `albumartistssort` tags, similar to how it handles regular artist tags. +While _LMS_ can manage multiple album artists using the `albumartist` tag, it works better when using the custom `albumartists` and `albumartistssort` tags, similar to how it handles regular artist tags. __Note__: if you use Picard, add the following script to include these tags: ``` @@ -59,6 +60,29 @@ $setmulti(albumartists,%_albumartists%) $setmulti(albumartistssort,%_albumartists_sort%) ``` +### Album track grouping +The recommended way to group tracks within an album is to use the `MUSICBRAINZ_ALBUMID` tag. + +When this tag is not present, _LMS_ will attempt to group them as best as possible: if the analyzed file contains a disc number and the total number of discs is greater than 1, sibling directories are also scanned to find a matching album. +Otherwise, _LMS_ will only consider albums within the current directory. + +For an album to be considered a match, the following conditions must be met: +* Same name +* Same sort name +* Same total number of discs +* Identical 'compilation' flag value +* Same record labels +* Same barcode + +## Artist image lookup +The recommended method is to name the artist image file using the artist's MusicBrainz ArtistID. This file can be placed anywhere within one of the scanned libraries. + +If no file with the MusicBrainz ArtistID is found, _LMS_ will look for a file named `artist` (or another name configured in `lms.conf`) in the artist's directories: + +1. Identify the artist's directory: _LMS_ selects all albums by the artist using the "AlbumArtist" link and determines the longest common path among them. +2. Scan for the image: the directory is scanned starting from this common path, moving upwards if needed, until the artist image file is found. +3. Fallback search: if no image is found, _LMS_ will then search within each individual album folder. + ## Playlist support _LMS_ supports playlist files in `m3u` and `m3u8` formats. These playlists are synced during the scan process and are available as public shared playlists. @@ -71,6 +95,8 @@ _LMS_ supports lyrics in `lrc` files, `txt` files, and embedded track metadata. * Next track: Ctrl + Right * Decrease volume: Ctrl + Down * Increase volume: Ctrl + Up +* Seek back by 5 seconds: Ctrl + Shift + Left +* Seek forward by 5 seconds: Ctrl + Shift + Right ## Installation diff --git a/SUBSONIC.md b/SUBSONIC.md index 9948280e..0ef54e21 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -25,6 +25,7 @@ The following extra fields are implemented: * `artists` * `discTitles`: discs with no subtitle are omitted * `displayArtist` + * `explicitStatus` * `genres` * `isCompilation` * `played` @@ -44,6 +45,7 @@ The following extra fields are implemented: * `contributors` * `displayAlbumArtist` * `displayArtist` + * `explicitStatus` * `genres` * `mediaType` * `moods` diff --git a/approot/messages.xml b/approot/messages.xml index b913e32e..c82129b1 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -177,6 +177,7 @@ Duration Filter added Filters +Label Music Library Most played MusicBrainz Artist @@ -191,6 +192,7 @@ Recently modified Recently played Release information +Release type Albums Search Star @@ -248,9 +250,10 @@ -Similar albums Copyright Disc {1} +Other versions +Similar albums Type Album Broadcast diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 34bb5cda..8083fc56 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -177,6 +177,7 @@ Durée Filtre ajouté Filtres +Label Bibliothèque musicale Plus joués MusicBrainz Artist @@ -191,6 +192,7 @@ Modifiés récemment Joués récemment Informations sur l'album +Type de publication Albums Rechercher Ajouter aux favoris @@ -248,9 +250,10 @@ -Albums similaires Copyright Disque {1} +Autres versions +Albums similaires Type Album Diffusion diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 6c18325b..567097ab 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -177,6 +177,7 @@ Durata Filtro aggiunto Filtri +Etichetta Libreria musicale Più ascoltati Artista MusicBrainz @@ -191,6 +192,7 @@ Modificati di recente Riprodotti di recente Informazioni sull'album +Tipo di pubblicazione Album Ricerca Aggiungi ai preferiti @@ -248,9 +250,10 @@ -Album simili Copyright Disco {1} +Altre versioni +Album simili Tipo Album Trasmissione diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 5ece9b62..ac97014a 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -194,6 +194,7 @@ Długość Filtr dodany Filtry +Wytwórnia Biblioteka muzyczna Najczęściej odtwarzane Artysta w MusicBrainz @@ -208,6 +209,7 @@ Ostatnio zmienione Ostatnio odtwarzane Informacje o wydaniu +Typ wydania Albumy Szukaj Wyróżnij @@ -274,9 +276,10 @@ -Podobne albumy Prawa autorskie Dysk {1} +Inne wersje +Podobne albumy Typ Album Audycja diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index de9ebbc1..21e240fa 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -178,6 +178,7 @@ 过滤器已添加 过滤器 + 播放最多 MusicBrainz 歌手 MusicBrainz 专辑 @@ -191,6 +192,7 @@ 最近更改 最近播放 + 专辑 搜索 收藏 @@ -248,10 +250,11 @@ -相似专辑 版权所有 唱片 {1} +相似专辑 + diff --git a/approot/release.xml b/approot/release.xml index 448868d9..46b190f8 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -9,10 +9,10 @@
- ${cover} + ${cover class="Lms-cursor-pointer rounded"}
-

${name}

+

${name}${}[${comment}]${}

${}${artists}${}
${}${year} · ${}${duration}
${clusters class="mb-2"} @@ -39,12 +39,28 @@ ${}
+ ${} +

${tr:Lms.Explore.Release.other-versions}

+ ${other-versions class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} + ${
} ${}

${tr:Lms.Explore.Release.similar-releases}

- ${similar-releases class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"} + ${similar-releases class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} ${
}
+ + + + ${artist class="text-decoration-none link-success"} @@ -132,6 +148,16 @@ ${} ${artist-table class="d-grid gap-2"} ${} + ${} +
+
+ ${tr:Lms.Explore.label} +
+
+ ${release-labels} +
+
+ ${
} ${}
diff --git a/approot/releases.xml b/approot/releases.xml index 534480d9..ed30939d 100644 --- a/approot/releases.xml +++ b/approot/releases.xml @@ -27,15 +27,15 @@
${cover class="shadow-sm"}
- ${release-name class="d-block text-truncate text-nowrap text-decoration-none link-success"} - ${}${artist-name class="d-block text-truncate text-nowrap"}${} + ${release-name class="Lms-multiline-clamp text-decoration-none link-success"} + ${}${artist-name class="Lms-multiline-clamp"}${} ${}
${year}
${
}
- ${elements class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"} + ${elements class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} ${loading-indicator} diff --git a/docroot/css/lms.css b/docroot/css/lms.css index 775c648a..cee169a6 100644 --- a/docroot/css/lms.css +++ b/docroot/css/lms.css @@ -87,6 +87,30 @@ body { min-width: 64px; } +.Lms-cover-full-modal-dialog { + width: auto; + max-width: none; + max-height: 90vh; + display: flex; + align-items: center; + justify-content: center; +} + +.Lms-cover-full { + max-width: 100%; + max-height: 95vh; + width: auto; + height: auto; + display: block; + margin: auto; + cursor: pointer; + object-fit: contain; +} + +.Lms-cursor-pointer { + cursor: pointer; +} + #lms-mp-progress { transition: none; } @@ -103,6 +127,15 @@ body { background-color: var(--bs-gray-900); } +.Lms-multiline-clamp { + line-clamp: 2; + -webkit-line-clamp: 2; + overflow: hidden; + display: -webkit-box; + word-break: break-word; + -webkit-box-orient: vertical; +} + .Lms-notification-container { position: fixed; bottom: 78px; diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index d926c5aa..bc594855 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -1,123 +1,253 @@ // @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later -var LMS = LMS || {}; - // Keep in sync with MediaPlayer::TranscodingMode cpp -const TranscodingMode = { +LMSTranscodingMode = { Never: 0, Always: 1, IfFormatNotSupported: 2, } +Object.freeze(LMSTranscodingMode); -const Mode = { - Transcoding: 1, - File: 2, -} -Object.freeze(Mode); +class LMSMediaPlayer { + // How much to increase / decrease volume when adjusting it with keyboard shortcuts + static #volumeStepAmount = 0.05; -// How much to increase / decrease volume when adjusting it with keyboard shortcuts -const volumeStepAmount = 0.05; + // How much to seek back / forward (in seconds) with keyboard shortcuts + static #seekAmount = 5; -LMS.mediaplayer = function () { - let _root = {}; - let _elems = {}; - let _offset = 0; - let _trackId = null; - let _duration = 0; - let _audioNativeSrc; - let _audioTranscodingSrc; - let _settings = {}; - let _playedDuration = 0; - let _lastStartPlaying = null; - let _audioIsInit = false; - let _pendingTrackParameters = null; - let _gainNode = null; - let _audioCtx = null; + static #Mode = { + Transcoding: 1, + File: 2, + } - let _unlock = function() { - document.removeEventListener("touchstart", _unlock); - document.removeEventListener("touchend", _unlock); - document.removeEventListener("click", _unlock); - _initAudioCtx(); + #root; + #elems; + #offset; + #trackId; + #duration; + #audioNativeSrc; + #audioTranscodingSrc; + #settings; + #playedDuration; + #lastStartPlaying; + #audioIsInit; + #pendingTrackParameters; + #gainNode; + #audioCtx; + + constructor(root, defaultSettings) { + this.#root = root; + this.#elems = {}; + this.#offset = 0; + this.#trackId = null; + this.#duration = 0; + this.#settings = {}; + this.#playedDuration = 0; + this.#lastStartPlaying = null; + this.#audioIsInit = false; + this.#pendingTrackParameters = null; + this.#gainNode = null; + this.#audioCtx = null; + + this.#elems.audio = document.getElementById("lms-mp-audio"); + this.#elems.playpause = document.getElementById("lms-mp-playpause"); + this.#elems.previous = document.getElementById("lms-mp-previous"); + this.#elems.next = document.getElementById("lms-mp-next"); + this.#elems.progress = document.getElementById("lms-mp-progress"); + this.#elems.seek = document.getElementById("lms-mp-seek"); + this.#elems.curtime = document.getElementById("lms-mp-curtime"); + this.#elems.duration = document.getElementById("lms-mp-duration"); + this.#elems.volume = document.getElementById("lms-mp-volume"); + this.#elems.volumeslider = document.getElementById("lms-mp-volume-slider"); + this.#elems.transcodingActive = document.getElementById("lms-transcoding-active"); + + this.#elems.playpause.addEventListener("click", () => { + this.#playPause(); + }); + + this.#elems.previous.addEventListener("click", () => { + this.#playPrevious(); + }); + this.#elems.next.addEventListener("click", () => { + this.#playNext(); + }); + this.#elems.seek.addEventListener("change", () => { + this.#seekTo(parseInt(this.#elems.seek.value, 10)); + }); + + this.#elems.audio.addEventListener("play", this.#updateControls.bind(this)); + this.#elems.audio.addEventListener("playing", this.#updateControls.bind(this)); + this.#elems.audio.addEventListener("pause", this.#updateControls.bind(this)); + + this.#elems.audio.addEventListener("play", this.#updateMediaSessionState.bind(this)); + this.#elems.audio.addEventListener("playing", this.#updateMediaSessionState.bind(this)); + this.#elems.audio.addEventListener("pause", this.#updateMediaSessionState.bind(this)); + + this.#elems.audio.addEventListener("pause", this.#pauseTimer.bind(this)); + this.#elems.audio.addEventListener("playing", this.#startTimer.bind(this)); + this.#elems.audio.addEventListener("waiting", this.#pauseTimer.bind(this)); + + this.#elems.audio.addEventListener("timeupdate", () => { + this.#elems.progress.style.width = "" + ((this.#offset + this.#elems.audio.currentTime) / this.#duration) * 100 + "%"; + this.#elems.curtime.innerHTML = this.#durationToString(this.#offset + this.#elems.audio.currentTime); + }); + + this.#elems.audio.addEventListener("ended", () => { + this.#resetTimer(); + Wt.emit(this.#root, "playbackEnded"); + }); + + this.#elems.audio.addEventListener("canplay", () => { + if (this.#getAudioMode() == LMSMediaPlayer.#Mode.Transcoding) { + this.#elems.transcodingActive.style.display = "inline"; + } + else { + this.#elems.transcodingActive.style.display = "none"; + } + }); + + this.#initVolume(); + this.#initDefaultSettings(defaultSettings); + + this.#elems.volumeslider.addEventListener("input", () => { + this.#setVolume(this.#elems.volumeslider.value); + }); + + this.#elems.volume.addEventListener("click", () => { + if (this.#elems.audio.volume != 0) { + this.#setVolume(0); + } + else { + this.#setVolume(this.#elems.lastvolume); + } + }); + + document.addEventListener("keydown", (event) => { + let handled = false; + + if (event.target instanceof HTMLInputElement) + return; + + if (event.keyCode == 32) { + this.#playPause(); + handled = true; + } + else if (event.ctrlKey && !event.shiftKey && event.keyCode == 37) { + this.#playPrevious(); + handled = true; + } + else if (event.ctrlKey && !event.shiftKey && event.keyCode == 39) { + this.#playNext(); + handled = true; + } + else if (event.ctrlKey && event.keyCode == 40) { + this.#stepVolumeDown(); + handled = true; + } + else if (event.ctrlKey && event.keyCode == 38) { + this.#stepVolumeUp(); + handled = true; + } + else if (event.ctrlKey && event.shiftKey && event.keyCode == 37) { + this.#seekBack(); + handled = true; + } + else if (event.ctrlKey && event.shiftKey && event.keyCode == 39) { + this.#seekForward(); + handled = true; + } + + if (handled) + event.preventDefault(); + }); + + document.addEventListener("touchstart", this.#unlock.bind(this)); + document.addEventListener("touchend", this.#unlock.bind(this)); + document.addEventListener("click", this.#unlock.bind(this)); + } + + #unlock() { + document.removeEventListener("touchstart", this.#unlock.bind(this)); + document.removeEventListener("touchend", this.#unlock.bind(this)); + document.removeEventListener("click", this.#unlock.bind(this)); + this.#initAudioCtx(); }; - let _initAudioCtx = function() { - if (_audioIsInit) { - _audioCtx.resume(); // not sure of this + #initAudioCtx() { + if (this.#audioIsInit) return; - } - _audioIsInit = true; + this.#audioIsInit = true; - _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - _gainNode = _audioCtx.createGain(); - let source = _audioCtx.createMediaElementSource(_elems.audio); - source.connect(_gainNode); - _gainNode.connect(_audioCtx.destination); - _audioCtx.resume(); // not sure of this + this.#audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + this.#gainNode = this.#audioCtx.createGain(); + let source = this.#audioCtx.createMediaElementSource(this.#elems.audio); + source.connect(this.#gainNode); + this.#gainNode.connect(this.#audioCtx.destination); + this.#audioCtx.resume(); // not sure of this if ("mediaSession" in navigator) { - navigator.mediaSession.setActionHandler("play", function() { - _playPause(); + navigator.mediaSession.setActionHandler("play", () => { + this.#playPause(); }); - navigator.mediaSession.setActionHandler("pause", function() { - _playPause(); + navigator.mediaSession.setActionHandler("pause", () => { + this.#playPause(); }); - navigator.mediaSession.setActionHandler("previoustrack", function() { - _playPrevious(); + navigator.mediaSession.setActionHandler("previoustrack", () => { + this.#playPrevious(); }); - navigator.mediaSession.setActionHandler("nexttrack", function() { - _playNext(); + navigator.mediaSession.setActionHandler("nexttrack", () => { + this.#playNext(); }); - navigator.mediaSession.setActionHandler("seekto", function(e) { - _seekTo(e.seekTime); + navigator.mediaSession.setActionHandler("seekto", (e) => { + this.#seekTo(e.seekTime); }); } - if (_pendingTrackParameters != null) { - _applyAudioTrackParameters(_pendingTrackParameters); - _pendingTrackParameters = null; + if (this.#pendingTrackParameters != null) { + this.#applyAudioTrackParameters(this.#pendingTrackParameters); + this.#pendingTrackParameters = null; } } - let _updateControls = function() { + #updateControls() { const pauseClass = "fa-pause"; const playClass = "fa-play"; - if (_elems.audio.paused) { - _elems.playpause.firstElementChild.classList.remove(pauseClass); - _elems.playpause.firstElementChild.classList.add(playClass); + if (this.#elems.audio.paused) { + this.#elems.playpause.firstElementChild.classList.remove(pauseClass); + this.#elems.playpause.firstElementChild.classList.add(playClass); } else { - _elems.playpause.firstElementChild.classList.remove(playClass); - _elems.playpause.firstElementChild.classList.add(pauseClass); + this.#elems.playpause.firstElementChild.classList.remove(playClass); + this.#elems.playpause.firstElementChild.classList.add(pauseClass); } } - let _startTimer = function() { - if (_lastStartPlaying == null) - Wt.emit(_root, "scrobbleListenNow", _trackId); - _lastStartPlaying = Date.now(); + #startTimer() { + if (this.#lastStartPlaying == null) + Wt.emit(this.#root, "scrobbleListenNow", this.#trackId); + this.#lastStartPlaying = Date.now(); } - let _pauseTimer = function() { - if (_lastStartPlaying != null) { - _playedDuration += Date.now() - _lastStartPlaying; - _lastStartPlaying = null; + #pauseTimer() { + if (this.#lastStartPlaying != null) { + this.#playedDuration += Date.now() - this.#lastStartPlaying; + this.#lastStartPlaying = null; } } - let _resetTimer = function() { - if (_lastStartPlaying != null) - _pauseTimer(); + #resetTimer() { + if (this.#lastStartPlaying != null) + this.#pauseTimer(); - if (_playedDuration > 0) { - Wt.emit(_root, "scrobbleListenFinished", _trackId, _playedDuration); - _playedDuration = 0; + if (this.#playedDuration > 0) { + Wt.emit(this.#root, "scrobbleListenFinished", this.#trackId, this.#playedDuration); + this.#playedDuration = 0; } } - let _durationToString = function (duration) { + #durationToString(duration) { const seconds = parseInt(duration, 10); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -129,312 +259,173 @@ LMS.mediaplayer = function () { ].filter(Boolean).join(':'); } - let _playTrack = function() { - _elems.audio.play() - .then(_ => {}) + #playTrack() { + this.#elems.audio.play() + .then(_ => { }) .catch(error => { console.log("Cannot play audio: " + error); }); } - let _playPause = function() { - _initAudioCtx(); + #playPause() { + this.#initAudioCtx(); - if (_elems.audio.paused && _elems.audio.children.length > 0) { - _playTrack(); + if (this.#elems.audio.paused && this.#elems.audio.children.length > 0) { + this.#playTrack(); } else - _elems.audio.pause(); + this.#elems.audio.pause(); } - let _playPrevious = function() { - _initAudioCtx(); - Wt.emit(_root, "playPrevious"); + #playPrevious() { + this.#initAudioCtx(); + Wt.emit(this.#root, "playPrevious"); } - let _playNext = function() { - _initAudioCtx(); - Wt.emit(_root, "playNext"); + #playNext() { + this.#initAudioCtx(); + Wt.emit(this.#root, "playNext"); } - let _initVolume = function() { - if (typeof(Storage) !== "undefined" && localStorage.volume) { - _elems.volumeslider.value = Number(localStorage.volume); + #initVolume() { + if (typeof (Storage) !== "undefined" && localStorage.volume) { + this.#elems.volumeslider.value = Number(localStorage.volume); } - _setVolume(_elems.volumeslider.value); + this.#setVolume(this.#elems.volumeslider.value); } - let _initDefaultSettings = function(defaultSettings) { - if (typeof(Storage) !== "undefined" && localStorage.settings) { - _settings = Object.assign(defaultSettings, JSON.parse(localStorage.settings)); + #initDefaultSettings = function (defaultSettings) { + if (typeof (Storage) !== "undefined" && localStorage.settings) { + this.#settings = Object.assign(defaultSettings, JSON.parse(localStorage.settings)); } else { - _settings = defaultSettings; + this.#settings = defaultSettings; } - Wt.emit(_root, "settingsLoaded", JSON.stringify(_settings)); + Wt.emit(this.#root, "settingsLoaded", JSON.stringify(this.#settings)); } - let _setVolume = function(volume) { - _elems.lastvolume = _elems.audio.volume; + #setVolume(volume) { + this.#elems.lastvolume = this.#elems.audio.volume; - _elems.audio.volume = volume; - _elems.volumeslider.value = volume; + this.#elems.audio.volume = volume; + this.#elems.volumeslider.value = volume; if (volume > 0.5) { - _elems.volume.classList.remove("fa-volume-off"); - _elems.volume.classList.remove("fa-volume-down"); - _elems.volume.classList.add("fa-volume-up"); + this.#elems.volume.classList.remove("fa-volume-off"); + this.#elems.volume.classList.remove("fa-volume-down"); + this.#elems.volume.classList.add("fa-volume-up"); } else if (volume > 0) { - _elems.volume.classList.remove("fa-volume-off"); - _elems.volume.classList.remove("fa-volume-up"); - _elems.volume.classList.add("fa-volume-down"); - + this.#elems.volume.classList.remove("fa-volume-off"); + this.#elems.volume.classList.remove("fa-volume-up"); + this.#elems.volume.classList.add("fa-volume-down"); } else { - _elems.volume.classList.remove("fa-volume-up"); - _elems.volume.classList.remove("fa-volume-down"); - _elems.volume.classList.add("fa-volume-off"); + this.#elems.volume.classList.remove("fa-volume-up"); + this.#elems.volume.classList.remove("fa-volume-down"); + this.#elems.volume.classList.add("fa-volume-off"); } - if (typeof(Storage) !== "undefined") { + if (typeof (Storage) !== "undefined") { localStorage.volume = volume; } } - let _stepVolumeDown = function() { - let currentVolume = _elems.audio.volume; - let remainder = (currentVolume * 10) % (volumeStepAmount * 10); - let newVolume = remainder === 0 ? currentVolume - volumeStepAmount : currentVolume - (remainder / 10); - _setVolume(Math.max(newVolume, 0)); + #stepVolumeDown() { + let currentVolume = this.#elems.audio.volume; + let remainder = (currentVolume * 10) % (LMSMediaPlayer.#volumeStepAmount * 10); + let newVolume = remainder === 0 ? currentVolume - LMSMediaPlayer.#volumeStepAmount : currentVolume - (remainder / 10); + this.#setVolume(Math.max(newVolume, 0)); } - let _stepVolumeUp = function() { - let currentVolume = _elems.audio.volume; - let remainder = (currentVolume * 10) % (volumeStepAmount * 10); - let newVolume = remainder === 0 ? currentVolume + volumeStepAmount : currentVolume + (volumeStepAmount - (remainder / 10)); - _setVolume(Math.min(newVolume, 1)); + #stepVolumeUp() { + let currentVolume = this.#elems.audio.volume; + let remainder = (currentVolume * 10) % (LMSMediaPlayer.#volumeStepAmount * 10); + let newVolume = remainder === 0 ? currentVolume + LMSMediaPlayer.#volumeStepAmount : currentVolume + (LMSMediaPlayer.#volumeStepAmount - (remainder / 10)); + this.#setVolume(Math.min(newVolume, 1)); } - let _setReplayGain = function (replayGain) { - _gainNode.gain.value = Math.pow(10, (_settings.replayGain.preAmpGain + replayGain) / 20); + #setReplayGain(replayGain) { + this.#gainNode.gain.value = Math.pow(10, (this.#settings.replayGain.preAmpGain + replayGain) / 20); } - let _seekTo = function(seekTime) { - _initAudioCtx(); - let mode = _getAudioMode(); + #seekTo(seekTime) { + this.#initAudioCtx(); + let mode = this.#getAudioMode(); if (!mode) return; switch (mode) { - case Mode.Transcoding: - _offset = seekTime; - _removeAudioSources(); - _addAudioSource(_audioTranscodingSrc + "&offset=" + _offset); - _elems.audio.load(); - _elems.audio.currentTime = 0; - _playTrack(); + case LMSMediaPlayer.#Mode.Transcoding: + this.#offset = seekTime; + this.#removeAudioSources(); + this.#addAudioSource(this.#audioTranscodingSrc + "&offset=" + this.#offset); + this.#elems.audio.load(); + this.#elems.audio.currentTime = 0; + this.#playTrack(); break; - case Mode.File: - _elems.audio.currentTime = seekTime; - _playTrack(); + case LMSMediaPlayer.#Mode.File: + this.#elems.audio.currentTime = seekTime; + this.#playTrack(); break; } - _updateMediaSessionState(); + this.#updateMediaSessionState(); } - let _updateMediaSessionState = function() { + #seekBack() { + let currentPosition = this.#offset + this.#elems.audio.currentTime; + let newPosition = currentPosition - LMSMediaPlayer.#seekAmount; + this.#seekTo(Math.max(newPosition, 0)); + } + + #seekForward() { + let currentPosition = this.#offset + this.#elems.audio.currentTime; + let newPosition = currentPosition + LMSMediaPlayer.#seekAmount; + this.#seekTo(Math.min(newPosition, this.#duration)); + } + + #updateMediaSessionState() { if ("mediaSession" in navigator) { navigator.mediaSession.setPositionState({ - duration: _duration, + duration: this.#duration, playbackRate: 1, - position: Math.min(_offset + _elems.audio.currentTime, _duration), + position: Math.min(this.#offset + this.#elems.audio.currentTime, this.#duration), }); - if (_elems.audio.paused) + if (this.#elems.audio.paused) navigator.mediaSession.playbackState = "paused"; else navigator.mediaSession.playbackState = "playing"; } } - let init = function(root, defaultSettings) { - _root = root; - - _elems.audio = document.getElementById("lms-mp-audio"); - _elems.playpause = document.getElementById("lms-mp-playpause"); - _elems.previous = document.getElementById("lms-mp-previous"); - _elems.next = document.getElementById("lms-mp-next"); - _elems.progress = document.getElementById("lms-mp-progress"); - _elems.seek = document.getElementById("lms-mp-seek"); - _elems.curtime = document.getElementById("lms-mp-curtime"); - _elems.duration = document.getElementById("lms-mp-duration"); - _elems.volume = document.getElementById("lms-mp-volume"); - _elems.volumeslider = document.getElementById("lms-mp-volume-slider"); - _elems.transcodingActive = document.getElementById("lms-transcoding-active"); - - _elems.playpause.addEventListener("click", function() { - _playPause(); - }); - - _elems.previous.addEventListener("click", function() { - _playPrevious(); - }); - _elems.next.addEventListener("click", function() { - _playNext(); - }); - _elems.seek.addEventListener("change", function() { - _seekTo(parseInt(_elems.seek.value, 10)); - }); - - _elems.audio.addEventListener("play", _updateControls); - _elems.audio.addEventListener("playing", _updateControls); - _elems.audio.addEventListener("pause", _updateControls); - - _elems.audio.addEventListener("play", _updateMediaSessionState); - _elems.audio.addEventListener("playing", _updateMediaSessionState); - _elems.audio.addEventListener("pause", _updateMediaSessionState); - - _elems.audio.addEventListener("pause", _pauseTimer); - _elems.audio.addEventListener("playing", _startTimer); - _elems.audio.addEventListener("waiting", _pauseTimer); - - _elems.audio.addEventListener("timeupdate", function() { - _elems.progress.style.width = "" + ((_offset + _elems.audio.currentTime) / _duration) * 100 + "%"; - _elems.curtime.innerHTML = _durationToString(_offset + _elems.audio.currentTime); - }); - - _elems.audio.addEventListener("ended", function() { - _resetTimer(); - Wt.emit(_root, "playbackEnded"); - }); - - _elems.audio.addEventListener("canplay", function() { - if (_getAudioMode() == Mode.Transcoding) { - _elems.transcodingActive.style.display = "inline"; - } - else { - _elems.transcodingActive.style.display = "none"; - } - }); - - _initVolume(); - _initDefaultSettings(defaultSettings); - - _elems.volumeslider.addEventListener("input", function() { - _setVolume(_elems.volumeslider.value); - }); - - _elems.volume.addEventListener("click", function () { - if (_elems.audio.volume != 0) { - _setVolume(0); - } - else { - _setVolume(_elems.lastvolume); - } - }); - - document.addEventListener("keydown", function(event) { - let handled = false; - - if (event.target instanceof HTMLInputElement) - return; - - if (event.keyCode == 32) { - _playPause(); - handled = true; - } - else if (event.ctrlKey && event.keyCode == 37) { - _playPrevious(); - handled = true; - } - else if (event.ctrlKey && event.keyCode == 39) { - _playNext(); - handled = true; - } - else if (event.ctrlKey && event.keyCode == 40) { - _stepVolumeDown(); - handled = true; - } - else if (event.ctrlKey && event.keyCode == 38) { - _stepVolumeUp(); - handled = true; - } - - if (handled) - event.preventDefault(); - }); - - document.addEventListener("touchstart", _unlock); - document.addEventListener("touchend", _unlock); - document.addEventListener("click", _unlock); - } - - let _removeAudioSources = function() { - while ( _elems.audio.lastElementChild) { - _elems.audio.removeChild( _elems.audio.lastElementChild); + #removeAudioSources() { + while (this.#elems.audio.lastElementChild) { + this.#elems.audio.removeChild(this.#elems.audio.lastElementChild); } } - let _addAudioSource = function(audioSrc) { + #addAudioSource(audioSrc) { let source = document.createElement('source'); source.src = audioSrc; - _elems.audio.appendChild(source); + this.#elems.audio.appendChild(source); } - let _getAudioMode = function() { - if (_elems.audio.currentSrc) { - if (_elems.audio.currentSrc.includes("format")) - return Mode.Transcoding; + #getAudioMode() { + if (this.#elems.audio.currentSrc) { + if (this.#elems.audio.currentSrc.includes("format")) + return LMSMediaPlayer.#Mode.Transcoding; else - return Mode.File; + return LMSMediaPlayer.#Mode.File; } else return undefined; } - let loadTrack = function(params, autoplay) { - _resetTimer(); - - _trackId = params.trackId; - _offset = 0; - _duration = params.duration; - _audioNativeSrc = params.nativeResource; - _audioTranscodingSrc = params.transcodingResource + "&bitrate=" + _settings.transcoding.bitrate + "&format=" + _settings.transcoding.format; - - _elems.seek.max = _duration; - - _removeAudioSources(); - // ! order is important - if (_settings.transcoding.mode == TranscodingMode.Never || _settings.transcoding.mode == TranscodingMode.IfFormatNotSupported) - { - _addAudioSource(_audioNativeSrc); - } - if (_settings.transcoding.mode == TranscodingMode.Always || _settings.transcoding.mode == TranscodingMode.IfFormatNotSupported) - { - _addAudioSource(_audioTranscodingSrc); - } - _elems.audio.load(); - - _elems.curtime.innerHTML = _durationToString(_offset); - _elems.duration.innerHTML = _durationToString(_duration); - - if (!_audioIsInit) { - _pendingTrackParameters = params; - return; - } - - _applyAudioTrackParameters(params); - - if (autoplay && _audioCtx.state == "running") - _playTrack(); - } - - let _applyAudioTrackParameters = function(params) - { - _setReplayGain(params.replayGain); + #applyAudioTrackParameters(params) { + this.#setReplayGain(params.replayGain); if ("mediaSession" in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title: params.title, @@ -445,24 +436,52 @@ LMS.mediaplayer = function () { } } - let stop = function() { - _elems.audio.pause(); + loadTrack(params, autoplay) { + this.#resetTimer(); + + this.#trackId = params.trackId; + this.#offset = 0; + this.#duration = params.duration; + this.#audioNativeSrc = params.nativeResource; + this.#audioTranscodingSrc = params.transcodingResource + "&bitrate=" + this.#settings.transcoding.bitrate + "&format=" + this.#settings.transcoding.format; + + this.#elems.seek.max = this.#duration; + + this.#removeAudioSources(); + // ! order is important + if (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) { + this.#addAudioSource(this.#audioTranscodingSrc); + } + this.#elems.audio.load(); + + this.#elems.curtime.innerHTML = this.#durationToString(this.#offset); + this.#elems.duration.innerHTML = this.#durationToString(this.#duration); + + if (!this.#audioIsInit) { + this.#pendingTrackParameters = params; + return; + } + + this.#applyAudioTrackParameters(params); + + if (autoplay && this.#audioCtx.state == "running") + this.#playTrack(); } - let setSettings = function(settings) { - _settings = settings; + stop() { + this.#elems.audio.pause(); + } - if (typeof(Storage) !== "undefined") { - localStorage.settings = JSON.stringify(_settings); + setSettings(settings) { + this.#settings = settings; + + if (typeof (Storage) !== "undefined") { + localStorage.settings = JSON.stringify(this.#settings); } } - - return { - init: init, - loadTrack: loadTrack, - stop: stop, - setSettings: setSettings, - }; -}(); +} // @license-end diff --git a/src/libs/core/impl/ArchiveZipper.cpp b/src/libs/core/impl/ArchiveZipper.cpp index 909a5b31..15042e87 100644 --- a/src/libs/core/impl/ArchiveZipper.cpp +++ b/src/libs/core/impl/ArchiveZipper.cpp @@ -21,13 +21,13 @@ #include #include -#include // strerror #include #include #include #include "core/ILogger.hpp" +#include "core/String.hpp" namespace lms::zip { @@ -45,7 +45,7 @@ namespace lms::zip } FileException(const std::filesystem::path& p, std::string_view message, int err) - : Exception{ "File '" + p.string() + "': " + std::string{ message } + ": " + ::strerror(err) } + : Exception{ "File '" + p.string() + "': " + std::string{ message } + ": " + core::stringUtils::systemErrorToString(err) } { } }; @@ -75,7 +75,7 @@ namespace lms::zip { const int res{ ::archive_write_free(arch) }; if (res != ARCHIVE_OK) - LMS_LOG(UTILS, ERROR, "Failure while freeing archive control struct: " << std::string{ ::strerror(res) }); + LMS_LOG(UTILS, ERROR, "Failure while freeing archive control struct: error code = " << res); } void ArchiveZipper::ArchiveEntryDeleter::operator()(struct ::archive_entry* archEntry) diff --git a/src/libs/core/impl/ChildProcess.cpp b/src/libs/core/impl/ChildProcess.cpp index 599bd16b..0f7b2ef9 100644 --- a/src/libs/core/impl/ChildProcess.cpp +++ b/src/libs/core/impl/ChildProcess.cpp @@ -20,10 +20,9 @@ #include "ChildProcess.hpp" #include -#include +#include #include #include -#include #include #include #include @@ -35,8 +34,8 @@ #include #include -#include "core/Exception.hpp" #include "core/ILogger.hpp" +#include "core/String.hpp" namespace lms::core { @@ -46,7 +45,7 @@ namespace lms::core { public: SystemException(int err, const std::string& errMsg) - : ChildProcessException{ errMsg + ": " + ::strerror(err) } + : ChildProcessException{ errMsg + ": " + stringUtils::systemErrorToString(err) } { } @@ -86,14 +85,30 @@ namespace lms::core throw SystemException{ errno, "fcntl failed to set FD_CLOEXEC!" }; #if defined(__linux__) && defined(F_SETPIPE_SZ) + for (const int fd : { pipefd[0], pipefd[1] }) { - // Just a hint here to prevent the writer from writing too many bytes ahead of the reader - constexpr std::size_t pipeSize{ 65536 * 4 }; - - if (fcntl(pipefd[0], F_SETPIPE_SZ, pipeSize) == -1) - throw SystemException{ errno, "fcntl failed!" }; - if (fcntl(pipefd[1], F_SETPIPE_SZ, pipeSize) == -1) - throw SystemException{ errno, "fcntl failed!" }; + constexpr std::size_t targetPipeSize{ static_cast(65'536) * 4 }; + std::size_t currentPipeSize{ 65'536 }; // common default value + #if defined(F_GETPIPE_SZ) + const int pipeSizeRes{ fcntl(fd, F_GETPIPE_SZ) }; + if (pipeSizeRes == -1) + { + const int err{ errno }; + LMS_LOG(CHILDPROCESS, DEBUG, "F_GETPIPE_SZ failed: " << stringUtils::systemErrorToString(err)); + } + else + { + currentPipeSize = pipeSizeRes; + } + #endif + if (currentPipeSize < targetPipeSize) + { + if (fcntl(fd, F_SETPIPE_SZ, targetPipeSize) == -1) + { + const int err{ errno }; + LMS_LOG(CHILDPROCESS, DEBUG, "F_SETPIPE_SZ failed: " << stringUtils::systemErrorToString(err)); + } + } } #endif @@ -153,7 +168,7 @@ namespace lms::core // process may already have finished LMS_LOG(CHILDPROCESS, DEBUG, "Killing child process..."); if (::kill(_childPID, SIGKILL) == -1) - LMS_LOG(CHILDPROCESS, DEBUG, "Kill failed: " << ::strerror(errno)); + LMS_LOG(CHILDPROCESS, DEBUG, "Kill failed: " << stringUtils::systemErrorToString(errno)); } bool ChildProcess::wait(bool block) @@ -165,7 +180,7 @@ namespace lms::core if (pid == -1) throw SystemException{ errno, "waitpid failed!" }; - else if (pid == 0) + if (pid == 0) return false; if (WIFEXITED(wstatus)) diff --git a/src/libs/core/impl/IOContextRunner.cpp b/src/libs/core/impl/IOContextRunner.cpp index 465cf0fe..7dbf435c 100644 --- a/src/libs/core/impl/IOContextRunner.cpp +++ b/src/libs/core/impl/IOContextRunner.cpp @@ -28,7 +28,7 @@ namespace lms::core { IOContextRunner::IOContextRunner(boost::asio::io_context& ioContext, std::size_t threadCount, std::string_view name) : _ioContext{ ioContext } - , _work{ ioContext } + , _work{ boost::asio::make_work_guard(ioContext) } { LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads..."); diff --git a/src/libs/core/impl/PartialDateTime.cpp b/src/libs/core/impl/PartialDateTime.cpp index 24173de9..bfa849ad 100644 --- a/src/libs/core/impl/PartialDateTime.cpp +++ b/src/libs/core/impl/PartialDateTime.cpp @@ -22,6 +22,10 @@ #include #include +#include +#include +#include + namespace lms::core { PartialDateTime::PartialDateTime(int year) @@ -84,6 +88,18 @@ namespace lms::core return res; } + PartialDateTime PartialDateTime::fromWtDateTime(const Wt::WDateTime& dateTime) + { + return core::PartialDateTime{ + dateTime.date().year(), + static_cast(dateTime.date().month()), + static_cast(dateTime.date().day()), + static_cast(dateTime.time().hour()), + static_cast(dateTime.time().minute()), + static_cast(dateTime.time().second()) + }; + } + std::string PartialDateTime::toISO8601String() const { if (_precision == Precision::Invalid) diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 3be9817b..418a8d33 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -20,6 +20,8 @@ #include "core/String.hpp" #include +#include +#include #include #include @@ -466,14 +468,24 @@ namespace lms::core::stringUtils std::string toISO8601String(const Wt::WDateTime& dateTime) { - // assume UTC - return dateTime.toString("yyyy-MM-ddThh:mm:ss.zzz", false).toUTF8(); + if (dateTime.isValid()) + { + // assume UTC + return dateTime.toString("yyyy-MM-ddThh:mm:ss.zzz", false).toUTF8(); + } + + return ""; } std::string toISO8601String(const Wt::WDate& date) { - // assume UTC - return date.toString("yyyy-MM-dd").toUTF8(); + if (date.isValid()) + { + // assume UTC + return date.toString("yyyy-MM-dd").toUTF8(); + } + + return ""; } Wt::WDateTime fromISO8601String(std::string_view dateTime) @@ -494,4 +506,11 @@ namespace lms::core::stringUtils return "[" + std::to_string(mins) + ":" + (secs < 10 ? "0" : "") + std::to_string(secs) + "." + (millis < 100 ? (millis < 10 ? "00" : "0") : "") + std::to_string(millis) + "]"; } + + std::string systemErrorToString(int err) + { + std::array buffer{}; + ::strerror_r(err, buffer.data(), buffer.size()); + return std::string{ buffer.data() }; + } } // namespace lms::core::stringUtils diff --git a/src/libs/core/impl/http/SendQueue.cpp b/src/libs/core/impl/http/SendQueue.cpp index 3ed4655c..3becfb26 100644 --- a/src/libs/core/impl/http/SendQueue.cpp +++ b/src/libs/core/impl/http/SendQueue.cpp @@ -21,6 +21,7 @@ #include #include +#include #include "core/Exception.hpp" #include "core/ILogger.hpp" @@ -64,9 +65,9 @@ namespace lms::core::http , _baseUrl{ baseUrl } { _client.done().connect([this](Wt::AsioWrapper::error_code ec, const Wt::Http::Message& msg) { - _strand.dispatch([this, ec, msg = std::move(msg)] { + boost::asio::post(boost::asio::bind_executor(_strand, [this, ec, msg = std::move(msg)] { onClientDone(ec, msg); - }); + })); }); } diff --git a/src/libs/core/include/core/IChildProcessManager.hpp b/src/libs/core/include/core/IChildProcessManager.hpp index 1df237dd..c4723dd3 100644 --- a/src/libs/core/include/core/IChildProcessManager.hpp +++ b/src/libs/core/include/core/IChildProcessManager.hpp @@ -21,7 +21,7 @@ #include #include -#include +#include #include "IChildProcess.hpp" @@ -35,5 +35,5 @@ namespace lms::core virtual std::unique_ptr spawnChildProcess(const std::filesystem::path& path, const IChildProcess::Args& args) = 0; }; - std::unique_ptr createChildProcessManager(boost::asio::io_service& ioService); + std::unique_ptr createChildProcessManager(boost::asio::io_context& ioContext); } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/include/core/IOContextRunner.hpp b/src/libs/core/include/core/IOContextRunner.hpp index 56b1f881..57a8f0a7 100644 --- a/src/libs/core/include/core/IOContextRunner.hpp +++ b/src/libs/core/include/core/IOContextRunner.hpp @@ -19,9 +19,9 @@ #pragma once -#include #include +#include #include namespace lms::core @@ -39,7 +39,7 @@ namespace lms::core private: boost::asio::io_context& _ioContext; - std::optional _work; + boost::asio::executor_work_guard _work; std::vector _threads; }; } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/include/core/PartialDateTime.hpp b/src/libs/core/include/core/PartialDateTime.hpp index a16a44d8..05947c2e 100644 --- a/src/libs/core/include/core/PartialDateTime.hpp +++ b/src/libs/core/include/core/PartialDateTime.hpp @@ -23,6 +23,11 @@ #include #include +namespace Wt +{ + class WDateTime; +} + namespace lms::core { class PartialDateTime @@ -35,13 +40,29 @@ namespace lms::core PartialDateTime(int year, unsigned month, unsigned day, unsigned hour, unsigned min, unsigned sec); static PartialDateTime fromString(std::string_view str); + static PartialDateTime fromWtDateTime(const Wt::WDateTime& dateTime); std::string toISO8601String() const; - bool isValid() const { return _precision != Precision::Invalid; } + constexpr bool isValid() const { return _precision != Precision::Invalid; } + + enum class Precision : std::uint8_t + { + Invalid, + Year, + Month, + Day, + Hour, + Min, + Sec, + }; + constexpr Precision getPrecision() const { return _precision; } constexpr std::optional getYear() const { return (_precision >= Precision::Year ? std::make_optional(_year) : std::nullopt); } constexpr std::optional getMonth() const { return (_precision >= Precision::Month ? std::make_optional(_month) : std::nullopt); } constexpr std::optional getDay() const { return (_precision >= Precision::Day ? std::make_optional(_day) : std::nullopt); } + constexpr std::optional getHour() const { return (_precision >= Precision::Hour ? std::make_optional(_hour) : std::nullopt); } + constexpr std::optional getMin() const { return (_precision >= Precision::Min ? std::make_optional(_min) : std::nullopt); } + constexpr std::optional getSec() const { return (_precision >= Precision::Sec ? std::make_optional(_sec) : std::nullopt); } constexpr auto operator<=>(const PartialDateTime& other) const = default; @@ -54,16 +75,6 @@ namespace lms::core std::uint8_t _hour{}; // 0 to 23 std::uint8_t _min{}; // 0 to 59 std::uint8_t _sec{}; // 0 to 59 - enum class Precision : std::uint8_t - { - Invalid, - Year, - Month, - Day, - Hour, - Min, - Sec, - }; Precision _precision{ Precision::Invalid }; }; } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 31542808..7c99a7cc 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -20,7 +20,6 @@ #pragma once #include -#include #include #include #include @@ -120,4 +119,6 @@ namespace lms::core::stringUtils // to "[minutes:seconds.milliseconds]" std::string formatTimestamp(std::chrono::milliseconds timestamp); + + std::string systemErrorToString(int err); } // namespace lms::core::stringUtils \ No newline at end of file diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp index a1fa628e..b137854b 100644 --- a/src/libs/core/test/String.cpp +++ b/src/libs/core/test/String.cpp @@ -301,14 +301,33 @@ namespace lms::core::stringUtils::tests TEST(Stringutils, DateToString) { - const Wt::WDate date{ 2020, 01, 03 }; - EXPECT_EQ(toISO8601String(date), "2020-01-03"); + { + const Wt::WDate date{ 2020, 01, 03 }; + EXPECT_EQ(toISO8601String(date), "2020-01-03"); + } + + { + const Wt::WDate date; + EXPECT_EQ(toISO8601String(date), ""); + } } TEST(Stringutils, DateTimeToString) { - const Wt::WDateTime dateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 75 } }; - EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T09:08:11.075"); + { + const Wt::WDateTime dateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 75 } }; + EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T09:08:11.075"); + } + + { + const Wt::WDateTime dateTime{ Wt::WDate{ 2020, 01, 03 } }; + EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T00:00:00.000"); + } + + { + const Wt::WDateTime dateTime; + EXPECT_EQ(toISO8601String(dateTime), ""); + } } TEST(Stringutils, DateTimeFromString) diff --git a/src/libs/database/impl/Artist.cpp b/src/libs/database/impl/Artist.cpp index b602ae0b..5e8e9b76 100644 --- a/src/libs/database/impl/Artist.cpp +++ b/src/libs/database/impl/Artist.cpp @@ -49,8 +49,10 @@ namespace lms::db || params.linkType || params.track.isValid() || params.release.isValid() - || params.clusters.size() == 1 - || params.mediaLibrary.isValid()) + || params.filters.clusters.size() == 1 + || params.filters.mediaLibrary.isValid() + || params.filters.label.isValid() + || params.filters.releaseType.isValid()) { query.join("track_artist_link t_a_l ON t_a_l.artist_id = a.id"); } @@ -59,7 +61,9 @@ namespace lms::db || params.sortMethod == ArtistSortMethod::AddedDesc || params.writtenAfter.isValid() || params.release.isValid() - || params.mediaLibrary.isValid()) + || params.filters.mediaLibrary.isValid() + || params.filters.label.isValid() + || params.filters.releaseType.isValid()) { query.join("track t ON t.id = t_a_l.track_id"); @@ -69,8 +73,20 @@ namespace lms::db if (params.release.isValid()) query.where("t.release_id = ?").bind(params.release); - if (params.mediaLibrary.isValid()) - query.where("t.media_library_id = ?").bind(params.mediaLibrary); + if (params.filters.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); + + if (params.filters.label.isValid()) + { + query.join("release_label r_l ON r_l.release_id = t.release_id"); + query.where("r_l.label_id = ?").bind(params.filters.label); + } + + if (params.filters.releaseType.isValid()) + { + query.join("release_release_type r_r_t ON r_r_t.release_id = t.release_id"); + query.where("r_r_t.release_type_id = ?").bind(params.filters.releaseType); + } } if (params.linkType) @@ -108,27 +124,27 @@ namespace lms::db .bind(SyncState::PendingRemove); } - if (params.clusters.size() == 1) + if (params.filters.clusters.size() == 1) { query.join("track_cluster t_c ON t_c.track_id = t_a_l.track_id") .where("t_c.cluster_id = ?") - .bind(params.clusters.front()); + .bind(params.filters.clusters.front()); } - else if (params.clusters.size() > 1) + else if (params.filters.clusters.size() > 1) { std::ostringstream oss; oss << "a.id IN (SELECT DISTINCT t_a_l.artist_id FROM track_artist_link t_a_l" " INNER JOIN track_cluster t_c ON t_c.track_id = t_a_l.track_id"; WhereClause clusterClause; - for (const ClusterId clusterId : params.clusters) + for (const ClusterId clusterId : params.filters.clusters) { clusterClause.Or(WhereClause("t_c.cluster_id = ?")); query.bind(clusterId); } oss << " " << clusterClause.get(); - oss << " GROUP BY t_a_l.track_id,t_a_l.artist_id HAVING COUNT(DISTINCT t_c.cluster_id) = " << params.clusters.size() << ")"; + oss << " GROUP BY t_a_l.track_id,t_a_l.artist_id HAVING COUNT(DISTINCT t_c.cluster_id) = " << params.filters.clusters.size() << ")"; query.where(oss.str()); } @@ -189,7 +205,6 @@ namespace lms::db : _mbid{ mbid ? mbid->getAsString() : "" } { setName(name); - _sortName = _name; } Artist::pointer Artist::create(Session& session, const std::string& name, const std::optional& mbid) diff --git a/src/libs/database/impl/Listen.cpp b/src/libs/database/impl/Listen.cpp index 9c5b6c17..be05082a 100644 --- a/src/libs/database/impl/Listen.cpp +++ b/src/libs/database/impl/Listen.cpp @@ -42,30 +42,46 @@ namespace lms::db assert(!params.artist.isValid()); // poor check - if (params.library.isValid()) + if (params.filters.mediaLibrary.isValid() + || params.filters.label.isValid() + || params.filters.releaseType.isValid()) { query.join("track t ON t.id = t_a_l.track_id"); - query.where("t.media_library_id = ?").bind(params.library); + + if (params.filters.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); + + if (params.filters.label.isValid()) + { + query.join("release_label r_l ON r_l.release_id = t.release_id"); + query.where("r_l.label_id = ?").bind(params.filters.label); + } + + if (params.filters.releaseType.isValid()) + { + query.join("release_release_type r_r_t ON r_r_t.release_id = t.release_id"); + query.where("r_r_t.release_type_id = ?").bind(params.filters.releaseType); + } } if (params.linkType) query.where("t_a_l.type = ?").bind(*params.linkType); - if (!params.clusters.empty()) + if (!params.filters.clusters.empty()) { std::ostringstream oss; oss << "a.id IN (SELECT DISTINCT t_a_l.artist_id FROM track_artist_link t_a_l" " INNER JOIN track_cluster t_c ON t_c.track_id = t_a_l.track_id"; WhereClause clusterClause; - for (auto id : params.clusters) + for (const db::ClusterId id : params.filters.clusters) { clusterClause.Or(WhereClause("t_c.cluster_id = ?")); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t_a_l.track_id,t_a_l.artist_id HAVING COUNT(DISTINCT t_c.cluster_id) = " << params.clusters.size() << ")"; + oss << " GROUP BY t_a_l.track_id,t_a_l.artist_id HAVING COUNT(DISTINCT t_c.cluster_id) = " << params.filters.clusters.size() << ")"; query.where(oss.str()); } @@ -110,10 +126,22 @@ namespace lms::db .bind(params.artist); } - if (params.library.isValid()) - query.where("t.media_library_id = ?").bind(params.library); + if (params.filters.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); - if (!params.clusters.empty()) + if (params.filters.label.isValid()) + { + query.join("release_label r_l ON r_l.release_id = r.id"); + query.where("r_l.label_id = ?").bind(params.filters.label); + } + + if (params.filters.releaseType.isValid()) + { + query.join("release_release_type r_r_t ON r_r_t.release_id = r.id"); + query.where("r_r_t.release_type_id = ?").bind(params.filters.releaseType); + } + + if (!params.filters.clusters.empty()) { std::ostringstream oss; oss << "r.id IN (SELECT DISTINCT r.id FROM release r" @@ -122,14 +150,14 @@ namespace lms::db " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; WhereClause clusterClause; - for (ClusterId id : params.clusters) + for (const ClusterId id : params.filters.clusters) { clusterClause.Or(WhereClause("c.id = ?")); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(DISTINCT c.id) = " << params.clusters.size() << ")"; + oss << " GROUP BY t.id HAVING COUNT(DISTINCT c.id) = " << params.filters.clusters.size() << ")"; query.where(oss.str()); } @@ -157,10 +185,22 @@ namespace lms::db .bind(params.artist); } - if (params.library.isValid()) - query.where("t.media_library_id = ?").bind(params.library); + if (params.filters.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); - if (!params.clusters.empty()) + if (params.filters.label.isValid()) + { + query.join("release_label r_l ON r_l.release_id = t.release_id"); + query.where("r_l.label_id = ?").bind(params.filters.label); + } + + if (params.filters.releaseType.isValid()) + { + query.join("release_release_type r_r_t ON r_r_t.release_id = t.release_id"); + query.where("r_r_t.release_type_id = ?").bind(params.filters.releaseType); + } + + if (!params.filters.clusters.empty()) { std::ostringstream oss; oss << "t.id IN (SELECT DISTINCT t.id FROM track t" @@ -168,14 +208,14 @@ namespace lms::db " INNER JOIN cluster c ON c.id = t_c.cluster_id"; WhereClause clusterClause; - for (auto id : params.clusters) + for (const ClusterId id : params.filters.clusters) { clusterClause.Or(WhereClause("c.id = ?")).bind(id.toString()); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(*) = " << params.clusters.size() << ")"; + oss << " GROUP BY t.id HAVING COUNT(*) = " << params.filters.clusters.size() << ")"; query.where(oss.str()); } diff --git a/src/libs/database/impl/MediaLibrary.cpp b/src/libs/database/impl/MediaLibrary.cpp index a01b291c..81033c7b 100644 --- a/src/libs/database/impl/MediaLibrary.cpp +++ b/src/libs/database/impl/MediaLibrary.cpp @@ -78,6 +78,13 @@ namespace lms::db }); } + bool MediaLibrary::isEmpty() const + { + assert(session()); + auto query{ session()->query("SELECT EXISTS (SELECT 1 FROM track WHERE media_library_id = ? LIMIT 1) AS is_media_library_empty").bind(getId()) }; + return !utils::fetchQuerySingleResult(query); + } + void MediaLibrary::setPath(const std::filesystem::path& p) { assert(p.is_absolute()); diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index c5be754e..08c8d31d 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 80 }; + static constexpr Version LMS_DATABASE_VERSION{ 82 }; } VersionInfo::VersionInfo() @@ -1063,6 +1063,42 @@ FROM tracklist)"); utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } + void migrateFromV80(Session& session) + { + // Add release comment support + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD COLUMN comment TEXT NOT NULL DEFAULT ''"); + + // Just increment the scan version of the settings to make the next scan rescan everything + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); + } + + void migrateFromV81(Session& session) + { + // Make sure we remove all the previoulsy created index, the createIndexesIfNeeded will recreate them all + dropIndexes(session); + + // Add country + release country + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "country" ( + "id" integer primary key autoincrement, + "version" integer not null, + "name" text not null + ))"); + + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_country" ( + "country_id" bigint, + "release_id" bigint, + primary key ("country_id", "release_id"), + constraint "fk_release_country_key1" foreign key ("country_id") references "country" ("id") on delete cascade deferrable initially deferred, + constraint "fk_release_country_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred + ))"); + + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_country_country" on "release_country" ("country_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_country_release" on "release_country" ("release_id"))"); + + // Just increment the scan version of the settings to make the next scan rescan everything + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1119,6 +1155,8 @@ FROM tracklist)"); { 77, migrateFromV77 }, { 78, migrateFromV78 }, { 79, migrateFromV79 }, + { 80, migrateFromV80 }, + { 81, migrateFromV81 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/PlayListFile.cpp b/src/libs/database/impl/PlayListFile.cpp index 20df473a..776b1af4 100644 --- a/src/libs/database/impl/PlayListFile.cpp +++ b/src/libs/database/impl/PlayListFile.cpp @@ -81,6 +81,8 @@ namespace lms::db std::vector PlayListFile::getFiles() const { std::vector files; + + try { Wt::Json::Object root; Wt::Json::parse(_entries, root); @@ -90,6 +92,10 @@ namespace lms::db for (const Wt::Json::Value& file : filesArray) files.push_back(static_cast(file.toString())); } + catch (const Wt::Json::ParseError& e) + { + LMS_LOG(DB, ERROR, "Cannot parse files saved in " << _absoluteFilePath << ": corrupted data?"); + } return files; } diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index 9cfb04e9..574a9401 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -28,6 +28,7 @@ #include "database/Image.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/Types.hpp" #include "database/User.hpp" #include "EnumSetTraits.hpp" @@ -59,8 +60,8 @@ namespace lms::db || params.writtenAfter.isValid() || params.dateRange || params.artist.isValid() - || params.clusters.size() == 1 - || params.mediaLibrary.isValid() + || params.filters.clusters.size() == 1 + || params.filters.mediaLibrary.isValid() || params.directory.isValid() || params.parentDirectory.isValid()) { @@ -73,8 +74,20 @@ namespace lms::db query.where("d.parent_directory_id = ?").bind(params.parentDirectory); } - if (params.mediaLibrary.isValid()) - query.where("t.media_library_id = ?").bind(params.mediaLibrary); + if (params.filters.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); + + if (params.filters.label.isValid()) + { + query.join("release_label r_l ON r_l.release_id = r.id"); + query.where("r_l.label_id = ?").bind(params.filters.label); + } + + if (params.filters.releaseType.isValid()) + { + query.join("release_release_type r_r_t ON r_r_t.release_id = r.id"); + query.where("r_r_t.release_type_id = ?").bind(params.filters.releaseType); + } if (params.directory.isValid()) query.where("t.directory_id = ?").bind(params.directory); @@ -167,31 +180,34 @@ namespace lms::db } } - if (params.clusters.size() == 1) + if (params.filters.clusters.size() == 1) { query.join("track_cluster t_c ON t_c.track_id = t.id") .where("t_c.cluster_id = ?") - .bind(params.clusters.front()); + .bind(params.filters.clusters.front()); } - else if (params.clusters.size() > 1) + else if (params.filters.clusters.size() > 1) { std::ostringstream oss; oss << "r.id IN (SELECT DISTINCT t.release_id FROM track t" " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; WhereClause clusterClause; - for (const ClusterId clusterId : params.clusters) + for (const ClusterId clusterId : params.filters.clusters) { clusterClause.Or(WhereClause("t_c.cluster_id = ?")); query.bind(clusterId); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(*) = " << params.clusters.size() << ")"; + oss << " GROUP BY t.id HAVING COUNT(*) = " << params.filters.clusters.size() << ")"; query.where(oss.str()); } + if (params.releaseGroupMBID) + query.where("group_mbid = ?").bind(params.releaseGroupMBID->getAsString()); + switch (params.sortMethod) { case ReleaseSortMethod::None: @@ -202,6 +218,9 @@ namespace lms::db case ReleaseSortMethod::Name: query.orderBy("r.name COLLATE NOCASE"); break; + case ReleaseSortMethod::SortName: + query.orderBy("r.sort_name COLLATE NOCASE"); + break; case ReleaseSortMethod::ArtistNameThenName: query.orderBy("a.name COLLATE NOCASE, r.name COLLATE NOCASE"); break; @@ -253,6 +272,50 @@ namespace lms::db } // namespace + Country::Country(std::string_view name) + : _name{ name } + { + // As we use the name to uniquely identoify release type, we must throw (and not truncate) + if (name.size() > _maxNameLength) + throw Exception{ "Country name is too long: " + std::string{ name } + "'" }; + } + + Country::pointer Country::create(Session& session, std::string_view name) + { + return session.getDboSession()->add(std::unique_ptr{ new Country{ name } }); + } + + std::size_t Country::getCount(Session& session) + { + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM country")); + } + + Country::pointer Country::find(Session& session, CountryId id) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT c from country c").where("c.id = ?").bind(id)); + } + + Country::pointer Country::find(Session& session, std::string_view name) + { + session.checkReadTransaction(); + + if (name.size() > _maxNameLength) + throw Exception{ "Requeted Country name is too long: " + std::string{ name } + "'" }; + + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT c from country c").where("c.name = ?").bind(name)); + } + + RangeResults Country::findOrphanIds(Session& session, std::optional range) + { + session.checkReadTransaction(); + + // select the labels that have no releases + auto query{ session.getDboSession()->query("select c.id from country c LEFT OUTER JOIN release_country r_c ON c.id = r_c.country_id WHERE r_c.release_id IS NULL") }; + return utils::execRangeQuery(query, range); + } + Label::Label(std::string_view name) : _name{ name } { @@ -288,6 +351,24 @@ namespace lms::db return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT l from label l").where("l.name = ?").bind(name)); } + void Label::find(Session& session, LabelSortMethod sortMethod, std::function func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->find