Merge branch 'develop' for release v3.67.0

This commit is contained in:
emeric
2025-06-24 08:18:33 +02:00
194 changed files with 4682 additions and 2009 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ _LMS_ supports an Artist information folder to manage metadata and images for ar
The folder must follow a structure defined by Kodi, as detailed [here](https://kodi.wiki/view/Artist_information_folder). `artist.nfo` files are used to define additional artist information such as biography, sort name, and MusicBrainz ArtistID. See the format [here](https://kodi.wiki/view/NFO_files/Artists).
__Note__: `artist.nfo` files must include both `name` and `musicBrainzArtistID` fields to be recognized.
__Note__: If no name is provided in the `artist.nfo` file, the name of the containing folder is used.
### Filtering
It is possible to apply global filters on your collection using `genre`, `mood`, `grouping`, `language`, and by music library. More tags, including custom ones, can be added in the database administration settings.
+11
View File
@@ -78,6 +78,17 @@
</div>
</div>
</div>
<div class="col-12">
<div class="form-check">
${artist-image-fallback-to-release class="form-check-input"}
<label class="form-check-label" for="${id:artist-image-fallback-to-release}">
${tr:Lms.Admin.Database.artist-image-fallback-to-release}
</label>
<div class="invalid-feedback">
${artist-image-fallback-to-release-info}
</div>
</div>
</div>
<div class="col-12">
<label class="form-label" for="${id:similarity-engine-type}">
${tr:Lms.Admin.Database.similarity-engine-type}
+1 -1
View File
@@ -5,7 +5,7 @@
<message id="Lms.Explore.Artist.template">
<div class="row mb-3 gy-3">
<div class="col-lg-3 col-md-4">
${artwork class="Lms-cursor-pointer rounded d-block mx-auto"}
${artwork class="rounded d-block mx-auto"}
</div>
<div class="col">
<h2>${name}</h2>
+2 -2
View File
@@ -14,9 +14,9 @@
</message>
<message id="Lms.Explore.Artists.template.entry">
<div class="col h-100 Lms-bg-dark-hover rounded">
<div class="col h-100 Lms-bg-dark-hover">
<div class="p-2">
<div class="Lms-responsive-square Lms-cover-container mb-1">
<div class="Lms-responsive-circle Lms-cover-container mb-1">
${image class="shadow-sm"}
</div>
${name class="d-block text-center text-truncate text-decoration-none link-secondary"}
+7 -1
View File
@@ -74,6 +74,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Allow merging artists without an MBID to those with one</message>
<message id="Lms.Admin.Database.artist-image-fallback-to-release">Use an album artwork if no artist image is available</message>
<message id="Lms.Admin.Database.artist-tag-delimiters">Delimiters to be used for splitting artist tags</message>
<message id="Lms.Admin.Database.artists-to-not-split">Artists to not split using the delimiters (one artist per line)</message>
<message id="Lms.Admin.Database.daily">Daily</message>
@@ -100,9 +101,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">Cannot get track duration</message>
<message id="Lms.Admin.ScannerController.bad-embedded-image">Unable to read the image at index {1}</message>
<message id="Lms.Admin.ScannerController.cannot-read-artist-info-file">Cannot parse artist info file</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Cannot parse audio file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Cannot read file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Cannot read file ({1})</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Cannot parse image file</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Cannot parse lyrics file</message>
<message id="Lms.Admin.ScannerController.cannot-read-playlist-file">Cannot parse playlist file</message>
@@ -115,6 +117,9 @@
<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">{1} files were scanned in {2} at {4} on {3} (UTC) - {5} errors, {6} duplicates</message>
<message id="Lms.Admin.ScannerController.missing-artist-name">Missing artist name</message>
<message id="Lms.Admin.ScannerController.playlist-all-pathes-missing">Unable to find track paths</message>
<message id="Lms.Admin.ScannerController.playlist-path-missing">Unable to find the path "{1}"</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 track MBID</message>
@@ -129,6 +134,7 @@
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associating external lyrics: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-playlist-tracks">Associating playlist tracks: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associating release images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-track-images">Associating track images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Checking for duplicate files... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Checking for removed files... {1}%</message>
<message id="Lms.Admin.ScannerController.step-compact">Compacting database...</message>
+7 -1
View File
@@ -74,6 +74,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Permitir la fusión de artistas sin MBID con aquellos que sí lo tienen</message>
<message id="Lms.Admin.Database.artist-image-fallback-to-release">Usar una imagen del álbum si no hay una imagen disponible del artista</message>
<message id="Lms.Admin.Database.artist-tag-delimiters">Delimitadores usados para separar las etiquetas de los artistas</message>
<message id="Lms.Admin.Database.artists-to-not-split">Artistas que no se deben separar usando los delimitadores (un artista por línea)</message>
<message id="Lms.Admin.Database.daily">Diariamente</message>
@@ -100,9 +101,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">No ha sido posible obtener la duración de la pista</message>
<message id="Lms.Admin.ScannerController.bad-embedded-image">No se puede leer la imagen en el índice {1}</message>
<message id="Lms.Admin.ScannerController.cannot-read-artist-info-file">No ha sido posible analizar el fichero de información sobre el artista</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">No ha sido posible analizar el fichero de audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">No ha sido posible leer el fichero</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">No ha sido posible leer el fichero ({1})</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">No ha sido posible analizar del fichero de imagen</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">No ha sido posible analizar el fichero de letras</message>
<message id="Lms.Admin.ScannerController.cannot-read-playlist-file">No ha sido posible analizar el fichero de la lista de distribución</message>
@@ -115,6 +117,9 @@
<message id="Lms.Admin.ScannerController.last-scan">Último escaneo</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">No disponible</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} ficheros analizados en {2} el {3} a las {4} (UTC) - {5} errores, {6} duplicados</message>
<message id="Lms.Admin.ScannerController.missing-artist-name">Falta el nombre del artista</message>
<message id="Lms.Admin.ScannerController.playlist-all-pathes-missing">No se pudieron encontrar las rutas de las pistas</message>
<message id="Lms.Admin.ScannerController.playlist-path-missing">No se pudo encontrar la ruta "{1}"</message>
<message id="Lms.Admin.ScannerController.no-audio-track">No es una pista de audio</message>
<message id="Lms.Admin.ScannerController.same-hash">Hash del fichero duplicado</message>
<message id="Lms.Admin.ScannerController.same-mbid">MBID de la pista duplicado</message>
@@ -129,6 +134,7 @@
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Association des paroles externes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-playlist-tracks">Association des pistes des listes de lectures: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Asignando imágenes de los álbumes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-track-images">Asignando imágenes de las pistas: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Comprobando ficheros duplicados ... {1} ficheros</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Comprobando ficheros eliminados... {1}%</message>
<message id="Lms.Admin.ScannerController.step-compact">Compactando la base de datos...</message>
+7 -1
View File
@@ -74,6 +74,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Permettre la fusion des artistes sans MBID avec ceux qui en ont un</message>
<message id="Lms.Admin.Database.artist-image-fallback-to-release">Utilizza un'immagine dell'album se non è disponibile un'immagine dell'artista</message>
<message id="Lms.Admin.Database.artist-tag-delimiters">Délimiteurs à utiliser pour séparer les tags d'artistes</message>
<message id="Lms.Admin.Database.artists-to-not-split">Artistes à ne pas séparer en utilisant les délimiteurs (un artiste par ligne)</message>
<message id="Lms.Admin.Database.daily">Tous les jours</message>
@@ -100,9 +101,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">Impossible de récupérer la durée de la piste</message>
<message id="Lms.Admin.ScannerController.bad-embedded-image">Impossible de lire l'image à l'indice {1}</message>
<message id="Lms.Admin.ScannerController.cannot-read-artist-info-file">Impossible d'analyser le fichier d'informations sur l'artiste</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossible d'analyser le fichier audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Impossible de lire le fichier</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Impossible de lire le fichier ({1})</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossible d'analyser le fichier image</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossible d'analyser le fichier de paroles</message>
<message id="Lms.Admin.ScannerController.cannot-read-playlist-file">Impossible d'analyser le fichier de liste de lecture</message>
@@ -115,6 +117,9 @@
<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 analysés en {2} à {4} le {3} (UTC) - {5} erreurs, {6} duplicatas</message>
<message id="Lms.Admin.ScannerController.missing-artist-name">Nom de l'artiste manquant</message>
<message id="Lms.Admin.ScannerController.playlist-all-pathes-missing">Impossible de trouver les chemins des pistes</message>
<message id="Lms.Admin.ScannerController.playlist-path-missing">Impossible de trouver le chemin "{1}"</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">Track MBID dupliqué</message>
@@ -129,6 +134,7 @@
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Association des paroles externes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-playlist-tracks">Association des pistes des listes de lectures: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Association des images des albums: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-track-images">Association des images des pistes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Vérification des fichiers dupliqués... {1} fichiers</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Vérification des fichiers supprimés... {1}%</message>
<message id="Lms.Admin.ScannerController.step-compact">Compactage de la base de données...</message>
+7 -1
View File
@@ -74,6 +74,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Consentire la fusione degli artisti senza MBID con quelli che ne hanno uno</message>
<message id="Lms.Admin.Database.artist-image-fallback-to-release">Usa l'immagine dell'album se non è disponibile un'immagine dell'artista</message>
<message id="Lms.Admin.Database.artist-tag-delimiters">Delimitatori da utilizzare per separare i tag degli artisti</message>
<message id="Lms.Admin.Database.artists-to-not-split">Artisti da non suddividere utilizzando i delimitatori (un artista per riga)</message>
<message id="Lms.Admin.Database.daily">Giornaliera</message>
@@ -100,9 +101,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">Non sono stato in grado di determinare la durata della traccia</message>
<message id="Lms.Admin.ScannerController.bad-embedded-image">Impossibile leggere l'immagine all'indice {1}</message>
<message id="Lms.Admin.ScannerController.cannot-read-artist-info-file">Impossibile analizzare il file delle informazioni sull'artista</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossibile analizzare il file audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Non in grado di leggere il file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Non in grado di leggere il file ({1})</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossibile analizzare il file immagine</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossibile analizzare il file dei testi</message>
<message id="Lms.Admin.ScannerController.cannot-read-playlist-file">Impossibile analizzare il file della playlist</message>
@@ -115,6 +117,9 @@
<message id="Lms.Admin.ScannerController.last-scan">Ultima scansione</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponibile</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} file scansionati in {2} alle {3} il {4} (UTC) - {5} errori, {6} duplicati</message>
<message id="Lms.Admin.ScannerController.missing-artist-name">Nome dell'artista mancante</message>
<message id="Lms.Admin.ScannerController.playlist-all-pathes-missing">Impossibile trovare i percorsi delle tracce</message>
<message id="Lms.Admin.ScannerController.playlist-path-missing">Impossibile trovare il percorso "{1}"</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Nessuna traccia audio</message>
<message id="Lms.Admin.ScannerController.same-hash">Hash doppio</message>
<message id="Lms.Admin.ScannerController.same-mbid">Track MBID doppio</message>
@@ -129,6 +134,7 @@
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associazione dei testi esterni: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-playlist-tracks">Associando brani della playlist: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associando immagini degli album: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-track-images">Associando immagini delle tracce: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Controllo duplicati... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Controllo file... {1}%</message>
<message id="Lms.Admin.ScannerController.step-compact">Compattazione del database...</message>
+7 -1
View File
@@ -75,6 +75,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Pozwól na łączenie artystów bez MBID z tymi, którzy go mają</message>
<message id="Lms.Admin.Database.artist-image-fallback-to-release">Użyj okładki albumu, jeśli zdjęcie artysty nie jest dostępne</message>
<message id="Lms.Admin.Database.artist-tag-delimiters">Znaki rozdzielające artystów</message>
<message id="Lms.Admin.Database.artists-to-not-split">Artyści, których nie należy dzielić przy użyciu separatorów (jeden artysta na linię)</message>
<message id="Lms.Admin.Database.daily">Codziennie</message>
@@ -101,9 +102,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">Nie udało się ustalić długości ścieżki</message>
<message id="Lms.Admin.ScannerController.bad-embedded-image">Nie można odczytać obrazu pod indeksem {1}</message>
<message id="Lms.Admin.ScannerController.cannot-read-artist-info-file">Nie można przetworzyć pliku z informacjami o artyście"</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Nie można przeanalizować pliku audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Nie udało się odczytać pliku</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Nie udało się odczytać pliku ({1})</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Nie można przeanalizować pliku obrazu</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Nie można przetworzyć pliku z tekstem</message>
<message id="Lms.Admin.ScannerController.cannot-read-playlist-file">Nie można przeanalizować pliku playlisty</message>
@@ -124,6 +126,9 @@
<message id="Lms.Admin.ScannerController.last-scan">Ostatnie skanowanie</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Niedostępne</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Przeskanowano {1} plików w {2} o {4} dnia {3} (UTC) - {5} błędów, {6} duplikatów</message>
<message id="Lms.Admin.ScannerController.missing-artist-name">Brakuje nazwy artysty</message>
<message id="Lms.Admin.ScannerController.playlist-all-pathes-missing">Nie można znaleźć ścieżek utworów</message>
<message id="Lms.Admin.ScannerController.playlist-path-missing">Nie można znaleźć ścieżki „{1}”</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Brak ścieżki dźwiękowej</message>
<message id="Lms.Admin.ScannerController.same-hash">Zduplikowny hash pliku</message>
<message id="Lms.Admin.ScannerController.same-mbid">Zduplikowany MBID pliku</message>
@@ -138,6 +143,7 @@
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Kojarzenie zewnętrznych tekstów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-playlist-tracks">Kojarzenie utworów z playlisty: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Kojarzenie obrazów albumów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-track-images">Kojarzenie obrazów ścieżek: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">
<plural case="0">Sprawdzanie duplikatów... {1} plik</plural>
<plural case="1">Sprawdzanie duplikatów... {1} pliki</plural>
+6 -1
View File
@@ -75,6 +75,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.daily">每日</message>
@@ -100,9 +101,10 @@
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">无法获得音轨时间</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">无法解析文件</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">无法读取文件</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">无法读取文件 ({1})</message>
@@ -114,6 +116,9 @@
<message id="Lms.Admin.ScannerController.last-scan">最后扫描</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">不可用</message>
<message id="Lms.Admin.ScannerController.last-scan-status">已扫描 {1} 个文件,用时 {2},在 {3} 的 {4} (UTC) - {5} 个错误,{6} 个重复项</message>
<message id="Lms.Admin.ScannerController.no-audio-track">没有音轨</message>
<message id="Lms.Admin.ScannerController.same-hash">相同文件哈希值</message>
<message id="Lms.Admin.ScannerController.same-mbid">相同 MBID</message>
+7 -4
View File
@@ -9,7 +9,7 @@
<message id="Lms.Explore.Release.template">
<div class="row mb-3 gy-3">
<div class="col-lg-3 col-md-4">
${cover class="Lms-cursor-pointer rounded d-block mx-auto"}
${artwork class="rounded d-block mx-auto"}
</div>
<div class="col">
<h2>${name}${<if-has-release-comment>}<span class="ms-1 text-muted">[${comment}]</span>${</if-has-release-comment>}</h2>
@@ -54,8 +54,11 @@
</message>
<message id="Lms.Explore.Release.template.entry-disc">
<div class="d-flex align-items-center">
<h4 class="flex-fill">${disc-title}</h4>
<h4 class="d-flex align-items-center">
${<if-has-artwork>}
<div class="p-1">${artwork class="Lms-cursor-pointer"}</div>
${</if-has-artwork>}
<div class="p-2 flex-fill">${disc-title}</div>
<div class="p-2 d-flex">
${play-btn class="d-none d-sm-block btn btn-sm btn-outline-secondary border-0"}
<div class="dropdown d-inline-block">
@@ -68,7 +71,7 @@
</ul>
</div>
</div>
</div>
</h4>
${tracks class="d-grid gap-1 Lms-row-container"}
</message>
+4
View File
@@ -11,7 +11,11 @@ ffmpeg-file = "/usr/bin/ffmpeg";
log-file = "";
access-log-file = "";
# Minimum severity, can be "debug", "info", "warning", "error" or "fatal"
# "debug" is useful for debugging purposes, but it will also generate a lot of log data and slow down the application
log-min-severity = "info";
# Database consistency check to run at startup.
# Can be "none", "quick", or "full"
db-integrity-check = "quick";
# Output db queries on stdout
db-show-queries = false;
+21
View File
@@ -110,6 +110,15 @@ body {
cursor: pointer;
}
.Lms-image-artist {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#lms-mp-progress {
transition: none;
}
@@ -184,6 +193,18 @@ body {
width: 0;
}
.Lms-responsive-circle {
width: 100%;
border-radius: 50%;
overflow: hidden;
}
.Lms-responsive-circle:after {
content: "";
display: block;
padding-bottom: 100%;
}
.Lms-responsive-square {
width: 100%;
}
-2
View File
@@ -2,9 +2,7 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat)
add_library(lmsav STATIC
impl/AudioFile.cpp
impl/RawResourceHandlerCreator.cpp
impl/Transcoder.cpp
impl/TranscodingResourceHandler.cpp
)
target_include_directories(lmsav INTERFACE
+1 -39
View File
@@ -34,7 +34,7 @@ extern "C"
#include "core/ILogger.hpp"
#include "core/String.hpp"
#include "av/Types.hpp"
#include "av/Exception.hpp"
namespace lms::av
{
@@ -321,42 +321,4 @@ namespace lms::av
return res;
}
std::string_view getMimeType(const std::filesystem::path& fileExtension)
{
// List should be sync with the demuxers shipped in the lms's docker version
// + the _audioFileExtensions in ScanSettings
// std::filesystem::path does not seem to have std::hash specialization on freebsd
static const std::unordered_map<std::string, std::string_view> entries{
{ ".mp3", "audio/mpeg" },
{ ".ogg", "audio/ogg" },
{ ".oga", "audio/ogg" },
{ ".opus", "audio/opus" },
{ ".aac", "audio/aac" },
{ ".alac", "audio/mp4" },
{ ".m4a", "audio/mp4" },
{ ".m4b", "audio/mp4" },
{ ".flac", "audio/flac" },
{ ".webm", "audio/webm" },
{ ".wav", "audio/x-wav" },
{ ".wma", "audio/x-ms-wma" },
{ ".ape", "audio/x-monkeys-audio" },
{ ".mpc", "audio/x-musepack" },
{ ".shn", "audio/x-shn" },
{ ".aif", "audio/x-aiff" },
{ ".aiff", "audio/x-aiff" },
{ ".m3u", "audio/x-mpegurl" },
{ ".pls", "audio/x-scpls" },
{ ".dsf", "audio/x-dsd" },
{ ".wv", "audio/x-wavpack" },
{ ".wvp", "audio/x-wavpack" },
{ ".mka", "audio/x-matroska" },
};
auto it{ entries.find(core::stringUtils::stringToLower(fileExtension.c_str())) };
if (it == std::cend(entries))
return "";
return it->second;
}
} // namespace lms::av
+45 -42
View File
@@ -27,35 +27,20 @@
#include "core/ILogger.hpp"
#include "core/Service.hpp"
#include "av/Types.hpp"
#include "av/Exception.hpp"
namespace lms::av::transcoding
namespace lms::av
{
#define LOG(severity, message) LMS_LOG(TRANSCODING, severity, "[" << _debugId << "] - " << message)
std::unique_ptr<ITranscoder> createTranscoder(const InputParameters& inputParameters, const OutputParameters& outputParameters)
{
return std::make_unique<Transcoder>(inputParameters, outputParameters);
}
static std::atomic<size_t> globalId{};
static std::filesystem::path ffmpegPath;
std::string_view formatToMimetype(OutputFormat format)
{
switch (format)
{
case OutputFormat::MP3:
return "audio/mpeg";
case OutputFormat::OGG_OPUS:
return "audio/opus";
case OutputFormat::MATROSKA_OPUS:
return "audio/x-matroska";
case OutputFormat::OGG_VORBIS:
return "audio/ogg";
case OutputFormat::WEBM_VORBIS:
return "audio/webm";
}
throw Exception{ "Invalid encoding" };
}
void Transcoder::init()
{
ffmpegPath = core::Service<core::IConfig>::get()->getPath("ffmpeg-file", "/usr/bin/ffmpeg");
@@ -63,10 +48,10 @@ namespace lms::av::transcoding
throw Exception{ "File '" + ffmpegPath.string() + "' does not exist!" };
}
Transcoder::Transcoder(const InputParameters& inputParameters, const OutputParameters& outputParameters)
Transcoder::Transcoder(const InputParameters& inputParams, const OutputParameters& outputParams)
: _debugId{ globalId++ }
, _inputParameters{ inputParameters }
, _outputParameters{ outputParameters }
, _inputParams{ inputParams }
, _outputParams{ outputParams }
{
start();
}
@@ -80,17 +65,18 @@ namespace lms::av::transcoding
try
{
if (!std::filesystem::exists(_inputParameters.trackPath))
throw Exception{ "File '" + _inputParameters.trackPath.string() + "' does not exist!" };
if (!std::filesystem::is_regular_file(_inputParameters.trackPath))
throw Exception{ "File '" + _inputParameters.trackPath.string() + "' is not regular!" };
if (!std::filesystem::exists(_inputParams.file))
throw Exception{ "File " + _inputParams.file.string() + " does not exist!" };
if (!std::filesystem::is_regular_file(_inputParams.file))
throw Exception{ "File " + _inputParams.file.string() + " is not regular!" };
}
catch (const std::filesystem::filesystem_error& e)
{
throw Exception{ "File error '" + _inputParameters.trackPath.string() + "': " + e.what() };
// TODO store/raise e.code()
throw Exception{ "File error '" + _inputParams.file.string() + "': " + e.what() };
}
LOG(INFO, "Transcoding file " << _inputParameters.trackPath);
LOG(INFO, "Transcoding file " << _inputParams.file);
std::vector<std::string> args;
@@ -109,22 +95,22 @@ namespace lms::av::transcoding
args.emplace_back("-ss");
std::ostringstream oss;
oss << std::fixed << std::showpoint << std::setprecision(3) << (_outputParameters.offset.count() / float{ 1'000 });
oss << std::fixed << std::showpoint << std::setprecision(3) << (_inputParams.offset.count() / float{ 1'000 });
args.emplace_back(oss.str());
}
// Input file
args.emplace_back("-i");
args.emplace_back(_inputParameters.trackPath.string());
args.emplace_back(_inputParams.file.string());
// Stream mapping, if set
if (_outputParameters.stream)
if (_inputParams.streamIndex)
{
args.emplace_back("-map");
args.emplace_back("0:" + std::to_string(*_outputParameters.stream));
args.emplace_back("0:" + std::to_string(*_inputParams.streamIndex));
}
if (_outputParameters.stripMetadata)
if (_outputParams.stripMetadata)
{
// Strip metadata
args.emplace_back("-map_metadata");
@@ -136,10 +122,10 @@ namespace lms::av::transcoding
// Output bitrates
args.emplace_back("-b:a");
args.emplace_back(std::to_string(_outputParameters.bitrate));
args.emplace_back(std::to_string(_outputParams.bitrate));
// Codecs and formats
switch (_outputParameters.format)
switch (_outputParams.format)
{
case OutputFormat::MP3:
args.emplace_back("-f");
@@ -175,11 +161,9 @@ namespace lms::av::transcoding
break;
default:
throw Exception{ "Unhandled format (" + std::to_string(static_cast<int>(_outputParameters.format)) + ")" };
throw Exception{ "Unhandled format (" + std::to_string(static_cast<int>(_outputParams.format)) + ")" };
}
_outputMimeType = formatToMimetype(_outputParameters.format);
args.emplace_back("pipe:1");
LOG(DEBUG, "Dumping args (" << args.size() << ")");
@@ -213,6 +197,25 @@ namespace lms::av::transcoding
return _childProcess->readSome(buffer, bufferSize);
}
std::string_view Transcoder::getOutputMimeType() const
{
switch (_outputParams.format)
{
case OutputFormat::MP3:
return "audio/mpeg";
case OutputFormat::OGG_OPUS:
return "audio/opus";
case OutputFormat::MATROSKA_OPUS:
return "audio/x-matroska";
case OutputFormat::OGG_VORBIS:
return "audio/ogg";
case OutputFormat::WEBM_VORBIS:
return "audio/webm";
}
return "application/octet-stream"; // default, should not happen
}
bool Transcoder::finished() const
{
assert(_childProcess);
@@ -220,4 +223,4 @@ namespace lms::av::transcoding
return _childProcess->finished();
}
} // namespace lms::av::transcoding
} // namespace lms::av
+14 -24
View File
@@ -19,47 +19,37 @@
#pragma once
#include <functional>
#include "av/TranscodingParameters.hpp"
#include "av/ITranscoder.hpp"
namespace lms::core
{
class IChildProcess;
}
namespace lms::av::transcoding
namespace lms::av
{
class Transcoder
class Transcoder : public ITranscoder
{
public:
Transcoder(const InputParameters& inputParameters, const OutputParameters& outputParameters);
~Transcoder();
~Transcoder() override;
Transcoder(const Transcoder&) = delete;
Transcoder& operator=(const Transcoder&) = delete;
Transcoder(Transcoder&&) = delete;
Transcoder& operator=(Transcoder&&) = delete;
// non blocking calls
using ReadCallback = std::function<void(std::size_t nbReadBytes)>;
void asyncRead(std::byte* buffer, std::size_t bufferSize, ReadCallback);
std::size_t readSome(std::byte* buffer, std::size_t bufferSize);
const std::string& getOutputMimeType() const { return _outputMimeType; }
const OutputParameters& getOutputParameters() const { return _outputParameters; }
bool finished() const;
private:
static void init();
void asyncRead(std::byte* buffer, std::size_t bufferSize, ReadCallback) override;
std::size_t readSome(std::byte* buffer, std::size_t bufferSize) override;
std::string_view getOutputMimeType() const override;
const OutputParameters& getOutputParameters() const override { return _outputParams; }
bool finished() const override;
static void init();
void start();
const std::size_t _debugId{};
const InputParameters _inputParameters;
const OutputParameters _outputParameters;
std::string _outputMimeType;
const InputParameters _inputParams;
const OutputParameters _outputParams;
std::unique_ptr<core::IChildProcess> _childProcess;
};
} // namespace lms::av::transcoding
} // namespace lms::av
-9
View File
@@ -101,13 +101,4 @@ namespace lms::av
};
std::unique_ptr<IAudioFile> parseAudioFile(const std::filesystem::path& p);
struct AudioFileFormat
{
std::string mimeType;
std::string format;
};
std::string_view getMimeType(const std::filesystem::path& fileExtension);
} // namespace lms::av
+71
View File
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2025 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 <cstddef>
#include <filesystem>
#include <functional>
#include <memory>
#include <optional>
#include <string_view>
namespace lms::av
{
struct InputParameters
{
std::filesystem::path file; // Path to the input file
std::chrono::milliseconds offset{}; // Offset in the input file to start transcoding from
std::optional<std::size_t> streamIndex; // Index of the stream to be transcoded (select "best" audio stream if not set)
};
enum class OutputFormat
{
MP3,
OGG_OPUS,
MATROSKA_OPUS,
OGG_VORBIS,
WEBM_VORBIS,
};
struct OutputParameters
{
OutputFormat format;
std::size_t bitrate{ 128'000 };
bool stripMetadata{ true };
};
class ITranscoder
{
public:
virtual ~ITranscoder() = default;
// non blocking calls
using ReadCallback = std::function<void(std::size_t nbReadBytes)>;
virtual void asyncRead(std::byte* buffer, std::size_t bufferSize, ReadCallback callback) = 0;
virtual std::size_t readSome(std::byte* buffer, std::size_t bufferSize) = 0;
virtual std::string_view getOutputMimeType() const = 0;
virtual const OutputParameters& getOutputParameters() const = 0;
virtual bool finished() const = 0;
};
std::unique_ptr<ITranscoder> createTranscoder(const InputParameters& inputParameters, const OutputParameters& outputParameters);
} // namespace lms::av
+2 -1
View File
@@ -12,6 +12,7 @@ add_library(lmscore STATIC
impl/FileResourceHandler.cpp
impl/IOContextRunner.cpp
impl/Logger.cpp
impl/MimeTypes.cpp
impl/NetAddress.cpp
impl/PartialDateTime.cpp
impl/Path.cpp
@@ -31,12 +32,12 @@ target_include_directories(lmscore INTERFACE
target_include_directories(lmscore PRIVATE
include
${XXHASH_INCLUDE_DIRS}
)
target_link_libraries(lmscore PRIVATE
PkgConfig::Config++
PkgConfig::Archive
PkgConfig::XXHASH
)
target_link_libraries(lmscore PUBLIC
+10 -6
View File
@@ -22,12 +22,12 @@
#include <algorithm>
#include <cassert>
#include <fstream>
#include <system_error>
#include <archive.h>
#include <archive_entry.h>
#include "core/ILogger.hpp"
#include "core/String.hpp"
namespace lms::zip
{
@@ -43,9 +43,13 @@ namespace lms::zip
: Exception{ "File '" + p.string() + "': " + std::string{ message } }
{
}
};
FileException(const std::filesystem::path& p, std::string_view message, int err)
: Exception{ "File '" + p.string() + "': " + std::string{ message } + ": " + core::stringUtils::systemErrorToString(err) }
class FileStdException : public FileException
{
public:
FileStdException(const std::filesystem::path& p, std::string_view message, int err)
: FileException{ p, std::string{ message } + "': " + std::error_code{ err, std::generic_category() }.message() }
{
}
};
@@ -240,7 +244,7 @@ namespace lms::zip
std::ifstream ifs{ _currentEntry->filePath, std::ios_base::binary };
if (!ifs)
throw FileException{ _currentEntry->filePath, "cannot open file", errno };
throw FileStdException{ _currentEntry->filePath, "cannot open file", errno };
ifs.seekg(0, std::ios::end);
const std::uint64_t fileSize{ static_cast<std::uint64_t>(ifs.tellg()) };
@@ -254,10 +258,10 @@ namespace lms::zip
// read from file
if (!ifs.seekg(_currentEntryOffset, std::ios::beg))
throw FileException{ _currentEntry->filePath, "seek failed", errno };
throw FileStdException{ _currentEntry->filePath, "seek failed", errno };
if (!ifs.read(reinterpret_cast<char*>(_readBuffer.data()), bytesToRead))
throw FileException{ _currentEntry->filePath, "read failed", errno };
throw FileStdException{ _currentEntry->filePath, "read failed", errno };
const std::uint64_t actualBytesRead{ static_cast<std::uint64_t>(ifs.gcount()) };
+38 -36
View File
@@ -19,8 +19,6 @@
#include "ChildProcess.hpp"
#include <cerrno>
#include <cstddef>
#include <fcntl.h>
#include <signal.h>
#include <sys/types.h>
@@ -28,14 +26,15 @@
#include <unistd.h>
#include <algorithm>
#include <iostream>
#include <cerrno>
#include <cstddef>
#include <mutex>
#include <system_error>
#include <boost/asio/buffer.hpp>
#include <boost/asio/read.hpp>
#include "core/ILogger.hpp"
#include "core/String.hpp"
namespace lms::core
{
@@ -44,8 +43,8 @@ namespace lms::core
class SystemException : public ChildProcessException
{
public:
SystemException(int err, const std::string& errMsg)
: ChildProcessException{ errMsg + ": " + stringUtils::systemErrorToString(err) }
SystemException(std::error_code err, const std::string& errMsg)
: ChildProcessException{ errMsg + ": " + err.message() }
{
}
@@ -63,38 +62,29 @@ namespace lms::core
{
// make sure only one thread is executing this part of code
static std::mutex mutex;
std::unique_lock<std::mutex> lock{ mutex };
const std::scoped_lock lock{ mutex };
int pipefd[2];
// Use 'pipe' instead of 'pipe2', more portable
if (pipe(pipefd) < 0)
throw SystemException{ errno, "pipe failed!" };
if (pipe(pipefd) == -1)
throw SystemException{ std::error_code{ errno, std::generic_category() }, "pipe failed!" };
// Manually set the O_NONBLOCK and O_CLOEXEC flags for both ends of the pipe
// Only set O_NONBLOCK on read end - usually programs don't expect stdout to be non-blocking
if (fcntl(pipefd[0], F_SETFL, O_NONBLOCK) == -1)
throw SystemException{ errno, "fcntl failed to set O_NONBLOCK!" };
if (fcntl(pipefd[1], F_SETFL, O_NONBLOCK) == -1)
throw SystemException{ errno, "fcntl failed to set O_NONBLOCK!" };
if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) == -1)
throw SystemException{ errno, "fcntl failed to set FD_CLOEXEC!" };
if (fcntl(pipefd[1], F_SETFD, FD_CLOEXEC) == -1)
throw SystemException{ errno, "fcntl failed to set FD_CLOEXEC!" };
throw SystemException{ std::error_code{ errno, std::generic_category() }, "fcntl failed to set O_NONBLOCK!" };
#if defined(__linux__) && defined(F_SETPIPE_SZ)
for (const int fd : { pipefd[0], pipefd[1] })
{
constexpr std::size_t targetPipeSize{ static_cast<long>(65'536) * 4 };
std::size_t currentPipeSize{ 65'536 }; // common default value
constexpr int targetPipeSize{ 65'536 * 4 };
int 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));
LMS_LOG(CHILDPROCESS, DEBUG, "F_GETPIPE_SZ failed: " << (std::error_code{ err, std::generic_category() }.message()));
}
else
{
@@ -103,36 +93,45 @@ namespace lms::core
#endif
if (currentPipeSize < targetPipeSize)
{
if (fcntl(fd, F_SETPIPE_SZ, targetPipeSize) == -1)
if (::fcntl(fd, F_SETPIPE_SZ, targetPipeSize) == -1)
{
const int err{ errno };
LMS_LOG(CHILDPROCESS, DEBUG, "F_SETPIPE_SZ failed: " << stringUtils::systemErrorToString(err));
LMS_LOG(CHILDPROCESS, DEBUG, "F_SETPIPE_SZ failed: " << (std::error_code{ err, std::generic_category() }.message()));
}
}
}
#endif
int res{ fork() };
const int res{ fork() };
if (res == -1)
throw SystemException{ errno, "fork failed!" };
throw SystemException{ std::error_code{ errno, std::generic_category() }, "fork failed!" };
if (res == 0) // CHILD
{
close(pipefd[0]);
close(STDIN_FILENO);
close(STDERR_FILENO);
// Never close stdin/out/err, most programs expect these to exist;
// rather connect them to /dev/null if unwanted
const int nullFd{ open("/dev/null", O_RDWR) };
// Ignore errors, worst thing is stderr writes to the same fd as lms
if (nullFd != -1)
{
dup2(nullFd, STDIN_FILENO);
dup2(nullFd, STDERR_FILENO);
close(nullFd);
}
// Replace stdout with pipe write
if (dup2(pipefd[1], STDOUT_FILENO) == -1)
exit(-1);
// Close pipe: read end not needed, write end was dup2ed
close(pipefd[0]);
close(pipefd[1]);
std::vector<const char*> execArgs;
std::transform(std::cbegin(args), std::cend(args), std::back_inserter(execArgs), [](const std::string& arg) { return arg.c_str(); });
execArgs.push_back(nullptr);
res = execv(path.string().c_str(), (char* const*)&execArgs[0]);
if (res == -1)
exit(-1);
execv(path.string().c_str(), (char* const*)&execArgs[0]);
exit(-1);
}
else // PARENT
{
@@ -141,7 +140,7 @@ namespace lms::core
boost::system::error_code assignError;
_childStdout.assign(pipefd[0], assignError);
if (assignError)
throw SystemException{ assignError, "fork failed!" };
throw SystemException{ assignError, "assigning read end of pipe to asio stream failed!" };
}
_childPID = res;
}
@@ -168,7 +167,10 @@ 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: " << stringUtils::systemErrorToString(errno));
{
const int err{ errno };
LMS_LOG(CHILDPROCESS, DEBUG, "Kill failed: " << (std::error_code{ err, std::generic_category() }.message()));
}
}
bool ChildProcess::wait(bool block)
@@ -179,7 +181,7 @@ namespace lms::core
const pid_t pid{ waitpid(_childPID, &wstatus, block ? 0 : WNOHANG) };
if (pid == -1)
throw SystemException{ errno, "waitpid failed!" };
throw SystemException{ std::error_code{ errno, std::generic_category() }, "waitpid failed!" };
if (pid == 0)
return false;
+38 -44
View File
@@ -19,50 +19,50 @@
#include "FileResourceHandler.hpp"
#include <fstream>
#include <algorithm>
#include "core/ILogger.hpp"
#include "core/MimeTypes.hpp"
namespace lms
namespace lms::core
{
std::unique_ptr<IResourceHandler> createFileResourceHandler(const std::filesystem::path& path, std::string_view mimeType)
{
return std::make_unique<FileResourceHandler>(path, mimeType);
return std::make_unique<FileResourceHandler>(path, mimeType.empty() ? getMimeType(path.extension()) : mimeType);
}
FileResourceHandler::FileResourceHandler(const std::filesystem::path& path, std::string_view mimeType)
: _path{ path }
, _mimeType{ mimeType }
: _mimeType{ mimeType }
, _ifs{ path, std::ios::in | std::ios::binary }
{
if (!_ifs)
LMS_LOG(UTILS, ERROR, "Cannot open file stream for " << path);
else
{
_ifs.seekg(0, std::ios::end);
if (!_ifs.fail())
_fileSize = static_cast<::uint64_t>(_ifs.tellg());
LMS_LOG(UTILS, DEBUG, "File " << path << ", fileSize = " << _fileSize);
}
}
Wt::Http::ResponseContinuation* FileResourceHandler::processRequest(const Wt::Http::Request& request, Wt::Http::Response& response)
{
::uint64_t startByte{ _offset };
std::ifstream ifs{ _path, std::ios::in | std::ios::binary };
if (startByte == 0)
if (_offset == 0)
{
if (!ifs)
if (!_ifs)
{
LMS_LOG(UTILS, ERROR, "Cannot open file stream for " << _path);
response.setStatus(404);
return {};
}
ifs.seekg(0, std::ios::end);
const ::uint64_t fileSize{ static_cast<::uint64_t>(ifs.tellg()) };
ifs.seekg(0, std::ios::beg);
LMS_LOG(UTILS, DEBUG, "File " << _path << ", fileSize = " << fileSize);
response.addHeader("Accept-Ranges", "bytes");
const Wt::Http::Request::ByteRangeSpecifier ranges{ request.getRanges(fileSize) };
const Wt::Http::Request::ByteRangeSpecifier ranges{ request.getRanges(_fileSize) };
if (!ranges.isSatisfiable())
{
std::ostringstream contentRange;
contentRange << "bytes */" << fileSize;
contentRange << "bytes */" << _fileSize;
response.setStatus(416); // Requested range not satisfiable
response.addHeader("Content-Range", contentRange.str());
@@ -75,57 +75,51 @@ namespace lms
LMS_LOG(UTILS, DEBUG, "Range requested = " << ranges[0].firstByte() << "-" << ranges[0].lastByte());
response.setStatus(206);
startByte = ranges[0].firstByte();
_offset = ranges[0].firstByte();
_beyondLastByte = ranges[0].lastByte() + 1;
std::ostringstream contentRange;
contentRange << "bytes " << startByte << "-"
<< _beyondLastByte - 1 << "/" << fileSize;
contentRange << "bytes " << _offset << "-"
<< _beyondLastByte - 1 << "/" << _fileSize;
response.addHeader("Content-Range", contentRange.str());
response.setContentLength(_beyondLastByte - startByte);
response.setContentLength(_beyondLastByte - _offset);
}
else
{
LMS_LOG(UTILS, DEBUG, "No range requested");
response.setStatus(200);
_beyondLastByte = fileSize;
_beyondLastByte = _fileSize;
response.setContentLength(_beyondLastByte);
}
LMS_LOG(UTILS, DEBUG, "Mimetype set to '" << _mimeType << "'");
response.setMimeType(_mimeType);
}
else if (!ifs)
{
LMS_LOG(UTILS, ERROR, "Cannot reopen file stream for " << _path);
return {};
}
ifs.seekg(static_cast<std::istream::pos_type>(startByte));
_ifs.seekg(static_cast<std::istream::pos_type>(_offset));
} // end initial response setup
std::vector<char> buf;
buf.resize(_chunkSize);
::uint64_t restSize = _beyondLastByte - _offset;
::uint64_t pieceSize = std::min(restSize, static_cast<::uint64_t>(_chunkSize));
::uint64_t restSize = _beyondLastByte - startByte;
::uint64_t pieceSize = buf.size() > restSize ? restSize : buf.size();
std::vector<char> buf(pieceSize);
ifs.read(&buf[0], pieceSize);
const ::uint64_t actualPieceSize{ static_cast<::uint64_t>(ifs.gcount()) };
_ifs.read(buf.data(), buf.size());
const ::uint64_t actualPieceSize{ static_cast<::uint64_t>(_ifs.gcount()) };
if (actualPieceSize > 0)
{
response.out().write(&buf[0], actualPieceSize);
LMS_LOG(UTILS, DEBUG, "Written " << actualPieceSize << " bytes, range = " << startByte << "-" << startByte + actualPieceSize - 1 << "");
response.out().write(buf.data(), actualPieceSize);
LMS_LOG(UTILS, DEBUG, "Written " << actualPieceSize << " bytes, range = " << _offset << "-" << _offset + actualPieceSize - 1 << "");
}
else
{
LMS_LOG(UTILS, DEBUG, "Written 0 byte");
}
if (ifs.good() && actualPieceSize < restSize)
if (!_ifs.good())
LMS_LOG(UTILS, WARNING, "Error reading from file!");
else if (actualPieceSize < restSize)
{
_offset = startByte + actualPieceSize;
_offset += actualPieceSize;
LMS_LOG(UTILS, DEBUG, "Job not complete! Remaining range: " << _offset << "-" << _beyondLastByte - 1);
return response.createContinuation();
@@ -134,4 +128,4 @@ namespace lms
LMS_LOG(UTILS, DEBUG, "Job complete!");
return nullptr;
}
} // namespace lms
} // namespace lms::core
+5 -3
View File
@@ -20,12 +20,13 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include "core/IResourceHandler.hpp"
namespace lms
namespace lms::core
{
class FileResourceHandler final : public IResourceHandler
{
@@ -38,9 +39,10 @@ namespace lms
static constexpr std::size_t _chunkSize{ 262'144 };
std::filesystem::path _path;
std::string _mimeType;
::uint64_t _beyondLastByte{};
::uint64_t _offset{};
::uint64_t _fileSize{};
std::ifstream _ifs;
};
} // namespace lms
} // namespace lms::core
+79
View File
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2025 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 "core/MimeTypes.hpp"
#include <unordered_map>
#include "core/String.hpp"
namespace lms::core
{
std::string_view getMimeType(const std::filesystem::path& fileExtension)
{
static const std::unordered_map<std::string, std::string_view> entries{
// audio
{ ".aac", "audio/aac" },
{ ".ac3", "audio/ac3" },
{ ".aif", "audio/x-aiff" },
{ ".aiff", "audio/x-aiff" },
{ ".alac", "audio/mp4" },
{ ".ape", "audio/x-monkeys-audio" },
{ ".dff", "audio/x-dsd-dff" },
{ ".dsdiff", "audio/x-dsd-diff" },
{ ".dsf", "audio/x-dsd" },
{ ".dsf", "audio/x-dsd-dsf" },
{ ".dts", "audio/vnd.dts" },
{ ".dtshd", "audio/vnd.dts.hd" },
{ ".eac3", "audio/eac3" },
{ ".flac", "audio/flac" },
{ ".m3u", "audio/x-mpegurl" },
{ ".m4a", "audio/mp4" },
{ ".m4b", "audio/mp4" },
{ ".mka", "audio/x-matroska" },
{ ".mka", "audio/x-matroska" },
{ ".mp3", "audio/mpeg" },
{ ".mpc", "audio/x-musepack" },
{ ".oga", "audio/ogg" },
{ ".ogg", "audio/ogg" },
{ ".opus", "audio/opus" },
{ ".pls", "audio/x-scpls" },
{ ".shn", "audio/x-shn" },
{ ".wav", "audio/x-wav" },
{ ".webm", "audio/webm" },
{ ".wma", "audio/x-ms-wma" },
{ ".wv", "audio/x-wavpack" },
{ ".wvp", "audio/x-wavpack" },
// image
{ ".bmp", "image/bmp" },
{ ".gif", "image/gif" },
{ ".jpg", "image/jpeg" },
{ ".jpeg", "image/jpeg" },
{ ".png", "image/png" },
{ ".webp", "image/webp" },
};
auto it{ entries.find(core::stringUtils::stringToLower(fileExtension.c_str())) };
if (it == std::cend(entries))
return "application/octet-stream";
return it->second;
}
} // namespace lms::core
+13 -4
View File
@@ -23,6 +23,7 @@
#include <fstream>
#include <sys/stat.h>
#include <sys/types.h>
#include <system_error>
#include <unistd.h>
#include <boost/tokenizer.hpp>
@@ -66,16 +67,24 @@ namespace lms::core::pathUtils
return std::filesystem::create_directory(dir);
}
Wt::WDateTime getLastWriteTime(const std::filesystem::path& file)
Wt::WDateTime getLastWriteTime(const std::filesystem::path& file, std::error_code& ec)
{
Wt::WDateTime res;
struct stat sb
{
};
if (stat(file.c_str(), &sb) == -1)
throw LmsException("Failed to get stats on file '" + file.string() + "'");
{
ec = std::error_code{ errno, std::generic_category() };
}
else
{
ec = std::error_code{};
res = Wt::WDateTime::fromTime_t(sb.st_mtime);
}
return Wt::WDateTime::fromTime_t(sb.st_mtime);
return res;
}
bool exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb, const std::filesystem::path* excludeDirFileName)
-8
View File
@@ -20,7 +20,6 @@
#include "core/String.hpp"
#include <algorithm>
#include <array>
#include <cstring>
#include <iomanip>
#include <utility>
@@ -509,11 +508,4 @@ 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<char, 128> buffer{};
::strerror_r(err, buffer.data(), buffer.size());
return std::string{ buffer.data() };
}
} // namespace lms::core::stringUtils
@@ -25,7 +25,7 @@
#include "core/IResourceHandler.hpp"
namespace lms
namespace lms::core
{
std::unique_ptr<IResourceHandler> createFileResourceHandler(const std::filesystem::path& path, std::string_view mimeType);
std::unique_ptr<IResourceHandler> createFileResourceHandler(const std::filesystem::path& path, std::string_view mimeType = "");
}
@@ -22,8 +22,7 @@
#include <Wt/Http/Request.h>
#include <Wt/Http/Response.h>
// TODO, move elsewhere
namespace lms
namespace lms::core
{
// Helper class to serve a resource (must be saved as continuation data if not complete)
class IResourceHandler
@@ -34,4 +33,4 @@ namespace lms
[[nodiscard]] virtual Wt::Http::ResponseContinuation* processRequest(const Wt::Http::Request& request, Wt::Http::Response& response) = 0;
virtual void abort() = 0;
};
} // namespace lms
} // namespace lms::core
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 Emeric Poupon
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
@@ -20,11 +20,9 @@
#pragma once
#include <filesystem>
#include <memory>
#include <string_view>
#include "core/IResourceHandler.hpp"
namespace lms::av
namespace lms::core
{
std::unique_ptr<IResourceHandler> createRawResourceHandler(const std::filesystem::path& path);
std::string_view getMimeType(const std::filesystem::path& fileExtension);
}
+2 -2
View File
@@ -22,7 +22,7 @@
#include <filesystem>
#include <functional>
#include <span>
#include <string>
#include <system_error>
#include <Wt/WDateTime.h>
@@ -35,7 +35,7 @@ namespace lms::core::pathUtils
bool ensureDirectory(const std::filesystem::path& dir);
// Get the last write time since Epoch
Wt::WDateTime getLastWriteTime(const std::filesystem::path& dir);
Wt::WDateTime getLastWriteTime(const std::filesystem::path& file, std::error_code& ec);
// returns false if aborted by user
bool exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb, const std::filesystem::path* excludeDirFileName = {});
-2
View File
@@ -119,6 +119,4 @@ namespace lms::core::stringUtils
// to "[minutes:seconds.milliseconds]"
std::string formatTimestamp(std::chrono::milliseconds timestamp);
std::string systemErrorToString(int err);
} // namespace lms::core::stringUtils
+1
View File
@@ -1,6 +1,7 @@
add_library(lmsdatabase STATIC
impl/Artist.cpp
impl/ArtistInfo.cpp
impl/Artwork.cpp
impl/AuthToken.cpp
impl/Cluster.cpp
impl/Db.cpp
+10 -5
View File
@@ -21,9 +21,9 @@
#include <Wt/Dbo/WtSqlTraits.h>
#include "core/ILogger.hpp"
#include "database/Artwork.hpp"
#include "database/Cluster.hpp"
#include "database/Directory.hpp"
#include "database/Image.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
@@ -315,9 +315,14 @@ AND NOT EXISTS (
return getMBID().has_value();
}
ObjectPtr<Image> Artist::getImage() const
ObjectPtr<Artwork> Artist::getPreferredArtwork() const
{
return ObjectPtr<Image>{ _image };
return ObjectPtr<Artwork>{ _preferredArtwork };
}
ArtworkId Artist::getPreferredArtworkId() const
{
return _preferredArtwork.id();
}
RangeResults<ArtistId> Artist::findSimilarArtistIds(core::EnumSet<TrackArtistLinkType> artistLinkTypes, std::optional<Range> range) const
@@ -416,8 +421,8 @@ AND NOT EXISTS (
LMS_LOG(DB, WARNING, "Artist sort name too long, truncated to '" << _sortName << "'");
}
void Artist::setImage(ObjectPtr<Image> image)
void Artist::setPreferredArtwork(ObjectPtr<Artwork> artwork)
{
_image = getDboPtr(image);
_preferredArtwork = getDboPtr(artwork);
}
} // namespace lms::db
+90
View File
@@ -0,0 +1,90 @@
/*
* Copyright (C) 2025 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 "database/Artwork.hpp"
#include <Wt/Dbo/WtSqlTraits.h>
#include "database/Image.hpp"
#include "database/Session.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
namespace lms::db
{
Artwork::Artwork(ObjectPtr<TrackEmbeddedImage> trackEmbeddedImage)
: _trackEmbeddedImage{ getDboPtr(trackEmbeddedImage) }
{
}
Artwork::Artwork(ObjectPtr<Image> image)
: _image{ getDboPtr(image) }
{
}
Artwork::pointer Artwork::create(Session& session, ObjectPtr<TrackEmbeddedImage> trackEmbeddedImage)
{
session.checkWriteTransaction();
return session.getDboSession()->add(std::unique_ptr<Artwork>{ new Artwork{ trackEmbeddedImage } });
}
Artwork::pointer Artwork::create(Session& session, ObjectPtr<Image> image)
{
session.checkWriteTransaction();
return session.getDboSession()->add(std::unique_ptr<Artwork>{ new Artwork{ image } });
}
std::size_t Artwork::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<long>("SELECT COUNT(*) FROM artwork"));
}
Artwork::pointer Artwork::find(Session& session, ArtworkId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<Artwork>>("SELECT a FROM artwork a").where("a.id = ?").bind(id));
}
Artwork::pointer Artwork::find(Session& session, TrackEmbeddedImageId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<Artwork>>("SELECT a FROM artwork a").where("a.track_embedded_image_id = ?").bind(id));
}
Artwork::pointer Artwork::find(Session& session, ImageId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<Artwork>>("SELECT a FROM artwork a").where("a.image_id = ?").bind(id));
}
Wt::WDateTime Artwork::getLastWrittenTime() const
{
auto query{ session()->query<Wt::WDateTime>("SELECT MAX(COALESCE(image.file_last_write, track.file_last_write)) AS last_written_datetime FROM artwork") };
query.leftJoin("image ON artwork.image_id = image.id");
query.leftJoin("track_embedded_image ON artwork.track_embedded_image_id = track_embedded_image.id");
query.leftJoin("track_embedded_image_link ON track_embedded_image.id = track_embedded_image_link.track_embedded_image_id");
query.leftJoin("track ON track.id = track_embedded_image_link.track_id");
query.where("artwork.id = ?").bind(getId());
return utils::fetchQuerySingleResult(query);
}
} // namespace lms::db
+1
View File
@@ -20,6 +20,7 @@
#include "database/Cluster.hpp"
#include "database/Artist.hpp"
#include "database/Artwork.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
+129
View File
@@ -19,6 +19,9 @@
#include "database/Db.hpp"
#include <functional>
#include <memory>
#include <Wt/Dbo/FixedSqlConnectionPool.h>
#include <Wt/Dbo/backend/Sqlite3.h>
@@ -70,21 +73,98 @@ namespace lms::db
std::filesystem::path _dbPath;
};
enum class IntegrityCheckType
{
Quick,
Full
};
bool checkDbIntegrity(Wt::Dbo::SqlConnection& connection, IntegrityCheckType checkType, std::function<void(std::string_view error)> errorCallback)
{
bool integrityCheckPassed{};
auto statement = connection.prepareStatement(checkType == IntegrityCheckType::Full ? "PRAGMA integrity_check" : "PRAGMA quick_check");
statement->execute();
std::string result;
result.reserve(32);
while (statement->nextRow())
{
result.clear();
statement->getResult(0, &result, result.capacity());
if (result == "ok")
{
integrityCheckPassed = true;
break;
}
errorCallback(result);
}
return integrityCheckPassed;
}
bool checkDbForeignKeyConstraints(Wt::Dbo::SqlConnection& connection, std::function<void(std::string_view table, long long rowId, std::string_view referredTable)> errorCallback)
{
bool foreignKeyConstraintsPassed{ true };
auto statement = connection.prepareStatement("PRAGMA foreign_key_check");
statement->execute();
std::string table;
std::string foreignTable;
// see https://www.sqlite.org/pragma.html#pragma_foreign_key_check for exepcted result
while (statement->nextRow())
{
foreignKeyConstraintsPassed = false;
table.clear();
foreignTable.clear();
long long rowId{};
statement->getResult(0, &table, static_cast<int>(table.capacity()));
statement->getResult(1, &rowId);
statement->getResult(2, &foreignTable, static_cast<int>(foreignTable.capacity()));
errorCallback(table, rowId, foreignTable);
}
return foreignKeyConstraintsPassed;
}
} // namespace
// Session living class handling the database and the login
Db::Db(const std::filesystem::path& dbPath, std::size_t connectionCount)
{
std::string checkType{ "quick" };
LMS_LOG(DB, INFO, "Creating connection pool on file " << dbPath);
auto connection{ std::make_unique<Connection>(dbPath) };
if (core::IConfig * config{ core::Service<core::IConfig>::get() }) // may not be here on testU
{
connection->setProperty("show-queries", config->getBool("db-show-queries", false) ? "true" : "false");
checkType = config->getString("db-integrity-check", "quick");
}
auto connectionPool{ std::make_unique<Wt::Dbo::FixedSqlConnectionPool>(std::move(connection), connectionCount) };
connectionPool->setTimeout(std::chrono::seconds{ 10 });
_connectionPool = std::move(connectionPool);
if (checkType == "quick")
{
performQuickCheck();
}
else if (checkType == "full")
{
performIntegrityCheck();
performForeignKeyConstraintsCheck();
}
else if (checkType != "none")
{
throw Exception("Invalid 'db-integrity-check' value: '" + checkType + "'. Expected 'quick', 'full' or 'none'.");
}
}
void Db::executeSql(const std::string& sql)
@@ -114,6 +194,55 @@ namespace lms::db
return *tlsSession;
}
void Db::performQuickCheck()
{
ScopedConnection connection{ *_connectionPool };
LMS_LOG(DB, INFO, "Performing quick database check...");
// Quick check is just a simple integrity check
bool quickCheckPassed{ checkDbIntegrity(*connection, IntegrityCheckType::Quick, [&](std::string_view error) {
LMS_LOG(DB, ERROR, "Quick check error: " << error);
}) };
if (quickCheckPassed)
LMS_LOG(DB, INFO, "Quick database check passed!");
else
LMS_LOG(DB, ERROR, "Quick database check done with errors!");
}
void Db::performIntegrityCheck()
{
ScopedConnection connection{ *_connectionPool };
LMS_LOG(DB, INFO, "Checking database integrity...");
bool integrityCheckPassed{ checkDbIntegrity(*connection, IntegrityCheckType::Full, [&](std::string_view error) {
LMS_LOG(DB, ERROR, "Integrity check error: " << error);
}) };
if (integrityCheckPassed)
LMS_LOG(DB, INFO, "Database integrity check passed!");
else
LMS_LOG(DB, ERROR, "Database integrity check done with errors!");
}
void Db::performForeignKeyConstraintsCheck()
{
ScopedConnection connection{ *_connectionPool };
LMS_LOG(DB, INFO, "Checking foreign key constraints...");
bool foreignKeyConstraintsPassed{ checkDbForeignKeyConstraints(*connection, [&](std::string_view table, long long rowId, std::string_view referredTable) {
LMS_LOG(DB, ERROR, "Foreign key constraint failed in table '" << table << "', rowid = " << rowId << ", referred table = '" << referredTable << "'");
}) };
if (!foreignKeyConstraintsPassed)
throw Exception("Foreign key constraints check failed! Please restore from a backup or recreate the database.");
LMS_LOG(DB, INFO, "Foreign key constraints check passed!");
}
Db::ScopedConnection::ScopedConnection(Wt::Dbo::SqlConnectionPool& pool)
: _connectionPool{ pool }
, _connection{ _connectionPool.getConnection() }
-1
View File
@@ -23,7 +23,6 @@
#include "database/Artist.hpp"
#include "database/Directory.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "Utils.hpp"
+186 -2
View File
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 90 };
static constexpr Version LMS_DATABASE_VERSION{ 94 };
}
VersionInfo::VersionInfo()
@@ -411,7 +411,7 @@ SELECT
copyright_url,
track_replay_gain,
release_replay_gain,
COALESCE(artist_display_name, ""),
artist_display_name,
release_id,
1
FROM track)");
@@ -1206,6 +1206,186 @@ FROM tracklist)");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET artist_info_scan_version = artist_info_scan_version + 1");
}
void migrateFromV90(Session& session)
{
// Added a fallback for missing name, using parent directory name
// Just increment the scan version of the settings to make the next scan rescan everything
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET artist_info_scan_version = artist_info_scan_version + 1");
}
void migrateFromV91(Session& session)
{
dropIndexes(session);
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_embedded_image_link DROP COLUMN is_preferred");
}
void migrateFromV92(Session& session)
{
// Create the new artwork table
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "artwork" (
"id" integer primary key autoincrement,
"version" integer not null,
"track_embedded_image_id" bigint,
"image_id" bigint,
constraint "fk_artwork_track_embedded_image" foreign key ("track_embedded_image_id") references "track_embedded_image" ("id") on delete cascade deferrable initially deferred,
constraint "fk_artwork_image" foreign key ("image_id") references "image" ("id") on delete cascade deferrable initially deferred))");
// Replaced image by artwork for release
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null,
"sort_name" text not null,
"mbid" text not null,
"group_mbid" text not null,
"total_disc" integer,
"artist_display_name" text not null,
"is_compilation" boolean not null,
"barcode" text not null,
"comment" text not null,
"preferred_artwork_id" bigint,
constraint "fk_release_preferred_artwork" foreign key ("preferred_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred))");
// Migrate data, with the new preferred_artwork_id field set to null
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO release_backup
SELECT
id,
version,
name,
sort_name,
mbid,
group_mbid,
total_disc,
COALESCE(artist_display_name, ''),
is_compilation,
barcode,
comment,
NULL as preferred_artwork_id
FROM release)");
utils::executeCommand(*session.getDboSession(), "DROP TABLE release");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release_backup RENAME TO release");
// Replaced image by artwork for track
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"scan_version" integer not null,
"track_number" integer,
"disc_number" integer,
"total_track" integer,
"disc_subtitle" text not null,
"name" text not null,
"duration" integer,
"bitrate" integer not null,
"bits_per_sample" integer not null,
"channel_count" integer not null,
"sample_rate" integer not null,
"date" text,
"original_date" text,
"absolute_file_path" text not null,
"relative_file_path" text not null,
"file_stem" text not null,
"file_name" text not null,
"file_size" bigint not null,
"file_last_write" text,
"file_added" text,
"mbid" text not null,
"recording_mbid" text not null,
"copyright" text not null,
"copyright_url" text not null,
"advisory" integer not null,
"track_replay_gain" real,
"release_replay_gain" real,
"artist_display_name" text not null,
"comment" text not null,
"release_id" bigint,
"media_library_id" bigint,
"directory_id" bigint,
"preferred_artwork_id" bigint,
"preferred_media_artwork_id" bigint,
constraint "fk_track_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred,
constraint "fk_track_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete set null deferrable initially deferred,
constraint "fk_track_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred,
constraint "fk_track_preferred_artwork" foreign key ("preferred_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred,
constraint "fk_track_preferred_media_artwork" foreign key ("preferred_media_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred
))");
// Migrate data, with the new preferred_artwork_id and preferred_media_artwork_id fields set to null
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup
SELECT
id,
version,
scan_version,
track_number,
disc_number,
total_track,
disc_subtitle,
name,
duration,
bitrate,
bits_per_sample,
channel_count,
sample_rate,
date,
original_date,
absolute_file_path,
relative_file_path,
file_stem,
file_name,
file_size,
file_last_write,
file_added,
mbid,
recording_mbid,
copyright,
copyright_url,
advisory,
track_replay_gain,
release_replay_gain,
artist_display_name,
comment,
release_id,
media_library_id,
directory_id,
NULL as preferred_artwork_id,
NULL as preferred_media_artwork_id
FROM track)");
utils::executeCommand(*session.getDboSession(), "DROP TABLE track");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track");
// Replaced image by artwork for artist
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "artist_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null,
"sort_name" text not null,
"mbid" text not null,
"preferred_artwork_id" bigint,
constraint "fk_artist_preferred_artwork" foreign key ("preferred_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred
))");
// Migrate data, with the new preferred_artwork_id field set to null
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO artist_backup
SELECT
id,
version,
name,
sort_name,
mbid,
NULL as preferred_artwork_id
FROM artist)");
utils::executeCommand(*session.getDboSession(), "DROP TABLE artist");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_backup RENAME TO artist");
// Just increment the scan version of the settings to make the next scan rescan everything
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1");
}
void migrateFromV93(Session& session)
{
// add artist_image_fallback_to_release in ScanSettings
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN artist_image_fallback_to_release BOOLEAN NOT NULL DEFAULT(false)");
}
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1272,6 +1452,10 @@ FROM tracklist)");
{ 87, migrateFromV87 },
{ 88, migrateFromV88 },
{ 89, migrateFromV89 },
{ 90, migrateFromV90 },
{ 91, migrateFromV91 },
{ 92, migrateFromV92 },
{ 93, migrateFromV93 },
};
bool migrationPerformed{};
+1
View File
@@ -21,6 +21,7 @@
#include <Wt/Dbo/SqlTraits.h>
#include "database/Artwork.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
+31 -10
View File
@@ -23,9 +23,9 @@
#include "core/PartialDateTime.hpp"
#include "database/Artist.hpp"
#include "database/Artwork.hpp"
#include "database/Cluster.hpp"
#include "database/Directory.hpp"
#include "database/Image.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/Types.hpp"
@@ -701,9 +701,14 @@ namespace lms::db
return utils::fetchQueryResults<Release::pointer>(query);
}
ObjectPtr<Image> Release::getImage() const
ObjectPtr<Artwork> Release::getPreferredArtwork() const
{
return ObjectPtr<Image>{ _image };
return ObjectPtr<Artwork>{ _preferredArtwork };
}
ArtworkId Release::getPreferredArtworkId() const
{
return _preferredArtwork.id();
}
void Release::clearLabels()
@@ -736,9 +741,9 @@ namespace lms::db
_releaseTypes.insert(getDboPtr(releaseType));
}
void Release::setImage(ObjectPtr<Image> image)
void Release::setPreferredArtwork(ObjectPtr<Artwork> artwork)
{
_image = getDboPtr(image);
_preferredArtwork = getDboPtr(artwork);
}
bool Release::hasVariousArtists() const
@@ -867,14 +872,11 @@ namespace lms::db
for (const std::string& bindArg : where.getBindArgs())
query.bind(bindArg);
auto queryRes{ query.resultList() };
std::map<ClusterTypeId, std::vector<Cluster::pointer>> clustersByType;
for (const Wt::Dbo::ptr<Cluster>& cluster : queryRes)
{
utils::forEachQueryResult(query, [&](const Wt::Dbo::ptr<Cluster>& cluster) {
if (clustersByType[cluster->getType()->getId()].size() < size)
clustersByType[cluster->getType()->getId()].push_back(cluster);
}
});
std::vector<std::vector<Cluster::pointer>> res;
for (const auto& [clusterTypeId, clusters] : clustersByType)
@@ -882,4 +884,23 @@ namespace lms::db
return res;
}
std::vector<ObjectPtr<Cluster>> Release::getClusters(ClusterTypeId clusterTypeId, std::size_t maxCount) const
{
assert(session());
auto query{ session()->query<Wt::Dbo::ptr<Cluster>>("SELECT c FROM cluster c INNER JOIN track_cluster t_c ON t_c.cluster_id = c.id INNER JOIN track t ON t.id = t_c.track_id") };
query.where("t.release_id = ?").bind(getId());
query.where("c.cluster_type_id = ?").bind(clusterTypeId);
query.groupBy("c.id");
query.orderBy("COUNT(c.id) DESC");
query.limit(static_cast<int>(maxCount));
std::vector<ObjectPtr<Cluster>> res;
utils::forEachQueryResult(query, [&](const Wt::Dbo::ptr<Cluster>& cluster) {
res.push_back(cluster);
});
return res;
}
} // namespace lms::db
+7 -4
View File
@@ -121,14 +121,17 @@ namespace lms::db
void ScanSettings::setSkipSingleReleasePlayLists(bool value)
{
if (_skipSingleReleasePlayLists != value)
_skipSingleReleasePlayLists = value;
_skipSingleReleasePlayLists = value;
}
void ScanSettings::setAllowMBIDArtistMerge(bool value)
{
if (_allowMBIDArtistMerge != value)
_allowMBIDArtistMerge = value;
_allowMBIDArtistMerge = value;
}
void ScanSettings::setArtistImageFallbackToReleaseField(bool value)
{
_artistImageFallbackToReleaseField = value;
}
void ScanSettings::incAudioScanVersion()
+21 -3
View File
@@ -23,6 +23,7 @@
#include "core/ITraceLogger.hpp"
#include "database/Artist.hpp"
#include "database/ArtistInfo.hpp"
#include "database/Artwork.hpp"
#include "database/AuthToken.hpp"
#include "database/Cluster.hpp"
#include "database/Db.hpp"
@@ -102,6 +103,7 @@ namespace lms::db
_session.mapClass<Artist>("artist");
_session.mapClass<ArtistInfo>("artist_info");
_session.mapClass<Artwork>("artwork");
_session.mapClass<AuthToken>("auth_token");
_session.mapClass<Cluster>("cluster");
_session.mapClass<ClusterType>("cluster_type");
@@ -196,7 +198,6 @@ namespace lms::db
{
auto transaction{ createWriteTransaction() };
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_id_idx ON artist(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_image_idx ON artist(image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_name_mbid_idx ON artist(name, mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)");
@@ -206,10 +207,15 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_info_artist_id_idx ON artist_info(artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_info_mbid_matched_artist_idx ON artist_info(mbid_matched, artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artwork_id_idx ON artwork(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artwork_image_idx ON artwork(image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artwork_track_embedded_image_idx ON artwork(track_embedded_image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_domain_idx ON auth_token(user_id, domain)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_value_idx ON auth_token(domain, value)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_id_idx ON cluster(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)");
@@ -225,9 +231,10 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_id_idx ON track_embedded_image(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_hash_idx ON track_embedded_image(hash)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_link_id_idx ON track_embedded_image_link(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_link_track_id_idx ON track_embedded_image_link(track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_link_track_embedded_image_id_track_id_idx ON track_embedded_image_link(track_embedded_image_id, track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_link_is_preferred_track_id_track_embedded_image_id_idx ON track_embedded_image_link(is_preferred, track_id, track_embedded_image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_embedded_image_link_type_track_embedded_image_id_track_id_idx ON track_embedded_image_link(type,track_embedded_image_id,track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_directory_stem_idx ON image(directory_id, stem COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_id_idx ON image(id)");
@@ -254,7 +261,6 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_image_idx ON release(image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_group_mbid_idx ON release(group_mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_mbid_idx ON release(mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)");
@@ -291,6 +297,7 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_file_added_idx ON track(release_id, file_added)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_file_added_desc_idx ON track(release_id, file_added DESC)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_id_idx ON tracklist(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_name_idx ON tracklist(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_user_type_idx ON tracklist(user_id, type)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_last_modified_date_time_idx ON tracklist(last_modified_date_time)");
@@ -298,6 +305,7 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_entry_idx ON tracklist_entry(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_entry_tracklist_track_idx ON tracklist_entry(tracklist_id, track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_id_idx ON track_artist_link(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_idx ON track_artist_link(artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_mbid_matched_artist_idx ON track_artist_link(artist_mbid_matched, artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_track_idx ON track_artist_link(artist_id, track_id)");
@@ -393,6 +401,16 @@ namespace lms::db
LMS_LOG(DB, INFO, "Analyze complete!");
}
bool Session::areAllTablesEmpty()
{
const std::vector<std::string> entryList{ utils::fetchQueryResults(_session.query<std::string>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")) };
return std::all_of(entryList.cbegin(), entryList.cend(), [this](const std::string& entry) {
const auto count{ utils::fetchQuerySingleResult(_session.query<long>("SELECT COUNT(*) FROM " + entry)) };
return count == 0;
});
}
void Session::retrieveEntriesToAnalyze(std::vector<std::string>& entryList)
{
auto transaction{ createReadTransaction() };
+71
View File
@@ -23,6 +23,7 @@
#include "core/ILogger.hpp"
#include "database/Artist.hpp"
#include "database/Artwork.hpp"
#include "database/Cluster.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
@@ -324,6 +325,26 @@ namespace lms::db
return utils::execRangeQuery<TrackId>(query, range);
}
void Track::updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId)
{
session.checkWriteTransaction();
if (artworkId.isValid())
utils::executeCommand(*session.getDboSession(), "UPDATE track SET preferred_artwork_id = ? WHERE id = ?", artworkId, trackId);
else
utils::executeCommand(*session.getDboSession(), "UPDATE track SET preferred_artwork_id = NULL WHERE id = ?", trackId);
}
void Track::updatePreferredMediaArtwork(Session& session, TrackId trackId, ArtworkId artworkId)
{
session.checkWriteTransaction();
if (artworkId.isValid())
utils::executeCommand(*session.getDboSession(), "UPDATE track SET preferred_media_artwork_id = ? WHERE id = ?", artworkId, trackId);
else
utils::executeCommand(*session.getDboSession(), "UPDATE track SET preferred_media_artwork_id = NULL WHERE id = ?", trackId);
}
std::vector<Cluster::pointer> Track::getClusters() const
{
return utils::fetchQueryResults<Cluster::pointer>(_clusters.find());
@@ -338,6 +359,36 @@ namespace lms::db
return utils::fetchQueryResults(query);
}
ObjectPtr<MediaLibrary> Track::getMediaLibrary() const
{
return _mediaLibrary;
}
ObjectPtr<Directory> Track::getDirectory() const
{
return _directory;
}
ObjectPtr<Artwork> Track::getPreferredArtwork() const
{
return _preferredArtwork;
}
ArtworkId Track::getPreferredArtworkId() const
{
return _preferredArtwork.id();
}
ObjectPtr<Artwork> Track::getPreferredMediaArtwork() const
{
return _preferredMediaArtwork;
}
ArtworkId Track::getPreferredMediaArtworkId() const
{
return _preferredMediaArtwork.id();
}
RangeResults<TrackId> Track::findIds(Session& session, const FindParameters& parameters)
{
session.checkReadTransaction();
@@ -484,6 +535,26 @@ namespace lms::db
_embeddedImageLinks.insert(getDboPtr(image));
}
void Track::setMediaLibrary(ObjectPtr<MediaLibrary> mediaLibrary)
{
_mediaLibrary = getDboPtr(mediaLibrary);
}
void Track::setDirectory(ObjectPtr<Directory> directory)
{
_directory = getDboPtr(directory);
}
void Track::setPreferredArtwork(ObjectPtr<Artwork> artwork)
{
_preferredArtwork = getDboPtr(artwork);
}
void Track::setPreferredMediaArtwork(ObjectPtr<Artwork> artwork)
{
_preferredMediaArtwork = getDboPtr(artwork);
}
std::optional<int> Track::getYear() const
{
return _date.getYear();
+58 -8
View File
@@ -38,24 +38,50 @@ namespace lms::db
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackEmbeddedImage>>("SELECT t_e_i FROM track_embedded_image t_e_i") };
if (params.isPreferred
if (params.artist.isValid()
|| params.discNumber.has_value()
|| params.track.isValid()
|| params.release.isValid()
|| params.trackList.isValid()
|| params.sortMethod == TrackEmbeddedImageSortMethod::FrontCoverAndSize)
|| !params.imageTypes.empty()
|| params.sortMethod == TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc
|| params.sortMethod == TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc)
{
query.join("track_embedded_image_link t_e_i_l ON t_e_i_l.track_embedded_image_id = t_e_i.id");
if (params.isPreferred)
query.where("t_e_i_l.is_preferred = ?").bind(params.isPreferred.value());
if (params.artist.isValid())
{
query.join("track_artist_link t_a_l ON t_a_l.track_id = t_e_i_l.track_id");
query.where("t_a_l.artist_id = ?").bind(params.artist);
if (!params.trackArtistLinkTypes.empty())
{
std::string clause{ "t_a_l.type IN (" };
for (const auto& type : params.trackArtistLinkTypes)
{
if (clause.back() != '(')
clause += ",";
clause += "?";
query.bind(type);
}
clause += ")";
query.where(clause);
}
}
if (params.track.isValid())
query.where("t_e_i_l.track_id = ?").bind(params.track);
if (params.release.isValid())
if (params.release.isValid()
|| params.discNumber.has_value()
|| params.sortMethod == TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc
|| params.sortMethod == TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc)
{
query.join("track t ON t_e_i_l.track_id = t.id");
query.where("t.release_id = ?").bind(params.release);
if (params.release.isValid())
query.where("t.release_id = ?").bind(params.release);
if (params.discNumber.has_value())
query.where("t.disc_number = ?").bind(params.discNumber.value());
}
if (params.trackList.isValid())
@@ -63,14 +89,38 @@ namespace lms::db
query.join("tracklist_entry t_l_e ON t_l_e.track_id = t_e_i_l.track_id");
query.where("t_l_e.tracklist_id = ?").bind(params.trackList);
}
if (!params.imageTypes.empty())
{
std::string clause{ "t_e_i_l.type IN (" };
for (const auto& type : params.imageTypes)
{
if (clause.back() != '(')
clause += ",";
clause += "?";
query.bind(type);
}
clause += ")";
query.where(clause);
}
}
switch (params.sortMethod)
{
case TrackEmbeddedImageSortMethod::None:
break;
case TrackEmbeddedImageSortMethod::FrontCoverAndSize:
query.orderBy("CASE WHEN t_e_i_l.type = ? THEN 0 ELSE 1 END, t_e_i.size").bind(ImageType::FrontCover);
case TrackEmbeddedImageSortMethod::SizeDesc:
query.orderBy("t_e_i.size DESC");
break;
case TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc:
query.orderBy("t.disc_number, t.track_number, t_e_i.size DESC");
break;
case TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc:
query.orderBy("t.track_number, t_e_i.size DESC");
break;
case TrackEmbeddedImageSortMethod::TrackListIndexAscThenSizeDesc:
assert(params.trackList.isValid());
query.orderBy("t_l_e.id, t_e_i.size DESC");
break;
}
@@ -54,6 +54,14 @@ namespace lms::db
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackEmbeddedImageLink>().where("id = ?").bind(id));
}
void TrackEmbeddedImageLink::find(Session& session, TrackEmbeddedImageId trackEmbeddedImageId, std::function<void(const pointer&)> visitor)
{
auto query{ session.getDboSession()->find<TrackEmbeddedImageLink>() };
query.where("track_embedded_image_id = ?").bind(trackEmbeddedImageId);
return utils::forEachQueryResult(query, visitor);
}
ObjectPtr<Track> TrackEmbeddedImageLink::getTrack() const
{
return _track;
@@ -31,6 +31,7 @@
#include "core/EnumSet.hpp"
#include "core/UUID.hpp"
#include "database/ArtistId.hpp"
#include "database/ArtworkId.hpp"
#include "database/ClusterId.hpp"
#include "database/Filters.hpp"
#include "database/MediaLibraryId.hpp"
@@ -42,10 +43,9 @@
namespace lms::db
{
class Artwork;
class Cluster;
class ClusterType;
class Image;
class Release;
class Session;
class StarredArtist;
@@ -136,7 +136,8 @@ namespace lms::db
const std::string& getSortName() const { return _sortName; }
std::optional<core::UUID> getMBID() const;
bool hasMBID() const;
ObjectPtr<Image> getImage() const;
ObjectPtr<Artwork> getPreferredArtwork() const;
ArtworkId getPreferredArtworkId() const;
void visitLinks(std::function<void(const ObjectPtr<TrackArtistLink>& link)> visitor) const;
// No artistLinkTypes means get them all
@@ -150,7 +151,7 @@ namespace lms::db
void setName(std::string_view name);
void setMBID(const std::optional<core::UUID>& mbid) { _mbid = mbid ? mbid->getAsString() : ""; }
void setSortName(std::string_view sortName);
void setImage(ObjectPtr<Image> image);
void setPreferredArtwork(ObjectPtr<Artwork> artwork);
template<class Action>
void persist(Action& a)
@@ -159,7 +160,7 @@ namespace lms::db
Wt::Dbo::field(a, _sortName, "sort_name");
Wt::Dbo::field(a, _mbid, "mbid");
Wt::Dbo::belongsTo(a, _image, "image", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::belongsTo(a, _preferredArtwork, "preferred_artwork", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "artist");
Wt::Dbo::hasMany(a, _starredArtists, Wt::Dbo::ManyToMany, "user_starred_artists", "", Wt::Dbo::OnDeleteCascade);
}
@@ -176,7 +177,7 @@ namespace lms::db
std::string _sortName;
std::string _mbid; // Musicbrainz Identifier
Wt::Dbo::ptr<Image> _image;
Wt::Dbo::ptr<Artwork> _preferredArtwork;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackArtistLink>> _trackArtistLinks; // Tracks involving this artist
Wt::Dbo::collection<Wt::Dbo::ptr<StarredArtist>> _starredArtists; // starred entries for this artist
};
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 Emeric Poupon
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2025 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/Dbo/Dbo.h>
#include <Wt/WDateTime.h>
#include "database/ArtworkId.hpp"
#include "database/ImageId.hpp"
#include "database/Object.hpp"
#include "database/TrackEmbeddedImageId.hpp"
namespace lms::db
{
class Image;
class Session;
class TrackEmbeddedImage;
class Artwork final : public Object<Artwork, ArtworkId>
{
public:
Artwork() = default;
// find
static std::size_t getCount(Session& session);
static pointer find(Session& session, ArtworkId id);
static pointer find(Session& session, TrackEmbeddedImageId id);
static pointer find(Session& session, ImageId id);
// getters
TrackEmbeddedImageId getTrackEmbeddedImageId() const { return _trackEmbeddedImage.id(); }
ImageId getImageId() const { return _image.id(); }
Wt::WDateTime getLastWrittenTime() const;
template<class Action>
void persist(Action& a)
{
Wt::Dbo::belongsTo(a, _trackEmbeddedImage, "track_embedded_image", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _image, "image", Wt::Dbo::OnDeleteCascade);
}
private:
friend class Session;
Artwork(ObjectPtr<TrackEmbeddedImage> trackEmbeddedImage);
Artwork(ObjectPtr<Image> image);
static pointer create(Session& session, ObjectPtr<TrackEmbeddedImage> trackEmbeddedImage);
static pointer create(Session& session, ObjectPtr<Image> image);
Wt::Dbo::ptr<TrackEmbeddedImage> _trackEmbeddedImage;
Wt::Dbo::ptr<Image> _image;
};
} // namespace lms::db
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025 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 "database/IdType.hpp"
LMS_DECLARE_IDTYPE(ArtworkId)
@@ -46,6 +46,10 @@ namespace lms::db
core::RecursiveSharedMutex& getMutex() { return _sharedMutex; }
Wt::Dbo::SqlConnectionPool& getConnectionPool() { return *_connectionPool; }
void performQuickCheck();
void performIntegrityCheck();
void performForeignKeyConstraintsCheck();
class ScopedConnection
{
public:
@@ -53,6 +57,7 @@ namespace lms::db
~ScopedConnection();
Wt::Dbo::SqlConnection* operator->() const;
Wt::Dbo::SqlConnection& operator*() const { return *_connection; }
private:
ScopedConnection(const ScopedConnection&) = delete;
@@ -32,9 +32,7 @@
namespace lms::db
{
class Artist;
class Directory;
class Release;
class Session;
class Image final : public Object<Image, ImageId>
@@ -100,8 +98,6 @@ namespace lms::db
Wt::Dbo::field(a, _width, "width");
Wt::Dbo::field(a, _height, "height");
Wt::Dbo::hasMany(a, _artists, Wt::Dbo::ManyToOne, "image");
Wt::Dbo::hasMany(a, _releases, Wt::Dbo::ManyToOne, "image");
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
}
@@ -117,8 +113,6 @@ namespace lms::db
int _width{};
int _height{};
Wt::Dbo::collection<Wt::Dbo::ptr<Artist>> _artists;
Wt::Dbo::collection<Wt::Dbo::ptr<Release>> _releases;
Wt::Dbo::ptr<Directory> _directory;
};
} // namespace lms::db
@@ -31,6 +31,7 @@
#include "core/PartialDateTime.hpp"
#include "core/UUID.hpp"
#include "database/ArtistId.hpp"
#include "database/ArtworkId.hpp"
#include "database/CountryId.hpp"
#include "database/DirectoryId.hpp"
#include "database/Filters.hpp"
@@ -45,9 +46,9 @@
namespace lms::db
{
class Artist;
class Artwork;
class Cluster;
class ClusterType;
class Image;
class Release;
class Session;
class Track;
@@ -258,6 +259,7 @@ namespace lms::db
// Each clusters are grouped by cluster type, sorted by the number of occurence (max to min)
// size is the max number of cluster per cluster type
std::vector<std::vector<ObjectPtr<Cluster>>> getClusterGroups(const std::vector<ClusterTypeId>& clusterTypeIds, std::size_t size) const;
std::vector<ObjectPtr<Cluster>> getClusters(ClusterTypeId clusterTypeId, std::size_t maxCount) const;
// Utility functions (if all tracks have the same values, which is legit to not be the case)
core::PartialDateTime getDate() const;
@@ -290,7 +292,8 @@ namespace lms::db
core::EnumSet<Advisory> getAdvisories() const;
std::string_view getBarcode() const { return _barcode; }
std::string_view getComment() const { return _comment; }
ObjectPtr<Image> getImage() const;
ObjectPtr<Artwork> getPreferredArtwork() const;
ArtworkId getPreferredArtworkId() const;
// Setters
void setName(std::string_view name) { _name = name; }
@@ -308,7 +311,7 @@ namespace lms::db
void addReleaseType(ObjectPtr<ReleaseType> releaseType);
void setBarcode(std::string_view barcode) { _barcode = barcode; }
void setComment(std::string_view comment) { _comment = comment; }
void setImage(ObjectPtr<Image> image);
void setPreferredArtwork(ObjectPtr<Artwork> artwork);
// Get the artists of this release
std::vector<ObjectPtr<Artist>> getArtists(TrackArtistLinkType type = TrackArtistLinkType::Artist) const;
@@ -330,9 +333,9 @@ namespace lms::db
Wt::Dbo::field(a, _isCompilation, "is_compilation");
Wt::Dbo::field(a, _barcode, "barcode");
Wt::Dbo::field(a, _comment, "comment");
Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release");
Wt::Dbo::belongsTo(a, _image, "image", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release");
Wt::Dbo::belongsTo(a, _preferredArtwork, "preferred_artwork", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::hasMany(a, _labels, Wt::Dbo::ManyToMany, "release_label", "", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _releaseTypes, Wt::Dbo::ManyToMany, "release_release_type", "", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _countries, Wt::Dbo::ManyToMany, "release_country", "", Wt::Dbo::OnDeleteCascade);
@@ -358,8 +361,8 @@ namespace lms::db
std::string _barcode;
std::string _comment;
Wt::Dbo::ptr<Image> _image;
Wt::Dbo::collection<Wt::Dbo::ptr<Track>> _tracks;
Wt::Dbo::ptr<Artwork> _preferredArtwork;
Wt::Dbo::collection<Wt::Dbo::ptr<Label>> _labels;
Wt::Dbo::collection<Wt::Dbo::ptr<ReleaseType>> _releaseTypes;
Wt::Dbo::collection<Wt::Dbo::ptr<Country>> _countries;
@@ -74,6 +74,7 @@ namespace lms::db
std::vector<std::string> getArtistsToNotSplit() const;
bool getSkipSingleReleasePlayLists() const { return _skipSingleReleasePlayLists; }
bool getAllowMBIDArtistMerge() const { return _allowMBIDArtistMerge; }
bool getArtistImageFallbackToReleaseField() const { return _artistImageFallbackToReleaseField; }
// Setters
void setUpdateStartTime(Wt::WTime t) { _startTime = t; }
@@ -85,6 +86,7 @@ namespace lms::db
void setDefaultTagDelimiters(std::span<const std::string_view> delimiters);
void setSkipSingleReleasePlayLists(bool value);
void setAllowMBIDArtistMerge(bool value);
void setArtistImageFallbackToReleaseField(bool value);
template<class Action>
void persist(Action& a)
@@ -101,6 +103,7 @@ namespace lms::db
Wt::Dbo::field(a, _defaultTagDelimiters, "default_tag_delimiters");
Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists");
Wt::Dbo::field(a, _allowMBIDArtistMerge, "allow_mbid_artist_merge");
Wt::Dbo::field(a, _artistImageFallbackToReleaseField, "artist_image_fallback_to_release");
}
private:
@@ -123,5 +126,6 @@ namespace lms::db
std::string _defaultTagDelimiters;
bool _skipSingleReleasePlayLists{};
bool _allowMBIDArtistMerge{};
bool _artistImageFallbackToReleaseField{};
};
} // namespace lms::db
@@ -96,6 +96,8 @@ namespace lms::db
void retrieveEntriesToAnalyze(std::vector<std::string>& entryList);
void analyzeEntry(const std::string& entry);
bool areAllTablesEmpty(); // need to acquire a read transaction
void prepareTablesIfNeeded(); // need to run only once at startup
bool migrateSchemaIfNeeded(); // returns true if migration was performed
void createIndexesIfNeeded();
+21 -5
View File
@@ -35,6 +35,7 @@
#include "core/PartialDateTime.hpp"
#include "core/UUID.hpp"
#include "database/ArtistId.hpp"
#include "database/ArtworkId.hpp"
#include "database/ClusterId.hpp"
#include "database/DirectoryId.hpp"
#include "database/Filters.hpp"
@@ -50,6 +51,7 @@
namespace lms::db
{
class Artist;
class Artwork;
class Cluster;
class ClusterType;
class Directory;
@@ -215,6 +217,10 @@ namespace lms::db
static RangeResults<TrackId> findIdsTrackMBIDDuplicates(Session& session, std::optional<Range> range = std::nullopt);
static RangeResults<TrackId> findIdsWithRecordingMBIDAndMissingFeatures(Session& session, std::optional<Range> range = std::nullopt);
// Update utility functions
static void updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId);
static void updatePreferredMediaArtwork(Session& session, TrackId trackId, ArtworkId artworkId);
// Accessors
void setScanVersion(std::size_t version) { _scanVersion = version; }
void setTrackNumber(std::optional<int> num) { _trackNumber = num; }
@@ -252,8 +258,10 @@ namespace lms::db
void addLyrics(const ObjectPtr<TrackLyrics>& lyrics);
void clearEmbeddedImageLinks();
void addEmbeddedImageLink(const ObjectPtr<TrackEmbeddedImageLink>& link);
void setMediaLibrary(ObjectPtr<MediaLibrary> mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); }
void setDirectory(ObjectPtr<Directory> directory) { _directory = getDboPtr(directory); }
void setMediaLibrary(ObjectPtr<MediaLibrary> mediaLibrary);
void setDirectory(ObjectPtr<Directory> directory);
void setPreferredArtwork(ObjectPtr<Artwork> artwork);
void setPreferredMediaArtwork(ObjectPtr<Artwork> artwork);
std::size_t getScanVersion() const { return _scanVersion; }
std::optional<std::size_t> getTrackNumber() const { return _trackNumber; }
@@ -295,8 +303,12 @@ namespace lms::db
ObjectPtr<Release> getRelease() const { return _release; }
std::vector<ObjectPtr<Cluster>> getClusters() const;
std::vector<ClusterId> getClusterIds() const;
ObjectPtr<MediaLibrary> getMediaLibrary() const { return _mediaLibrary; }
ObjectPtr<Directory> getDirectory() const { return _directory; }
ObjectPtr<MediaLibrary> getMediaLibrary() const;
ObjectPtr<Directory> getDirectory() const;
ObjectPtr<Artwork> getPreferredArtwork() const;
ArtworkId getPreferredArtworkId() const;
ObjectPtr<Artwork> getPreferredMediaArtwork() const;
ArtworkId getPreferredMediaArtworkId() const;
std::vector<std::vector<ObjectPtr<Cluster>>> getClusterGroups(const std::vector<ClusterTypeId>& clusterTypes, std::size_t size) const;
@@ -331,11 +343,13 @@ namespace lms::db
Wt::Dbo::field(a, _trackReplayGain, "track_replay_gain");
Wt::Dbo::field(a, _releaseReplayGain, "release_replay_gain"); // here in Track since Release does not have concept of "disc" (yet?)
Wt::Dbo::field(a, _artistDisplayName, "artist_display_name");
Wt::Dbo::field(a, _comment, "comment");
Wt::Dbo::field(a, _comment, "comment"); // TODO: move in a dedicated table
Wt::Dbo::belongsTo(a, _release, "release", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _mediaLibrary, "media_library", Wt::Dbo::OnDeleteSetNull); // don't delete track on media library removal, we want to wait for the next scan to have a chance to migrate files
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _preferredArtwork, "preferred_artwork", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::belongsTo(a, _preferredMediaArtwork, "preferred_media_artwork", Wt::Dbo::OnDeleteSetNull);
Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track");
Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _trackLyrics, Wt::Dbo::ManyToOne, "track");
@@ -382,6 +396,8 @@ namespace lms::db
Wt::Dbo::ptr<Release> _release;
Wt::Dbo::ptr<MediaLibrary> _mediaLibrary;
Wt::Dbo::ptr<Directory> _directory;
Wt::Dbo::ptr<Artwork> _preferredArtwork;
Wt::Dbo::ptr<Artwork> _preferredMediaArtwork;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackArtistLink>> _trackArtistLinks;
Wt::Dbo::collection<Wt::Dbo::ptr<Cluster>> _clusters;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackLyrics>> _trackLyrics;
@@ -25,6 +25,8 @@
#include <Wt/Dbo/Dbo.h>
#include "core/EnumSet.hpp"
#include "database/ArtistId.hpp"
#include "database/Object.hpp"
#include "database/ReleaseId.hpp"
#include "database/TrackEmbeddedImageId.hpp"
@@ -46,10 +48,13 @@ namespace lms::db
struct FindParameters
{
std::optional<Range> range;
ArtistId artist;
core::EnumSet<TrackArtistLinkType> trackArtistLinkTypes;
TrackId track;
ReleaseId release;
std::optional<int> discNumber;
TrackListId trackList;
std::optional<bool> isPreferred;
core::EnumSet<ImageType> imageTypes;
TrackEmbeddedImageSortMethod sortMethod{ TrackEmbeddedImageSortMethod::None };
FindParameters& setRange(std::optional<Range> _range)
@@ -57,6 +62,13 @@ namespace lms::db
range = _range;
return *this;
}
FindParameters& setArtist(ArtistId _artist, core::EnumSet<TrackArtistLinkType> _trackArtistLinkTypes = {})
{
artist = _artist;
trackArtistLinkTypes = _trackArtistLinkTypes;
return *this;
}
FindParameters& setTrack(TrackId _track)
{
track = _track;
@@ -67,14 +79,19 @@ namespace lms::db
release = _release;
return *this;
}
FindParameters& setDiscNumber(std::optional<int> _discNumber)
{
discNumber = _discNumber;
return *this;
}
FindParameters& setTrackList(TrackListId _trackList)
{
trackList = _trackList;
return *this;
}
FindParameters& setIsPreferred(std::optional<bool> _isPreferred)
FindParameters& setImageTypes(core::EnumSet<ImageType> _imageTypes)
{
isPreferred = _isPreferred;
imageTypes = _imageTypes;
return *this;
}
FindParameters& setSortMethod(TrackEmbeddedImageSortMethod _sortMethod)
@@ -25,6 +25,7 @@
#include <Wt/Dbo/Dbo.h>
#include "database/Object.hpp"
#include "database/TrackEmbeddedImageId.hpp"
#include "database/TrackEmbeddedImageLinkId.hpp"
#include "database/Types.hpp"
@@ -44,18 +45,17 @@ namespace lms::db
// find
static std::size_t getCount(Session& session);
static pointer find(Session& session, TrackEmbeddedImageLinkId id);
static void find(Session& session, TrackEmbeddedImageId trackEmbeddedImageId, std::function<void(const pointer&)> visitor);
// getters
ObjectPtr<Track> getTrack() const;
ObjectPtr<TrackEmbeddedImage> getImage() const;
std::size_t getIndex() const { return _index; }
bool isPreferred() const { return _isPreferred; }
ImageType getType() const { return _type; }
std::string_view getDescription() const { return _description; }
// setters
void setIndex(std::size_t index) { _index = static_cast<int>(index); }
void setIsPreferred(bool isPreferred) { _isPreferred = isPreferred; }
void setType(ImageType type) { _type = type; }
void setDescription(std::string_view description) { _description = description; }
@@ -63,7 +63,6 @@ namespace lms::db
void persist(Action& a)
{
Wt::Dbo::field(a, _index, "index");
Wt::Dbo::field(a, _isPreferred, "is_preferred");
Wt::Dbo::field(a, _type, "type");
Wt::Dbo::field(a, _description, "description");
@@ -78,7 +77,6 @@ namespace lms::db
static pointer create(Session& session, ObjectPtr<Track> track, ObjectPtr<TrackEmbeddedImage> image);
int _index{}; // index within the track
bool _isPreferred{};
ImageType _type{ ImageType::Unknown };
std::string _description;
+5 -1
View File
@@ -19,6 +19,7 @@
#pragma once
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <functional>
@@ -171,7 +172,10 @@ namespace lms::db
enum class TrackEmbeddedImageSortMethod
{
None,
FrontCoverAndSize,
SizeDesc,
TrackNumberThenSizeDesc,
DiscNumberThenTrackNumberThenSizeDesc,
TrackListIndexAscThenSizeDesc,
};
enum class TrackListSortMethod
+12 -9
View File
@@ -19,10 +19,12 @@
#include "Common.hpp"
#include "database/Artwork.hpp"
#include "database/Image.hpp"
namespace lms::db::tests
{
using ScopedArtwork = ScopedEntity<db::Artwork>;
using ScopedImage = ScopedEntity<db::Image>;
TEST_F(DatabaseFixture, Artist)
@@ -711,27 +713,28 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, Artist_image)
TEST_F(DatabaseFixture, Artist_artwork)
{
ScopedArtist release{ session, "MyArtist" };
ScopedImage image{ session, "/image1.jpg" };
ScopedArtwork artwork{ session, image.lockAndGet() };
ScopedArtist artist{ session, "MyArtist" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_FALSE(release.get()->getImage());
EXPECT_FALSE(artist.get()->getPreferredArtwork());
}
ScopedImage image{ session, "/myImage" };
{
auto transaction{ session.createWriteTransaction() };
release.get().modify()->setImage(image.get());
artist.get().modify()->setPreferredArtwork(artwork.get());
}
{
auto transaction{ session.createReadTransaction() };
auto artistImage(release.get()->getImage());
ASSERT_TRUE(artistImage);
EXPECT_EQ(artistImage->getId(), image.getId());
auto artistArtwork(artist.get()->getPreferredArtwork());
ASSERT_TRUE(artistArtwork);
EXPECT_EQ(artistArtwork->getId(), artwork.getId());
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2025of 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 "Common.hpp"
#include "database/Artwork.hpp"
#include "database/Image.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
namespace lms::db::tests
{
using ScopedArtwork = ScopedEntity<db::Artwork>;
using ScopedImage = ScopedEntity<db::Image>;
using ScopedTrackEmbeddedImage = ScopedEntity<db::TrackEmbeddedImage>;
TEST_F(DatabaseFixture, Artwork_image)
{
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(Artwork::getCount(session), 0);
}
ScopedImage image{ session, "/MyImage" };
ScopedArtwork artwork{ session, image.lockAndGet() };
const Wt::WDateTime dateTime{ Wt::WDate{ 2025, 1, 1 } };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(Artwork::getCount(session), 1);
}
{
auto transaction{ session.createWriteTransaction() };
image.get().modify()->setLastWriteTime(dateTime);
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(artwork.get()->getLastWrittenTime(), dateTime);
}
}
TEST_F(DatabaseFixture, Artwork_trackEmbeddedImage)
{
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(Artwork::getCount(session), 0);
}
ScopedTrackEmbeddedImage image{ session };
ScopedTrack track{ session };
ScopedArtwork artwork{ session, image.lockAndGet() };
const Wt::WDateTime dateTime{ Wt::WDate{ 2025, 1, 1 } };
{
auto transaction{ session.createWriteTransaction() };
session.create<db::TrackEmbeddedImageLink>(track.get(), image.get());
track.get().modify()->setLastWriteTime(dateTime);
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(artwork.get()->getLastWrittenTime(), dateTime);
}
}
} // namespace lms::db::tests
+1
View File
@@ -2,6 +2,7 @@
add_executable(test-database
Artist.cpp
ArtistInfo.cpp
Artwork.cpp
AuthToken.cpp
Cluster.cpp
Common.cpp
+2 -30
View File
@@ -19,23 +19,10 @@
#include "Common.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Db.hpp"
#include "database/Image.hpp"
#include "database/Listen.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/StarredArtist.hpp"
#include "database/StarredRelease.hpp"
#include "database/StarredTrack.hpp"
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackBookmark.hpp"
#include "database/TrackList.hpp"
#include "database/Types.hpp"
#include "database/User.hpp"
namespace lms::db::tests
{
@@ -75,24 +62,9 @@ namespace lms::db::tests
{
using namespace db;
auto transaction{ session.createWriteTransaction() };
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(Artist::getCount(session), 0);
EXPECT_EQ(Cluster::getCount(session), 0);
EXPECT_EQ(ClusterType::getCount(session), 0);
EXPECT_EQ(Label::getCount(session), 0);
EXPECT_EQ(Listen::getCount(session), 0);
EXPECT_EQ(Image::getCount(session), 0);
EXPECT_EQ(MediaLibrary::getCount(session), 0);
EXPECT_EQ(Release::getCount(session), 0);
EXPECT_EQ(ReleaseType::getCount(session), 0);
EXPECT_EQ(StarredArtist::getCount(session), 0);
EXPECT_EQ(StarredRelease::getCount(session), 0);
EXPECT_EQ(StarredTrack::getCount(session), 0);
EXPECT_EQ(Track::getCount(session), 0);
EXPECT_EQ(TrackBookmark::getCount(session), 0);
EXPECT_EQ(TrackList::getCount(session), 0);
EXPECT_EQ(User::getCount(session), 0);
EXPECT_TRUE(session.areAllTablesEmpty());
}
TEST_F(DatabaseFixture, vacuum)
+22 -7
View File
@@ -20,10 +20,12 @@
#include "Common.hpp"
#include "core/PartialDateTime.hpp"
#include "database/Artwork.hpp"
#include "database/Image.hpp"
namespace lms::db::tests
{
using ScopedArtwork = ScopedEntity<db::Artwork>;
using ScopedImage = ScopedEntity<db::Image>;
using ScopedLabel = ScopedEntity<db::Label>;
using ScopedCountry = ScopedEntity<db::Country>;
@@ -1195,27 +1197,40 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, Release_image)
TEST_F(DatabaseFixture, Release_artwork)
{
ScopedRelease release{ session, "MyRelease" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_FALSE(release.get()->getImage());
EXPECT_FALSE(release.get()->getPreferredArtwork());
}
ScopedImage image{ session, "/myImage" };
ScopedImage image{ session, "/image.jpg" };
ScopedArtwork artwork{ session, image.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
release.get().modify()->setImage(image.get());
release.get().modify()->setPreferredArtwork(artwork.get());
}
{
auto transaction{ session.createReadTransaction() };
auto releaseImage(release.get()->getImage());
ASSERT_TRUE(releaseImage);
EXPECT_EQ(releaseImage->getId(), image.getId());
auto releaseArtwork(release.get()->getPreferredArtwork());
ASSERT_TRUE(releaseArtwork);
EXPECT_EQ(releaseArtwork->getId(), artwork.getId());
}
// Check cascade delete
{
auto transaction{ session.createWriteTransaction() };
image.lockAndGet().remove();
}
{
auto transaction{ session.createReadTransaction() };
auto releaseArtwork(release.get()->getPreferredArtwork());
ASSERT_FALSE(releaseArtwork);
}
}
+43
View File
@@ -21,8 +21,14 @@
#include <algorithm>
#include "database/Artwork.hpp"
#include "database/Image.hpp"
namespace lms::db::tests
{
using ScopedArtwork = ScopedEntity<db::Artwork>;
using ScopedImage = ScopedEntity<db::Image>;
TEST_F(DatabaseFixture, Track)
{
{
@@ -439,4 +445,41 @@ namespace lms::db::tests
EXPECT_EQ(tracks.results[3], track3.getId());
}
}
TEST_F(DatabaseFixture, Track_updateArtworks)
{
ScopedTrack track{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(track->getPreferredArtwork(), Artwork::pointer{});
EXPECT_EQ(track->getPreferredMediaArtwork(), Artwork::pointer{});
}
ScopedImage image1{ session, "/image1.jpg" };
ScopedArtwork artwork1{ session, image1.lockAndGet() };
ScopedImage image2{ session, "/image2.jpg" };
ScopedArtwork artwork2{ session, image2.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
Track::updatePreferredArtwork(session, track->getId(), artwork1->getId());
Track::updatePreferredMediaArtwork(session, track->getId(), artwork2->getId());
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(track->getPreferredArtwork()->getId(), artwork1->getId());
EXPECT_EQ(track->getPreferredMediaArtwork()->getId(), artwork2->getId());
}
{
auto transaction{ session.createWriteTransaction() };
Track::updatePreferredArtwork(session, track->getId(), ArtworkId{});
Track::updatePreferredMediaArtwork(session, track->getId(), ArtworkId{});
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(track->getPreferredArtwork(), Artwork::pointer{});
EXPECT_EQ(track->getPreferredMediaArtwork(), Artwork::pointer{});
}
}
} // namespace lms::db::tests
+207 -30
View File
@@ -21,6 +21,7 @@
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackList.hpp"
#include "database/Types.hpp"
#include "Common.hpp"
@@ -118,23 +119,6 @@ namespace lms::db::tests
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setIsPreferred(true);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; });
EXPECT_FALSE(visited);
}
{
auto transaction{ session.createWriteTransaction() };
link.get().modify()->setIsPreferred(true);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setIsPreferred(true);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; });
@@ -142,15 +126,8 @@ namespace lms::db::tests
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setIsPreferred(true);
params.setRelease(release.getId());
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; });
EXPECT_FALSE(visited);
auto transaction{ session.createWriteTransaction() };
link.get().modify()->setType(ImageType::FrontCover);
}
{
@@ -162,9 +139,8 @@ namespace lms::db::tests
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setIsPreferred(true);
params.setRelease(release.getId());
params.setSortMethod(TrackEmbeddedImageSortMethod::FrontCoverAndSize);
params.setSortMethod(TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; });
@@ -175,9 +151,8 @@ namespace lms::db::tests
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setIsPreferred(true);
params.setTrack(track.getId());
params.setSortMethod(TrackEmbeddedImageSortMethod::FrontCoverAndSize);
params.setSortMethod(TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; });
@@ -185,6 +160,159 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, TrackEmbeddedImage_findByParams_sorts)
{
ScopedTrackEmbeddedImage image1{ session };
ScopedTrackEmbeddedImage image2{ session };
ScopedTrackEmbeddedImage image3{ session };
ScopedTrackEmbeddedImage image4{ session };
ScopedTrack track1{ session };
ScopedTrack track2{ session };
ScopedRelease release{ session, "MyRelease" };
ScopedTrackEmbeddedImageLink link1{ session, track1.lockAndGet(), image1.lockAndGet() };
ScopedTrackEmbeddedImageLink link2{ session, track1.lockAndGet(), image2.lockAndGet() };
ScopedTrackEmbeddedImageLink link3{ session, track1.lockAndGet(), image3.lockAndGet() };
ScopedTrackEmbeddedImageLink link4{ session, track2.lockAndGet(), image4.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
track1.get().modify()->setRelease(release.get());
track1.get().modify()->setTrackNumber(2);
link1.get().modify()->setType(ImageType::FrontCover);
image1.get().modify()->setSize(750);
link2.get().modify()->setType(ImageType::Media);
image2.get().modify()->setSize(1000);
link3.get().modify()->setType(ImageType::Media);
image3.get().modify()->setSize(2000);
track2.get().modify()->setRelease(release.get());
track2.get().modify()->setTrackNumber(1);
link4.get().modify()->setType(ImageType::Media);
image4.get().modify()->setSize(1500);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setRelease(release.getId());
params.setImageTypes({ ImageType::Media });
params.setSortMethod(TrackEmbeddedImageSortMethod::SizeDesc);
std::vector<TrackEmbeddedImageId> visitedIds;
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer& image) { visitedIds.push_back(image->getId()); });
ASSERT_EQ(visitedIds.size(), 3);
EXPECT_EQ(visitedIds[0], image3.getId());
EXPECT_EQ(visitedIds[1], image4.getId());
EXPECT_EQ(visitedIds[2], image2.getId());
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setRelease(release.getId());
params.setImageTypes({ ImageType::Media });
params.setSortMethod(TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc);
std::vector<TrackEmbeddedImageId> visitedIds;
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer& image) { visitedIds.push_back(image->getId()); });
ASSERT_EQ(visitedIds.size(), 3);
EXPECT_EQ(visitedIds[0], image4.getId());
EXPECT_EQ(visitedIds[1], image3.getId());
EXPECT_EQ(visitedIds[2], image2.getId());
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setRelease(release.getId());
params.setImageTypes({ ImageType::Media });
params.setSortMethod(TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc);
std::vector<TrackEmbeddedImageId> visitedIds;
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer& image) { visitedIds.push_back(image->getId()); });
ASSERT_EQ(visitedIds.size(), 3);
EXPECT_EQ(visitedIds[0], image4.getId());
EXPECT_EQ(visitedIds[1], image3.getId());
EXPECT_EQ(visitedIds[2], image2.getId());
}
}
TEST_F(DatabaseFixture, TrackEmbeddedImage_findByParams_artist)
{
ScopedTrackEmbeddedImage image{ session };
ScopedTrack track{ session };
ScopedArtist artist{ session, "MyArtist" };
ScopedTrackEmbeddedImageLink link{ session, track.lockAndGet(), image.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setArtist(artist.getId());
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer&) { visited = true; });
ASSERT_TRUE(visited);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist });
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer&) { visited = true; });
ASSERT_FALSE(visited);
}
}
TEST_F(DatabaseFixture, TrackEmbeddedImage_findByParams_discNumber)
{
ScopedTrackEmbeddedImage image{ session };
ScopedTrack track{ session };
ScopedRelease release{ session, "MyRelease" };
ScopedTrackEmbeddedImageLink link{ session, track.lockAndGet(), image.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
track.get().modify()->setRelease(release.get());
track.get().modify()->setDiscNumber(1);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setDiscNumber(1);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer&) { visited = true; });
ASSERT_TRUE(visited);
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setDiscNumber(2);
bool visited{};
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer&) { visited = true; });
ASSERT_FALSE(visited);
}
}
TEST_F(DatabaseFixture, Track_findByEmbeddedImage)
{
ScopedTrackEmbeddedImage image{ session };
@@ -279,5 +407,54 @@ namespace lms::db::tests
EXPECT_EQ(img->getType(), ImageType::FrontCover);
EXPECT_EQ(img->getDescription(), "MyDesc");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackEmbeddedImageLink::find(session, image->getId(), [&](const TrackEmbeddedImageLink::pointer& link) {
EXPECT_EQ(link->getIndex(), 2);
EXPECT_EQ(link->getType(), ImageType::FrontCover);
EXPECT_EQ(link->getDescription(), "MyDesc");
EXPECT_EQ(link->getTrack(), track.get());
visited = true;
});
EXPECT_TRUE(visited);
}
}
TEST_F(DatabaseFixture, TrackEmbeddedImage_TrackList)
{
ScopedTrackList trackList{ session, "MytrackList", TrackListType::PlayList };
ScopedTrack track1{ session };
ScopedTrack track2{ session };
ScopedTrackEmbeddedImage image1{ session };
ScopedTrackEmbeddedImageLink imageLink1{ session, track1.lockAndGet(), image1.lockAndGet() };
ScopedTrackEmbeddedImage image2{ session };
ScopedTrackEmbeddedImageLink imageLink2{ session, track2.lockAndGet(), image2.lockAndGet() };
{
auto transaction{ session.createWriteTransaction() };
session.create<TrackListEntry>(track2.get(), trackList.get());
session.create<TrackListEntry>(track1.get(), trackList.get());
}
{
auto transaction{ session.createReadTransaction() };
TrackEmbeddedImage::FindParameters params;
params.setTrackList(trackList.getId());
params.setSortMethod(TrackEmbeddedImageSortMethod::TrackListIndexAscThenSizeDesc);
std::vector<TrackEmbeddedImageId> visitedIds;
TrackEmbeddedImage::find(session, params, [&](const TrackEmbeddedImage::pointer& image) {
visitedIds.push_back(image->getId());
});
ASSERT_EQ(visitedIds.size(), 2);
EXPECT_EQ(visitedIds[0], image2.getId());
EXPECT_EQ(visitedIds[1], image1.getId());
}
}
} // namespace lms::db::tests
+7 -4
View File
@@ -11,12 +11,14 @@ endif()
add_library(lmsmetadata STATIC
impl/ArtistInfo.cpp
impl/AudioFileParser.cpp
impl/AvFormatImageReader.cpp
impl/AvFormatTagReader.cpp
impl/avformat/AvFormatImageReader.cpp
impl/avformat/AvFormatTagReader.cpp
impl/avformat/Utils.cpp
impl/Lyrics.cpp
impl/PlayList.cpp
impl/TagLibImageReader.cpp
impl/TagLibTagReader.cpp
impl/taglib/TagLibImageReader.cpp
impl/taglib/TagLibTagReader.cpp
impl/taglib/Utils.cpp
impl/Utils.cpp
)
@@ -26,6 +28,7 @@ target_include_directories(lmsmetadata INTERFACE
target_include_directories(lmsmetadata PRIVATE
include
impl
)
target_link_libraries(lmsmetadata PRIVATE
+4 -4
View File
@@ -27,10 +27,10 @@
namespace lms::metadata
{
std::span<const std::filesystem::path> getSupportedInfoFileExtensions()
std::span<const std::filesystem::path> getSupportedArtistInfoFiles()
{
static const std::array<std::filesystem::path, 1> fileExtensions{ ".nfo" };
return fileExtensions;
static const std::array<std::filesystem::path, 1> files{ "artist.nfo" };
return files;
}
ArtistInfo parseArtistInfo(std::istream& is)
@@ -56,7 +56,7 @@ namespace lms::metadata
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(RECOMMENDATION, ERROR, "Cannot read artist xml info: " << error.what());
LMS_LOG(METADATA, ERROR, "Cannot read artist xml info: " << error.what());
throw ArtistInfoParseException{ error.what() };
}
}
+42 -66
View File
@@ -28,11 +28,13 @@
#include "core/String.hpp"
#include "metadata/Exception.hpp"
#include "AvFormatImageReader.hpp"
#include "AvFormatTagReader.hpp"
#include "TagLibImageReader.hpp"
#include "TagLibTagReader.hpp"
#include "Utils.hpp"
#include "avformat/AvFormatImageReader.hpp"
#include "avformat/AvFormatTagReader.hpp"
#include "avformat/Utils.hpp"
#include "taglib/TagLibImageReader.hpp"
#include "taglib/TagLibTagReader.hpp"
#include "taglib/Utils.hpp"
namespace lms::metadata
{
@@ -338,82 +340,56 @@ namespace lms::metadata
std::span<const std::filesystem::path> AudioFileParser::getSupportedExtensions() const
{
// TODO: use backend capability to retrieve supported formats
static const std::array<std::filesystem::path, 18> fileExtensions{
".aac",
".alac",
".aif",
".aiff",
".ape",
".dsf",
".flac",
".m4a",
".m4b",
".mp3",
".mpc",
".oga",
".ogg",
".opus",
".shn",
".wav",
".wma",
".wv",
};
return fileExtensions;
switch (_params.backend)
{
case ParserBackend::TagLib:
return taglib::utils::getSupportedExtensions();
break;
case ParserBackend::AvFormat:
return avformat::utils::getSupportedExtensions();
break;
}
return {};
}
std::unique_ptr<Track> AudioFileParser::parseMetaData(const std::filesystem::path& p) const
{
try
std::unique_ptr<ITagReader> tagReader;
switch (_params.backend)
{
std::unique_ptr<ITagReader> tagReader;
switch (_params.backend)
{
case ParserBackend::TagLib:
tagReader = std::make_unique<TagLibTagReader>(p, _params.readStyle, _params.debug);
break;
case ParserBackend::TagLib:
tagReader = std::make_unique<taglib::TagLibTagReader>(p, _params.readStyle, _params.debug);
break;
case ParserBackend::AvFormat:
tagReader = std::make_unique<AvFormatTagReader>(p, _params.debug);
break;
}
if (!tagReader)
throw ParseException{ "Unhandled parser backend" };
case ParserBackend::AvFormat:
tagReader = std::make_unique<avformat::AvFormatTagReader>(p, _params.debug);
break;
}
if (!tagReader)
throw AudioFileParsingException{ "Unhandled parser backend" };
return parseMetaData(*tagReader);
}
catch (const Exception& e)
{
LMS_LOG(METADATA, ERROR, "File " << p << ": metadata parsing failed");
throw ParseException{};
}
return parseMetaData(*tagReader);
}
void AudioFileParser::parseImages(const std::filesystem::path& p, ImageVisitor visitor) const
{
try
std::unique_ptr<IImageReader> imageReader;
switch (_params.backend)
{
std::unique_ptr<IImageReader> imageReader;
switch (_params.backend)
{
case ParserBackend::TagLib:
imageReader = std::make_unique<TagLibImageReader>(p);
break;
case ParserBackend::TagLib:
imageReader = std::make_unique<taglib::TagLibImageReader>(p);
break;
case ParserBackend::AvFormat:
imageReader = std::make_unique<AvFormatImageReader>(p);
break;
}
if (!imageReader)
throw ParseException{ "Unhandled parser backend" };
case ParserBackend::AvFormat:
imageReader = std::make_unique<avformat::AvFormatImageReader>(p);
break;
}
if (!imageReader)
throw AudioFileParsingException{ "Unhandled parser backend" };
parseImages(*imageReader, std::move(visitor));
}
catch (const Exception& e)
{
LMS_LOG(METADATA, ERROR, "File " << p << ": image parsing failed");
throw ParseException{};
}
parseImages(*imageReader, std::move(visitor));
}
std::unique_ptr<Track> AudioFileParser::parseMetaData(const ITagReader& tagReader) const
-1
View File
@@ -19,7 +19,6 @@
#pragma once
#include <chrono>
#include <functional>
#include "metadata/Types.hpp"
+1 -1
View File
@@ -108,7 +108,7 @@ namespace lms::metadata::utils
return "accurate";
}
throw core::LmsException{ "Unknown read style" };
throw Exception{ "Unknown read style" };
}
PerformerArtist extractPerformerAndRole(std::string_view entry)
@@ -18,10 +18,12 @@
*/
#include "AvFormatImageReader.hpp"
#include "av/IAudioFile.hpp"
#include "av/Types.hpp"
namespace lms::metadata
#include "av/Exception.hpp"
#include "av/IAudioFile.hpp"
#include "metadata/Exception.hpp"
namespace lms::metadata::avformat
{
AvFormatImageReader::AvFormatImageReader(const std::filesystem::path& p)
{
@@ -31,7 +33,7 @@ namespace lms::metadata
}
catch (av::Exception& e)
{
throw ParseException{};
throw AudioFileParsingException{ e.what() };
}
}
@@ -56,4 +58,4 @@ namespace lms::metadata
});
}
} // namespace lms::metadata
} // namespace lms::metadata::avformat
@@ -26,7 +26,7 @@ namespace lms::av
class IAudioFile;
}
namespace lms::metadata
namespace lms::metadata::avformat
{
class AvFormatImageReader : public IImageReader
{
@@ -42,4 +42,4 @@ namespace lms::metadata
std::unique_ptr<av::IAudioFile> _audioFile;
};
} // namespace lms::metadata
} // namespace lms::metadata::avformat
@@ -19,13 +19,13 @@
#include "AvFormatTagReader.hpp"
#include "av/Exception.hpp"
#include "av/IAudioFile.hpp"
#include "av/Types.hpp"
#include "core/ILogger.hpp"
#include "core/String.hpp"
#include "metadata/Exception.hpp"
namespace lms::metadata
namespace lms::metadata::avformat
{
namespace
{
@@ -165,10 +165,12 @@ namespace lms::metadata
}
catch (av::Exception& e)
{
throw ParseException{};
throw AudioFileParsingException{ e.what() };
}
}
AvFormatTagReader::~AvFormatTagReader() = default;
void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
{
auto itTagNames{ avFormatTagMapping.find(tag) };
@@ -223,4 +225,4 @@ namespace lms::metadata
visitor("", value);
});
}
} // namespace lms::metadata
} // namespace lms::metadata::avformat
@@ -25,13 +25,13 @@
#include "ITagReader.hpp"
namespace lms::metadata
namespace lms::metadata::avformat
{
class AvFormatTagReader : public ITagReader
{
public:
AvFormatTagReader(const std::filesystem::path& path, bool debug);
~AvFormatTagReader() override = default;
~AvFormatTagReader() override;
AvFormatTagReader(const AvFormatTagReader&) = delete;
AvFormatTagReader& operator=(const AvFormatTagReader&) = delete;
@@ -46,4 +46,4 @@ namespace lms::metadata
av::IAudioFile::MetadataMap _metaDataMap;
av::ContainerInfo _containerInfo;
};
} // namespace lms::metadata
} // namespace lms::metadata::avformat
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright (C) 2025 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 "Utils.hpp"
namespace lms::metadata::avformat::utils
{
std::span<const std::filesystem::path> getSupportedExtensions()
{
// TODO: use av capability to retrieve supported formats
static const std::array<std::filesystem::path, 18> fileExtensions{
".aac",
".alac",
".aif",
".aiff",
".ape",
".dsf",
".flac",
".m4a",
".m4b",
".mp3",
".mpc",
".oga",
".ogg",
".opus",
".shn",
".wav",
".wma",
".wv",
};
return fileExtensions;
}
} // namespace lms::metadata::avformat::utils
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,14 +19,10 @@
#pragma once
#include <memory>
#include <filesystem>
#include <span>
#include "core/IResourceHandler.hpp"
namespace lms::av::transcoding
namespace lms::metadata::avformat::utils
{
struct InputParameters;
struct OutputParameters;
std::unique_ptr<IResourceHandler> createResourceHandler(const InputParameters& inputParameters, const OutputParameters& outputParameters, bool estimateContentLength);
} // namespace lms::av::transcoding
std::span<const std::filesystem::path> getSupportedExtensions();
} // namespace lms::metadata::avformat::utils
@@ -21,6 +21,10 @@
#include <taglib/taglib.h>
#if (TAGLIB_MAJOR_VERSION > 2)
#define TAGLIB_HAS_DSF 1
#endif
// TAGLIB_HAS_MP4_ITEM_TYPE if version >= 2.0.1
#if ((TAGLIB_MAJOR_VERSION > 2) || (TAGLIB_MAJOR_VERSION == 2 && TAGLIB_MINOR_VERSION > 0) || (TAGLIB_MAJOR_VERSION == 2 && TAGLIB_PATCH_VERSION >= 1))
#define TAGLIB_HAS_MP4_ITEM_TYPE 1
@@ -19,6 +19,8 @@
#include "TagLibImageReader.hpp"
#include "TagLibDefs.hpp"
#include <taglib/aifffile.h>
#include <taglib/apetag.h>
#include <taglib/asffile.h>
@@ -34,24 +36,16 @@
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/wavpackfile.h>
#include <tbytevector.h>
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/String.hpp"
#include "metadata/Exception.hpp"
#include "TagLibDefs.hpp"
#include "taglib/Utils.hpp"
namespace lms::metadata
namespace lms::metadata::taglib
{
namespace
{
class ImageParsingFailedException : public Exception
{
};
Image::Type imageTypeFromfromIDv2(TagLib::ID3v2::AttachedPictureFrame::Type type)
{
switch (type)
@@ -349,44 +343,37 @@ namespace lms::metadata
}
} // namespace
TagLib::FileRef parseFile(const std::filesystem::path& p)
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFileForImages");
return TagLib::FileRef{ p.c_str(), false };
}
TagLibImageReader::TagLibImageReader(const std::filesystem::path& p)
: _file{ parseFile(p) }
: _file{ utils::parseFile(p, TagLib::AudioProperties::ReadStyle::Fast, utils::ReadAudioProperties{ false }) }
{
if (_file.isNull())
if (!_file)
{
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw ImageParsingFailedException{};
throw AudioFileParsingException{};
}
}
void TagLibImageReader::visitImages(ImageVisitor visitor) const
{
// MP3
if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_file.file()) })
if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_file.get()) })
{
if (mp3File->hasID3v2Tag())
visitID3V2Images(*mp3File->ID3v2Tag(), std::move(visitor));
}
// MP4
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(_file.file()) })
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(_file.get()) })
{
visitMP4Images(*mp4File, std::move(visitor));
}
// WMA
else if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.file()) })
else if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.get()) })
{
if (const TagLib::ASF::Tag * tag{ asfFile->tag() })
visitASFImages(*tag, std::move(visitor));
}
// FLAC
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(_file.file()) })
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(_file.get()) })
{
if (flacFile->hasID3v2Tag()) // usage discouraged
visitID3V2Images(*flacFile->ID3v2Tag(), std::move(visitor));
@@ -394,38 +381,38 @@ namespace lms::metadata
visitFLACImages(flacFile->pictureList(), std::move(visitor));
}
// Ogg vorbis
else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast<TagLib::Ogg::Vorbis::File*>(_file.file()) })
else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast<TagLib::Ogg::Vorbis::File*>(_file.get()) })
{
visitFLACImages(vorbisFile->tag()->pictureList(), std::move(visitor));
}
// Ogg Opus
else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast<TagLib::Ogg::Opus::File*>(_file.file()) })
else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast<TagLib::Ogg::Opus::File*>(_file.get()) })
{
visitFLACImages(opusFile->tag()->pictureList(), std::move(visitor));
}
// Aiff
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File*>(_file.file()) })
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File*>(_file.get()) })
{
if (aiffFile->hasID3v2Tag())
visitID3V2Images(*aiffFile->tag(), std::move(visitor));
}
// Wav
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(_file.file()) })
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(_file.get()) })
{
if (wavFile->hasID3v2Tag())
visitID3V2Images(*wavFile->ID3v2Tag(), std::move(visitor));
}
// MPC
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(_file.file()) })
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(_file.get()) })
{
if (mpcFile->hasAPETag())
visitAPEImages(*mpcFile->APETag(), std::move(visitor));
}
// WavPack
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(_file.file()) })
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(_file.get()) })
{
if (wavPackFile->hasAPETag())
visitAPEImages(*wavPackFile->APETag(), std::move(visitor));
}
}
} // namespace lms::metadata
} // namespace lms::metadata::taglib
@@ -21,9 +21,9 @@
#include "IImageReader.hpp"
#include <taglib/fileref.h>
#include <taglib/tfile.h>
namespace lms::metadata
namespace lms::metadata::taglib
{
class TagLibImageReader : public IImageReader
{
@@ -32,6 +32,6 @@ namespace lms::metadata
void visitImages(ImageVisitor visitor) const override;
const TagLib::FileRef _file;
std::unique_ptr<TagLib::File> _file;
};
} // namespace lms::metadata
} // namespace lms::metadata::taglib
@@ -19,44 +19,49 @@
#include "TagLibTagReader.hpp"
#include <algorithm>
#include <unordered_map>
#include "TagLibDefs.hpp"
#include <taglib/aifffile.h>
#include <taglib/apefile.h>
#include <taglib/apeproperties.h>
#include <taglib/apetag.h>
#include <taglib/asffile.h>
#if TAGLIB_MAJOR_VERSION >= 2
#include <taglib/dsffile.h>
#endif
#include <taglib/fileref.h>
#include <taglib/flacfile.h>
#include <taglib/id3v2tag.h>
#include <taglib/mp4file.h>
#include <taglib/mpcfile.h>
#include <taglib/mpegfile.h>
#include <taglib/oggflacfile.h>
#include <taglib/opusfile.h>
#include <taglib/speexfile.h>
#include <taglib/synchronizedlyricsframe.h>
#include <taglib/tag.h>
#include <taglib/tpropertymap.h>
#include <taglib/trueaudiofile.h>
#include <taglib/unsynchronizedlyricsframe.h>
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/wavpackfile.h>
#if TAGLIB_HAS_DSF
#include <taglib/dsffile.h>
#endif
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/String.hpp"
#include "metadata/Exception.hpp"
#include "TagLibDefs.hpp"
#include "Utils.hpp"
namespace lms::metadata
namespace lms::metadata::taglib
{
namespace
{
class TagParsingFailedException : public Exception
{
public:
using Exception::Exception;
};
// Mapping to internal taglib names and/or common alternative custom names
@@ -166,21 +171,6 @@ namespace lms::metadata
{ TagType::Writer, { "WRITER" } },
};
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle)
{
switch (readStyle)
{
case ParserReadStyle::Fast:
return TagLib::AudioProperties::ReadStyle::Fast;
case ParserReadStyle::Average:
return TagLib::AudioProperties::ReadStyle::Average;
case ParserReadStyle::Accurate:
return TagLib::AudioProperties::ReadStyle::Accurate;
}
throw core::LmsException{ "Cannot convert read style" };
}
void mergeTagMaps(TagLib::PropertyMap& dst, TagLib::PropertyMap&& src)
{
for (auto&& [tag, values] : src)
@@ -211,35 +201,26 @@ namespace lms::metadata
}
}
}
TagLib::FileRef parseFile(const std::filesystem::path& p, ParserReadStyle parserReadStyle)
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFileForMetaData");
return TagLib::FileRef{ p.c_str(), true // read audio properties
,
readStyleToTagLibReadStyle(parserReadStyle) };
}
} // namespace
TagLibTagReader::TagLibTagReader(const std::filesystem::path& p, ParserReadStyle parserReadStyle, bool debug)
: _file{ parseFile(p, parserReadStyle) }
: _file{ utils::parseFile(p, utils::readStyleToTagLibReadStyle(parserReadStyle), utils::ReadAudioProperties{ true }) }
{
if (_file.isNull())
if (!_file)
{
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw TagParsingFailedException{};
throw AudioFileParsingException{ "Parsing failed" };
}
if (!_file.audioProperties())
if (!_file->audioProperties())
{
LMS_LOG(METADATA, ERROR, "File " << p << ": no audio properties");
throw TagParsingFailedException{};
throw AudioFileNoAudioPropertiesException{};
}
computeAudioProperties();
_propertyMap = _file.file()->properties();
_propertyMap = _file->properties();
if (debug && core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
{
@@ -319,7 +300,7 @@ namespace lms::metadata
};
// WMA
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.file()) })
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.get()) })
{
if (const TagLib::ASF::Tag * tag{ asfFile->tag() })
{
@@ -365,7 +346,7 @@ namespace lms::metadata
}
}
// MP3
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_file.file()) })
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_file.get()) })
{
if (mp3File->hasID3v2Tag())
processID3v2Tags(*mp3File->ID3v2Tag());
@@ -373,7 +354,7 @@ namespace lms::metadata
getAPETags(mp3File->APETag());
}
// MP4
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(_file.file()) })
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(_file.get()) })
{
// Taglib does not expose rtng in properties
if (const TagLib::MP4::Item rtngItem{ mp4File->tag()->item("rtng") }; rtngItem.isValid())
@@ -406,40 +387,42 @@ namespace lms::metadata
}
}
// MPC
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(_file.file()) })
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(_file.get()) })
{
getAPETags(mpcFile->APETag());
}
// WavPack
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(_file.file()) })
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(_file.get()) })
{
getAPETags(wavPackFile->APETag());
}
// FLAC
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(_file.file()) })
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(_file.get()) })
{
if (flacFile->hasID3v2Tag()) // discouraged usage
processID3v2Tags(*flacFile->ID3v2Tag());
}
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File*>(_file.file()) })
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File*>(_file.get()) })
{
if (aiffFile->hasID3v2Tag())
processID3v2Tags(*aiffFile->tag());
}
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(_file.file()) })
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(_file.get()) })
{
if (wavFile->hasID3v2Tag())
processID3v2Tags(*wavFile->ID3v2Tag());
}
}
TagLibTagReader::~TagLibTagReader() = default;
void TagLibTagReader::computeAudioProperties()
{
const TagLib::AudioProperties* properties{ _file.audioProperties() };
const TagLib::AudioProperties* properties{ _file->audioProperties() };
// Common properties
_audioProperties.bitrate = static_cast<std::size_t>(properties->bitrate() * 1000);
_audioProperties.channelCount = static_cast<std::size_t>(_file.audioProperties()->channels());
_audioProperties.channelCount = static_cast<std::size_t>(_file->audioProperties()->channels());
_audioProperties.duration = std::chrono::milliseconds{ properties->lengthInMilliseconds() };
_audioProperties.sampleRate = static_cast<std::size_t>(properties->sampleRate());
@@ -457,7 +440,7 @@ namespace lms::metadata
_audioProperties.bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(properties) })
_audioProperties.bitsPerSample = wavProperties->bitsPerSample();
#if TAGLIB_MAJOR_VERSION >= 2
#if TAGLIB_HAS_DSF
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(properties) })
_audioProperties.bitsPerSample = dsfProperties->bitsPerSample();
#endif
@@ -534,4 +517,4 @@ namespace lms::metadata
});
}
}
} // namespace lms::metadata
} // namespace lms::metadata::taglib
@@ -21,20 +21,21 @@
#include <filesystem>
#include <map>
#include <span>
#include <string>
#include <taglib/fileref.h>
#include <taglib/tfile.h>
#include <taglib/tpropertymap.h>
#include "ITagReader.hpp"
namespace lms::metadata
namespace lms::metadata::taglib
{
class TagLibTagReader : public ITagReader
{
public:
TagLibTagReader(const std::filesystem::path& path, ParserReadStyle parserReadStyle, bool debug);
~TagLibTagReader() = default;
~TagLibTagReader() override;
TagLibTagReader(const TagLibTagReader&) = delete;
TagLibTagReader& operator=(const TagLibTagReader&) = delete;
@@ -47,9 +48,9 @@ namespace lms::metadata
const AudioProperties& getAudioProperties() const override { return _audioProperties; }
const TagLib::FileRef _file;
std::unique_ptr<TagLib::File> _file;
AudioProperties _audioProperties;
TagLib::PropertyMap _propertyMap; // case-insensitive keys
std::multimap<std::string /* language*/, std::string /* lyrics */> _id3v2Lyrics;
};
} // namespace lms::metadata
} // namespace lms::metadata::taglib
+230
View File
@@ -0,0 +1,230 @@
/*
* Copyright (C) 2025 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 "Utils.hpp"
#include "TagLibDefs.hpp"
#include <taglib/aifffile.h>
#include <taglib/apefile.h>
#include <taglib/asffile.h>
#include <taglib/audioproperties.h>
#include <taglib/flacfile.h>
#include <taglib/id3v2framefactory.h>
#include <taglib/mp4file.h>
#include <taglib/mpcfile.h>
#include <taglib/mpegfile.h>
#include <taglib/oggflacfile.h>
#include <taglib/opusfile.h>
#include <taglib/speexfile.h>
#include <taglib/tfile.h>
#include <taglib/tfilestream.h>
#include <taglib/trueaudiofile.h>
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/wavpackfile.h>
#if TAGLIB_HAS_DSF
#include <taglib/dsffile.h>
#endif
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
namespace lms::metadata::taglib::utils
{
std::span<const std::filesystem::path> getSupportedExtensions()
{
static const std::vector<std::filesystem::path> supportedExtensions
{
".mp3", ".mp2", ".aac", ".ogg", ".oga", ".flac", ".spx", ".opus",
".mpc", ".wv", ".ape", ".tta", ".m4a", ".m4r", ".m4b", ".m4p",
".3g2", ".m4v", ".wma", ".asf", ".aif", ".aiff", ".afc", ".aifc",
".wav",
#if TAGLIB_HAS_DSF
".dsf", ".dff", ".dsdiff"
#endif
};
return std::span<const std::filesystem::path>{ supportedExtensions };
}
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle)
{
switch (readStyle)
{
case ParserReadStyle::Fast:
return TagLib::AudioProperties::ReadStyle::Fast;
case ParserReadStyle::Average:
return TagLib::AudioProperties::ReadStyle::Average;
case ParserReadStyle::Accurate:
return TagLib::AudioProperties::ReadStyle::Accurate;
}
throw Exception{ "Cannot convert read style" };
}
TagLib::FileStream createFileStream(const std::filesystem::path& p)
{
FILE* file{ std::fopen(p.c_str(), "r") };
if (!file)
{
const std::error_code ec{ errno, std::generic_category() };
LMS_LOG(METADATA, DEBUG, "fopen failed for " << p << ": " << ec.message());
throw IOException{ "fopen failed", ec };
}
int fd{ ::fileno(file) };
if (fd == -1)
{
const std::error_code ec{ errno, std::generic_category() };
LMS_LOG(METADATA, DEBUG, "fileno failed for " << p << ": " << ec.message());
throw IOException{ "fileno failed", ec };
}
return TagLib::FileStream{ fd, true };
}
std::unique_ptr<TagLib::File> parseFileByExtension(TagLib::FileStream* stream, const std::filesystem::path& extension, TagLib::AudioProperties::ReadStyle audioPropertiesStyle, bool readAudioProperties)
{
const std::string ext{ core::stringUtils::stringToUpper(extension.string().substr(1)) };
std::unique_ptr<TagLib::File> file;
// MP3
if (ext == "MP3" || ext == "MP2" || ext == "AAC")
file = std::make_unique<TagLib::MPEG::File>(stream, TagLib::ID3v2::FrameFactory::instance(), readAudioProperties, audioPropertiesStyle);
// VORBIS
else if (ext == "OGG")
return std::make_unique<TagLib::Ogg::Vorbis::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "OGA")
{
/* .oga can be any audio in the Ogg container. First try FLAC, then Vorbis. */
file = std::make_unique<TagLib::Ogg::FLAC::File>(stream, readAudioProperties, audioPropertiesStyle);
if (!file->isValid())
file = std::make_unique<TagLib::Ogg::Vorbis::File>(stream, readAudioProperties, audioPropertiesStyle);
}
else if (ext == "FLAC")
file = std::make_unique<TagLib::FLAC::File>(stream, TagLib::ID3v2::FrameFactory::instance(), readAudioProperties, audioPropertiesStyle);
else if (ext == "SPX")
file = std::make_unique<TagLib::Ogg::Speex::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "OPUS")
file = std::make_unique<TagLib::Ogg::Opus::File>(stream, readAudioProperties, audioPropertiesStyle);
// APE
else if (ext == "MPC")
file = std::make_unique<TagLib::MPC::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "WV")
file = std::make_unique<TagLib::WavPack::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "APE")
file = std::make_unique<TagLib::APE::File>(stream, readAudioProperties, audioPropertiesStyle);
// TRUEAUDIO
else if (ext == "TTA")
file = std::make_unique<TagLib::TrueAudio::File>(stream, readAudioProperties, audioPropertiesStyle);
// MP4
else if (ext == "M4A" || ext == "M4R" || ext == "M4B" || ext == "M4P" || ext == "MP4" || ext == "3G2" || ext == "M4V")
file = std::make_unique<TagLib::MP4::File>(stream, readAudioProperties, audioPropertiesStyle);
// ASF
else if (ext == "WMA" || ext == "ASF")
file = std::make_unique<TagLib::ASF::File>(stream, readAudioProperties, audioPropertiesStyle);
// RIFF
else if (ext == "AIF" || ext == "AIFF" || ext == "AFC" || ext == "AIFC")
file = std::make_unique<TagLib::RIFF::AIFF::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "WAV")
file = std::make_unique<TagLib::RIFF::WAV::File>(stream, readAudioProperties, audioPropertiesStyle);
#if TAGLIB_HAS_DSF
else if (ext == "DSF")
file = std::make_unique<TagLib::DSF::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (ext == "DFF" || ext == "DSDIFF")
file = std::make_unique<TagLib::DSDIFF::File>(stream, readAudioProperties, audioPropertiesStyle);
#endif
if (file && !file->isValid())
{
LMS_LOG(METADATA, DEBUG, "File " << file << ": failed to parse by extension");
file.reset();
}
return file;
}
std::unique_ptr<TagLib::File> parseFileByContent(TagLib::FileStream* stream, TagLib::AudioProperties::ReadStyle audioPropertiesStyle, bool readAudioProperties)
{
std::unique_ptr<TagLib::File> file;
if (TagLib::MPEG::File::isSupported(stream))
file = std::make_unique<TagLib::MPEG::File>(stream, TagLib::ID3v2::FrameFactory::instance(), readAudioProperties, audioPropertiesStyle);
// VORBIS
else if (TagLib::Ogg::Vorbis::File::isSupported(stream))
file = std::make_unique<TagLib::Ogg::Vorbis::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::Ogg::FLAC::File::isSupported(stream))
file = std::make_unique<TagLib::Ogg::FLAC::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::FLAC::File::isSupported(stream))
file = std::make_unique<TagLib::FLAC::File>(stream, TagLib::ID3v2::FrameFactory::instance(), readAudioProperties, audioPropertiesStyle);
else if (TagLib::Ogg::Speex::File::isSupported(stream))
file = std::make_unique<TagLib::Ogg::Speex::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::Ogg::Opus::File::isSupported(stream))
file = std::make_unique<TagLib::Ogg::Opus::File>(stream, readAudioProperties, audioPropertiesStyle);
// APE
else if (TagLib::MPC::File::isSupported(stream))
file = std::make_unique<TagLib::MPC::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::WavPack::File::isSupported(stream))
file = std::make_unique<TagLib::WavPack::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::APE::File::isSupported(stream))
file = std::make_unique<TagLib::APE::File>(stream, readAudioProperties, audioPropertiesStyle);
// TRUEAUDIO
else if (TagLib::TrueAudio::File::isSupported(stream))
file = std::make_unique<TagLib::TrueAudio::File>(stream, readAudioProperties, audioPropertiesStyle);
// MP4
else if (TagLib::MP4::File::isSupported(stream))
file = std::make_unique<TagLib::MP4::File>(stream, readAudioProperties, audioPropertiesStyle);
//_ASF
else if (TagLib::ASF::File::isSupported(stream))
file = std::make_unique<TagLib::ASF::File>(stream, readAudioProperties, audioPropertiesStyle);
// RIFF
else if (TagLib::RIFF::AIFF::File::isSupported(stream))
file = std::make_unique<TagLib::RIFF::AIFF::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (TagLib::RIFF::WAV::File::isSupported(stream))
file = std::make_unique<TagLib::RIFF::WAV::File>(stream, readAudioProperties, audioPropertiesStyle);
#if TAGLIB_HAS_DSF
else if (DSF::File::isSupported(stream))
file = std::make_unique<TagLib::DSF::File>(stream, readAudioProperties, audioPropertiesStyle);
else if (DSDIFF::File::isSupported(stream))
file = std::make_unique<TagLib::DSDIFF::File>(stream, readAudioProperties, audioPropertiesStyle);
#endif
if (file && !file->isValid())
{
LMS_LOG(METADATA, DEBUG, "File " << file << ": failed to parse by content");
file.reset();
}
return file;
}
std::unique_ptr<TagLib::File> parseFile(const std::filesystem::path& p, TagLib::AudioProperties::ReadStyle readStyle, ReadAudioProperties readAudioProperties)
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile");
TagLib::FileStream fileStream{ createFileStream(p) };
std::unique_ptr<TagLib::File> file{ parseFileByExtension(&fileStream, p.extension(), readStyle, readAudioProperties.value()) };
if (!file)
file = parseFileByContent(&fileStream, readStyle, readAudioProperties.value());
return file;
}
} // namespace lms::metadata::taglib::utils
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 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 <filesystem>
#include <memory>
#include <span>
#include <taglib/audioproperties.h>
#include <taglib/tfile.h>
#include "core/TaggedType.hpp"
#include "metadata/Types.hpp"
namespace lms::metadata::taglib::utils
{
std::span<const std::filesystem::path> getSupportedExtensions();
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle);
using ReadAudioProperties = core::TaggedBool<struct ReadAudioPropertiesTag>;
std::unique_ptr<TagLib::File> parseFile(const std::filesystem::path& p, TagLib::AudioProperties::ReadStyle readStyle, ReadAudioProperties readAudioProperties);
} // namespace lms::metadata::taglib::utils
@@ -51,6 +51,6 @@ namespace lms::metadata
using Exception::Exception;
};
std::span<const std::filesystem::path> getSupportedInfoFileExtensions();
std::span<const std::filesystem::path> getSupportedArtistInfoFiles();
ArtistInfo parseArtistInfo(std::istream& is);
} // namespace lms::metadata
@@ -19,6 +19,8 @@
#pragma once
#include <system_error>
#include "core/Exception.hpp"
namespace lms::metadata
@@ -29,9 +31,31 @@ namespace lms::metadata
using LmsException::LmsException;
};
class ParseException : public Exception
class IOException : public Exception
{
public:
IOException(std::string_view message, std::error_code err)
: Exception{ std::string{ message } + ": " + err.message() }
, _err{ err }
{
}
std::error_code getErrorCode() const { return _err; }
private:
std::error_code _err;
};
class AudioFileParsingException : public Exception
{
public:
using Exception::Exception;
};
class AudioFileNoAudioPropertiesException : public AudioFileParsingException
{
public:
using AudioFileParsingException::AudioFileParsingException;
};
} // namespace lms::metadata
@@ -24,6 +24,7 @@
#include <memory>
#include <span>
#include "metadata/Exception.hpp"
#include "metadata/Types.hpp"
namespace lms::metadata
+1
View File
@@ -30,6 +30,7 @@ namespace lms::metadata::tests
#PLAYLIST:My super playlist
01-Foo.mp3
#EXTINF:263,Alice in Chains - Don't Follow
02-FooBar.mp3
+1
View File
@@ -4,3 +4,4 @@ add_subdirectory(feedback)
add_subdirectory(recommendation)
add_subdirectory(scanner)
add_subdirectory(scrobbling)
add_subdirectory(transcoding)
@@ -19,31 +19,27 @@
#include "ArtworkService.hpp"
#include <algorithm>
#include <functional>
#include "core/IConfig.hpp"
#include "core/ILogger.hpp"
#include "core/Utils.hpp"
#include "database/Artist.hpp"
#include "database/Artwork.hpp"
#include "database/Db.hpp"
#include "database/Image.hpp"
#include "database/ImageId.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackList.hpp"
#include "image/Exception.hpp"
#include "image/IEncodedImage.hpp"
#include "image/Image.hpp"
#include "metadata/IAudioFileParser.hpp"
namespace lms::cover
namespace lms::artwork
{
namespace
{
bool isFileSupported(const std::filesystem::path& file, const std::vector<std::filesystem::path>& extensions)
{
return (std::find(std::cbegin(extensions), std::cend(extensions), file.extension()) != std::cend(extensions));
}
} // namespace
std::unique_ptr<IArtworkService> createArtworkService(db::Db& db, const std::filesystem::path& defaultReleaseCoverSvgPath, const std::filesystem::path& defaultArtistImageSvgPath)
{
return std::make_unique<ArtworkService>(db, defaultReleaseCoverSvgPath, defaultArtistImageSvgPath);
@@ -92,59 +88,28 @@ namespace lms::cover
return image;
}
std::shared_ptr<image::IEncodedImage> ArtworkService::getDefaultReleaseCover()
std::shared_ptr<image::IEncodedImage> ArtworkService::getDefaultReleaseArtwork()
{
return _defaultReleaseCover;
}
std::shared_ptr<image::IEncodedImage> ArtworkService::getDefaultArtistImage()
std::shared_ptr<image::IEncodedImage> ArtworkService::getDefaultArtistArtwork()
{
return _defaultArtistImage;
}
bool ArtworkService::checkImageFile(const std::filesystem::path& filePath)
std::unique_ptr<image::IEncodedImage> ArtworkService::getTrackImage(const std::filesystem::path& p, std::size_t index, std::optional<image::ImageSize> width) const
{
std::error_code ec;
if (!isFileSupported(filePath, _fileExtensions))
return false;
if (!std::filesystem::exists(filePath, ec))
return false;
if (!std::filesystem::is_regular_file(filePath, ec))
return false;
return true;
}
std::unique_ptr<image::IEncodedImage> ArtworkService::getTrackImage(const std::filesystem::path& p, std::optional<image::ImageSize> width) const
{
struct CandidateImage
{
std::unique_ptr<image::IEncodedImage> image;
bool isFront{};
std::size_t index;
// > means is better candidate
bool operator>(const CandidateImage& other) const
{
if (!isFront && other.isFront)
return false;
if (isFront && !other.isFront)
return true;
return index < other.index;
}
};
std::vector<CandidateImage> candidateImages;
std::size_t pictureIndex{};
std::unique_ptr<image::IEncodedImage> image;
try
{
std::size_t currentIndex{};
_audioFileParser->parseImages(p, [&](const metadata::Image& parsedImage) {
std::unique_ptr<image::IEncodedImage> image;
if (currentIndex++ != index)
return;
try
{
if (!width)
@@ -162,8 +127,6 @@ namespace lms::cover
{
LMS_LOG(COVER, ERROR, "Cannot decode image from track " << p << ": " << e.what());
}
candidateImages.emplace_back(CandidateImage{ .image = std::move(image), .isFront = parsedImage.type == metadata::Image::Type::FrontCover, .index = pictureIndex++ });
});
}
catch (const metadata::Exception& e)
@@ -171,63 +134,108 @@ namespace lms::cover
LMS_LOG(COVER, ERROR, "Cannot parse images from track " << p << ": " << e.what());
}
std::stable_sort(std::begin(candidateImages), std::end(candidateImages), std::greater<>());
if (!candidateImages.empty())
return std::move(candidateImages.front().image);
return image;
}
return {};
db::ArtworkId ArtworkService::findTrackListImage(db::TrackListId trackListId)
{
db::ArtworkId artworkId;
// Iterate over all tracks and stop when we find an artwork
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
db::TrackList::pointer trackList{ db::TrackList::find(session, trackListId) };
if (!trackList)
return artworkId;
const auto entries{ trackList->getEntries(db::Range{ 0, 10 }) };
for (const auto& entry : entries.results)
{
const auto track{ entry->getTrack() };
if (track->getPreferredMediaArtworkId().isValid())
{
artworkId = track->getPreferredMediaArtworkId();
break; // stop iteration
}
if (track->getPreferredArtworkId().isValid())
{
artworkId = track->getPreferredArtworkId();
break; // stop iteration
}
}
return artworkId;
}
std::shared_ptr<image::IEncodedImage> ArtworkService::getImage(db::ArtworkId artworkId, std::optional<image::ImageSize> width)
{
const ImageCache::EntryDesc cacheEntryDesc{ artworkId, width };
std::shared_ptr<image::IEncodedImage> image{ _cache.getImage(cacheEntryDesc) };
if (image)
return image;
db::TrackEmbeddedImageId trackEmbeddedImageId;
db::ImageId imageId;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
db::Artwork::pointer artwork{ db::Artwork::find(session, artworkId) };
if (artwork)
{
trackEmbeddedImageId = artwork->getTrackEmbeddedImageId();
imageId = artwork->getImageId();
}
}
if (trackEmbeddedImageId.isValid())
image = getTrackEmbeddedImage(trackEmbeddedImageId, width);
else if (imageId.isValid())
image = getImage(imageId, width);
if (image)
_cache.addImage(cacheEntryDesc, image);
return image;
}
std::shared_ptr<image::IEncodedImage> ArtworkService::getImage(db::ImageId imageId, std::optional<image::ImageSize> width)
{
const ImageCache::EntryDesc cacheEntryDesc{ imageId, width };
std::shared_ptr<image::IEncodedImage> cover{ _cache.getImage(cacheEntryDesc) };
if (cover)
return cover;
std::filesystem::path imageFile;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
const db::Image::pointer image{ db::Image::find(session, imageId) };
if (image)
imageFile = image->getAbsoluteFilePath();
if (!image)
return nullptr;
imageFile = image->getAbsoluteFilePath();
}
cover = getFromImageFile(imageFile, width);
if (cover)
_cache.addImage(cacheEntryDesc, cover);
return cover;
return getFromImageFile(imageFile, width);
}
std::shared_ptr<image::IEncodedImage> ArtworkService::getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional<image::ImageSize> width)
{
const ImageCache::EntryDesc cacheEntryDesc{ trackEmbeddedImageId, width };
std::shared_ptr<image::IEncodedImage> image;
std::shared_ptr<image::IEncodedImage> cover{ _cache.getImage(cacheEntryDesc) };
if (cover)
return cover;
std::filesystem::path trackFile;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
db::Track::FindParameters params;
params.setEmbeddedImage(trackEmbeddedImageId);
db::Track::find(session, params, [&](const db::Track::pointer& track) {
if (!cover)
cover = getTrackImage(track->getAbsoluteFilePath(), width);
// TODO: could be put outside transaction
db::TrackEmbeddedImageLink::find(session, trackEmbeddedImageId, [&](const db::TrackEmbeddedImageLink::pointer& link) {
if (!image)
image = getTrackImage(link->getTrack()->getAbsoluteFilePath(), link->getIndex(), width);
});
}
if (cover)
_cache.addImage(cacheEntryDesc, cover);
return cover;
return image;
}
void ArtworkService::flushCache()
@@ -241,5 +249,4 @@ namespace lms::cover
LMS_LOG(COVER, INFO, "JPEG export quality = " << _jpegQuality);
}
} // namespace lms::cover
} // namespace lms::artwork
@@ -22,6 +22,8 @@
#include <filesystem>
#include <vector>
#include "database/ImageId.hpp"
#include "database/TrackEmbeddedImageId.hpp"
#include "services/artwork/IArtworkService.hpp"
#include "ImageCache.hpp"
@@ -36,7 +38,7 @@ namespace lms::metadata
class IAudioFileParser;
}
namespace lms::cover
namespace lms::artwork
{
class ArtworkService : public IArtworkService
{
@@ -47,19 +49,21 @@ namespace lms::cover
ArtworkService& operator=(const ArtworkService&) = delete;
private:
std::shared_ptr<image::IEncodedImage> getImage(db::ImageId imageId, std::optional<image::ImageSize> width) override;
std::shared_ptr<image::IEncodedImage> getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional<image::ImageSize> width) override;
db::ArtworkId findTrackListImage(db::TrackListId trackListId) override;
std::shared_ptr<image::IEncodedImage> getDefaultReleaseCover() override;
std::shared_ptr<image::IEncodedImage> getDefaultArtistImage() override;
std::shared_ptr<image::IEncodedImage> getImage(db::ArtworkId artworkId, std::optional<image::ImageSize> width) override;
std::shared_ptr<image::IEncodedImage> getDefaultReleaseArtwork() override;
std::shared_ptr<image::IEncodedImage> getDefaultArtistArtwork() override;
void flushCache() override;
void setJpegQuality(unsigned quality) override;
std::unique_ptr<image::IEncodedImage> getFromImageFile(const std::filesystem::path& p, std::optional<image::ImageSize> width) const;
std::unique_ptr<image::IEncodedImage> getTrackImage(const std::filesystem::path& path, std::optional<image::ImageSize> width) const;
std::shared_ptr<image::IEncodedImage> getImage(db::ImageId imageId, std::optional<image::ImageSize> width);
std::shared_ptr<image::IEncodedImage> getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional<image::ImageSize> width);
static bool checkImageFile(const std::filesystem::path& filePath);
std::unique_ptr<image::IEncodedImage> getFromImageFile(const std::filesystem::path& p, std::optional<image::ImageSize> width) const;
std::unique_ptr<image::IEncodedImage> getTrackImage(const std::filesystem::path& path, std::size_t index, std::optional<image::ImageSize> width) const;
db::Db& _db;
@@ -72,4 +76,4 @@ namespace lms::cover
unsigned _jpegQuality;
};
} // namespace lms::cover
} // namespace lms::artwork
@@ -24,7 +24,7 @@
#include "core/ILogger.hpp"
#include "core/Random.hpp"
namespace lms::cover
namespace lms::artwork
{
ImageCache::ImageCache(std::size_t maxCacheSize)
: _maxCacheSize{ maxCacheSize }
@@ -79,4 +79,4 @@ namespace lms::cover
_cacheSize = 0;
_cache.clear();
}
} // namespace lms::cover
} // namespace lms::artwork
+7 -10
View File
@@ -25,13 +25,11 @@
#include <optional>
#include <shared_mutex>
#include <unordered_map>
#include <variant>
#include "database/ImageId.hpp"
#include "database/TrackEmbeddedImageId.hpp"
#include "database/ArtworkId.hpp"
#include "image/IEncodedImage.hpp"
namespace lms::cover
namespace lms::artwork
{
class ImageCache
{
@@ -40,8 +38,7 @@ namespace lms::cover
struct EntryDesc
{
using VariantType = std::variant<db::TrackEmbeddedImageId, db::ImageId>;
VariantType id;
db::ArtworkId id;
std::optional<std::size_t> size;
bool operator==(const EntryDesc& other) const = default;
@@ -63,13 +60,13 @@ namespace lms::cover
std::size_t operator()(const EntryDesc& entry) const
{
assert(entry.size); // should not cache unresized images
return std::hash<EntryDesc::VariantType>{}(entry.id) ^ std::hash<std::size_t>{}(*entry.size);
return std::hash<db::ArtworkId>{}(entry.id) ^ std::hash<std::size_t>{}(*entry.size);
}
};
std::unordered_map<EntryDesc, std::shared_ptr<image::IEncodedImage>, EntryHasher> _cache;
std::size_t _cacheSize{};
mutable std::atomic<std::size_t> _cacheMisses{};
mutable std::atomic<std::size_t> _cacheHits{};
mutable std::atomic<std::size_t> _cacheMisses;
mutable std::atomic<std::size_t> _cacheHits;
};
} // namespace lms::cover
} // namespace lms::artwork
@@ -23,8 +23,8 @@
#include <memory>
#include <optional>
#include "database/ImageId.hpp"
#include "database/TrackEmbeddedImageId.hpp"
#include "database/ArtworkId.hpp"
#include "database/TrackListId.hpp"
#include "image/IEncodedImage.hpp"
namespace lms::db
@@ -32,19 +32,22 @@ namespace lms::db
class Db;
}
namespace lms::cover
namespace lms::artwork
{
class IArtworkService
{
public:
virtual ~IArtworkService() = default;
virtual std::shared_ptr<image::IEncodedImage> getImage(db::ImageId imageId, std::optional<image::ImageSize> width) = 0;
virtual std::shared_ptr<image::IEncodedImage> getTrackEmbeddedImage(db::TrackEmbeddedImageId trackEmbeddedImageId, std::optional<image::ImageSize> width) = 0;
// Helpers to get preferred artworks
virtual db::ArtworkId findTrackListImage(db::TrackListId trackListId) = 0;
// Svg images dont have image "size"
virtual std::shared_ptr<image::IEncodedImage> getDefaultReleaseCover() = 0;
virtual std::shared_ptr<image::IEncodedImage> getDefaultArtistImage() = 0;
// Image retrieval, no width means original size
virtual std::shared_ptr<image::IEncodedImage> getImage(db::ArtworkId artworkId, std::optional<image::ImageSize> width) = 0;
// Svg images don't have image "size"
virtual std::shared_ptr<image::IEncodedImage> getDefaultReleaseArtwork() = 0;
virtual std::shared_ptr<image::IEncodedImage> getDefaultArtistArtwork() = 0;
virtual void flushCache() = 0;
@@ -53,4 +56,4 @@ namespace lms::cover
std::unique_ptr<IArtworkService> createArtworkService(db::Db& db, const std::filesystem::path& defaultReleaseCoverSvgPath, const std::filesystem::path& defaultArtistImageSvgPath);
} // namespace lms::cover
} // namespace lms::artwork
+2 -5
View File
@@ -18,14 +18,11 @@ target_include_directories(lmsauth PRIVATE
impl
)
target_link_libraries(lmsauth PRIVATE
lmscore
lmsdatabase
)
target_link_libraries(lmsauth PUBLIC
Boost::system
Wt::Wt
lmscore
lmsdatabase
)
# PAM
@@ -21,7 +21,11 @@ target_include_directories(lmsrecommendation PRIVATE
)
target_link_libraries(lmsrecommendation PRIVATE
lmsdatabase
lmssom
std::filesystem
)
target_link_libraries(lmsrecommendation PUBLIC
lmsdatabase
)
@@ -19,6 +19,8 @@
#include "PlaylistGeneratorService.hpp"
#include <algorithm>
#include "core/ILogger.hpp"
#include "database/Db.hpp"
#include "database/Session.hpp"
@@ -23,6 +23,8 @@
#include <vector>
#include "core/EnumSet.hpp"
#include "database/ArtistId.hpp"
#include "database/ReleaseId.hpp"
#include "database/TrackListId.hpp"
#include "database/Types.hpp"
#include "services/recommendation/Types.hpp"
+4
View File
@@ -2,17 +2,21 @@ add_library(lmsscanner STATIC
impl/helpers/ArtistHelpers.cpp
impl/scanners/ArtistInfoFileScanner.cpp
impl/scanners/AudioFileScanOperation.cpp
impl/scanners/FileScanOperationBase.cpp
impl/scanners/AudioFileScanner.cpp
impl/scanners/ImageFileScanner.cpp
impl/scanners/LyricsFileScanner.cpp
impl/scanners/PlayListFileScanner.cpp
impl/scanners/Utils.cpp
impl/steps/ArtworkUtils.cpp
impl/steps/FileScanQueue.cpp
impl/steps/ScanErrorLogger.cpp
impl/steps/ScanStepArtistReconciliation.cpp
impl/steps/ScanStepAssociateArtistImages.cpp
impl/steps/ScanStepAssociateExternalLyrics.cpp
impl/steps/ScanStepAssociatePlayListTracks.cpp
impl/steps/ScanStepAssociateReleaseImages.cpp
impl/steps/ScanStepAssociateTrackImages.cpp
impl/steps/ScanStepBase.cpp
impl/steps/ScanStepCheckForDuplicatedFiles.cpp
impl/steps/ScanStepCheckForRemovedFiles.cpp

Some files were not shown because too many files have changed in this diff Show More