Merge branch 'develop' for release v3.66.0

This commit is contained in:
emeric
2025-04-28 22:58:17 +02:00
126 changed files with 3301 additions and 1465 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
name: Build (alpine)
on: [push, pull_request]
name: Multi-platform Builds (Alpine)
on: [pull_request]
jobs:
Build:
strategy:
@@ -20,9 +20,9 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build (alpine)
- name: Build
uses: docker/build-push-action@v3
with:
context: ./
@@ -32,4 +32,4 @@ jobs:
push: false
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
platforms: linux/amd64,linux/arm/v6
platforms: linux/amd64,linux/arm64,linux/arm/v6
+39
View File
@@ -0,0 +1,39 @@
name: All-option Builds (Arch)
on: [pull_request]
jobs:
Build:
strategy:
matrix:
LMS_BUILD_TYPE: [Release, Debug]
LMS_UNITY_BUILD: [ON, OFF]
LMS_IMAGE_BACKEND: [stb, graphicsmagick]
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile-build-arch
builder: ${{ steps.buildx.outputs.name }}
build-args: |
LMS_BUILD_TYPE=${{ matrix.LMS_BUILD_TYPE }}
LMS_UNITY_BUILD=${{ matrix.LMS_UNITY_BUILD }}
LMS_IMAGE_BACKEND=${{ matrix.LMS_IMAGE_BACKEND }}
push: false
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
@@ -1,10 +1,7 @@
name: Build (arch)
on: [push, pull_request]
name: Basic Build (Arch)
on: [push]
jobs:
Build:
strategy:
matrix:
BUILD_TYPE: [Release, Debug]
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
@@ -20,7 +17,7 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v3
@@ -28,7 +25,10 @@ jobs:
context: ./
file: ./Dockerfile-build-arch
builder: ${{ steps.buildx.outputs.name }}
build-args: LMS_BUILD_TYPE=${{ matrix.BUILD_TYPE }}
build-args: |
LMS_BUILD_TYPE=Release
LMS_UNITY_BUILD=ON
LMS_IMAGE_BACKEND=stb
push: false
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
+4 -6
View File
@@ -1,8 +1,6 @@
name: "CodeQL"
on:
push:
branches: [ "master", "develop" ]
pull_request:
branches: [ "master", "develop" ]
schedule:
@@ -42,24 +40,24 @@ jobs:
popd
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- if: matrix.language == 'javascript'
name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
- if: matrix.language == 'cpp'
name: Build
run: |
mkdir -p build
cd build
cmake -DCMAKE_VERBOSE_MAKEFILE=ON -DCMAKE_BUILD_TYPE=Release ..
cmake -DCMAKE_VERBOSE_MAKEFILE=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_UNITY_BUILD=ON ..
make -j$(nproc)
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ ARG LMS_BUILD_TYPE="Release"
RUN \
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \
xx-info is-cross && export BUILD_TESTS=OFF || export BUILD_TESTS=ON && \
PKG_CONFIG_PATH=/$(xx-info)/usr/lib/pkgconfig cmake /tmp/lms/ -DCMAKE_INCLUDE_PATH=/$(xx-info)/usr/include -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} $(xx-clang --print-cmake-defines) -DCMAKE_PREFIX_PATH=/$(xx-info)/usr/lib/cmake -DBUILD_TESTING=${BUILD_TESTS} -DBUILD_BENCHMARKS=ON && \
PKG_CONFIG_PATH=/$(xx-info)/usr/lib/pkgconfig cmake /tmp/lms/ -DCMAKE_INCLUDE_PATH=/$(xx-info)/usr/include -DCMAKE_UNITY_BUILD=ON -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} $(xx-clang --print-cmake-defines) -DCMAKE_PREFIX_PATH=/$(xx-info)/usr/lib/cmake -DBUILD_TESTING=${BUILD_TESTS} -DBUILD_BENCHMARKS=ON && \
VERBOSE=1 make -j$(nproc) && \
xx-verify src/lms/lms && \
(xx-info is-cross || make test)
+13 -8
View File
@@ -12,18 +12,23 @@ ARG BUILD_PACKAGES="\
libconfig \
make \
pkgconfig \
stb \
taglib \
wt \
xxhash"
RUN pacman -Syu --noconfirm
RUN pacman -S --noconfirm ${BUILD_PACKAGES}
RUN pacman -Syu --noconfirm ${BUILD_PACKAGES} && \
pacman -Scc --noconfirm && \
rm -rf /var/cache/pacman/pkg/*
# LMS
COPY . /tmp/lms/
ARG LMS_BUILD_TYPE="Release"
RUN \
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \
cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} -DCMAKE_INSTALL_PREFIX=/usr -DLMS_IMAGE_BACKEND=graphicsmagick -DBUILD_BENCHMARKS=ON && \
VERBOSE=1 make -j$(nproc) && \
make test
RUN mkdir -p /tmp/lms/build
WORKDIR /tmp/lms/build
ARG LMS_BUILD_TYPE=Release
ARG LMS_UNITY_BUILD=ON
ARG LMS_IMAGE_BACKEND=stb
RUN cmake .. -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} -DCMAKE_UNITY_BUILD=${LMS_UNITY_BUILD} -DLMS_IMAGE_BACKEND=${LMS_IMAGE_BACKEND} -DBUILD_BENCHMARKS=ON
RUN VERBOSE=1 make -j$(nproc)
RUN make test
+1 -1
View File
@@ -116,7 +116,7 @@ RUN \
COPY . /tmp/lms/
RUN \
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \
PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \
PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_UNITY_BUILD=ON -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \
LD_LIBRARY_PATH=${PREFIX}/lib make -j$(nproc) install && \
LD_LIBRARY_PATH=${PREFIX}/lib make test && \
mkdir -p ${PREFIX}/etc/ && \
+2 -3
View File
@@ -51,15 +51,14 @@ git clone https://github.com/epoupon/lms.git lms
cd lms
mkdir build
cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_UNITY_BUILD=ON -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_INSTALL_PREFIX=/usr
```
__Notes__:
* you can customize the installation directory using `-DCMAKE_INSTALL_PREFIX=path` (defaults to `/usr/local`).
* you can customize the image library using `-DLMS_IMAGE_BACKEND=<stb|graphicsmagick>` (defaults to `stb`)
```sh
make
make -j$(nproc)
```
__Note__: you can use `make -jN` to speed up compilation time (N is the number of compilation workers to spawn).
### Installation
__Note__: the commands of this section require root privileges.
```sh
+20
View File
@@ -39,6 +39,15 @@
</label>
${artist-tag-delimiter-container class="row gy-3"}
</div>
<div class="col-12">
<label class="form-label" for="${id:artists-to-not-split}">
${tr:Lms.Admin.Database.artists-to-not-split}
</label>
${artists-to-not-split class="form-control"}
<div class="invalid-feedback">
${artists-to-not-split-info}
</div>
</div>
<div class="col-12">
<label class="form-label">
${tr:Lms.Admin.Database.default-tag-delimiters}
@@ -58,6 +67,17 @@
</div>
</div>
</div>
<div class="col-12">
<div class="form-check">
${allow-mbid-artist-merge class="form-check-input"}
<label class="form-check-label" for="${id:allow-mbid-artist-merge}">
${tr:Lms.Admin.Database.allow-mbid-artist-merge}
</label>
<div class="invalid-feedback">
${allow-mbid-artist-merge-info}
</div>
</div>
</div>
<div class="col-12">
<label class="form-label" for="${id:similarity-engine-type}">
${tr:Lms.Admin.Database.similarity-engine-type}
+5 -1
View File
@@ -33,6 +33,7 @@
<plural case="1">{1} tracks</plural>
</message>
<message id="Lms.user">User</message>
<message id="Lms.uuid-invalid">Invalid UUID</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Root directory</message>
<!--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-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>
<message id="Lms.Admin.Database.default-tag-delimiters">Delimiters to be used for splitting other tags</message>
<message id="Lms.Admin.Database.extra-tags-to-scan">Extra tags to scan</message>
@@ -107,7 +110,7 @@
<message id="Lms.Admin.ScannerController.duplicates-header">{1} duplicate files:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} errors:</message>
<message id="Lms.Admin.ScannerController.force-optimize">Force database optimization</message>
<message id="Lms.Admin.ScannerController.full-scan">Rescan all files</message>
<message id="Lms.Admin.ScannerController.full-scan">Force scan all files</message>
<message id="Lms.Admin.ScannerController.get-report">Get report</message>
<message id="Lms.Admin.ScannerController.last-scan">Last scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Not available</message>
@@ -133,6 +136,7 @@
<message id="Lms.Admin.ScannerController.step-discovering-files">Discovering files: {1} files</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-optimize">Optimizing database... {1}%...</message>
<message id="Lms.Admin.ScannerController.step-reconciliate-artists">Reconciliating artists: {1} entries...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Reloading similarity engine: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-removing-orphaned-entries">Removing orphaned entries: {1} entries...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scanning files: {1}/{2} ({3}%)...</message>
+396
View File
@@ -0,0 +1,396 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions"
nplurals="2" plural="n > 1 ? 1 : 0">
<!--Common-->
<message id="Lms.add">Añadir</message>
<message id="Lms.administration">Administración</message>
<message id="Lms.cancel">Cancelar</message>
<message id="Lms.clusters">Etiquetas</message>
<message id="Lms.create">Crear</message>
<message id="Lms.delete">Eliminar</message>
<message id="Lms.discard">Descartar</message>
<message id="Lms.edit">Editar</message>
<message id="Lms.field-must-be-in-upper-case">Este campo debe estar en mayúsculas</message>
<message id="Lms.loading">Cargando...</message>
<message id="Lms.login">Usuario</message>
<message id="Lms.logout"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> Salir</message>
<message id="Lms.ok">Ok</message>
<message id="Lms.password">Contraseña</message>
<message id="Lms.password-bad-login-combination">Combinación incorrecta de usuario / contraseña</message>
<message id="Lms.password-client-throttled">Demasiados intentos de conexión, inténtelo de nuevo mas tarde</message>
<message id="Lms.password-confirm">Confirma la contraseña</message>
<message id="Lms.password-must-match-login">¡La contraseña debe coincidir con el usuario!</message>
<message id="Lms.password-new">Contraseña nueva</message>
<message id="Lms.password-old">Contraseña antigua</message>
<message id="Lms.password-too-weak">La contraseña es demasiado débil</message>
<message id="Lms.passwords-dont-match">Las contraseñas no coinciden</message>
<message id="Lms.quit-other-session">Se ha abierto otra sesión. ¿Reabrir la actúal?</message>
<message id="Lms.save">Guardar</message>
<message id="Lms.settings-saved">¡Ajustes guardados!</message>
<message id="Lms.track-count">
<plural case="0">{1} pista</plural>
<plural case="1">{1} pistas</plural>
</message>
<message id="Lms.user">Usuario</message>
<message id="Lms.uuid-invalid">UUID inválido</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator"></message>
<message id="Lms.locale.date-format">dd/MM/yyyy</message>
<message id="Lms.locale.time-format">HH:mm:ss</message>
<message id="Lms.locale.date-time-format">dd/MM/yyyy HH:mm:ss</message>
<!--Errors-->
<message id="Lms.Error.artist-not-found">No se ha entrado el artista</message>
<message id="Lms.Error.error-occurred">¡Ha ocurrido un error!</message>
<message id="Lms.Error.go-home">Volver a la pantalla de bienvenida</message>
<message id="Lms.Error.release-not-found">Este álbum no existe</message>
<message id="Lms.Error.tracklist-not-found">Lista de reproducción no encontrada</message>
<message id="Lms.Error.user-not-allowed">No tiene los permisos para efectuar esta operación</message>
<message id="Lms.Error.user-not-found">No existe el usuario</message>
<!--Administration-->
<message id="Lms.Admin.menu-media-libraries"><i class="fa fa-fw fa-database" aria-hidden="true"></i> Bibliotecas</message>
<message id="Lms.Admin.menu-scan-settings"><i class="fa fa-fw fa-cogs" aria-hidden="true"></i> Opciones de escaneo</message>
<message id="Lms.Admin.menu-scanner"><i class="fa fa-fw fa-wrench" aria-hidden="true"></i> Escanear</message>
<message id="Lms.Admin.menu-tracing"><i class="fa fa-fw fa-bar-chart" aria-hidden="true"></i> Traces</message>
<message id="Lms.Admin.menu-users"><i class="fa fa-fw fa-users" aria-hidden="true"></i> Usuarios</message>
<!--MediaLibraries-->
<message id="Lms.Admin.MediaLibraries.media-libraries">Bibliotecas musicales</message>
<message id="Lms.Admin.MediaLibrary.create-library">Crear una biblioteca</message>
<message id="Lms.Admin.MediaLibrary.del-library-confirm">¿Eliminar la biblioteca?</message>
<message id="Lms.Admin.MediaLibrary.edit-library">Edición de la biblioteca</message>
<message id="Lms.Admin.MediaLibrary.library-created">¡Biblioteca creada!</message>
<message id="Lms.Admin.MediaLibrary.library-deleted">¡Biblioteca eliminada!</message>
<message id="Lms.Admin.MediaLibrary.name">Nombre de la biblioteca</message>
<message id="Lms.Admin.MediaLibrary.name-already-exists">Ya hay una biblioteca con ese nombre</message>
<message id="Lms.Admin.MediaLibrary.path-must-be-absolute">La ruta del fichero debe ser absoluta</message>
<message id="Lms.Admin.MediaLibrary.path-must-not-overlap">La ruta no debe solapar la de otra biblioteca</message>
<message id="Lms.Admin.MediaLibrary.path-must-be-existing-directory">El directorio no existe</message>
<message id="Lms.Admin.MediaLibrary.root-path">Ruta raíz</message>
<!--Scan settings-->
<message id="Lms.Admin.Database.allow-mbid-artist-merge">Permitir la fusión de artistas sin MBID con aquellos que tienen uno</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 dividir usando los delimitadores (un artista por línea)</message>
<message id="Lms.Admin.Database.daily">Diariamente</message>
<message id="Lms.Admin.Database.default-tag-delimiters">Delimitadores usados para separar otras etiquetas</message>
<message id="Lms.Admin.Database.extra-tags-to-scan">Etiquetas adicionales a escanear</message>
<message id="Lms.Admin.Database.hourly">Cada hora</message>
<message id="Lms.Admin.Database.immediate-scan">¡Escanear ahora!</message>
<message id="Lms.Admin.Database.misc">Varios</message>
<message id="Lms.Admin.Database.monthly">Mensualmente</message>
<message id="Lms.Admin.Database.never">Nunca</message>
<message id="Lms.Admin.Database.scan-aborted">¡Escaneo interrumpido!</message>
<message id="Lms.Admin.Database.scan-complete">Escaneo terminado : {1} ficheros, {2} añadidos, {3} actualizados, {4} eliminados, {5} duplicados, {6} errores</message>
<message id="Lms.Admin.Database.scan-launched">¡Escaneo comenzado!</message>
<message id="Lms.Admin.Database.scan-settings">Opciones de escaneo</message>
<message id="Lms.Admin.Database.similarity-engine-type">Motor de semejanza</message>
<message id="Lms.Admin.Database.similarity-engine-type.clusters">Basado en las etiquetas</message>
<message id="Lms.Admin.Database.similarity-engine-type.none">Ninguno</message>
<message id="Lms.Admin.Database.skip-single-release-playlists">Ignorar las lista de reproducción que contienen pistas del mismo álbum</message>
<message id="Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces">Los delimitadores de las etiquetas no deben consistir solo en espacios</message>
<message id="Lms.Admin.Database.tag-parsing">Análisis de las etiquetas</message>
<message id="Lms.Admin.Database.update-period">Frecuencia de la actualización</message>
<message id="Lms.Admin.Database.update-start-time">Hora de la actualización</message>
<message id="Lms.Admin.Database.weekly">Semanalmente</message>
<!--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.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-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>
<message id="Lms.Admin.ScannerController.compact">Compactar la base de datos. <strong>¡Atención!:</strong> esta operación puede tardar bastante tiempo y la aplicación se bloqueará mientras dura la operación de compactación</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} ficheros duplicados:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} errores :</message>
<message id="Lms.Admin.ScannerController.force-optimize">Forzar la optimización de la base de datos</message>
<message id="Lms.Admin.ScannerController.full-scan">Reescanear todos los ficheros</message>
<message id="Lms.Admin.ScannerController.get-report">Informe</message>
<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.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>
<message id="Lms.Admin.ScannerController.scan-now">Escanear ahora</message>
<message id="Lms.Admin.ScannerController.scan-options">Opciones de escaneo</message>
<message id="Lms.Admin.ScannerController.scanner">Escanear</message>
<message id="Lms.Admin.ScannerController.status">Estado</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">No progamado</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Programado el {1} a las {2} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Escaneando: etapa {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Association des images des artistes: {1}%...</message>
<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-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>
<message id="Lms.Admin.ScannerController.step-compute-cluster-stats">Calculando estadísticas... {1}%</message>
<message id="Lms.Admin.ScannerController.step-discovering-files">Descubriendo ficheros: {1} ficheros</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Accediendo a las características de la pista desde AcousticBrainz : {1}/{2} ficheros ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-optimize">Optimizando la base de datos... {1}%...</message>
<message id="Lms.Admin.ScannerController.step-reconciliate-artists">Reconciliando artistas: {1} entradas...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Recargando el motor de similitud: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-removing-orphaned-entries">Borrando entradas huérfanas: {1} entradas...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Escaneando ficheros: {1} / {2} ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-status">Estado de las etapas</message>
<message id="Lms.Admin.ScannerController.step-updating-library-fields">Actualizando campos de la biblioteca: {1} entradas</message>
<!--Tracing-->
<message id="Lms.Admin.Tracing.export-current-buffer">Exportar las trazas</message>
<message id="Lms.Admin.Tracing.tracing">Trazas</message>
<!--Users-->
<message id="Lms.Admin.Users.add">Añadir</message>
<message id="Lms.Admin.Users.admin">Administrador</message>
<message id="Lms.Admin.Users.del-user-confirm">¿Eliminar el usuario?</message>
<message id="Lms.Admin.Users.demo">Demo</message>
<message id="Lms.Admin.Users.users">Usuarios</message>
<!--User-->
<message id="Lms.Admin.User.demo-account">Cuenta de demostración</message>
<message id="Lms.Admin.User.demo-account-already-exists">¡Ya existe una cuenta de demostración!</message>
<message id="Lms.Admin.User.last-login">Última fecha de inicio de sesión</message>
<message id="Lms.Admin.User.user-already-exists">¡Ya existe ese usuario!</message>
<message id="Lms.Admin.User.user-create">Nuevo usuario</message>
<message id="Lms.Admin.User.user-created">¡Nuevo usuario creado!</message>
<message id="Lms.Admin.User.user-edit">Usuario '{1}'</message>
<message id="Lms.Admin.User.user-updated">¡Se ha actualizado el usuario!</message>
<!--Wizard-->
<message id="Lms.Admin.InitWizard.done">Se ha creado la cuenta de administrador. ¡Recarga la página para continuar!</message>
<message id="Lms.Admin.InitWizard.header">Crear cuenta de administrador</message>
<!--Auth-->
<message id="Lms.Auth.remember-me">Recordarme</message>
<message id="Lms.Auth.welcome">¡Bienvenido!</message>
<!--Explore-->
<message id="Lms.Explore.add-filter">Añadir filtro</message>
<message id="Lms.Explore.all">Todos</message>
<message id="Lms.Explore.artists">Artistas</message>
<message id="Lms.Explore.bitrate">Bitrate</message>
<message id="Lms.Explore.codec">Codec</message>
<message id="Lms.Explore.download">Descargar</message>
<message id="Lms.Explore.duration">Duración</message>
<message id="Lms.Explore.filter-added">Filtro añadido</message>
<message id="Lms.Explore.filters">Filtros</message>
<message id="Lms.Explore.label">Sello discográfico</message>
<message id="Lms.Explore.media-library">Biblioteca musical</message>
<message id="Lms.Explore.most-played">Reproducidos frecuentemente</message>
<message id="Lms.Explore.musicbrainz-artist">Artista de MusicBrainz</message>
<message id="Lms.Explore.musicbrainz-release">Álbum de MusicBrainz</message>
<message id="Lms.Explore.play">Reproducir</message>
<message id="Lms.Explore.play-last">Reproducir al final</message>
<message id="Lms.Explore.play-next">Reproducir a continuación</message>
<message id="Lms.Explore.play-shuffled">Reproducción aleatoria</message>
<message id="Lms.Explore.playcount">Número de reproducciones</message>
<message id="Lms.Explore.random">Aleatorio</message>
<message id="Lms.Explore.recently-added">Añadidos recientemente</message>
<message id="Lms.Explore.recently-modified">Modificados recientemente</message>
<message id="Lms.Explore.recently-played">Reproducidos recientemente</message>
<message id="Lms.Explore.release-info">Información sobre el álbum</message>
<message id="Lms.Explore.release-type">Tipo de publicación</message>
<message id="Lms.Explore.releases">Álbumes</message>
<message id="Lms.Explore.search">Buscar</message>
<message id="Lms.Explore.star">Añadir a favoritos</message>
<message id="Lms.Explore.starred">Favoritos</message>
<message id="Lms.Explore.track-info">Información sobre la pista</message>
<message id="Lms.Explore.track-lyrics">Letras</message>
<message id="Lms.Explore.tracklists">Listas de reproducción</message>
<message id="Lms.Explore.tracks">Pistas</message>
<message id="Lms.Explore.type">Type</message>
<message id="Lms.Explore.unstar">Eliminar de favoritos</message>
<message id="Lms.Explore.value">Valor</message>
<message id="Lms.Explore.various-artists">Varios artistas</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Aparece en</message>
<message id="Lms.Explore.Artist.biography">Biografía</message>
<message id="Lms.Explore.Artist.similar-artists">Artistas similares</message>
<!--Explore:Artists-->
<message id="Lms.Explore.Artists.linktype-all">Todos los artistas</message>
<message id="Lms.Explore.Artists.linktype-artist">
<plural case="0">Artista de la pista</plural>
<plural case="1">Artistas de la pista</plural>
</message>
<message id="Lms.Explore.Artists.linktype-composer">
<plural case="0">Compositor</plural>
<plural case="1">Compositores</plural>
</message>
<message id="Lms.Explore.Artists.linktype-conductor">
<plural case="0">Director de orquesta</plural>
<plural case="1">Directores de orquesta</plural>
</message>
<message id="Lms.Explore.Artists.linktype-lyricist">
<plural case="0">Letrista</plural>
<plural case="1">Letristas</plural>
</message>
<message id="Lms.Explore.Artists.linktype-mixer">
<plural case="0">Mezclador</plural>
<plural case="1">Mezcladores</plural>
</message>
<message id="Lms.Explore.Artists.linktype-performer">
<plural case="0">Intérprete</plural>
<plural case="1">Intérpretes</plural>
</message>
<message id="Lms.Explore.Artists.linktype-producer">
<plural case="0">Productor</plural>
<plural case="1">Productores</plural>
</message>
<message id="Lms.Explore.Artists.linktype-releaseartist">
<plural case="0">Artista del álbum</plural>
<plural case="1">Artistas del álbum</plural>
</message>
<message id="Lms.Explore.Artists.linktype-remixer">
<plural case="0">Remezclador</plural>
<plural case="1">Remezcladores</plural>
</message>
<!--Explore:Release-->
<message id="Lms.Explore.Release.copyright">Copyright</message>
<message id="Lms.Explore.Release.disc">Disco {1}</message>
<message id="Lms.Explore.Release.other-versions">Otras versiones</message>
<message id="Lms.Explore.Release.similar-releases">Álbumes similares</message>
<message id="Lms.Explore.Release.type">Tipo</message>
<message id="Lms.Explore.Release.type-primary-album">Álbum</message>
<message id="Lms.Explore.Release.type-primary-broadcast">Difusión</message>
<message id="Lms.Explore.Release.type-primary-ep">EP</message>
<message id="Lms.Explore.Release.type-primary-other">Otro</message>
<message id="Lms.Explore.Release.type-primary-single">Single</message>
<message id="Lms.Explore.Release.type-secondary-audiobook">Audiolibro</message>
<message id="Lms.Explore.Release.type-secondary-audiodrama">Drama</message>
<message id="Lms.Explore.Release.type-secondary-compilation">Recopilatorio</message>
<message id="Lms.Explore.Release.type-secondary-demo">Demo</message>
<message id="Lms.Explore.Release.type-secondary-djmix">DJ-mix</message>
<message id="Lms.Explore.Release.type-secondary-field-recording">Grabación de campo</message>
<message id="Lms.Explore.Release.type-secondary-interview">Entrevista</message>
<message id="Lms.Explore.Release.type-secondary-live">En vivo</message>
<message id="Lms.Explore.Release.type-secondary-mixtape-street">Mixtape/Street</message>
<message id="Lms.Explore.Release.type-secondary-remix">Remix</message>
<message id="Lms.Explore.Release.type-secondary-soundtrack">Banda sonora</message>
<message id="Lms.Explore.Release.type-secondary-spokenword">Creación locutada</message>
<!--Explore:TrackLists-->
<message id="Lms.Explore.TrackLists.del-tracklist-confirm">¿Eliminar la lista de reproducción?</message>
<message id="Lms.Explore.TrackLists.type-owned">Mis listas</message>
<message id="Lms.Explore.TrackLists.type-shared">Listas compartidas</message>
<!--Explore:Search-->
<message id="Lms.Explore.Search.search-placeholder">Buscar...</message>
<!--Player-->
<message id="Lms.Player.transcoding-active">Transcodificación activa</message>
<!--Playqueue-->
<message id="Lms.PlayQueue.clear">Vaciar la lista</message>
<message id="Lms.PlayQueue.create-tracklist">Crear una nueva lista de reproducción</message>
<message id="Lms.PlayQueue.playqueue">Lista de reproducción</message>
<message id="Lms.PlayQueue.radio-mode">Modo radio</message>
<message id="Lms.PlayQueue.repeat">Repetir</message>
<message id="Lms.PlayQueue.replace-tracklist">Reemplazar una lista de distribución existente</message>
<message id="Lms.PlayQueue.shuffle">Modo aleatorio</message>
<!--PlayHistory-->
<message id="Lms.PlayHistory.playhistory">Histórico de reproducciones</message>
<!--Settings-->
<message id="Lms.Settings.artist-release-sort-method">Método para ordenar los álbumes del artista</message>
<message id="Lms.Settings.audio">Audio</message>
<message id="Lms.Settings.audio-settings-are-local">Estas opciones de audio son locales a tu navegador</message>
<message id="Lms.Settings.backend.internal">Interno</message>
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
<message id="Lms.Settings.backend.listenbrainz-token">Token de la API de ListenBrainz</message>
<message id="Lms.Settings.change-password">Cambiar contraseña</message>
<message id="Lms.Settings.date-asc">Fecha de publicación (Ascendiente)</message>
<message id="Lms.Settings.date-desc">Fecha de publicación (Descendiente)</message>
<message id="Lms.Settings.default-transcoding-output-bitrate">Bitrate por omisión de transcodificación</message>
<message id="Lms.Settings.default-transcoding-output-format">Formato por omisión de transcodificación</message>
<message id="Lms.Settings.demo-cannot-save">¡No se puede guardar usando una cuenta de demostración!</message>
<message id="Lms.Settings.enable-inline-artist-relationships">Mostrar las relaciones entre artistas dentro de los álbumes</message>
<message id="Lms.Settings.enable-transcoding-by-default">Activar por defecto la transcodificación</message>
<message id="Lms.Settings.feedback">Feedback</message>
<message id="Lms.Settings.inline-artist-relationships">Tipos de relaciones entre artistas a mostrar</message>
<message id="Lms.Settings.menu-settings"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> Ajustes</message>
<message id="Lms.Settings.name">Nombre del álbum</message>
<message id="Lms.Settings.original-date-asc">Fecha de publicación original (Ascendiente)</message>
<message id="Lms.Settings.original-date-desc">Fecha de publicación original (Descendiente)</message>
<message id="Lms.Settings.password-bad">Contraseña incorrecta</message>
<message id="Lms.Settings.password-must-fill-old-password">La contraseña antigua debe ser introducida</message>
<message id="Lms.Settings.regen-token">Regenerar</message>
<message id="Lms.Settings.replaygain-mode">Modo ReplayGain</message>
<message id="Lms.Settings.replaygain-mode.none">Sin ReplayGain</message>
<message id="Lms.Settings.replaygain-mode.auto">Automático</message>
<message id="Lms.Settings.replaygain-mode.track">Pista</message>
<message id="Lms.Settings.replaygain-mode.release">Álbum</message>
<message id="Lms.Settings.replaygain-preamp">Pre-amplificación ReplayGain</message>
<message id="Lms.Settings.replaygain-preamp-no-rg-info">Pre-amplification ReplayGain (si no hay información)</message>
<message id="Lms.Settings.scrobbling">Scrobbling</message>
<message id="Lms.Settings.services">Servicios</message>
<message id="Lms.Settings.settings">Ajustes</message>
<message id="Lms.Settings.settings-saved">¡Ajustes guardados!</message>
<message id="Lms.Settings.subsonic-artist-list-mode">Modo de listado de artistas</message>
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Todos los artistas</message>
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Todos los artistas de álbum</message>
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Todos los artistas de pistas</message>
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
<message id="Lms.Settings.subsonic-token">Clave para la API de OpenSubsonic</message>
<message id="Lms.Settings.subsonic-token-usage">Use esta clave como la contraseña en aquellos clientes que no soporten la extensión 'API Key Authentication'</message>
<message id="Lms.Settings.transcoding">Transcodificación</message>
<message id="Lms.Settings.transcoding-mode">Aplicar la transcodificación</message>
<message id="Lms.Settings.transcoding-mode.always">Siempre</message>
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Únicamente cuando el formato no esté soportado por el navegador</message>
<message id="Lms.Settings.transcoding-mode.never">Nunca</message>
<message id="Lms.Settings.transcoding-output-bitrate">Bitrate de transcodificación</message>
<message id="Lms.Settings.transcoding-output-format">Formato de transcodificación</message>
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
<message id="Lms.Settings.transcoding-output-format.mp3">MP3</message>
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
<message id="Lms.Settings.user-interface">Interfaz de usuario</message>
<!--Wt-->
<message id="Wt.WDateTime.LessThanASecond">menos de uns segundo</message>
<message id="Wt.WDateTime.seconds">
<plural case="0">un segundo</plural>
<plural case="1">{1} segundos</plural>
</message>
<message id="Wt.WDateTime.minutes">
<plural case="0">un minuto</plural>
<plural case="1">{1} minutos</plural>
</message>
<message id="Wt.WDateTime.hours">
<plural case="0">una hora</plural>
<plural case="1">{1} horas</plural>
</message>
<message id="Wt.WDateTime.days">
<plural case="0">un día</plural>
<plural case="1">{1} días</plural>
</message>
<message id="Wt.WDateTime.weeks">
<plural case="0">una semana</plural>
<plural case="1">{1} semanas</plural>
</message>
<message id="Wt.WDateTime.months">
<plural case="0">un mes</plural>
<plural case="1">{1} meses</plural>
</message>
<message id="Wt.WDateTime.years">
<plural case="0">un año</plural>
<plural case="1">{1} años</plural>
</message>
<message id="Wt.WDateTime.null"></message>
<message id="Wt.WMessageBox.Yes">Si</message>
<message id="Wt.WMessageBox.No">No</message>
<message id="Wt.WValidator.Invalid">Este campo no puede estar vacío</message>
<message id="Wt.WDoubleValidator.BadRange">La cifra debe estar comprendida entre {1} y {2}</message>
</messages>
+5 -1
View File
@@ -33,6 +33,7 @@
<plural case="1">{1} pistes</plural>
</message>
<message id="Lms.user">Utilisateur</message>
<message id="Lms.uuid-invalid">UUID invalide</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">,</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Répertoire racine</message>
<!--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-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>
<message id="Lms.Admin.Database.default-tag-delimiters">Délimiteurs à utiliser pour séparer les autres tags</message>
<message id="Lms.Admin.Database.extra-tags-to-scan">Tags supplémentaires à scanner</message>
@@ -107,7 +110,7 @@
<message id="Lms.Admin.ScannerController.duplicates-header">{1} fichiers dupliqués :</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} erreurs :</message>
<message id="Lms.Admin.ScannerController.force-optimize">Forcer l'optimisation de la base de données</message>
<message id="Lms.Admin.ScannerController.full-scan">Rescanner tous les fichiers</message>
<message id="Lms.Admin.ScannerController.full-scan">Forcer le scan de tous les fichiers</message>
<message id="Lms.Admin.ScannerController.get-report">Rapport</message>
<message id="Lms.Admin.ScannerController.last-scan">Dernier scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponible</message>
@@ -133,6 +136,7 @@
<message id="Lms.Admin.ScannerController.step-discovering-files">Découverte des fichiers : {1} fichiers</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Récupération des métadonnées AcousticBrainz : {1}/{2} fichiers ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-optimize">Optimisation de la base de données... {1}%...</message>
<message id="Lms.Admin.ScannerController.step-reconciliate-artists">Reconciliation des artistes: {1} entrées...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Rechargement du moteur de recommandation : {1}%...</message>
<message id="Lms.Admin.ScannerController.step-removing-orphaned-entries">Retrait des entrées orphelines: {1} entrées...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scan des fichiers : {1}/{2} ({3}%)...</message>
+4
View File
@@ -33,6 +33,7 @@
<plural case="1">{1} tracce</plural>
</message>
<message id="Lms.user">Utente</message>
<message id="Lms.uuid-invalid">UUID non valido</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">,</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Cartella principale</message>
<!--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-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>
<message id="Lms.Admin.Database.default-tag-delimiters">Delimitatori da utilizzare per separare gli altri tag</message>
<message id="Lms.Admin.Database.extra-tags-to-scan">Tag aggiuntivi da scansionare</message>
@@ -133,6 +136,7 @@
<message id="Lms.Admin.ScannerController.step-discovering-files">File trovati: {1} files</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Recupero metadati da AcousticBrainz: {1}/{2} tracce ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-optimize">Ottimizzazione del database... {1}%...</message>
<message id="Lms.Admin.ScannerController.step-reconciliate-artists">Riconciliazione artisti: {1} voci...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Ricarica motore di tracce simili: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-removing-orphaned-entries">Rimozione voci orfane: {1} voci...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scansione dei file: {1}/{2} ({3}%)...</message>
+5 -1
View File
@@ -34,6 +34,7 @@
<plural case="2">{1} ścieżek</plural>
</message>
<message id="Lms.user">Użytkownik</message>
<message id="Lms.uuid-invalid">Nieprawidłowy UUID</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">,</message>
@@ -73,7 +74,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Katalog główny</message>
<!--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-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>
<message id="Lms.Admin.Database.default-tag-delimiters">Znaki rozdzielające inne oznaczenia</message>
<message id="Lms.Admin.Database.extra-tags-to-scan">Szukaj dodatkowych znaczników</message>
@@ -116,7 +119,7 @@
<plural case="2">{1} błędów:</plural>
</message>
<message id="Lms.Admin.ScannerController.force-optimize">Wymuś optymalizację bazy danych</message>
<message id="Lms.Admin.ScannerController.full-scan">Ponownie przeskanuj wszystkie pliki</message>
<message id="Lms.Admin.ScannerController.full-scan">Wymuś skanowanie wszystkich plików</message>
<message id="Lms.Admin.ScannerController.get-report">Pokaż raport</message>
<message id="Lms.Admin.ScannerController.last-scan">Ostatnie skanowanie</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Niedostępne</message>
@@ -150,6 +153,7 @@
</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">Pobieranie danych o ścieżce z AcousticBrainz: {1}/{2} ścieżek ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-optimize">Optymalizowanie bazy danych... {1}%...</message>
<message id="Lms.Admin.ScannerController.step-reconciliate-artists">Uzgodnianie artystów: {1} wpisów...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">Przeładowywanie silnika podobieństw: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-removing-orphaned-entries">Usuwanie osieroconych wpisów: {1} wpisów...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Skanowanie plików: {1}/{2} ({3}%)...</message>
+4
View File
@@ -34,6 +34,7 @@
<message id="Lms.user">用户</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator">,</message>
@@ -73,6 +74,7 @@
<!--Scan settings-->
<message id="Lms.Admin.Database.daily">每日</message>
@@ -133,6 +135,7 @@
<message id="Lms.Admin.ScannerController.step-discovering-files">检索文件中: {1} 文件</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">从 AcousticBrainz 获取音轨特征: {1}/{2} 音轨 ({3}%)...</message>
<message id="Lms.Admin.ScannerController.step-reloading-similarity-engine">重载相似引擎中 {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">扫描文件中: {1}/{2} 个文件 ({3}%)...</message>
@@ -353,6 +356,7 @@
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
<!--Wt-->
<message id="Wt.WMessageBox.Yes"></message>
<message id="Wt.WMessageBox.No"></message>
+1 -1
View File
@@ -100,7 +100,7 @@ namespace lms::core
};
}
std::string PartialDateTime::toISO8601String() const
std::string PartialDateTime::toString() const
{
if (_precision == Precision::Invalid)
return "";
+4 -1
View File
@@ -471,7 +471,7 @@ namespace lms::core::stringUtils
if (dateTime.isValid())
{
// assume UTC
return dateTime.toString("yyyy-MM-ddThh:mm:ss.zzz", false).toUTF8();
return dateTime.toString("yyyy-MM-ddThh:mm:ss.zzz", false).toUTF8() + 'Z';
}
return "";
@@ -491,6 +491,9 @@ namespace lms::core::stringUtils
Wt::WDateTime fromISO8601String(std::string_view dateTime)
{
// assume UTC
if (!dateTime.empty() && dateTime.back() == 'Z')
dateTime.remove_suffix(1);
return Wt::WDateTime::fromString(Wt::WString{ std::string{ dateTime } }, "yyyy-MM-ddThh:mm:ss.zzz");
}
@@ -41,7 +41,7 @@ namespace lms::core
static PartialDateTime fromString(std::string_view str);
static PartialDateTime fromWtDateTime(const Wt::WDateTime& dateTime);
std::string toISO8601String() const;
std::string toString() const;
constexpr bool isValid() const { return _precision != Precision::Invalid; }
@@ -38,4 +38,7 @@ namespace lms::core
private:
T _value{};
};
template<typename Tag>
using TaggedBool = TaggedType<Tag, bool>;
} // namespace lms::core
+16 -16
View File
@@ -61,21 +61,21 @@ namespace lms::core::stringUtils::tests
TEST(PartialDateTime, stringComparison)
{
EXPECT_EQ((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3, 27 }.toISO8601String()));
EXPECT_EQ((PartialDateTime{ 1992, 3 }.toISO8601String()), (PartialDateTime{ 1992, 3 }.toISO8601String()));
EXPECT_EQ(PartialDateTime{ 1992 }.toISO8601String(), PartialDateTime{ 1992 }.toISO8601String());
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3 }.toISO8601String()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992 }.toISO8601String()));
EXPECT_NE((PartialDateTime{ 1992, 3 }.toISO8601String()), (PartialDateTime{ 1992 }.toISO8601String()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3, 28 }.toISO8601String()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 4, 27 }.toISO8601String()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String()));
EXPECT_GT((PartialDateTime{ 1993, 3, 28 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String()));
EXPECT_GT((PartialDateTime{ 1993, 4 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String()));
EXPECT_GT((PartialDateTime{ 1994 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 3, 28 }.toISO8601String()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 4 }.toISO8601String()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1994 }.toISO8601String()));
EXPECT_EQ((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3, 27 }.toString()));
EXPECT_EQ((PartialDateTime{ 1992, 3 }.toString()), (PartialDateTime{ 1992, 3 }.toString()));
EXPECT_EQ(PartialDateTime{ 1992 }.toString(), PartialDateTime{ 1992 }.toString());
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3 }.toString()), (PartialDateTime{ 1992 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3, 28 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 4, 27 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1993, 3, 28 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1993, 4 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1994 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1993, 3, 28 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1993, 4 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1994 }.toString()));
}
TEST(PartialDateTime, stringConversions)
@@ -120,7 +120,7 @@ namespace lms::core::stringUtils::tests
for (const TestCase& test : tests)
{
const PartialDateTime dateTime{ PartialDateTime::fromString(test.input) };
EXPECT_EQ(dateTime.toISO8601String(), test.expectedOutput) << "Input = '" << test.input;
EXPECT_EQ(dateTime.toString(), test.expectedOutput) << "Input = '" << test.input;
}
}
} // namespace lms::core::stringUtils::tests
+3 -2
View File
@@ -316,12 +316,12 @@ namespace lms::core::stringUtils::tests
{
{
const Wt::WDateTime dateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 75 } };
EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T09:08:11.075");
EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T09:08:11.075Z");
}
{
const Wt::WDateTime dateTime{ Wt::WDate{ 2020, 01, 03 } };
EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T00:00:00.000");
EXPECT_EQ(toISO8601String(dateTime), "2020-01-03T00:00:00.000Z");
}
{
@@ -332,6 +332,7 @@ namespace lms::core::stringUtils::tests
TEST(Stringutils, DateTimeFromString)
{
EXPECT_EQ(fromISO8601String("2020-01-03T09:08:11.075Z"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 75 } }));
EXPECT_EQ(fromISO8601String("2020-01-03T09:08:11.075"), (Wt::WDateTime{ Wt::WDate{ 2020, 01, 03 }, Wt::WTime{ 9, 8, 11, 75 } }));
EXPECT_EQ(fromISO8601String("2020-01-03"), Wt::WDateTime{});
EXPECT_EQ(fromISO8601String(""), Wt::WDateTime{});
+1
View File
@@ -5,6 +5,7 @@ add_library(lmsdatabase STATIC
impl/Cluster.cpp
impl/Db.cpp
impl/Directory.cpp
impl/IdType.cpp
impl/Image.cpp
impl/Listen.cpp
impl/MediaLibrary.cpp
+35 -24
View File
@@ -256,30 +256,6 @@ namespace lms::db
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<Artist>>("SELECT a FROM artist a").where("a.id = ?").bind(id));
}
bool Artist::exists(Session& session, ArtistId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT 1 FROM artist").where("id = ?").bind(id)) == 1;
}
RangeResults<ArtistId> Artist::findOrphanIds(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<ArtistId>(R"(SELECT DISTINCT a.id FROM artist a
WHERE NOT EXISTS (
SELECT 1
FROM track t
INNER JOIN track_artist_link t_a_l
ON t_a_l.artist_id = a.id
WHERE t.id = t_a_l.track_id
)
AND NOT EXISTS (
SELECT 1
FROM artist_info ai
WHERE ai.artist_id = a.id))") };
return utils::execRangeQuery<ArtistId>(query, range);
}
RangeResults<ArtistId> Artist::findIds(Session& session, const FindParameters& params)
{
session.checkReadTransaction();
@@ -304,6 +280,41 @@ AND NOT EXISTS (
utils::forEachQueryRangeResult(query, params.range, func);
}
RangeResults<ArtistId> Artist::findOrphanIds(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<ArtistId>(R"(SELECT DISTINCT a.id FROM artist a
WHERE NOT EXISTS (
SELECT 1
FROM track t
INNER JOIN track_artist_link t_a_l
ON t_a_l.artist_id = a.id
WHERE t.id = t_a_l.track_id
)
AND NOT EXISTS (
SELECT 1
FROM artist_info ai
WHERE ai.artist_id = a.id))") };
return utils::execRangeQuery<ArtistId>(query, range);
}
bool Artist::exists(Session& session, ArtistId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT 1 FROM artist").where("id = ?").bind(id)) == 1;
}
std::optional<core::UUID> Artist::getMBID() const
{
return core::UUID::fromString(_mbid);
}
bool Artist::hasMBID() const
{
// TODO optim this
return getMBID().has_value();
}
ObjectPtr<Image> Artist::getImage() const
{
return ObjectPtr<Image>{ _image };
+40 -1
View File
@@ -70,7 +70,7 @@ namespace lms::db
void ArtistInfo::find(Session& session, ArtistId id, const std::function<void(const pointer&)>& func)
{
find(session, id, std::nullopt, std::move(func));
find(session, id, std::nullopt, func);
}
void ArtistInfo::find(Session& session, ArtistInfoId& lastRetrievedId, std::size_t count, const std::function<void(const pointer&)>& func)
@@ -85,6 +85,45 @@ namespace lms::db
});
}
void ArtistInfo::findArtistNameNoLongerMatch(Session& session, std::optional<Range> range, const std::function<void(const pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<ArtistInfo>>("SELECT a_i FROM artist_info a_i") };
query.join("artist a ON a_i.artist_id = a.id");
query.where("a_i.mbid_matched = FALSE");
query.where("a_i.name <> a.name");
utils::applyRange(query, range);
utils::forEachQueryResult(query, [&](const pointer& info) {
func(info);
});
}
void ArtistInfo::findWithArtistNameAmbiguity(Session& session, std::optional<Range> range, bool allowArtistMBIDFallback, const std::function<void(const pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<ArtistInfo>>("SELECT a_i FROM artist_info a_i") };
query.join("artist a ON a_i.artist_id = a.id");
query.where("a_i.mbid_matched = FALSE");
if (!allowArtistMBIDFallback)
{
query.where("a.mbid <> ''");
}
else
{
query.where(R"(
(a.mbid <> '' AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '' AND a2.mbid <> a.mbid))
OR (a.mbid = '' AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '') = 1))");
}
utils::applyRange(query, range);
utils::forEachQueryResult(query, [&](const pointer& info) {
func(info);
});
}
Artist::pointer ArtistInfo::getArtist() const
{
return _artist;
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2021 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/IdType.hpp"
#include <cassert>
#include <type_traits>
#include <Wt/Dbo/ptr.h>
namespace lms::db
{
static_assert(std::is_same_v<IdType::ValueType, Wt::Dbo::dbo_default_traits::IdType>);
IdType::IdType()
: _id{ Wt::Dbo::dbo_default_traits::invalidId() }
{
}
IdType::IdType(ValueType id)
: _id{ id }
{
}
bool IdType::isValid() const
{
return _id != Wt::Dbo::dbo_default_traits::invalidId();
}
std::string IdType::toString() const
{
assert(isValid());
return std::to_string(_id);
}
} // namespace lms::db
+34 -1
View File
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 85 };
static constexpr Version LMS_DATABASE_VERSION{ 88 };
}
VersionInfo::VersionInfo()
@@ -1159,6 +1159,36 @@ FROM tracklist)");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV85(Session& session)
{
dropIndexes(session);
// Artist merging feature
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN allow_mbid_artist_merge BOLLEAN DEFAULT(false)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link ADD COLUMN artist_name TEXT NULL DEFAULT('')");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link ADD COLUMN artist_sort_name TEXT NULL DEFAULT('')");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link ADD COLUMN artist_mbid_matched BOOLEAN NOT NULL DEFAULT(false)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_info ADD COLUMN name TEXT NULL DEFAULT('')");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_info ADD COLUMN sort_name TEXT NULL DEFAULT('')");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_info ADD COLUMN mbid_matched BOOLEAN NOT NULL DEFAULT(false)");
// Just increment the scan version of the settings to make the next scan rescan everything
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV86(Session& session)
{
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN name TEXT NON NULL DEFAULT('')");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings RENAME COLUMN scan_version TO audio_scan_version");
}
void migrateFromV87(Session& session)
{
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN artists_to_not_split TEXT NON NULL DEFAULT('')");
}
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1220,6 +1250,9 @@ FROM tracklist)");
{ 82, migrateFromV82 },
{ 83, migrateFromV83 },
{ 84, migrateFromV84 },
{ 85, migrateFromV85 },
{ 86, migrateFromV86 },
{ 87, migrateFromV87 },
};
bool migrationPerformed{};
+47 -19
View File
@@ -26,24 +26,33 @@
#include "database/Session.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
#include "traits/StringViewTraits.hpp"
namespace lms::db
{
void ScanSettings::init(Session& session)
ScanSettings::ScanSettings(std::string_view name)
: _name{ name }
{
session.checkWriteTransaction();
if (pointer settings{ get(session) })
return;
session.getDboSession()->add(std::make_unique<ScanSettings>());
}
ScanSettings::pointer ScanSettings::get(Session& session)
ScanSettings::pointer ScanSettings::create(Session& session, std::string_view name)
{
return session.getDboSession()->add(std::unique_ptr<ScanSettings>(new ScanSettings{ name }));
}
ScanSettings::pointer ScanSettings::find(Session& session, ScanSettingsId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<ScanSettings>());
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<ScanSettings>>("SELECT s_s from scan_settings s_s").where("s_s.id = ?").bind(id));
}
ScanSettings::pointer ScanSettings::find(Session& session, std::string_view name)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<ScanSettings>().where("name = ?").bind(name));
}
std::vector<std::string_view> ScanSettings::getExtraTagsToScan() const
@@ -65,13 +74,19 @@ namespace lms::db
return core::stringUtils::splitEscapedStrings(_defaultTagDelimiters, ';', '\\');
}
std::vector<std::string> ScanSettings::getArtistsToNotSplit() const
{
return core::stringUtils::splitEscapedStrings(_artistsToNotSplit, ';', '\\');
}
void ScanSettings::setExtraTagsToScan(std::span<const std::string_view> extraTags)
{
std::string newTagsToScan{ core::stringUtils::joinStrings(extraTags, ";") };
if (newTagsToScan != _extraTagsToScan)
incScanVersion();
_extraTagsToScan = std::move(newTagsToScan);
{
_extraTagsToScan.swap(newTagsToScan);
incAudioScanVersion();
}
}
void ScanSettings::setArtistTagDelimiters(std::span<const std::string_view> delimiters)
@@ -80,7 +95,17 @@ namespace lms::db
if (tagDelimiters != _artistTagDelimiters)
{
_artistTagDelimiters.swap(tagDelimiters);
incScanVersion();
incAudioScanVersion();
}
}
void ScanSettings::setArtistsToNotSplit(std::span<const std::string_view> artists)
{
std::string artistsToNotSplit{ core::stringUtils::escapeAndJoinStrings(artists, ';', '\\') };
if (artistsToNotSplit != _artistsToNotSplit)
{
_artistsToNotSplit.swap(artistsToNotSplit);
incAudioScanVersion();
}
}
@@ -90,21 +115,24 @@ namespace lms::db
if (tagDelimiters != _defaultTagDelimiters)
{
_defaultTagDelimiters.swap(tagDelimiters);
incScanVersion();
incAudioScanVersion();
}
}
void ScanSettings::setSkipSingleReleasePlayLists(bool value)
{
if (_skipSingleReleasePlayLists != value)
{
_skipSingleReleasePlayLists = value;
incScanVersion();
}
}
void ScanSettings::incScanVersion()
void ScanSettings::setAllowMBIDArtistMerge(bool value)
{
_scanVersion += 1;
if (_allowMBIDArtistMerge != value)
_allowMBIDArtistMerge = value;
}
void ScanSettings::incAudioScanVersion()
{
_audioScanVersion += 1;
}
} // namespace lms::db
+6 -2
View File
@@ -180,7 +180,9 @@ namespace lms::db
// TODO: move this elsewhere
{
auto uniqueTransaction{ createWriteTransaction() };
ScanSettings::init(*this);
if (!ScanSettings::find(*this))
create<ScanSettings>();
}
return migrationPerformed;
@@ -195,13 +197,14 @@ 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_idx ON artist(name)");
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)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_info_path_idx ON artist_info(absolute_file_path)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_info_directory_id_idx ON artist_info(directory_id)");
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 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)");
@@ -296,6 +299,7 @@ namespace lms::db
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_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)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_type_idx ON track_artist_link(artist_id, type)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)");
+71 -4
View File
@@ -55,28 +55,41 @@ namespace lms::db
}
} // namespace
TrackArtistLink::TrackArtistLink(ObjectPtr<Track> track, ObjectPtr<Artist> artist, TrackArtistLinkType type, std::string_view subType)
TrackArtistLink::TrackArtistLink(const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, std::string_view subType, bool artistMBIDMatched)
: _type{ type }
, _subType{ subType }
, _artistMBIDMatched{ artistMBIDMatched }
, _track{ getDboPtr(track) }
, _artist{ getDboPtr(artist) }
{
}
TrackArtistLink::pointer TrackArtistLink::create(Session& session, ObjectPtr<Track> track, ObjectPtr<Artist> artist, TrackArtistLinkType type, std::string_view subType)
TrackArtistLink::pointer TrackArtistLink::create(Session& session, const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, std::string_view subType, bool artistMBIDMatched)
{
session.checkWriteTransaction();
TrackArtistLink::pointer res{ session.getDboSession()->add(std::make_unique<TrackArtistLink>(track, artist, type, subType)) };
TrackArtistLink::pointer res{ session.getDboSession()->add(std::make_unique<TrackArtistLink>(track, artist, type, subType, artistMBIDMatched)) };
session.getDboSession()->flush();
return res;
}
std::size_t TrackArtistLink::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM track_artist_link"));
}
TrackArtistLink::pointer TrackArtistLink::create(Session& session, const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, bool artistMBIDMatched)
{
return create(session, track, artist, type, std::string_view{}, artistMBIDMatched);
}
TrackArtistLink::pointer TrackArtistLink::find(Session& session, TrackArtistLinkId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackArtistLink>().where("id = ?").bind(id));
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<TrackArtistLink>>("SELECT t_a_l from track_artist_link t_a_l").where("t_a_l.id = ?").bind(id));
}
void TrackArtistLink::find(Session& session, TrackId trackId, const std::function<void(const TrackArtistLink::pointer& link, const ObjectPtr<Artist>& artist)>& func)
@@ -113,4 +126,58 @@ namespace lms::db
});
return res;
}
void TrackArtistLink::findArtistNameNoLongerMatch(Session& session, std::optional<Range> range, const std::function<void(const TrackArtistLink::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackArtistLink>>("SELECT t_a_l FROM track_artist_link t_a_l") };
query.join("artist a ON t_a_l.artist_id = a.id");
query.where("t_a_l.artist_mbid_matched = FALSE");
query.where("t_a_l.artist_name <> a.name");
utils::applyRange(query, range);
utils::forEachQueryResult(query, [&](const TrackArtistLink::pointer& link) {
func(link);
});
}
void TrackArtistLink::findWithArtistNameAmbiguity(Session& session, std::optional<Range> range, bool allowArtistMBIDFallback, const std::function<void(const TrackArtistLink::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackArtistLink>>("SELECT t_a_l FROM track_artist_link t_a_l") };
query.join("artist a ON t_a_l.artist_id = a.id");
query.where("t_a_l.artist_mbid_matched = FALSE");
if (!allowArtistMBIDFallback)
{
query.where("a.mbid <> ''");
}
else
{
query.where(R"(
(a.mbid <> '' AND EXISTS (SELECT 1 FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '' AND a2.mbid <> a.mbid))
OR (a.mbid = '' AND (SELECT COUNT(*) FROM artist a2 WHERE a2.name = a.name AND a2.mbid <> '') = 1))");
}
utils::applyRange(query, range);
utils::forEachQueryResult(query, [&](const TrackArtistLink::pointer& link) {
func(link);
});
}
void TrackArtistLink::setArtist(ObjectPtr<Artist> artist)
{
_artist = getDboPtr(artist);
}
void TrackArtistLink::setArtistName(std::string_view artistName)
{
_artistName = artistName;
}
void TrackArtistLink::setArtistSortName(std::string_view artistSortName)
{
_artistSortName = artistSortName;
}
} // namespace lms::db
@@ -38,7 +38,7 @@ namespace Wt::Dbo
if (!dateTime.isValid())
statement->bindNull(column);
else
statement->bind(column, dateTime.toISO8601String());
statement->bind(column, dateTime.toString());
}
static bool read(lms::core::PartialDateTime& dateTime, SqlStatement* statement, int column, int size)
@@ -134,8 +134,10 @@ namespace lms::db
// Accessors
const std::string& getName() const { return _name; }
const std::string& getSortName() const { return _sortName; }
std::optional<core::UUID> getMBID() const { return core::UUID::fromString(_mbid); }
std::optional<core::UUID> getMBID() const;
bool hasMBID() const;
ObjectPtr<Image> getImage() const;
void visitLinks(std::function<void(const ObjectPtr<TrackArtistLink>& link)> visitor) const;
// No artistLinkTypes means get them all
RangeResults<ArtistId> findSimilarArtistIds(core::EnumSet<TrackArtistLinkType> artistLinkTypes = {}, std::optional<Range> range = std::nullopt) const;
@@ -51,6 +51,8 @@ namespace lms::db
static void find(Session& session, ArtistId id, const std::function<void(const pointer&)>& func);
static pointer find(Session& session, const std::filesystem::path& path);
static void find(Session& session, ArtistInfoId& lastRetrievedId, std::size_t count, const std::function<void(const pointer&)>& func);
static void findArtistNameNoLongerMatch(Session& session, std::optional<Range> range, const std::function<void(const pointer&)>& func);
static void findWithArtistNameAmbiguity(Session& session, std::optional<Range> range, bool allowArtistMBIDFallback, const std::function<void(const pointer&)>& func);
// getters
const std::filesystem::path& getAbsoluteFilePath() const { return _absoluteFilePath; }
@@ -58,20 +60,26 @@ namespace lms::db
ObjectPtr<Directory> getDirectory() const;
ObjectPtr<Artist> getArtist() const;
DirectoryId getDirectoryId() const { return _directory.id(); }
std::string_view getName() const { return _name; }
std::string_view getSortName() const { return _name; }
std::string_view getType() const { return _type; }
std::string_view getGender() const { return _gender; }
std::string_view getDisambiguation() const { return _disambiguation; }
std::string_view getBiography() const { return _biography; }
bool isMBIDMatched() const { return _MBIDMatched; }
// setters
void setAbsoluteFilePath(const std::filesystem::path& filePath);
void setLastWriteTime(Wt::WDateTime time) { _fileLastWrite = time; }
void setDirectory(ObjectPtr<Directory> directory);
void setArtist(ObjectPtr<Artist> artist);
void setName(std::string_view name) { _name = name; }
void setSortName(std::string_view sortName) { _sortName = sortName; }
void setType(std::string_view type) { _type = type; }
void setGender(std::string_view gender) { _gender = gender; }
void setDisambiguation(std::string_view disambiguation) { _disambiguation = disambiguation; }
void setBiography(std::string_view biography) { _biography = biography; };
void setMBIDMatched(bool matched) { _MBIDMatched = matched; }
template<class Action>
void persist(Action& a)
@@ -79,11 +87,15 @@ namespace lms::db
Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path");
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::field(a, _sortName, "sort_name");
Wt::Dbo::field(a, _type, "type");
Wt::Dbo::field(a, _gender, "gender");
Wt::Dbo::field(a, _disambiguation, "disambiguation");
Wt::Dbo::field(a, _biography, "biography");
Wt::Dbo::field(a, _MBIDMatched, "mbid_matched");
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade);
}
@@ -97,11 +109,18 @@ namespace lms::db
std::string _fileStem;
Wt::WDateTime _fileLastWrite;
// this info may be redondant with what found in the linked artist
// but we actually need them in case of artist merge/split
std::string _name;
std::string _sortName;
std::string _type;
std::string _gender;
std::string _disambiguation;
std::string _biography;
bool _MBIDMatched{};
Wt::Dbo::ptr<Directory> _directory;
Wt::Dbo::ptr<Artist> _artist;
};
@@ -20,6 +20,7 @@
#pragma once
#include <span>
#include <vector>
#include "database/ClusterId.hpp"
#include "database/LabelId.hpp"
+6 -14
View File
@@ -19,9 +19,6 @@
#pragma once
#include <Wt/Dbo/ptr.h>
#include <cassert>
#include <functional>
#include <string>
namespace lms::db
@@ -29,24 +26,19 @@ namespace lms::db
class IdType
{
public:
using ValueType = Wt::Dbo::dbo_default_traits::IdType;
using ValueType = long long;
IdType() = default;
IdType(ValueType id)
: _id{ id } {}
IdType();
IdType(ValueType id);
bool isValid() const { return _id != Wt::Dbo::dbo_default_traits::invalidId(); }
std::string toString() const
{
assert(isValid());
return std::to_string(_id);
}
bool isValid() const;
std::string toString() const;
ValueType getValue() const { return _id; }
auto operator<=>(const IdType& other) const = default;
private:
Wt::Dbo::dbo_default_traits::IdType _id{ Wt::Dbo::dbo_default_traits::invalidId() };
ValueType _id;
};
#define LMS_DECLARE_IDTYPE(name) \
@@ -19,7 +19,6 @@
#pragma once
#include <filesystem>
#include <span>
#include <string>
#include <string_view>
@@ -58,19 +57,22 @@ namespace lms::db
None,
};
static void init(Session& session);
ScanSettings() = default;
static pointer get(Session& session);
static pointer find(Session& session, std::string_view name = "");
static pointer find(Session& session, ScanSettingsId id);
// Getters
std::size_t getScanVersion() const { return _scanVersion; }
std::size_t getAudioScanVersion() const { return _audioScanVersion; }
Wt::WTime getUpdateStartTime() const { return _startTime; }
UpdatePeriod getUpdatePeriod() const { return _updatePeriod; }
std::vector<std::string_view> getExtraTagsToScan() const;
SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; }
std::vector<std::string> getArtistTagDelimiters() const;
std::vector<std::string> getDefaultTagDelimiters() const;
std::vector<std::string> getArtistsToNotSplit() const;
bool getSkipSingleReleasePlayLists() const { return _skipSingleReleasePlayLists; }
bool getAllowMBIDArtistMerge() const { return _allowMBIDArtistMerge; }
// Setters
void setUpdateStartTime(Wt::WTime t) { _startTime = t; }
@@ -78,31 +80,45 @@ namespace lms::db
void setExtraTagsToScan(std::span<const std::string_view> extraTags);
void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; }
void setArtistTagDelimiters(std::span<const std::string_view> delimiters);
void setArtistsToNotSplit(std::span<const std::string_view> artists);
void setDefaultTagDelimiters(std::span<const std::string_view> delimiters);
void setSkipSingleReleasePlayLists(bool value);
void incScanVersion();
void setAllowMBIDArtistMerge(bool value);
template<class Action>
void persist(Action& a)
{
Wt::Dbo::field(a, _scanVersion, "scan_version");
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::field(a, _audioScanVersion, "audio_scan_version");
Wt::Dbo::field(a, _startTime, "start_time");
Wt::Dbo::field(a, _updatePeriod, "update_period");
Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type");
Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan");
Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters");
Wt::Dbo::field(a, _artistsToNotSplit, "artists_to_not_split");
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");
}
private:
int _scanVersion{};
friend class Session;
ScanSettings(std::string_view name);
static pointer create(Session& session, std::string_view name = "");
void incAudioScanVersion();
std::string _name;
int _audioScanVersion{};
Wt::WTime _startTime = Wt::WTime{ 0, 0, 0 };
UpdatePeriod _updatePeriod{ UpdatePeriod::Never };
SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters };
std::string _extraTagsToScan;
std::string _artistTagDelimiters;
std::string _artistsToNotSplit;
std::string _defaultTagDelimiters;
bool _skipSingleReleasePlayLists{ false };
bool _skipSingleReleasePlayLists{};
bool _allowMBIDArtistMerge{};
};
} // namespace lms::db
@@ -80,32 +80,51 @@ namespace lms::db
};
TrackArtistLink() = default;
TrackArtistLink(ObjectPtr<Track> track, ObjectPtr<Artist> artist, TrackArtistLinkType type, std::string_view subType);
TrackArtistLink(const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, std::string_view subType, bool artistMBIDMatched);
static void find(Session& session, TrackId trackId, const std::function<void(const TrackArtistLink::pointer&, const ObjectPtr<Artist>&)>&);
static void find(Session& session, const FindParameters& parameters, const std::function<void(const TrackArtistLink::pointer&)>&);
static void find(Session& session, TrackId trackId, const std::function<void(const pointer&, const ObjectPtr<Artist>&)>& func);
static void find(Session& session, const FindParameters& parameters, const std::function<void(const pointer&)>& func);
static pointer find(Session& session, TrackArtistLinkId linkId);
static pointer create(Session& session, ObjectPtr<Track> track, ObjectPtr<Artist> artist, TrackArtistLinkType type, std::string_view subType = {});
static std::size_t getCount(Session& session);
static pointer create(Session& session, const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, std::string_view subType, bool artistMBIDMatched = false);
static pointer create(Session& session, const ObjectPtr<Track>& track, const ObjectPtr<Artist>& artist, TrackArtistLinkType type, bool artistMBIDMatched = false);
static core::EnumSet<TrackArtistLinkType> findUsedTypes(Session& session, ArtistId _artist);
static void findArtistNameNoLongerMatch(Session& session, std::optional<Range> range, const std::function<void(const pointer&)>& func);
static void findWithArtistNameAmbiguity(Session& session, std::optional<Range> range, bool allowArtistMBIDFallback, const std::function<void(const pointer&)>& func);
// accessors
ObjectPtr<Track> getTrack() const { return _track; }
ObjectPtr<Artist> getArtist() const { return _artist; }
TrackArtistLinkType getType() const { return _type; }
std::string_view getSubType() const { return _subType; }
std::string_view getArtistName() const { return _artistName; }
std::string_view getArtistSortName() const { return _artistSortName; }
bool isArtistMBIDMatched() const { return _artistMBIDMatched; }
// setters
void setArtist(ObjectPtr<Artist> artist);
void setArtistName(std::string_view artistName);
void setArtistSortName(std::string_view artistSortName);
template<class Action>
void persist(Action& a)
{
Wt::Dbo::field(a, _type, "type");
Wt::Dbo::field(a, _subType, "subtype");
Wt::Dbo::field(a, _artistName, "artist_name");
Wt::Dbo::field(a, _artistSortName, "artist_sort_name");
Wt::Dbo::field(a, _artistMBIDMatched, "artist_mbid_matched");
Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade);
}
private:
TrackArtistLinkType _type;
TrackArtistLinkType _type{ TrackArtistLinkType::Artist };
std::string _subType;
std::string _artistName; // as it was in the tags
std::string _artistSortName; // as it was in the tags
bool _artistMBIDMatched{};
Wt::Dbo::ptr<Track> _track;
Wt::Dbo::ptr<Artist> _artist;
+99
View File
@@ -109,4 +109,103 @@ namespace lms::db::tests
EXPECT_TRUE(visited);
}
}
TEST_F(DatabaseFixture, ArtistInfo_findArtistNameNoLongerMatch)
{
ScopedArtistInfo artistInfo{ session };
ScopedArtist artist{ session, "MyArtist" };
{
auto transaction{ session.createWriteTransaction() };
artistInfo.get().modify()->setArtist(artist.get());
artistInfo.get().modify()->setName("MyArtist");
artistInfo.get().modify()->setMBIDMatched(false);
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findArtistNameNoLongerMatch(session, std::nullopt, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
{
auto transaction{ session.createWriteTransaction() };
artist.get().modify()->setName("MyArtist2");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findArtistNameNoLongerMatch(session, std::nullopt, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
}
TEST_F(DatabaseFixture, ArtistInfo_findWithArtistNameAmbiguity_split)
{
ScopedArtistInfo artistInfo1{ session };
ScopedArtist artist1{ session, "MyArtist", core::UUID::fromString("b227426f-98b8-4b39-b3a7-ff25e7711e9b") };
{
auto transaction{ session.createWriteTransaction() };
artistInfo1.get().modify()->setArtist(artist1.get());
artistInfo1.get().modify()->setName("MyArtist");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
ScopedArtist artist2{ session, "MyArtist", core::UUID::fromString("97d1fb6f-db09-4760-b0b3-816559bcb632") };
ScopedArtistInfo artistInfo2{ session };
{
auto transaction{ session.createWriteTransaction() };
artistInfo2.get().modify()->setArtist(artist2.get());
artistInfo2.get().modify()->setName("MyArtist");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
ArtistInfo::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const ArtistInfo::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
}
} // namespace lms::db::tests
+2
View File
@@ -15,10 +15,12 @@ add_executable(test-database
RatedRelease.cpp
RatedTrack.cpp
Release.cpp
ScanSettings.cpp
StarredArtist.cpp
StarredRelease.cpp
StarredTrack.cpp
Track.cpp
TrackArtistLink.cpp
TrackBookmark.cpp
TrackEmbeddedImage.cpp
TrackFeatures.cpp
+4
View File
@@ -31,9 +31,11 @@
#include "database/RatedArtist.hpp"
#include "database/RatedRelease.hpp"
#include "database/RatedTrack.hpp"
#include "database/ScanSettings.hpp"
#include "database/StarredArtist.hpp"
#include "database/StarredRelease.hpp"
#include "database/StarredTrack.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackLyrics.hpp"
@@ -361,10 +363,12 @@ VALUES
EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{}));
EXPECT_FALSE(Release::find(session, ReleaseId{}));
EXPECT_FALSE(ReleaseType::find(session, ReleaseTypeId{}));
EXPECT_FALSE(ScanSettings::find(session, ScanSettingsId{}));
EXPECT_FALSE(StarredArtist::find(session, StarredArtistId{}));
EXPECT_FALSE(StarredRelease::find(session, StarredReleaseId{}));
EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{}));
EXPECT_FALSE(Track::find(session, TrackId{}));
EXPECT_FALSE(TrackArtistLink::find(session, TrackArtistLinkId{}));
EXPECT_FALSE(TrackList::find(session, TrackListId{}));
EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{}));
EXPECT_FALSE(UIState::find(session, UIStateId{}));
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2021 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/ScanSettings.hpp"
#include <initializer_list>
#include "Common.hpp"
namespace lms::db::tests
{
using ScopedScanSettings = ScopedEntity<db::ScanSettings>;
TEST_F(DatabaseFixture, ScanSettings)
{
ScopedScanSettings settings{ session, "test" };
{
auto transaction{ session.createReadTransaction() };
const auto artists{ settings.get()->getArtistsToNotSplit() };
ASSERT_EQ(artists.size(), 0);
}
{
auto transaction{ session.createWriteTransaction() };
settings.get().modify()->setArtistsToNotSplit(std::initializer_list<std::string_view>{ "AC/DC", "My/Artist" });
}
{
auto transaction{ session.createReadTransaction() };
const auto artists{ settings.get()->getArtistsToNotSplit() };
ASSERT_EQ(artists.size(), 2);
}
}
} // namespace lms::db::tests
+186
View File
@@ -0,0 +1,186 @@
/*
* 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 "Common.hpp"
#include "database/Types.hpp"
#include "database/TrackArtistLink.hpp"
namespace lms::db::tests
{
TEST_F(DatabaseFixture, TrackArtistLink_findArtistNameNoLongerMatch)
{
ScopedTrack track{ session };
ScopedArtist artist{ session, "MyArtist" };
{
auto transaction{ session.createWriteTransaction() };
auto link{ session.create<TrackArtistLink>(track.get(), artist.get(), TrackArtistLinkType::Artist, false) };
link.modify()->setArtistName("MyArtist");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findArtistNameNoLongerMatch(session, std::nullopt, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
{
auto transaction{ session.createWriteTransaction() };
artist.get().modify()->setName("MyArtist2");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findArtistNameNoLongerMatch(session, std::nullopt, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
}
TEST_F(DatabaseFixture, TrackArtistLink_findWithArtistNameAmbiguity_split)
{
ScopedTrack track{ session };
ScopedArtist artist1{ session, "MyArtist", core::UUID::fromString("b227426f-98b8-4b39-b3a7-ff25e7711e9b") };
{
auto transaction{ session.createWriteTransaction() };
auto link{ session.create<TrackArtistLink>(track.get(), artist1.get(), TrackArtistLinkType::Artist, false) };
link.modify()->setArtistName("MyArtist");
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
ScopedArtist artist2{ session, "MyArtist", core::UUID::fromString("97d1fb6f-db09-4760-b0b3-816559bcb632") };
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
{
auto transaction{ session.createReadTransaction() };
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
}
TEST_F(DatabaseFixture, TrackArtistLink_findWithArtistNameAmbiguity_merge)
{
ScopedTrack track{ session };
ScopedArtist artist1{ session, "MyArtist" };
{
auto transaction{ session.createWriteTransaction() };
auto link{ session.create<TrackArtistLink>(track.get(), artist1.get(), TrackArtistLinkType::Artist, false) };
link.modify()->setArtistName("MyArtist");
}
{
auto transaction{ session.createReadTransaction() };
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = false;
});
ASSERT_FALSE(visited);
}
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
}
ScopedArtist artist2{ session, "MyArtist", core::UUID::fromString("97d1fb6f-db09-4760-b0b3-816559bcb632") };
{
auto transaction{ session.createReadTransaction() };
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_TRUE(visited);
}
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
}
ScopedArtist artist3{ session, "MyArtist", core::UUID::fromString("3d46c4fb-110d-4d4f-a2d5-5ca57ef1d582") };
{
auto transaction{ session.createReadTransaction() };
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, true /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
{
bool visited{};
TrackArtistLink::findWithArtistNameAmbiguity(session, std::nullopt, false /*allow fallback*/, [&](const TrackArtistLink::pointer&) {
visited = true;
});
ASSERT_FALSE(visited);
}
}
}
} // namespace lms::db::tests
+6
View File
@@ -26,11 +26,17 @@ if (${LMS_IMAGE_BACKEND} STREQUAL "stb")
target_sources(lmsimage PRIVATE
impl/stb/Image.cpp
impl/stb/Exception.cpp
impl/stb/RawImage.cpp
impl/stb/StbImage.cpp
impl/stb/StbImageResize.cpp
impl/stb/StbImageWrite.cpp
)
set_property(SOURCE impl/stb/StbImage.cpp PROPERTY SKIP_UNITY_BUILD_INCLUSION ON)
set_property(SOURCE impl/stb/StbImageResize.cpp PROPERTY SKIP_UNITY_BUILD_INCLUSION ON)
set_property(SOURCE impl/stb/StbImageWrite.cpp PROPERTY SKIP_UNITY_BUILD_INCLUSION ON)
target_compile_options(lmsimage PRIVATE "-DSTB_IMAGE_RESIZE_VERSION=${STB_IMAGE_RESIZE_VERSION}")
target_include_directories(lmsimage PRIVATE ${STB_IMAGE_INCLUDE_DIR})
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Exception.hpp"
#include "StbImage.hpp"
namespace lms::image
{
StbiException::StbiException(std::string_view desc)
: Exception{ std::string{ desc } + ": " + getLastFailureReason() }
{
}
std::string StbiException::getLastFailureReason()
{
const char* failureReason{ ::stbi_failure_reason() };
return failureReason ? failureReason : "unknown reason";
}
} // namespace lms::image
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <string_view>
#include "image/Exception.hpp"
namespace lms::image
{
class StbiException : public Exception
{
public:
StbiException(std::string_view desc);
private:
static std::string getLastFailureReason();
};
} // namespace lms::image
+1
View File
@@ -21,6 +21,7 @@
#include <array>
#include "Exception.hpp"
#include "StbImage.hpp"
#include "StbImageWrite.hpp"
+1
View File
@@ -19,6 +19,7 @@
#include "RawImage.hpp"
#include "Exception.hpp"
#include "StbImage.hpp"
#include "StbImageResize.hpp"
-14
View File
@@ -19,17 +19,3 @@
#define STB_IMAGE_IMPLEMENTATION
#include "StbImage.hpp"
namespace lms::image
{
StbiException::StbiException(std::string_view desc)
: Exception{ std::string{ desc } + ": " + getLastFailureReason() }
{
}
std::string StbiException::getLastFailureReason()
{
const char* failureReason{ ::stbi_failure_reason() };
return failureReason ? failureReason : "unknown reason";
}
} // namespace lms::image
-16
View File
@@ -24,19 +24,3 @@
#define STBI_FAILURE_USERMSG
#include <stb_image.h>
#include <string_view>
#include "image/Exception.hpp"
namespace lms::image
{
class StbiException : public Exception
{
public:
StbiException(std::string_view desc);
private:
static std::string getLastFailureReason();
};
} // namespace lms::image
+2
View File
@@ -17,6 +17,8 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cstddef>
namespace lms::image
+7 -6
View File
@@ -23,6 +23,7 @@
#include <boost/property_tree/xml_parser.hpp>
#include "core/ILogger.hpp"
#include "core/String.hpp"
namespace lms::metadata
{
@@ -43,12 +44,12 @@ namespace lms::metadata
const auto& artistNode{ root.get_child("artist") };
artistInfo.mbid = core::UUID::fromString(artistNode.get_optional<std::string>("musicBrainzArtistID").value_or(""));
artistInfo.name = artistNode.get_optional<std::string>("name").value_or("");
artistInfo.sortName = artistNode.get_optional<std::string>("sortname").value_or("");
artistInfo.type = artistNode.get_optional<std::string>("type").value_or("");
artistInfo.gender = artistNode.get_optional<std::string>("gender").value_or("");
artistInfo.disambiguation = artistNode.get_optional<std::string>("disambiguation").value_or("");
artistInfo.mbid = core::UUID::fromString(core::stringUtils::stringTrim(artistNode.get_optional<std::string>("musicBrainzArtistID").value_or("")));
artistInfo.name = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("name").value_or(""));
artistInfo.sortName = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("sortname").value_or(""));
artistInfo.type = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("type").value_or(""));
artistInfo.gender = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("gender").value_or(""));
artistInfo.disambiguation = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("disambiguation").value_or(""));
artistInfo.biography = artistNode.get_optional<std::string>("biography").value_or("");
return artistInfo;
+21 -65
View File
@@ -62,36 +62,41 @@ namespace lms::metadata
}
template<typename T>
std::vector<T> getTagValuesFirstMatchAs(const ITagReader& tagReader, std::initializer_list<TagType> tagTypes, std::span<const std::string> tagDelimiters)
void addTagIfNonEmpty(std::vector<T>& res, std::string_view tag)
{
tag = core::stringUtils::stringTrim(tag);
if (tag.empty())
return;
if (std::optional<T> val{ core::stringUtils::readAs<T>(tag) })
res.emplace_back(std::move(*val));
}
template<typename T>
std::vector<T> getTagValuesFirstMatchAs(const ITagReader& tagReader, std::initializer_list<TagType> tagTypes, std::span<const std::string> tagDelimiters, const WhiteList* whitelist = nullptr)
{
std::vector<T> res;
for (const TagType tagType : tagTypes)
{
auto addTagIfNonEmpty{ [&res](std::string_view tag) {
tag = core::stringUtils::stringTrim(tag);
if (!tag.empty())
{
std::optional<T> val{ core::stringUtils::readAs<T>(tag) };
if (val)
res.emplace_back(std::move(*val));
}
} };
tagReader.visitTagValues(tagType, [&](std::string_view value) {
value = core::stringUtils::stringTrim(value);
if (!whitelist || !whitelist->contains(value))
{
for (std::string_view tagDelimiter : tagDelimiters)
{
if (value.find(tagDelimiter) != std::string_view::npos)
{
for (std::string_view splitTag : core::stringUtils::splitString(value, tagDelimiters))
addTagIfNonEmpty(splitTag);
addTagIfNonEmpty(res, splitTag);
return;
}
}
}
// no delimiter found, or no delimiter to be used
addTagIfNonEmpty(value);
addTagIfNonEmpty(res, value);
});
if (!res.empty())
@@ -153,11 +158,11 @@ namespace lms::metadata
std::initializer_list<TagType> artistMBIDTagNames,
const AudioFileParserParameters& params)
{
std::vector<std::string> artistNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistTagNames, params.artistTagDelimiters) };
std::vector<std::string> artistNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistTagNames, params.artistTagDelimiters, &params.artistsToNotSplit) };
if (artistNames.empty())
return {};
std::vector<std::string> artistSortNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistSortTagNames, params.artistTagDelimiters) };
std::vector<std::string> artistSortNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistSortTagNames, params.artistTagDelimiters, &params.artistsToNotSplit) };
std::vector<core::UUID> artistMBIDs{ getTagValuesFirstMatchAs<core::UUID>(tagReader, artistMBIDTagNames, params.defaultTagDelimiters) };
std::vector<Artist> artists;
@@ -238,6 +243,7 @@ namespace lms::metadata
// Otherwise, we reconstruct the string using a standard, hardcoded, join
if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames))
{
// Limitation: this test does not take the whitelist into account
if (!strIsContainingAny(*artistTag, artistTagDelimiters))
artistDisplayName = *artistTag;
}
@@ -267,54 +273,6 @@ namespace lms::metadata
return std::nullopt;
}
void fillInArtistsWithMbid(std::span<const Artist> artists, std::unordered_map<std::string_view, core::UUID>& artistsWithMbid)
{
for (const Artist& artist : artists)
{
if (artist.mbid.has_value())
{
// there may collisions, we don't want to replace
artistsWithMbid.emplace(artist.name, *artist.mbid);
}
}
}
void fillInMbids(std::span<Artist> artists, const std::unordered_map<std::string_view, core::UUID>& artistsWithMbid)
{
for (Artist& artist : artists)
{
if (!artist.mbid)
{
const auto it{ artistsWithMbid.find(artist.name) };
if (it != std::cend(artistsWithMbid))
artist.mbid = it->second;
}
}
}
void fillMissingMbids(Track& track)
{
// first pass: collect all artists that have mbids
std::unordered_map<std::string_view, core::UUID> artistsWithMbid;
// For now, mbids can only set in artist and album artist tags
// filling order is important: we estimate track-level artists are more likely
// to be set in other fields than album artists
fillInArtistsWithMbid(track.artists, artistsWithMbid);
if (track.medium && track.medium->release)
fillInArtistsWithMbid(track.medium->release->artists, artistsWithMbid);
// second pass: fill in all artists that have no mbid set with the same name
fillInMbids(track.conductorArtists, artistsWithMbid);
fillInMbids(track.composerArtists, artistsWithMbid);
fillInMbids(track.lyricistArtists, artistsWithMbid);
fillInMbids(track.mixerArtists, artistsWithMbid);
fillInMbids(track.producerArtists, artistsWithMbid);
fillInMbids(track.remixerArtists, artistsWithMbid);
for (auto& [role, artists] : track.performerArtists)
fillInMbids(artists, artistsWithMbid);
}
} // namespace
std::unique_ptr<IAudioFileParser> createAudioFileParser(const AudioFileParserParameters& params)
@@ -489,8 +447,6 @@ namespace lms::metadata
track.remixerArtists = getArtists(tagReader, { TagType::Remixers, TagType::Remixer }, { TagType::RemixersSortOrder, TagType::RemixerSortOrder }, {}, _params);
track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported
fillMissingMbids(track);
// If a file has originalDate but no originalYear, set it
if (!track.originalYear)
track.originalYear = track.originalDate.getYear();
+3 -3
View File
@@ -30,7 +30,7 @@ namespace lms::metadata
namespace
{
// Mapping to internal avformat names and/or common alternative custom names
static const std::unordered_map<TagType, std::vector<std::string>> tagMapping{
static const std::unordered_map<TagType, std::vector<std::string>> avFormatTagMapping{
{ TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
{ TagType::Advisory, { "ITUNESADVISORY" } },
{ TagType::Album, { "ALBUM", "TALB", "WM/ALBUMTITLE" } },
@@ -171,8 +171,8 @@ namespace lms::metadata
void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
{
auto itTagNames{ tagMapping.find(tag) };
if (itTagNames == std::cend(tagMapping))
auto itTagNames{ avFormatTagMapping.find(tag) };
if (itTagNames == std::cend(avFormatTagMapping))
return;
for (const std::string& tagName : itTagNames->second)
+2 -2
View File
@@ -48,7 +48,7 @@ namespace lms::metadata
{
namespace
{
class ParsingFailedException : public Exception
class ImageParsingFailedException : public Exception
{
};
@@ -362,7 +362,7 @@ namespace lms::metadata
if (_file.isNull())
{
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw ParsingFailedException{};
throw ImageParsingFailedException{};
}
}
+6 -6
View File
@@ -55,12 +55,12 @@ namespace lms::metadata
{
namespace
{
class ParsingFailedException : public Exception
class TagParsingFailedException : public Exception
{
};
// Mapping to internal taglib names and/or common alternative custom names
const std::unordered_map<TagType, std::vector<std::string>> tagMapping{
const std::unordered_map<TagType, std::vector<std::string>> tagLibTagMapping{
{ TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
{ TagType::Advisory, { "ITUNESADVISORY" } },
{ TagType::Album, { "ALBUM" } },
@@ -228,13 +228,13 @@ namespace lms::metadata
if (_file.isNull())
{
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw ParsingFailedException{};
throw TagParsingFailedException{};
}
if (!_file.audioProperties())
{
LMS_LOG(METADATA, ERROR, "File " << p << ": no audio properties");
throw ParsingFailedException{};
throw TagParsingFailedException{};
}
computeAudioProperties();
@@ -465,8 +465,8 @@ namespace lms::metadata
void TagLibTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
{
auto itTagNames{ tagMapping.find(tag) };
if (itTagNames == std::cend(tagMapping))
auto itTagNames{ tagLibTagMapping.find(tag) };
if (itTagNames == std::cend(tagLibTagMapping))
return;
for (const std::string& tagName : itTagNames->second)
@@ -25,6 +25,7 @@
#include <span>
#include <string>
#include <string_view>
#include <unordered_set>
#include <vector>
#include "core/PartialDateTime.hpp"
@@ -213,11 +214,28 @@ namespace lms::metadata
Accurate,
};
struct WhiteListHash : std::hash<std::string>, std::hash<std::string_view>
{
using is_transparent = void;
[[nodiscard]] size_t operator()(std::string_view str) const
{
return std::hash<std::string_view>{}(str);
}
[[nodiscard]] size_t operator()(const std::string& str) const
{
return std::hash<std::string>{}(str);
}
};
using WhiteList = std::unordered_set<std::string, WhiteListHash, std::equal_to<>>;
struct AudioFileParserParameters
{
ParserBackend backend{ ParserBackend::TagLib };
ParserReadStyle readStyle{ ParserReadStyle::Average };
std::vector<std::string> artistTagDelimiters;
WhiteList artistsToNotSplit;
std::vector<std::string> defaultTagDelimiters;
std::vector<std::string> userExtraTags;
bool debug{};
+18
View File
@@ -76,4 +76,22 @@ He moved from the UK to Montreal in 1984 to become resident DJ at a number of cl
ASSERT_EQ(artistInfo.disambiguation, "Timothy Taylor");
ASSERT_EQ(artistInfo.biography, "DJ and producer based in London, UK. Founder of Missile Records and Planet Of Drums.\r\n\r\nHe moved from the UK to Montreal in 1984 to become resident DJ at a number of clubs. In 1987, he began working as an A&R for JSE Agency & Management in New York, managing the likes of Tommy Musto, Frankie Bones, and The KLF. He also arranged and was tour manager for artists such as Womack & Womack, Jungle Brothers, Ice-T, and Guru Josh.");
}
TEST(ArtistInfo, trim)
{
std::istringstream is{ R"(<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<artist>
<name> My Artist </name>
<musicBrainzArtistID> 38811c52-85e3-4e2e-3319-ab7d9f2cfa5b </musicBrainzArtistID>
<sortname> Artist, My </sortname>
<disambiguation> My Artist </disambiguation>
</artist>)" };
const ArtistInfo artistInfo{ parseArtistInfo(is) };
EXPECT_EQ(artistInfo.mbid, core::UUID::fromString("38811c52-85e3-4e2e-3319-ab7d9f2cfa5b"));
EXPECT_EQ(artistInfo.name, "My Artist");
ASSERT_EQ(artistInfo.sortName, "Artist, My");
ASSERT_EQ(artistInfo.disambiguation, "My Artist");
}
} // namespace lms::metadata::tests
+49 -125
View File
@@ -295,11 +295,60 @@ namespace lms::metadata
// Release
ASSERT_TRUE(track->medium->release.has_value());
EXPECT_EQ(track->medium->release->name, "MyAlbum");
ASSERT_EQ(track->medium->release->artists.size(), 2);
EXPECT_EQ(track->medium->release->artists[0].name, "AlbumArtist1");
EXPECT_EQ(track->medium->release->artists[1].name, "AlbumArtist2");
EXPECT_EQ(track->medium->release->artistDisplayName, "AlbumArtist1, AlbumArtist2");
}
TEST(AudioFileParser, customArtistDelimiters_whitelist)
{
const TestTagReader testTags{
{
{ TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtist, { " AC/DC " } },
{ TagType::Artist, { "AC/DC " } },
}
};
AudioFileParserParameters params;
params.artistTagDelimiters = { "/" };
params.artistsToNotSplit = { "AC/DC" };
TestAudioFileParser parser{ params };
std::unique_ptr<Track> track{ parser.parseMetaData(testTags) };
ASSERT_EQ(track->artists.size(), 1);
EXPECT_EQ(track->artists[0].name, "AC/DC");
EXPECT_EQ(track->artistDisplayName, "AC/DC");
ASSERT_TRUE(track->medium.has_value());
ASSERT_TRUE(track->medium->release.has_value());
EXPECT_EQ(track->medium->release->name, "MyAlbum");
ASSERT_EQ(track->medium->release->artists.size(), 1);
EXPECT_EQ(track->medium->release->artists[0].name, "AC/DC");
EXPECT_EQ(track->medium->release->artistDisplayName, "AC/DC");
}
TEST(AudioFileParser, customArtistDelimiters_whitelist_multi)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "AC/DC and MyArtist" } },
{ TagType::Artists, { "AC/DC", "MyArtist" } },
}
};
AudioFileParserParameters params;
params.artistTagDelimiters = { "/" };
params.artistsToNotSplit = { "AC/DC" };
TestAudioFileParser parser{ params };
std::unique_ptr<Track> track{ parser.parseMetaData(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "AC/DC");
EXPECT_EQ(track->artists[1].name, "MyArtist");
EXPECT_EQ(track->artistDisplayName, "AC/DC, MyArtist"); // Reconstructed since this use case is not handled
}
TEST(AudioFileParser, customDelimiters_foundInArtist)
{
const TestTagReader testTags{
@@ -605,131 +654,6 @@ namespace lms::metadata
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstruct the artist display name
}
TEST(AudioFileParser, MBIDs_fallback)
{
TestTagReader testTags{
{
{ TagType::Artist, { "Artist1", "Artist2" } },
{ TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtists, { "Artist3", "Artist4" } },
{ TagType::MusicBrainzArtistID, { "6643f584-5edc-45ce-927d-0a4ab25c2673", "481c5912-bf1a-47f7-b03c-d34e49711706" } },
{ TagType::MusicBrainzReleaseArtistID, { "ed42bcaf-e147-4f34-8f26-d74acc97670a", "6fc64a4b-26f5-441f-993c-fd511290233b" } },
{ TagType::Composer, { "Artist1", "Artist3" } },
{ TagType::Conductor, { "Artist1", "Artist3" } },
{ TagType::Lyricist, { "Artist1", "Artist3" } },
{ TagType::Mixer, { "Artist1", "Artist3" } },
{ TagType::Producer, { "Artist1", "Artist3" } },
{ TagType::Remixers, { "Artist1", "Artist3" } },
}
};
testTags.setPerformersTags({ { "RoleA", { "Artist1", "Artist3" } },
{ "RoleB", { "Artist2", "Artist4" } } });
std::unique_ptr<Track> track{ TestAudioFileParser{}.parseMetaData(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
ASSERT_TRUE(track->artists[0].mbid.has_value());
EXPECT_EQ(track->artists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->artists[1].name, "Artist2");
ASSERT_TRUE(track->artists[1].mbid.has_value());
EXPECT_EQ(track->artists[1].mbid.value(), core::UUID::fromString("481c5912-bf1a-47f7-b03c-d34e49711706"));
ASSERT_TRUE(track->medium.has_value());
ASSERT_TRUE(track->medium->release.has_value());
ASSERT_EQ(track->medium->release->artists.size(), 2);
EXPECT_EQ(track->medium->release->artists[0].name, "Artist3");
ASSERT_TRUE(track->medium->release->artists[0].mbid.has_value());
EXPECT_EQ(track->medium->release->artists[0].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
EXPECT_EQ(track->medium->release->artists[1].name, "Artist4");
ASSERT_TRUE(track->medium->release->artists[1].mbid.has_value());
EXPECT_EQ(track->medium->release->artists[1].mbid.value(), core::UUID::fromString("6fc64a4b-26f5-441f-993c-fd511290233b"));
ASSERT_EQ(track->composerArtists.size(), 2);
EXPECT_EQ(track->composerArtists[0].name, "Artist1");
ASSERT_TRUE(track->composerArtists[0].mbid.has_value());
EXPECT_EQ(track->composerArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->composerArtists[1].name, "Artist3");
ASSERT_TRUE(track->composerArtists[1].mbid.has_value());
EXPECT_EQ(track->composerArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->conductorArtists.size(), 2);
EXPECT_EQ(track->conductorArtists[0].name, "Artist1");
ASSERT_TRUE(track->conductorArtists[0].mbid.has_value());
EXPECT_EQ(track->conductorArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->conductorArtists[1].name, "Artist3");
ASSERT_TRUE(track->conductorArtists[1].mbid.has_value());
EXPECT_EQ(track->conductorArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->lyricistArtists.size(), 2);
EXPECT_EQ(track->lyricistArtists[0].name, "Artist1");
ASSERT_TRUE(track->lyricistArtists[0].mbid.has_value());
EXPECT_EQ(track->lyricistArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->lyricistArtists[1].name, "Artist3");
ASSERT_TRUE(track->lyricistArtists[1].mbid.has_value());
EXPECT_EQ(track->lyricistArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->mixerArtists.size(), 2);
EXPECT_EQ(track->mixerArtists[0].name, "Artist1");
ASSERT_TRUE(track->mixerArtists[0].mbid.has_value());
EXPECT_EQ(track->mixerArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->mixerArtists[1].name, "Artist3");
ASSERT_TRUE(track->mixerArtists[1].mbid.has_value());
EXPECT_EQ(track->mixerArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->producerArtists.size(), 2);
EXPECT_EQ(track->producerArtists[0].name, "Artist1");
ASSERT_TRUE(track->producerArtists[0].mbid.has_value());
EXPECT_EQ(track->producerArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->producerArtists[1].name, "Artist3");
ASSERT_TRUE(track->producerArtists[1].mbid.has_value());
EXPECT_EQ(track->producerArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->remixerArtists.size(), 2);
EXPECT_EQ(track->remixerArtists[0].name, "Artist1");
ASSERT_TRUE(track->remixerArtists[0].mbid.has_value());
EXPECT_EQ(track->remixerArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->remixerArtists[1].name, "Artist3");
ASSERT_TRUE(track->remixerArtists[1].mbid.has_value());
EXPECT_EQ(track->remixerArtists[1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_TRUE(track->performerArtists.contains("Rolea"));
ASSERT_EQ(track->performerArtists["Rolea"].size(), 2);
EXPECT_EQ(track->performerArtists["Rolea"][0].name, "Artist1");
ASSERT_TRUE(track->performerArtists["Rolea"][0].mbid.has_value());
EXPECT_EQ(track->performerArtists["Rolea"][0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
EXPECT_EQ(track->performerArtists["Rolea"][1].name, "Artist3");
ASSERT_TRUE(track->performerArtists["Rolea"][1].mbid.has_value());
EXPECT_EQ(track->performerArtists["Rolea"][1].mbid.value(), core::UUID::fromString("ed42bcaf-e147-4f34-8f26-d74acc97670a"));
ASSERT_EQ(track->performerArtists["Roleb"].size(), 2);
EXPECT_EQ(track->performerArtists["Roleb"][0].name, "Artist2");
ASSERT_TRUE(track->performerArtists["Roleb"][0].mbid.has_value());
EXPECT_EQ(track->performerArtists["Roleb"][0].mbid.value(), core::UUID::fromString("481c5912-bf1a-47f7-b03c-d34e49711706"));
EXPECT_EQ(track->performerArtists["Roleb"][1].name, "Artist4");
ASSERT_TRUE(track->performerArtists["Roleb"][1].mbid.has_value());
EXPECT_EQ(track->performerArtists["Roleb"][1].mbid.value(), core::UUID::fromString("6fc64a4b-26f5-441f-993c-fd511290233b"));
}
TEST(AudioFileParser, MBIDs_fallback_priority)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "Artist1" } },
{ TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtists, { "Artist1" } },
{ TagType::MusicBrainzArtistID, { "6643f584-5edc-45ce-927d-0a4ab25c2673" } },
{ TagType::MusicBrainzReleaseArtistID, { "ed42bcaf-e147-4f34-8f26-d74acc97670a" } },
{ TagType::Composer, { "Artist1" } },
}
};
std::unique_ptr<Track> track{ TestAudioFileParser{}.parseMetaData(testTags) };
ASSERT_EQ(track->composerArtists.size(), 1);
EXPECT_EQ(track->composerArtists[0].name, "Artist1");
ASSERT_TRUE(track->composerArtists[0].mbid.has_value());
EXPECT_EQ(track->composerArtists[0].mbid.value(), core::UUID::fromString("6643f584-5edc-45ce-927d-0a4ab25c2673"));
}
TEST(AudioFileParser, release_sortNameFallback)
{
const TestTagReader testTags{
@@ -20,6 +20,8 @@
#pragma once
#include <atomic>
#include <cassert>
#include <memory>
#include <optional>
#include <shared_mutex>
#include <unordered_map>
@@ -27,7 +29,6 @@
#include "database/ImageId.hpp"
#include "database/TrackEmbeddedImageId.hpp"
#include "database/TrackId.hpp"
#include "image/IEncodedImage.hpp"
namespace lms::cover
@@ -19,6 +19,7 @@
#pragma once
#include <memory>
#include <string_view>
#include "database/UserId.hpp"
@@ -19,6 +19,8 @@
#pragma once
#include <memory>
#include "database/StarredArtistId.hpp"
#include "database/StarredReleaseId.hpp"
#include "database/StarredTrackId.hpp"
@@ -36,7 +36,7 @@ namespace lms::recommendation
{
auto transaction{ session.createReadTransaction() };
return db::ScanSettings::get(session)->getSimilarityEngineType();
return db::ScanSettings::find(session)->getSimilarityEngineType();
}
} // namespace
+4
View File
@@ -1,15 +1,19 @@
add_library(lmsscanner STATIC
impl/helpers/ArtistHelpers.cpp
impl/scanners/ArtistInfoFileScanner.cpp
impl/scanners/AudioFileScanOperation.cpp
impl/scanners/AudioFileScanner.cpp
impl/scanners/ImageFileScanner.cpp
impl/scanners/LyricsFileScanner.cpp
impl/scanners/PlayListFileScanner.cpp
impl/scanners/Utils.cpp
impl/steps/FileScanQueue.cpp
impl/steps/ScanStepArtistReconciliation.cpp
impl/steps/ScanStepAssociateArtistImages.cpp
impl/steps/ScanStepAssociateExternalLyrics.cpp
impl/steps/ScanStepAssociatePlayListTracks.cpp
impl/steps/ScanStepAssociateReleaseImages.cpp
impl/steps/ScanStepBase.cpp
impl/steps/ScanStepCheckForDuplicatedFiles.cpp
impl/steps/ScanStepCheckForRemovedFiles.cpp
impl/steps/ScanStepCompact.cpp
+110 -62
View File
@@ -28,6 +28,7 @@
#include "core/ITraceLogger.hpp"
#include "database/MediaLibrary.hpp"
#include "database/ScanSettings.hpp"
#include "database/Session.hpp"
#include "scanners/ArtistInfoFileScanner.hpp"
#include "scanners/AudioFileScanner.hpp"
@@ -35,6 +36,7 @@
#include "scanners/LyricsFileScanner.hpp"
#include "scanners/PlayListFileScanner.hpp"
#include "steps/ScanStepArtistReconciliation.hpp"
#include "steps/ScanStepAssociateArtistImages.hpp"
#include "steps/ScanStepAssociateExternalLyrics.hpp"
#include "steps/ScanStepAssociatePlayListTracks.hpp"
@@ -55,6 +57,9 @@ namespace lms::scanner
namespace
{
static constexpr std::string_view currentSettingsName{ "" };
static constexpr std::string_view lastScanSettingsName{ "last_scan" };
Wt::WDate getNextMonday(Wt::WDate current)
{
do
@@ -74,6 +79,61 @@ namespace lms::scanner
return current;
}
std::optional<ScannerSettings> readScannerSettings(db::Session& session, std::string_view name)
{
std::optional<ScannerSettings> settings;
auto transaction{ session.createReadTransaction() };
const ScanSettings::pointer scanSettings{ ScanSettings::find(session, name) };
if (!scanSettings)
return settings;
settings.emplace();
settings->audioScanVersion = scanSettings->getAudioScanVersion();
settings->startTime = scanSettings->getUpdateStartTime();
settings->updatePeriod = scanSettings->getUpdatePeriod();
MediaLibrary::find(session, [&](const MediaLibrary::pointer& mediaLibrary) {
MediaLibraryInfo info;
info.firstScan = mediaLibrary->isEmpty();
info.id = mediaLibrary->getId();
info.rootDirectory = mediaLibrary->getPath().lexically_normal();
settings->mediaLibraries.push_back(info);
});
{
const auto& tags{ scanSettings->getExtraTagsToScan() };
std::transform(std::cbegin(tags), std::cend(tags), std::back_inserter(settings->extraTags), [](std::string_view tag) { return std::string{ tag }; });
}
settings->artistTagDelimiters = scanSettings->getArtistTagDelimiters();
settings->defaultTagDelimiters = scanSettings->getDefaultTagDelimiters();
settings->artistsToNotSplit = scanSettings->getArtistsToNotSplit();
settings->skipSingleReleasePlayLists = scanSettings->getSkipSingleReleasePlayLists();
settings->allowArtistMBIDFallback = scanSettings->getAllowMBIDArtistMerge();
// TODO, store this in DB + expose in UI
settings->skipDuplicateTrackMBID = core::Service<core::IConfig>::get()->getBool("scanner-skip-duplicate-mbid", false);
return settings;
}
void writeScannerSettings(db::Session& session, std::string_view name, const ScannerSettings& settings)
{
auto transaction{ session.createWriteTransaction() };
ScanSettings::pointer scanSettings{ ScanSettings::find(session, name) };
if (!scanSettings)
scanSettings = session.create<ScanSettings>(name);
scanSettings.modify()->setAllowMBIDArtistMerge(settings.allowArtistMBIDFallback);
scanSettings.modify()->setSkipSingleReleasePlayLists(settings.skipSingleReleasePlayLists);
// TODO add more fields
}
} // namespace
std::unique_ptr<IScannerService> createScannerService(Db& db)
@@ -282,26 +342,7 @@ namespace lms::scanner
ScanStats& stats{ scanContext.stats };
stats.startTime = Wt::WDateTime::currentDateTime();
std::size_t stepIndex{};
for (auto& scanStep : _scanSteps)
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", scanStep->getStepName());
LMS_LOG(DBUPDATER, DEBUG, "Starting scan step '" << scanStep->getStepName() << "'");
scanContext.currentStepStats = ScanStepStats{
.startTime = Wt::WDateTime::currentDateTime(),
.stepCount = _scanSteps.size(),
.stepIndex = stepIndex++,
.currentStep = scanStep->getStep(),
.totalElems = 0,
.processedElems = 0
};
notifyInProgress(scanContext.currentStepStats);
scanStep->process(scanContext);
notifyInProgress(scanContext.currentStepStats);
LMS_LOG(DBUPDATER, DEBUG, "Completed scan step '" << scanStep->getStepName() << "'");
}
processScanSteps(scanContext);
{
std::unique_lock lock{ _statusMutex };
@@ -321,6 +362,10 @@ namespace lms::scanner
_lastCompleteScanStats = stats;
}
// save current settings as last scan settings to compare during next scans if something changed
writeScannerSettings(_db.getTLSSession(), lastScanSettingsName, _settings);
_lastScanSettings = _settings;
LMS_LOG(DBUPDATER, DEBUG, "Scan not aborted, scheduling next scan!");
scheduleNextScan();
@@ -332,23 +377,61 @@ namespace lms::scanner
}
}
void ScannerService::processScanSteps(ScanContext& context)
{
std::size_t stepIndex{};
for (auto& scanStep : _scanSteps)
{
context.currentStepStats = ScanStepStats{
.startTime = Wt::WDateTime::currentDateTime(),
.stepCount = _scanSteps.size(),
.stepIndex = stepIndex++,
.currentStep = scanStep->getStep(),
.totalElems = 0,
.processedElems = 0
};
if (_abortScan)
break;
if (!scanStep->needProcess(context))
{
LMS_LOG(DBUPDATER, DEBUG, "Skipping scan step '" << scanStep->getStepName() << "'");
continue;
}
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", scanStep->getStepName());
LMS_LOG(DBUPDATER, DEBUG, "Starting scan step '" << scanStep->getStepName() << "'");
notifyInProgress(context.currentStepStats);
scanStep->process(context);
notifyInProgress(context.currentStepStats);
LMS_LOG(DBUPDATER, DEBUG, "Completed scan step '" << scanStep->getStepName() << "'");
}
}
}
void ScannerService::refreshScanSettings()
{
ScannerSettings newSettings{ readSettings() };
if (_settings == newSettings)
std::optional<ScannerSettings> newSettings{ readScannerSettings(_db.getTLSSession(), currentSettingsName) };
assert(newSettings.has_value());
if (_settings == *newSettings)
return;
LMS_LOG(DBUPDATER, DEBUG, "Scanner settings updated");
LMS_LOG(DBUPDATER, DEBUG, "Using scan settings version " << newSettings.scanVersion);
LMS_LOG(DBUPDATER, DEBUG, "Using audio scan settings version " << newSettings->audioScanVersion);
_settings = std::move(newSettings);
_settings = std::move(*newSettings);
if (!_lastScanSettings)
_lastScanSettings = readScannerSettings(_db.getTLSSession(), lastScanSettingsName);
auto cbFunc{ [this](const ScanStepStats& stats) {
notifyInProgressIfNeeded(stats);
} };
_fileScanners.clear();
_fileScanners.emplace_back(std::make_unique<ArtistInfoFileScanner>(_db));
_fileScanners.emplace_back(std::make_unique<ArtistInfoFileScanner>(_settings, _db));
_fileScanners.emplace_back(std::make_unique<AudioFileScanner>(_db, _settings));
_fileScanners.emplace_back(std::make_unique<ImageFileScanner>(_db));
_fileScanners.emplace_back(std::make_unique<LyricsFileScanner>(_db));
@@ -359,6 +442,7 @@ namespace lms::scanner
ScanStepBase::InitParams params{
.settings = _settings,
.lastScanSettings = _lastScanSettings.has_value() ? &(_lastScanSettings.value()) : nullptr,
.progressCallback = cbFunc,
.abortScan = _abortScan,
.db = _db,
@@ -370,6 +454,7 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique<ScanStepDiscoverFiles>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepScanFiles>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepCheckForRemovedFiles>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepArtistReconciliation>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepAssociatePlayListTracks>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepUpdateLibraryFields>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepAssociateArtistImages>(params));
@@ -382,43 +467,6 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique<ScanStepCheckForDuplicatedFiles>(params));
}
ScannerSettings ScannerService::readSettings()
{
ScannerSettings newSettings;
newSettings.skipDuplicateMBID = core::Service<core::IConfig>::get()->getBool("scanner-skip-duplicate-mbid", false);
{
auto transaction{ _db.getTLSSession().createReadTransaction() };
const ScanSettings::pointer scanSettings{ ScanSettings::get(_db.getTLSSession()) };
newSettings.scanVersion = scanSettings->getScanVersion();
newSettings.startTime = scanSettings->getUpdateStartTime();
newSettings.updatePeriod = scanSettings->getUpdatePeriod();
MediaLibrary::find(_db.getTLSSession(), [&](const MediaLibrary::pointer& mediaLibrary) {
MediaLibraryInfo info;
info.firstScan = mediaLibrary->isEmpty();
info.id = mediaLibrary->getId();
info.rootDirectory = mediaLibrary->getPath().lexically_normal();
newSettings.mediaLibraries.push_back(info);
});
{
const auto& tags{ scanSettings->getExtraTagsToScan() };
std::transform(std::cbegin(tags), std::cend(tags), std::back_inserter(newSettings.extraTags), [](std::string_view tag) { return std::string{ tag }; });
}
newSettings.artistTagDelimiters = scanSettings->getArtistTagDelimiters();
newSettings.defaultTagDelimiters = scanSettings->getDefaultTagDelimiters();
newSettings.skipSingleReleasePlayLists = scanSettings->getSkipSingleReleasePlayLists();
}
return newSettings;
}
void ScannerService::notifyInProgress(const ScanStepStats& stepStats)
{
{
@@ -39,6 +39,9 @@ namespace lms::scanner
{
class IFileScanner;
// Main goals to keepthe scanner fast:
// - single pass on files: only 1 filesystem exploration must be done (no further reads triggered by parsed values)
// - stable: 1 single scan (full or not) is enough: successive scans must have no effect if there is no change in the files
class ScannerService : public IScannerService
{
public:
@@ -65,12 +68,12 @@ namespace lms::scanner
// Update database (scheduled callback)
void scan(const ScanOptions& scanOptions);
void processScanSteps(ScanContext& context);
void scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats);
// Helpers
void refreshScanSettings();
ScannerSettings readSettings();
void notifyInProgressIfNeeded(const ScanStepStats& stats);
void notifyInProgress(const ScanStepStats& stats);
@@ -93,5 +96,6 @@ namespace lms::scanner
Wt::WDateTime _nextScheduledScan;
ScannerSettings _settings;
std::optional<ScannerSettings> _lastScanSettings;
};
} // namespace lms::scanner
@@ -35,14 +35,16 @@ namespace lms::scanner
struct ScannerSettings
{
std::size_t scanVersion{};
std::size_t audioScanVersion{};
Wt::WTime startTime;
db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never };
bool skipDuplicateMBID{};
bool skipDuplicateTrackMBID{};
std::vector<std::string> extraTags;
std::vector<std::string> artistTagDelimiters;
std::vector<std::string> artistsToNotSplit;
std::vector<std::string> defaultTagDelimiters;
bool skipSingleReleasePlayLists{};
bool allowArtistMBIDFallback{ true }; // TODO false?
std::vector<MediaLibraryInfo> mediaLibraries;
@@ -0,0 +1,151 @@
/*
* 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 "ArtistHelpers.hpp"
#include "core/ILogger.hpp"
#include "database/Session.hpp"
#include "metadata/Types.hpp"
namespace lms::scanner::helpers
{
namespace
{
db::Artist::pointer createArtist(db::Session& session, const metadata::Artist& artistInfo)
{
db::Artist::pointer artist{ session.create<db::Artist>(artistInfo.name) };
if (artistInfo.mbid)
artist.modify()->setMBID(artistInfo.mbid);
artist.modify()->setSortName(artistInfo.sortName ? *artistInfo.sortName : artistInfo.name);
return artist;
}
std::string optionalMBIDAsString(const std::optional<core::UUID>& uuid)
{
return uuid ? std::string{ uuid->getAsString() } : "<no MBID>";
}
void updateArtistIfNeeded(db::Artist::pointer artist, const metadata::Artist& artistInfo)
{
// MBID may be set
if (artist->getMBID() != artistInfo.mbid)
artist.modify()->setMBID(artistInfo.mbid);
// Name may have been updated
if (artist->getName() != artistInfo.name)
{
LMS_LOG(DBUPDATER, DEBUG, "Artist [" << optionalMBIDAsString(artist->getMBID()) << "], updated name from '" << artist->getName() << "' to '" << artistInfo.name << "'");
artist.modify()->setName(artistInfo.name);
}
// Sortname may have been updated
// As the sort name is quite often not filled in, we update it only if already set (for now?)
if (artistInfo.sortName && *artistInfo.sortName != artist->getSortName())
{
LMS_LOG(DBUPDATER, DEBUG, "Artist [" << optionalMBIDAsString(artist->getMBID()) << "], updated sort name from '" << artist->getSortName() << "' to '" << *artistInfo.sortName << "'");
artist.modify()->setSortName(*artistInfo.sortName);
}
}
} // namespace
db::Artist::pointer getOrCreateArtistByMBID(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries)
{
assert(artistInfo.mbid.has_value());
db::Artist::pointer artist{ db::Artist::find(session, *artistInfo.mbid) };
if (artist)
{
updateArtistIfNeeded(artist, artistInfo);
}
else
{
if (allowFallbackOnMBIDEntries.value())
{
// an artist with the same name may already exist, let's recycle it
for (const db::Artist::pointer& artistWithSameName : db::Artist::find(session, artistInfo.name))
{
if (!artistWithSameName->hasMBID())
{
artist = artistWithSameName;
updateArtistIfNeeded(artist, artistInfo);
break;
}
}
}
if (!artist)
artist = createArtist(session, artistInfo);
}
return artist;
}
db::Artist::pointer getOrCreateArtistByName(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries)
{
db::Artist::pointer artist;
// Here we can have only one artist with no MBID, others all have mbids
const std::vector<db::Artist::pointer> artistsWithSameName{ db::Artist::find(session, artistInfo.name) };
const auto itArtistWithoutMBID{ std::find_if(std::begin(artistsWithSameName), std::end(artistsWithSameName), [](const db::Artist::pointer& artist) { return !artist->hasMBID(); }) };
const std::size_t artistCountWithMBID{ artistsWithSameName.size() - (itArtistWithoutMBID != std::end(artistsWithSameName) ? 1 : 0) };
if (!allowFallbackOnMBIDEntries.value() || artistCountWithMBID > 1)
{
if (itArtistWithoutMBID != std::end(artistsWithSameName))
{
artist = *itArtistWithoutMBID;
updateArtistIfNeeded(artist, artistInfo);
}
else
artist = createArtist(session, artistInfo);
}
else
{
const auto itArtistWithMBID{ std::find_if(std::begin(artistsWithSameName), std::end(artistsWithSameName), [](const db::Artist::pointer& artist) { return artist->hasMBID(); }) };
if (itArtistWithMBID != std::end(artistsWithSameName))
{
artist = *itArtistWithMBID;
// not updating artist here: consider metadata quality is less good
}
else if (itArtistWithoutMBID != std::end(artistsWithSameName))
{
artist = *itArtistWithoutMBID;
updateArtistIfNeeded(artist, artistInfo);
}
else
artist = createArtist(session, artistInfo);
}
return artist;
}
db::Artist::pointer getOrCreateArtist(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries)
{
// First try to get by MBID
if (artistInfo.mbid)
return getOrCreateArtistByMBID(session, artistInfo, allowFallbackOnMBIDEntries);
// Fall back on artist name (collisions may occur)
return getOrCreateArtistByName(session, artistInfo, allowFallbackOnMBIDEntries);
}
} // namespace lms::scanner::helpers
@@ -0,0 +1,38 @@
/*
* 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 "core/TaggedType.hpp"
#include "database/Artist.hpp"
namespace lms::metadata
{
struct Artist;
}
namespace lms::scanner::helpers
{
using AllowFallbackOnMBIDEntry = core::TaggedBool<struct AllowFallbackOnMBIDEntryTag>;
db::Artist::pointer getOrCreateArtistByMBID(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries);
db::Artist::pointer getOrCreateArtistByName(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries);
db::Artist::pointer getOrCreateArtist(db::Session& session, const metadata::Artist& artistInfo, AllowFallbackOnMBIDEntry allowFallbackOnMBIDEntries);
} // namespace lms::scanner::helpers
@@ -32,7 +32,10 @@
#include "IFileScanOperation.hpp"
#include "ScanContext.hpp"
#include "ScannerSettings.hpp"
#include "Utils.hpp"
#include "helpers/ArtistHelpers.hpp"
#include "metadata/Types.hpp"
namespace lms::scanner
{
@@ -41,10 +44,13 @@ namespace lms::scanner
class ArtistInfoFileScanOperation : public IFileScanOperation
{
public:
ArtistInfoFileScanOperation(const FileToScan& file, db::Db& db)
ArtistInfoFileScanOperation(const FileToScan& file, const ScannerSettings& settings, db::Db& db)
: _file{ file.file }
, _mediaLibrary{ file.mediaLibrary }
, _db{ db } {}
, _settings{ settings }
, _db{ db }
{
}
~ArtistInfoFileScanOperation() override = default;
ArtistInfoFileScanOperation(const ArtistInfoFileScanOperation&) = delete;
ArtistInfoFileScanOperation& operator=(const ArtistInfoFileScanOperation&) = delete;
@@ -59,6 +65,7 @@ namespace lms::scanner
const std::filesystem::path _file;
const MediaLibraryInfo _mediaLibrary;
const ScannerSettings& _settings;
db::Db& _db;
std::optional<metadata::ArtistInfo> _parsedArtistInfo;
@@ -76,12 +83,7 @@ namespace lms::scanner
}
_parsedArtistInfo = metadata::parseArtistInfo(ifs);
if (!_parsedArtistInfo->mbid.has_value())
{
LMS_LOG(DBUPDATER, DEBUG, "Discarding artist info in file " << _file << ": no mbid set");
_parsedArtistInfo.reset();
}
else if (_parsedArtistInfo->name.empty())
if (_parsedArtistInfo->name.empty())
{
LMS_LOG(DBUPDATER, DEBUG, "Discarding artist info in file " << _file << ": no name set");
_parsedArtistInfo.reset();
@@ -125,6 +127,8 @@ namespace lms::scanner
artistInfo.modify()->setAbsoluteFilePath(_file);
}
artistInfo.modify()->setName(_parsedArtistInfo->name);
artistInfo.modify()->setSortName(_parsedArtistInfo->sortName);
artistInfo.modify()->setLastWriteTime(fileInfo->lastWriteTime);
artistInfo.modify()->setType(_parsedArtistInfo->type);
artistInfo.modify()->setGender(_parsedArtistInfo->gender);
@@ -134,12 +138,8 @@ namespace lms::scanner
db::MediaLibrary::pointer mediaLibrary{ db::MediaLibrary::find(dbSession, _mediaLibrary.id) }; // may be null if settings are updated in // => next scan will correct this
artistInfo.modify()->setDirectory(utils::getOrCreateDirectory(dbSession, _file.parent_path(), mediaLibrary));
db::Artist::pointer artist{ db::Artist::find(dbSession, *_parsedArtistInfo->mbid) };
if (!artist)
artist = dbSession.create<db::Artist>(_parsedArtistInfo->name, _parsedArtistInfo->mbid);
artist.modify()->setName(_parsedArtistInfo->name);
artist.modify()->setSortName(_parsedArtistInfo->sortName);
const metadata::Artist artistMetadata{ _parsedArtistInfo->mbid, _parsedArtistInfo->name, _parsedArtistInfo->sortName.empty() ? std::nullopt : std::make_optional<std::string>(_parsedArtistInfo->sortName) };
db::Artist::pointer artist{ helpers::getOrCreateArtist(dbSession, artistMetadata, helpers::AllowFallbackOnMBIDEntry{ _settings.allowArtistMBIDFallback }) };
artistInfo.modify()->setArtist(artist);
if (added)
@@ -155,8 +155,9 @@ namespace lms::scanner
}
} // namespace
ArtistInfoFileScanner::ArtistInfoFileScanner(db::Db& db)
: _db{ db }
ArtistInfoFileScanner::ArtistInfoFileScanner(const ScannerSettings& settings, db::Db& db)
: _settings{ settings }
, _db{ db }
{
}
@@ -202,6 +203,6 @@ namespace lms::scanner
std::unique_ptr<IFileScanOperation> ArtistInfoFileScanner::createScanOperation(const FileToScan& fileToScan) const
{
return std::make_unique<ArtistInfoFileScanOperation>(fileToScan, _db);
return std::make_unique<ArtistInfoFileScanOperation>(fileToScan, _settings, _db);
}
} // namespace lms::scanner
@@ -31,10 +31,12 @@ namespace lms
namespace lms::scanner
{
struct ScannerSettings;
class ArtistInfoFileScanner : public IFileScanner
{
public:
ArtistInfoFileScanner(db::Db& db);
ArtistInfoFileScanner(const ScannerSettings& _settings, db::Db& db);
~ArtistInfoFileScanner() override = default;
ArtistInfoFileScanner(const ArtistInfoFileScanner&) = delete;
ArtistInfoFileScanner& operator=(const ArtistInfoFileScanner&) = delete;
@@ -45,6 +47,7 @@ namespace lms::scanner
bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
const ScannerSettings& _settings;
db::Db& _db;
};
} // namespace lms::scanner
@@ -0,0 +1,744 @@
/*
* Copyright (C) 2024 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 "AudioFileScanOperation.hpp"
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/PartialDateTime.hpp"
#include "core/Path.hpp"
#include "core/XxHash3.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Db.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackFeatures.hpp"
#include "database/TrackLyrics.hpp"
#include "database/Types.hpp"
#include "image/Exception.hpp"
#include "image/Image.hpp"
#include "metadata/Exception.hpp"
#include "IFileScanOperation.hpp"
#include "ScanContext.hpp"
#include "ScannerSettings.hpp"
#include "Utils.hpp"
#include "helpers/ArtistHelpers.hpp"
namespace lms::scanner
{
namespace
{
void createTrackArtistLinks(db::Session& session, const db::Track::pointer& track, db::TrackArtistLinkType linkType, std::string_view role, std::span<const metadata::Artist> artists, helpers::AllowFallbackOnMBIDEntry allowArtistMBIDFallback)
{
for (const metadata::Artist& artistInfo : artists)
{
db::Artist::pointer artist{ helpers::getOrCreateArtist(session, artistInfo, allowArtistMBIDFallback) };
const bool matchedUsingMbid{ artist->getMBID() == artistInfo.mbid };
db::TrackArtistLink::pointer link{ session.create<db::TrackArtistLink>(track, artist, linkType, role, matchedUsingMbid) };
link.modify()->setArtistName(artistInfo.name);
if (artistInfo.sortName)
link.modify()->setArtistSortName(*artistInfo.sortName);
}
}
void createTrackArtistLinks(db::Session& session, const db::Track::pointer& track, db::TrackArtistLinkType linkType, std::span<const metadata::Artist> artists, helpers::AllowFallbackOnMBIDEntry allowArtistMBIDFallback)
{
constexpr std::string_view noRole{};
createTrackArtistLinks(session, track, linkType, noRole, artists, allowArtistMBIDFallback);
}
db::ReleaseType::pointer getOrCreateReleaseType(db::Session& session, std::string_view name)
{
db::ReleaseType::pointer releaseType{ db::ReleaseType::find(session, name) };
if (!releaseType)
releaseType = session.create<db::ReleaseType>(name);
return releaseType;
}
db::Country::pointer getOrCreateCountry(db::Session& session, std::string_view name)
{
db::Country::pointer country{ db::Country::find(session, name) };
if (!country)
country = session.create<db::Country>(name);
return country;
}
db::Label::pointer getOrCreateLabel(db::Session& session, std::string_view name)
{
db::Label::pointer label{ db::Label::find(session, name) };
if (!label)
label = session.create<db::Label>(name);
return label;
}
void updateReleaseIfNeeded(db::Session& session, db::Release::pointer release, const metadata::Release& releaseInfo)
{
if (release->getName() != releaseInfo.name)
release.modify()->setName(releaseInfo.name);
if (release->getSortName() != releaseInfo.sortName)
release.modify()->setSortName(releaseInfo.sortName);
if (release->getGroupMBID() != releaseInfo.groupMBID)
release.modify()->setGroupMBID(releaseInfo.groupMBID);
if (release->getTotalDisc() != releaseInfo.mediumCount)
release.modify()->setTotalDisc(releaseInfo.mediumCount);
if (release->getArtistDisplayName() != releaseInfo.artistDisplayName)
release.modify()->setArtistDisplayName(releaseInfo.artistDisplayName);
if (release->isCompilation() != releaseInfo.isCompilation)
release.modify()->setCompilation(releaseInfo.isCompilation);
if (release->getBarcode() != releaseInfo.barcode)
release.modify()->setBarcode(releaseInfo.barcode);
if (release->getComment() != releaseInfo.comment)
release.modify()->setComment(releaseInfo.comment);
if (release->getReleaseTypeNames() != releaseInfo.releaseTypes)
{
release.modify()->clearReleaseTypes();
for (std::string_view releaseType : releaseInfo.releaseTypes)
release.modify()->addReleaseType(getOrCreateReleaseType(session, releaseType));
}
if (release->getCountryNames() != releaseInfo.countries)
{
release.modify()->clearCountries();
for (std::string_view country : releaseInfo.countries)
release.modify()->addCountry(getOrCreateCountry(session, country));
}
if (release->getLabelNames() != releaseInfo.labels)
{
release.modify()->clearLabels();
for (std::string_view label : releaseInfo.labels)
release.modify()->addLabel(getOrCreateLabel(session, label));
}
}
// Compare release level info
bool isReleaseMatching(const db::Release::pointer& candidateRelease, const metadata::Release& releaseInfo)
{
// TODO: add more criterias?
return candidateRelease->getName() == releaseInfo.name
&& candidateRelease->getSortName() == releaseInfo.sortName
&& candidateRelease->getTotalDisc() == releaseInfo.mediumCount
&& candidateRelease->isCompilation() == releaseInfo.isCompilation
&& candidateRelease->getLabelNames() == releaseInfo.labels
&& candidateRelease->getBarcode() == releaseInfo.barcode;
}
db::Release::pointer getOrCreateRelease(db::Session& session, const metadata::Release& releaseInfo, const db::Directory::pointer& currentDirectory)
{
db::Release::pointer release;
// First try to get by MBID: fastest, safest
if (releaseInfo.mbid)
{
release = db::Release::find(session, *releaseInfo.mbid);
if (!release)
release = session.create<db::Release>(releaseInfo.name, releaseInfo.mbid);
}
else if (releaseInfo.name.empty())
{
// No release name (only mbid) -> nothing to do
return release;
}
// Fall back on release name (collisions may occur)
// First try using all sibling directories (case for Album/DiscX), only if the disc number is set
const db::DirectoryId parentDirectoryId{ currentDirectory->getParentDirectoryId() };
if (!release && releaseInfo.mediumCount && *releaseInfo.mediumCount > 1 && parentDirectoryId.isValid())
{
db::Release::FindParameters params;
params.setParentDirectory(parentDirectoryId);
params.setName(releaseInfo.name);
db::Release::find(session, params, [&](const db::Release::pointer& candidateRelease) {
// Already found a candidate
if (release)
return;
// Do not fallback on properly tagged releases
if (candidateRelease->getMBID().has_value())
return;
if (!isReleaseMatching(candidateRelease, releaseInfo))
return;
release = candidateRelease;
});
}
// Lastly try in the current directory: we do this at last to have
// opportunities to merge releases in case of migration / rescan
if (!release)
{
db::Release::FindParameters params;
params.setDirectory(currentDirectory->getId());
params.setName(releaseInfo.name);
db::Release::find(session, params, [&](const db::Release::pointer& candidateRelease) {
// Already found a candidate
if (release)
return;
// Do not fallback on properly tagged releases
if (candidateRelease->getMBID().has_value())
return;
if (!isReleaseMatching(candidateRelease, releaseInfo))
return;
release = candidateRelease;
});
}
if (!release)
release = session.create<db::Release>(releaseInfo.name);
updateReleaseIfNeeded(session, release, releaseInfo);
return release;
}
std::vector<db::Cluster::pointer> getOrCreateClusters(db::Session& session, const metadata::Track& track)
{
std::vector<db::Cluster::pointer> clusters;
auto getOrCreateClusters{ [&](std::string_view tag, std::span<const std::string> values) {
auto clusterType = db::ClusterType::find(session, tag);
if (!clusterType)
clusterType = session.create<db::ClusterType>(tag);
for (const auto& value : values)
{
auto cluster{ clusterType->getCluster(value) };
if (!cluster)
cluster = session.create<db::Cluster>(clusterType, value);
clusters.push_back(cluster);
}
} };
// TODO: migrate these fields in dedicated tables in DB
getOrCreateClusters("GENRE", track.genres);
getOrCreateClusters("MOOD", track.moods);
getOrCreateClusters("LANGUAGE", track.languages);
getOrCreateClusters("GROUPING", track.groupings);
for (const auto& [tag, values] : track.userExtraTags)
getOrCreateClusters(tag, values);
return clusters;
}
db::TrackLyrics::pointer createLyrics(db::Session& session, const metadata::Lyrics& lyricsInfo)
{
db::TrackLyrics::pointer lyrics{ session.create<db::TrackLyrics>() };
lyrics.modify()->setLanguage(!lyricsInfo.language.empty() ? lyricsInfo.language : "xxx");
lyrics.modify()->setOffset(lyricsInfo.offset);
lyrics.modify()->setDisplayArtist(lyricsInfo.displayArtist);
lyrics.modify()->setDisplayTitle(lyricsInfo.displayTitle);
if (!lyricsInfo.synchronizedLines.empty())
lyrics.modify()->setSynchronizedLines(lyricsInfo.synchronizedLines);
else
lyrics.modify()->setUnsynchronizedLines(lyricsInfo.unsynchronizedLines);
return lyrics;
}
db::ImageType convertImageType(metadata::Image::Type type)
{
switch (type)
{
case metadata::Image::Type::Unknown:
return db::ImageType::Unknown;
case metadata::Image::Type::Other:
return db::ImageType::Other;
case metadata::Image::Type::FileIcon:
return db::ImageType::FileIcon;
case metadata::Image::Type::OtherFileIcon:
return db::ImageType::OtherFileIcon;
case metadata::Image::Type::FrontCover:
return db::ImageType::FrontCover;
case metadata::Image::Type::BackCover:
return db::ImageType::BackCover;
case metadata::Image::Type::LeafletPage:
return db::ImageType::LeafletPage;
case metadata::Image::Type::Media:
return db::ImageType::Media;
case metadata::Image::Type::LeadArtist:
return db::ImageType::LeadArtist;
case metadata::Image::Type::Artist:
return db::ImageType::Artist;
case metadata::Image::Type::Conductor:
return db::ImageType::Conductor;
case metadata::Image::Type::Band:
return db::ImageType::Band;
case metadata::Image::Type::Composer:
return db::ImageType::Composer;
case metadata::Image::Type::Lyricist:
return db::ImageType::Lyricist;
case metadata::Image::Type::RecordingLocation:
return db::ImageType::RecordingLocation;
case metadata::Image::Type::DuringRecording:
return db::ImageType::DuringRecording;
case metadata::Image::Type::DuringPerformance:
return db::ImageType::DuringPerformance;
case metadata::Image::Type::MovieScreenCapture:
return db::ImageType::MovieScreenCapture;
case metadata::Image::Type::ColouredFish:
return db::ImageType::ColouredFish;
case metadata::Image::Type::Illustration:
return db::ImageType::Illustration;
case metadata::Image::Type::BandLogo:
return db::ImageType::BandLogo;
case metadata::Image::Type::PublisherLogo:
return db::ImageType::PublisherLogo;
}
return db::ImageType::Unknown;
}
db::TrackEmbeddedImage::pointer getOrCreateTrackEmbeddedImage(db::Session& session, const ImageInfo& imageInfo)
{
db::TrackEmbeddedImage::pointer image{ db::TrackEmbeddedImage::find(session, imageInfo.size, db::ImageHashType{ imageInfo.hash }) };
if (!image)
{
image = session.create<db::TrackEmbeddedImage>();
image.modify()->setSize(imageInfo.size);
image.modify()->setHash(db::ImageHashType{ imageInfo.hash });
image.modify()->setWidth(imageInfo.properties.width);
image.modify()->setHeight(imageInfo.properties.height);
image.modify()->setMimeType(imageInfo.mimeType);
}
return image;
}
db::TrackEmbeddedImageLink::pointer createTrackEmbeddedImageLink(db::Session& session, const db::Track::pointer& track, const ImageInfo& imageInfo)
{
const db::TrackEmbeddedImage::pointer image{ getOrCreateTrackEmbeddedImage(session, imageInfo) };
db::TrackEmbeddedImageLink::pointer imageLink{ session.create<db::TrackEmbeddedImageLink>(track, image) };
imageLink.modify()->setIndex(imageInfo.index);
imageLink.modify()->setType(convertImageType(imageInfo.type));
imageLink.modify()->setDescription(imageInfo.description);
return imageLink;
}
void updateEmbeddedImages(db::Session& session, db::Track::pointer& track, std::span<const ImageInfo> images)
{
db::TrackEmbeddedImageLink::pointer preferredImageLink;
track.modify()->clearEmbeddedImageLinks();
for (const ImageInfo& imageInfo : images)
{
db::TrackEmbeddedImageLink::pointer link{ createTrackEmbeddedImageLink(session, track, imageInfo) };
track.modify()->addEmbeddedImageLink(link);
if (!preferredImageLink
|| (preferredImageLink->getType() != db::ImageType::FrontCover && link->getType() == db::ImageType::FrontCover)
|| (preferredImageLink->getImage()->getSize() < link->getImage()->getSize()))
{
preferredImageLink = link;
}
}
if (preferredImageLink)
preferredImageLink.modify()->setIsPreferred(true);
}
db::Advisory getAdvisory(std::optional<metadata::Track::Advisory> advisory)
{
if (!advisory)
return db::Advisory::UnSet;
switch (advisory.value())
{
case metadata::Track::Advisory::Clean:
return db::Advisory::Clean;
case metadata::Track::Advisory::Explicit:
return db::Advisory::Explicit;
case metadata::Track::Advisory::Unknown:
return db::Advisory::Unknown;
}
return db::Advisory::UnSet;
}
db::Track::pointer findMovedTrackBySizeAndMetaData(db::Session& session, const metadata::Track& parsedTrack, const FileInfo& fileInfo)
{
db::Track::FindParameters params;
// Add as many fields as possible to limit errors
params.setName(parsedTrack.title);
if (parsedTrack.medium)
{
if (parsedTrack.medium->position)
params.setDiscNumber(*parsedTrack.medium->position);
if (parsedTrack.medium->release)
params.setReleaseName(parsedTrack.medium->release->name);
}
if (parsedTrack.position)
params.setTrackNumber(*parsedTrack.position);
params.setFileSize(fileInfo.fileSize);
bool error{};
db::Track::pointer res;
db::Track::find(session, params, [&](const db::Track::pointer& track) {
// Check that the track is truly no longer where it was during the last scan
std::error_code ec;
if (std::filesystem::exists(track->getAbsoluteFilePath(), ec))
return;
if (res)
{
LMS_LOG(DBUPDATER, DEBUG, "Found too many candidates for file move. New file = " << fileInfo.relativePath << ", candidate = " << track->getAbsoluteFilePath() << ", previous candidate = " << res->getAbsoluteFilePath());
error = true;
}
res = track;
});
if (error)
res = db::Track::pointer{};
return res;
}
void fillInArtistsWithMbid(std::span<const metadata::Artist> artists, std::unordered_map<std::string_view, core::UUID>& artistsWithMbid)
{
for (const metadata::Artist& artist : artists)
{
if (artist.mbid.has_value())
{
// there may collisions, we don't want to replace
artistsWithMbid.emplace(artist.name, *artist.mbid);
}
}
}
void fillInMbids(std::span<metadata::Artist> artists, const std::unordered_map<std::string_view, core::UUID>& artistsWithMbid)
{
for (metadata::Artist& artist : artists)
{
if (!artist.mbid)
{
const auto it{ artistsWithMbid.find(artist.name) };
if (it != std::cend(artistsWithMbid))
artist.mbid = it->second;
}
}
}
void fillMissingMbids(metadata::Track& track)
{
// first pass: collect all artists that have mbids
std::unordered_map<std::string_view, core::UUID> artistsWithMbid;
// For now, mbids can only set in artist and album artist tags
// filling order is important: we estimate track-level artists are more likely
// to be set in other fields than album artists
fillInArtistsWithMbid(track.artists, artistsWithMbid);
if (track.medium && track.medium->release)
fillInArtistsWithMbid(track.medium->release->artists, artistsWithMbid);
// second pass: fill in all artists that have no mbid set with the same name
fillInMbids(track.conductorArtists, artistsWithMbid);
fillInMbids(track.composerArtists, artistsWithMbid);
fillInMbids(track.lyricistArtists, artistsWithMbid);
fillInMbids(track.mixerArtists, artistsWithMbid);
fillInMbids(track.producerArtists, artistsWithMbid);
fillInMbids(track.remixerArtists, artistsWithMbid);
for (auto& [role, artists] : track.performerArtists)
fillInMbids(artists, artistsWithMbid);
}
} // namespace
void AudioFileScanOperation::scan()
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ScanAudioFile");
std::unique_ptr<metadata::Track> track;
try
{
_parsedTrack = _parser.parseMetaData(_file);
// We fill missing artist mbids with mbids found on other artist roles
fillMissingMbids(*_parsedTrack);
std::size_t index{};
_parser.parseImages(_file, [&](const metadata::Image& image) {
try
{
image::ImageProperties properties{ image::probeImage(image.data) };
ImageInfo info;
info.index = index;
info.type = image.type;
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ImageHash");
info.hash = core::xxHash3_64(image.data);
}
info.size = image.data.size();
info.mimeType = image.mimeType;
info.description = image.description;
info.properties = properties;
_parsedImages.push_back(std::move(info));
}
catch (const image::Exception& e)
{
LMS_LOG(DBUPDATER, ERROR, "Failed to parse image in track file " << _file);
}
index++;
});
}
catch (const metadata::Exception& e)
{
LMS_LOG(DBUPDATER, ERROR, "Failed to parse audio file " << _file);
}
}
void AudioFileScanOperation::processResult(ScanContext& context)
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessAudioScanData");
ScanStats& stats{ context.stats };
const std::optional<FileInfo> fileInfo{ utils::retrieveFileInfo(_file, _mediaLibrary.rootDirectory) };
if (!fileInfo)
{
stats.skips++;
return;
}
db::Session& dbSession{ _db.getTLSSession() };
db::Track::pointer track{ db::Track::findByPath(dbSession, _file) };
if (!_parsedTrack)
{
if (track)
{
track.remove();
stats.deletions++;
}
context.stats.errors.emplace_back(_file, ScanErrorType::CannotReadAudioFile);
return;
}
if (_parsedTrack->mbid && (!track || _settings.skipDuplicateTrackMBID))
{
std::vector<db::Track::pointer> duplicateTracks{ db::Track::findByMBID(dbSession, *_parsedTrack->mbid) };
// find for an existing track MBID as the file may have just been moved
if (!track && duplicateTracks.size() == 1)
{
db::Track::pointer otherTrack{ duplicateTracks.front() };
std::error_code ec;
if (!std::filesystem::exists(otherTrack->getAbsoluteFilePath(), ec))
{
LMS_LOG(DBUPDATER, DEBUG, "Considering track " << _file << " moved from " << otherTrack->getAbsoluteFilePath());
track = otherTrack;
track.modify()->setAbsoluteFilePath(_file);
}
}
// Skip duplicate track MBID
if (_settings.skipDuplicateTrackMBID)
{
for (db::Track::pointer& otherTrack : duplicateTracks)
{
// Skip ourselves
if (track && track->getId() == otherTrack->getId())
continue;
// Skip if duplicate files no longer in media root: as it will be removed later, we will end up with no file
if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries),
[&](const MediaLibraryInfo& libraryInfo) {
return core::pathUtils::isPathInRootPath(_file, libraryInfo.rootDirectory, &excludeDirFileName);
}))
{
continue;
}
LMS_LOG(DBUPDATER, DEBUG, "Skipped " << _file << " (similar MBID in " << otherTrack->getAbsoluteFilePath() << ")");
// As this MBID already exists, just remove what we just scanned
if (track)
{
track.remove();
stats.deletions++;
}
return;
}
}
}
if (!track)
{
// maybe the file just moved?
track = findMovedTrackBySizeAndMetaData(dbSession, *_parsedTrack, *fileInfo);
if (track)
{
LMS_LOG(DBUPDATER, DEBUG, "Considering track " << _file << " moved from " << track->getAbsoluteFilePath());
track.modify()->setAbsoluteFilePath(_file);
}
}
// We estimate this is an audio file if the duration is not null
if (_parsedTrack->audioProperties.duration == std::chrono::milliseconds::zero())
{
LMS_LOG(DBUPDATER, DEBUG, "Skipped " << _file << " (duration is 0)");
// If Track exists here, delete it!
if (track)
{
track.remove();
stats.deletions++;
}
stats.errors.emplace_back(_file, ScanErrorType::BadDuration);
return;
}
// ***** Title
std::string title;
if (!_parsedTrack->title.empty())
title = _parsedTrack->title;
else
{
// TODO parse file name guess track etc.
// For now juste use file name as title
title = _file.filename().string();
}
// If file already exists, update its data
// Otherwise, create it
bool added{};
if (!track)
{
track = dbSession.create<db::Track>();
added = true;
track.modify()->setAbsoluteFilePath(_file);
track.modify()->setAddedTime(_mediaLibrary.firstScan ? fileInfo->lastWriteTime : Wt::WDateTime::currentDateTime()); // may be erased by encodingTime
}
// Track related data
assert(track);
track.modify()->setScanVersion(_settings.audioScanVersion);
// Audio properties
track.modify()->setBitrate(_parsedTrack->audioProperties.bitrate);
track.modify()->setBitsPerSample(_parsedTrack->audioProperties.bitsPerSample);
track.modify()->setChannelCount(_parsedTrack->audioProperties.channelCount);
track.modify()->setDuration(_parsedTrack->audioProperties.duration);
track.modify()->setSampleRate(_parsedTrack->audioProperties.sampleRate);
track.modify()->setRelativeFilePath(fileInfo->relativePath);
track.modify()->setFileSize(fileInfo->fileSize);
track.modify()->setLastWriteTime(fileInfo->lastWriteTime);
if (_parsedTrack->encodingTime.isValid())
{
const core::PartialDateTime& encodingTime{ _parsedTrack->encodingTime };
Wt::WDate date;
Wt::WTime time;
if (encodingTime.getPrecision() >= core::PartialDateTime::Precision::Day)
date = Wt::WDate{ *encodingTime.getYear(), *encodingTime.getMonth(), *encodingTime.getDay() };
if (encodingTime.getPrecision() >= core::PartialDateTime::Precision::Sec)
time = Wt::WTime{ *encodingTime.getHour(), *encodingTime.getMin(), *encodingTime.getSec() };
if (date.isValid())
track.modify()->setAddedTime(time.isValid() ? Wt::WDateTime{ date, time } : Wt::WDateTime{ date });
}
db::MediaLibrary::pointer mediaLibrary{ db::MediaLibrary::find(dbSession, _mediaLibrary.id) }; // may be null if settings are updated in // => next scan will correct this
track.modify()->setMediaLibrary(mediaLibrary);
db::Directory::pointer directory{ utils::getOrCreateDirectory(dbSession, _file.parent_path(), mediaLibrary) };
track.modify()->setDirectory(directory);
track.modify()->clearArtistLinks();
const helpers::AllowFallbackOnMBIDEntry allowFallback{ _settings.allowArtistMBIDFallback };
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Artist, _parsedTrack->artists, allowFallback);
if (_parsedTrack->medium && _parsedTrack->medium->release)
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::ReleaseArtist, _parsedTrack->medium->release->artists, allowFallback);
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Conductor, _parsedTrack->conductorArtists, allowFallback);
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Composer, _parsedTrack->composerArtists, allowFallback);
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Lyricist, _parsedTrack->lyricistArtists, allowFallback);
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Mixer, _parsedTrack->mixerArtists, allowFallback);
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Remixer, _parsedTrack->remixerArtists, allowFallback);
for (const auto& [role, performers] : _parsedTrack->performerArtists)
createTrackArtistLinks(dbSession, track, db::TrackArtistLinkType::Performer, role, performers, allowFallback);
if (_parsedTrack->medium && _parsedTrack->medium->release)
track.modify()->setRelease(getOrCreateRelease(dbSession, *_parsedTrack->medium->release, directory));
else
track.modify()->setRelease({});
track.modify()->setTotalTrack(_parsedTrack->medium ? _parsedTrack->medium->trackCount : std::nullopt);
track.modify()->setReleaseReplayGain(_parsedTrack->medium ? _parsedTrack->medium->replayGain : std::nullopt);
track.modify()->setDiscSubtitle(_parsedTrack->medium ? _parsedTrack->medium->name : "");
track.modify()->setClusters(getOrCreateClusters(dbSession, *_parsedTrack));
track.modify()->setName(title);
track.modify()->setTrackNumber(_parsedTrack->position);
track.modify()->setDiscNumber(_parsedTrack->medium ? _parsedTrack->medium->position : std::nullopt);
track.modify()->setDate(_parsedTrack->date);
track.modify()->setOriginalDate(_parsedTrack->originalDate);
if (!track->getOriginalDate().isValid() && _parsedTrack->originalYear)
track.modify()->setOriginalDate(core::PartialDateTime{ *_parsedTrack->originalYear });
// If a file has an OriginalDate but no date, set it to ease filtering
if (!_parsedTrack->date.isValid() && _parsedTrack->originalDate.isValid())
track.modify()->setDate(_parsedTrack->originalDate);
track.modify()->setRecordingMBID(_parsedTrack->recordingMBID);
track.modify()->setTrackMBID(_parsedTrack->mbid);
if (auto trackFeatures{ db::TrackFeatures::find(dbSession, track->getId()) })
trackFeatures.remove(); // TODO: only if MBID changed?
track.modify()->setCopyright(_parsedTrack->copyright);
track.modify()->setCopyrightURL(_parsedTrack->copyrightURL);
track.modify()->setAdvisory(getAdvisory(_parsedTrack->advisory));
track.modify()->setComment(!_parsedTrack->comments.empty() ? _parsedTrack->comments.front() : ""); // only take the first one for now
track.modify()->setTrackReplayGain(_parsedTrack->replayGain);
track.modify()->setArtistDisplayName(_parsedTrack->artistDisplayName);
track.modify()->clearEmbeddedLyrics();
for (const metadata::Lyrics& lyricsInfo : _parsedTrack->lyrics)
track.modify()->addLyrics(createLyrics(dbSession, lyricsInfo));
updateEmbeddedImages(dbSession, track, _parsedImages);
if (added)
{
LMS_LOG(DBUPDATER, DEBUG, "Added audio file " << _file);
stats.additions++;
}
else
{
LMS_LOG(DBUPDATER, DEBUG, "Updated audio file " << _file);
stats.updates++;
}
}
} // namespace lms::scanner
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2024 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 "IFileScanOperation.hpp"
#include <memory>
#include <vector>
#include "image/Types.hpp"
#include "metadata/IAudioFileParser.hpp"
#include "FileToScan.hpp"
#include "IFileScanner.hpp"
namespace lms::db
{
class Db;
} // namespace lms::db
namespace lms::scanner
{
struct ImageInfo
{
std::size_t index;
metadata::Image::Type type{ metadata::Image::Type::Unknown };
std::uint64_t hash{};
std::size_t size{};
image::ImageProperties properties;
std::string mimeType;
std::string description;
};
class AudioFileScanOperation : public IFileScanOperation
{
public:
AudioFileScanOperation(const FileToScan& fileToScan, db::Db& db, metadata::IAudioFileParser& parser, const ScannerSettings& settings)
: _file{ fileToScan.file }
, _mediaLibrary{ fileToScan.mediaLibrary }
, _db{ db }
, _parser{ parser }
, _settings{ settings }
{
}
~AudioFileScanOperation() override = default;
AudioFileScanOperation(const AudioFileScanOperation&) = delete;
AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete;
private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanAudioFile"; }
void scan() override;
void processResult(ScanContext& context) override;
const std::filesystem::path _file;
const MediaLibraryInfo _mediaLibrary;
db::Db& _db;
metadata::IAudioFileParser& _parser;
const ScannerSettings& _settings;
std::unique_ptr<metadata::Track> _parsedTrack;
std::vector<ImageInfo> _parsedImages;
};
} // namespace lms::scanner
@@ -20,804 +20,21 @@
#include "AudioFileScanner.hpp"
#include "core/IConfig.hpp"
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/PartialDateTime.hpp"
#include "core/Path.hpp"
#include "core/Service.hpp"
#include "core/XxHash3.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Db.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackFeatures.hpp"
#include "database/TrackLyrics.hpp"
#include "database/Types.hpp"
#include "image/Exception.hpp"
#include "image/Image.hpp"
#include "metadata/Exception.hpp"
#include "metadata/IAudioFileParser.hpp"
#include "IFileScanOperation.hpp"
#include "AudioFileScanOperation.hpp"
#include "ScanContext.hpp"
#include "ScannerSettings.hpp"
#include "Utils.hpp"
#include "metadata/Types.hpp"
namespace lms::scanner
{
namespace
{
db::Artist::pointer createArtist(db::Session& session, const metadata::Artist& artistInfo)
{
db::Artist::pointer artist{ session.create<db::Artist>(artistInfo.name) };
if (artistInfo.mbid)
artist.modify()->setMBID(artistInfo.mbid);
artist.modify()->setSortName(artistInfo.sortName ? *artistInfo.sortName : artistInfo.name);
return artist;
}
std::string optionalMBIDAsString(const std::optional<core::UUID>& uuid)
{
return uuid ? std::string{ uuid->getAsString() } : "<no MBID>";
}
void updateArtistIfNeeded(db::Artist::pointer artist, const metadata::Artist& artistInfo)
{
// Name may have been updated
if (artist->getName() != artistInfo.name)
{
LMS_LOG(DBUPDATER, DEBUG, "Artist [" << optionalMBIDAsString(artist->getMBID()) << "], updated name from '" << artist->getName() << "' to '" << artistInfo.name << "'");
artist.modify()->setName(artistInfo.name);
}
// Sortname may have been updated
// As the sort name is quite often not filled in, we update it only if already set (for now?)
if (artistInfo.sortName && *artistInfo.sortName != artist->getSortName())
{
LMS_LOG(DBUPDATER, DEBUG, "Artist [" << optionalMBIDAsString(artist->getMBID()) << "], updated sort name from '" << artist->getSortName() << "' to '" << *artistInfo.sortName << "'");
artist.modify()->setSortName(*artistInfo.sortName);
}
}
std::vector<db::Artist::pointer> getOrCreateArtists(db::Session& session, const std::vector<metadata::Artist>& artistsInfo, bool allowFallbackOnMBIDEntries)
{
std::vector<db::Artist::pointer> artists;
for (const metadata::Artist& artistInfo : artistsInfo)
{
db::Artist::pointer artist;
// First try to get by MBID
if (artistInfo.mbid)
{
artist = db::Artist::find(session, *artistInfo.mbid);
if (!artist)
artist = createArtist(session, artistInfo);
else
updateArtistIfNeeded(artist, artistInfo);
artists.emplace_back(std::move(artist));
continue;
}
// Fall back on artist name (collisions may occur)
if (!artistInfo.name.empty())
{
for (const db::Artist::pointer& sameNamedArtist : db::Artist::find(session, artistInfo.name))
{
// Do not fallback on artist that is correctly tagged
if (!allowFallbackOnMBIDEntries && sameNamedArtist->getMBID())
continue;
artist = sameNamedArtist;
break;
}
// No Artist found with the same name and without MBID -> creating
if (!artist)
artist = createArtist(session, artistInfo);
else
updateArtistIfNeeded(artist, artistInfo);
artists.emplace_back(std::move(artist));
continue;
}
}
return artists;
}
db::ReleaseType::pointer getOrCreateReleaseType(db::Session& session, std::string_view name)
{
db::ReleaseType::pointer releaseType{ db::ReleaseType::find(session, name) };
if (!releaseType)
releaseType = session.create<db::ReleaseType>(name);
return releaseType;
}
db::Country::pointer getOrCreateCountry(db::Session& session, std::string_view name)
{
db::Country::pointer country{ db::Country::find(session, name) };
if (!country)
country = session.create<db::Country>(name);
return country;
}
db::Label::pointer getOrCreateLabel(db::Session& session, std::string_view name)
{
db::Label::pointer label{ db::Label::find(session, name) };
if (!label)
label = session.create<db::Label>(name);
return label;
}
void updateReleaseIfNeeded(db::Session& session, db::Release::pointer release, const metadata::Release& releaseInfo)
{
if (release->getName() != releaseInfo.name)
release.modify()->setName(releaseInfo.name);
if (release->getSortName() != releaseInfo.sortName)
release.modify()->setSortName(releaseInfo.sortName);
if (release->getGroupMBID() != releaseInfo.groupMBID)
release.modify()->setGroupMBID(releaseInfo.groupMBID);
if (release->getTotalDisc() != releaseInfo.mediumCount)
release.modify()->setTotalDisc(releaseInfo.mediumCount);
if (release->getArtistDisplayName() != releaseInfo.artistDisplayName)
release.modify()->setArtistDisplayName(releaseInfo.artistDisplayName);
if (release->isCompilation() != releaseInfo.isCompilation)
release.modify()->setCompilation(releaseInfo.isCompilation);
if (release->getBarcode() != releaseInfo.barcode)
release.modify()->setBarcode(releaseInfo.barcode);
if (release->getComment() != releaseInfo.comment)
release.modify()->setComment(releaseInfo.comment);
if (release->getReleaseTypeNames() != releaseInfo.releaseTypes)
{
release.modify()->clearReleaseTypes();
for (std::string_view releaseType : releaseInfo.releaseTypes)
release.modify()->addReleaseType(getOrCreateReleaseType(session, releaseType));
}
if (release->getCountryNames() != releaseInfo.countries)
{
release.modify()->clearCountries();
for (std::string_view country : releaseInfo.countries)
release.modify()->addCountry(getOrCreateCountry(session, country));
}
if (release->getLabelNames() != releaseInfo.labels)
{
release.modify()->clearLabels();
for (std::string_view label : releaseInfo.labels)
release.modify()->addLabel(getOrCreateLabel(session, label));
}
}
// Compare release level info
bool isReleaseMatching(const db::Release::pointer& candidateRelease, const metadata::Release& releaseInfo)
{
// TODO: add more criterias?
return candidateRelease->getName() == releaseInfo.name
&& candidateRelease->getSortName() == releaseInfo.sortName
&& candidateRelease->getTotalDisc() == releaseInfo.mediumCount
&& candidateRelease->isCompilation() == releaseInfo.isCompilation
&& candidateRelease->getLabelNames() == releaseInfo.labels
&& candidateRelease->getBarcode() == releaseInfo.barcode;
}
db::Release::pointer getOrCreateRelease(db::Session& session, const metadata::Release& releaseInfo, const db::Directory::pointer& currentDirectory)
{
db::Release::pointer release;
// First try to get by MBID: fastest, safest
if (releaseInfo.mbid)
{
release = db::Release::find(session, *releaseInfo.mbid);
if (!release)
release = session.create<db::Release>(releaseInfo.name, releaseInfo.mbid);
}
else if (releaseInfo.name.empty())
{
// No release name (only mbid) -> nothing to do
return release;
}
// Fall back on release name (collisions may occur)
// First try using all sibling directories (case for Album/DiscX), only if the disc number is set
const db::DirectoryId parentDirectoryId{ currentDirectory->getParentDirectoryId() };
if (!release && releaseInfo.mediumCount && *releaseInfo.mediumCount > 1 && parentDirectoryId.isValid())
{
db::Release::FindParameters params;
params.setParentDirectory(parentDirectoryId);
params.setName(releaseInfo.name);
db::Release::find(session, params, [&](const db::Release::pointer& candidateRelease) {
// Already found a candidate
if (release)
return;
// Do not fallback on properly tagged releases
if (candidateRelease->getMBID().has_value())
return;
if (!isReleaseMatching(candidateRelease, releaseInfo))
return;
release = candidateRelease;
});
}
// Lastly try in the current directory: we do this at last to have
// opportunities to merge releases in case of migration / rescan
if (!release)
{
db::Release::FindParameters params;
params.setDirectory(currentDirectory->getId());
params.setName(releaseInfo.name);
db::Release::find(session, params, [&](const db::Release::pointer& candidateRelease) {
// Already found a candidate
if (release)
return;
// Do not fallback on properly tagged releases
if (candidateRelease->getMBID().has_value())
return;
if (!isReleaseMatching(candidateRelease, releaseInfo))
return;
release = candidateRelease;
});
}
if (!release)
release = session.create<db::Release>(releaseInfo.name);
updateReleaseIfNeeded(session, release, releaseInfo);
return release;
}
std::vector<db::Cluster::pointer> getOrCreateClusters(db::Session& session, const metadata::Track& track)
{
std::vector<db::Cluster::pointer> clusters;
auto getOrCreateClusters{ [&](std::string_view tag, std::span<const std::string> values) {
auto clusterType = db::ClusterType::find(session, tag);
if (!clusterType)
clusterType = session.create<db::ClusterType>(tag);
for (const auto& value : values)
{
auto cluster{ clusterType->getCluster(value) };
if (!cluster)
cluster = session.create<db::Cluster>(clusterType, value);
clusters.push_back(cluster);
}
} };
// TODO: migrate these fields in dedicated tables in DB
getOrCreateClusters("GENRE", track.genres);
getOrCreateClusters("MOOD", track.moods);
getOrCreateClusters("LANGUAGE", track.languages);
getOrCreateClusters("GROUPING", track.groupings);
for (const auto& [tag, values] : track.userExtraTags)
getOrCreateClusters(tag, values);
return clusters;
}
db::TrackLyrics::pointer createLyrics(db::Session& session, const metadata::Lyrics& lyricsInfo)
{
db::TrackLyrics::pointer lyrics{ session.create<db::TrackLyrics>() };
lyrics.modify()->setLanguage(!lyricsInfo.language.empty() ? lyricsInfo.language : "xxx");
lyrics.modify()->setOffset(lyricsInfo.offset);
lyrics.modify()->setDisplayArtist(lyricsInfo.displayArtist);
lyrics.modify()->setDisplayTitle(lyricsInfo.displayTitle);
if (!lyricsInfo.synchronizedLines.empty())
lyrics.modify()->setSynchronizedLines(lyricsInfo.synchronizedLines);
else
lyrics.modify()->setUnsynchronizedLines(lyricsInfo.unsynchronizedLines);
return lyrics;
}
struct ImageInfo
{
std::size_t index;
metadata::Image::Type type{ metadata::Image::Type::Unknown };
std::uint64_t hash{};
std::size_t size{};
image::ImageProperties properties;
std::string mimeType;
std::string description;
};
db::ImageType convertImageType(metadata::Image::Type type)
{
switch (type)
{
case metadata::Image::Type::Unknown:
return db::ImageType::Unknown;
case metadata::Image::Type::Other:
return db::ImageType::Other;
case metadata::Image::Type::FileIcon:
return db::ImageType::FileIcon;
case metadata::Image::Type::OtherFileIcon:
return db::ImageType::OtherFileIcon;
case metadata::Image::Type::FrontCover:
return db::ImageType::FrontCover;
case metadata::Image::Type::BackCover:
return db::ImageType::BackCover;
case metadata::Image::Type::LeafletPage:
return db::ImageType::LeafletPage;
case metadata::Image::Type::Media:
return db::ImageType::Media;
case metadata::Image::Type::LeadArtist:
return db::ImageType::LeadArtist;
case metadata::Image::Type::Artist:
return db::ImageType::Artist;
case metadata::Image::Type::Conductor:
return db::ImageType::Conductor;
case metadata::Image::Type::Band:
return db::ImageType::Band;
case metadata::Image::Type::Composer:
return db::ImageType::Composer;
case metadata::Image::Type::Lyricist:
return db::ImageType::Lyricist;
case metadata::Image::Type::RecordingLocation:
return db::ImageType::RecordingLocation;
case metadata::Image::Type::DuringRecording:
return db::ImageType::DuringRecording;
case metadata::Image::Type::DuringPerformance:
return db::ImageType::DuringPerformance;
case metadata::Image::Type::MovieScreenCapture:
return db::ImageType::MovieScreenCapture;
case metadata::Image::Type::ColouredFish:
return db::ImageType::ColouredFish;
case metadata::Image::Type::Illustration:
return db::ImageType::Illustration;
case metadata::Image::Type::BandLogo:
return db::ImageType::BandLogo;
case metadata::Image::Type::PublisherLogo:
return db::ImageType::PublisherLogo;
}
return db::ImageType::Unknown;
}
db::TrackEmbeddedImage::pointer getOrCreateTrackEmbeddedImage(db::Session& session, const ImageInfo& imageInfo)
{
db::TrackEmbeddedImage::pointer image{ db::TrackEmbeddedImage::find(session, imageInfo.size, db::ImageHashType{ imageInfo.hash }) };
if (!image)
{
image = session.create<db::TrackEmbeddedImage>();
image.modify()->setSize(imageInfo.size);
image.modify()->setHash(db::ImageHashType{ imageInfo.hash });
image.modify()->setWidth(imageInfo.properties.width);
image.modify()->setHeight(imageInfo.properties.height);
image.modify()->setMimeType(imageInfo.mimeType);
}
return image;
}
db::TrackEmbeddedImageLink::pointer createTrackEmbeddedImageLink(db::Session& session, const db::Track::pointer& track, const ImageInfo& imageInfo)
{
const db::TrackEmbeddedImage::pointer image{ getOrCreateTrackEmbeddedImage(session, imageInfo) };
db::TrackEmbeddedImageLink::pointer imageLink{ session.create<db::TrackEmbeddedImageLink>(track, image) };
imageLink.modify()->setIndex(imageInfo.index);
imageLink.modify()->setType(convertImageType(imageInfo.type));
imageLink.modify()->setDescription(imageInfo.description);
return imageLink;
}
void updateEmbeddedImages(db::Session& session, db::Track::pointer& track, std::span<const ImageInfo> images)
{
db::TrackEmbeddedImageLink::pointer preferredImageLink;
track.modify()->clearEmbeddedImageLinks();
for (const ImageInfo& imageInfo : images)
{
db::TrackEmbeddedImageLink::pointer link{ createTrackEmbeddedImageLink(session, track, imageInfo) };
track.modify()->addEmbeddedImageLink(link);
if (!preferredImageLink
|| (preferredImageLink->getType() != db::ImageType::FrontCover && link->getType() == db::ImageType::FrontCover)
|| (preferredImageLink->getImage()->getSize() < link->getImage()->getSize()))
{
preferredImageLink = link;
}
}
if (preferredImageLink)
preferredImageLink.modify()->setIsPreferred(true);
}
db::Advisory getAdvisory(std::optional<metadata::Track::Advisory> advisory)
{
if (!advisory)
return db::Advisory::UnSet;
switch (advisory.value())
{
case metadata::Track::Advisory::Clean:
return db::Advisory::Clean;
case metadata::Track::Advisory::Explicit:
return db::Advisory::Explicit;
case metadata::Track::Advisory::Unknown:
return db::Advisory::Unknown;
}
return db::Advisory::UnSet;
}
db::Track::pointer findMovedTrackBySizeAndMetaData(db::Session& session, const metadata::Track& parsedTrack, const FileInfo& fileInfo)
{
db::Track::FindParameters params;
// Add as many fields as possible to limit errors
params.setName(parsedTrack.title);
if (parsedTrack.medium)
{
if (parsedTrack.medium->position)
params.setDiscNumber(*parsedTrack.medium->position);
if (parsedTrack.medium->release)
params.setReleaseName(parsedTrack.medium->release->name);
}
if (parsedTrack.position)
params.setTrackNumber(*parsedTrack.position);
params.setFileSize(fileInfo.fileSize);
bool error{};
db::Track::pointer res;
db::Track::find(session, params, [&](const db::Track::pointer& track) {
// Check that the track is truly no longer where it was during the last scan
std::error_code ec;
if (std::filesystem::exists(track->getAbsoluteFilePath(), ec))
return;
if (res)
{
LMS_LOG(DBUPDATER, DEBUG, "Found too many candidates for file move. New file = " << fileInfo.relativePath << ", candidate = " << track->getAbsoluteFilePath() << ", previous candidate = " << res->getAbsoluteFilePath());
error = true;
}
res = track;
});
if (error)
res = db::Track::pointer{};
return res;
}
class AudioFileScanOperation : public IFileScanOperation
{
public:
AudioFileScanOperation(const FileToScan& fileToScan, db::Db& db, metadata::IAudioFileParser& parser, const ScannerSettings& settings)
: _file{ fileToScan.file }
, _mediaLibrary{ fileToScan.mediaLibrary }
, _db{ db }
, _parser{ parser }
, _settings{ settings }
{
}
~AudioFileScanOperation() override = default;
AudioFileScanOperation(const AudioFileScanOperation&) = delete;
AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete;
private:
const std::filesystem::path& getFile() const override { return _file; };
core::LiteralString getName() const override { return "ScanAudioFile"; }
void scan() override;
void processResult(ScanContext& context) override;
const std::filesystem::path _file;
const MediaLibraryInfo _mediaLibrary;
db::Db& _db;
metadata::IAudioFileParser& _parser;
const ScannerSettings& _settings;
std::unique_ptr<metadata::Track> _parsedTrack;
std::vector<ImageInfo> _parsedImages;
};
void AudioFileScanOperation::scan()
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ScanAudioFile");
std::unique_ptr<metadata::Track> track;
try
{
_parsedTrack = _parser.parseMetaData(_file);
std::size_t index{};
_parser.parseImages(_file, [&](const metadata::Image& image) {
try
{
image::ImageProperties properties{ image::probeImage(image.data) };
ImageInfo info;
info.index = index;
info.type = image.type;
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ImageHash");
info.hash = core::xxHash3_64(image.data);
}
info.size = image.data.size();
info.mimeType = image.mimeType;
info.description = image.description;
info.properties = properties;
_parsedImages.push_back(std::move(info));
}
catch (const image::Exception& e)
{
LMS_LOG(DBUPDATER, ERROR, "Failed to parse image in track file " << _file);
}
index++;
});
}
catch (const metadata::Exception& e)
{
LMS_LOG(DBUPDATER, ERROR, "Failed to parse audio file " << _file);
}
}
void AudioFileScanOperation::processResult(ScanContext& context)
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessAudioScanData");
ScanStats& stats{ context.stats };
const std::optional<FileInfo> fileInfo{ utils::retrieveFileInfo(_file, _mediaLibrary.rootDirectory) };
if (!fileInfo)
{
stats.skips++;
return;
}
db::Session& dbSession{ _db.getTLSSession() };
db::Track::pointer track{ db::Track::findByPath(dbSession, _file) };
if (!_parsedTrack)
{
if (track)
{
track.remove();
stats.deletions++;
}
context.stats.errors.emplace_back(_file, ScanErrorType::CannotReadAudioFile);
return;
}
if (_parsedTrack->mbid && (!track || _settings.skipDuplicateMBID))
{
std::vector<db::Track::pointer> duplicateTracks{ db::Track::findByMBID(dbSession, *_parsedTrack->mbid) };
// find for an existing track MBID as the file may have just been moved
if (!track && duplicateTracks.size() == 1)
{
db::Track::pointer otherTrack{ duplicateTracks.front() };
std::error_code ec;
if (!std::filesystem::exists(otherTrack->getAbsoluteFilePath(), ec))
{
LMS_LOG(DBUPDATER, DEBUG, "Considering track " << _file << " moved from " << otherTrack->getAbsoluteFilePath());
track = otherTrack;
track.modify()->setAbsoluteFilePath(_file);
}
}
// Skip duplicate track MBID
if (_settings.skipDuplicateMBID)
{
for (db::Track::pointer& otherTrack : duplicateTracks)
{
// Skip ourselves
if (track && track->getId() == otherTrack->getId())
continue;
// Skip if duplicate files no longer in media root: as it will be removed later, we will end up with no file
if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries),
[&](const MediaLibraryInfo& libraryInfo) {
return core::pathUtils::isPathInRootPath(_file, libraryInfo.rootDirectory, &excludeDirFileName);
}))
{
continue;
}
LMS_LOG(DBUPDATER, DEBUG, "Skipped " << _file << " (similar MBID in " << otherTrack->getAbsoluteFilePath() << ")");
// As this MBID already exists, just remove what we just scanned
if (track)
{
track.remove();
stats.deletions++;
}
return;
}
}
}
if (!track)
{
// maybe the file just moved?
track = findMovedTrackBySizeAndMetaData(dbSession, *_parsedTrack, *fileInfo);
if (track)
{
LMS_LOG(DBUPDATER, DEBUG, "Considering track " << _file << " moved from " << track->getAbsoluteFilePath());
track.modify()->setAbsoluteFilePath(_file);
}
}
// We estimate this is an audio file if the duration is not null
if (_parsedTrack->audioProperties.duration == std::chrono::milliseconds::zero())
{
LMS_LOG(DBUPDATER, DEBUG, "Skipped " << _file << " (duration is 0)");
// If Track exists here, delete it!
if (track)
{
track.remove();
stats.deletions++;
}
stats.errors.emplace_back(_file, ScanErrorType::BadDuration);
return;
}
// ***** Title
std::string title;
if (!_parsedTrack->title.empty())
title = _parsedTrack->title;
else
{
// TODO parse file name guess track etc.
// For now juste use file name as title
title = _file.filename().string();
}
// If file already exists, update its data
// Otherwise, create it
bool added{};
if (!track)
{
track = dbSession.create<db::Track>();
added = true;
track.modify()->setAbsoluteFilePath(_file);
track.modify()->setAddedTime(_mediaLibrary.firstScan ? fileInfo->lastWriteTime : Wt::WDateTime::currentDateTime()); // may be erased by encodingTime
}
// Track related data
assert(track);
// Audio properties
track.modify()->setBitrate(_parsedTrack->audioProperties.bitrate);
track.modify()->setBitsPerSample(_parsedTrack->audioProperties.bitsPerSample);
track.modify()->setChannelCount(_parsedTrack->audioProperties.channelCount);
track.modify()->setDuration(_parsedTrack->audioProperties.duration);
track.modify()->setSampleRate(_parsedTrack->audioProperties.sampleRate);
track.modify()->setRelativeFilePath(fileInfo->relativePath);
track.modify()->setFileSize(fileInfo->fileSize);
track.modify()->setLastWriteTime(fileInfo->lastWriteTime);
if (_parsedTrack->encodingTime.isValid())
{
const core::PartialDateTime& encodingTime{ _parsedTrack->encodingTime };
Wt::WDate date;
Wt::WTime time;
if (encodingTime.getPrecision() >= core::PartialDateTime::Precision::Day)
date = Wt::WDate{ *encodingTime.getYear(), *encodingTime.getMonth(), *encodingTime.getDay() };
if (encodingTime.getPrecision() >= core::PartialDateTime::Precision::Sec)
time = Wt::WTime{ *encodingTime.getHour(), *encodingTime.getMin(), *encodingTime.getSec() };
if (date.isValid())
track.modify()->setAddedTime(time.isValid() ? Wt::WDateTime{ date, time } : Wt::WDateTime{ date });
}
db::MediaLibrary::pointer mediaLibrary{ db::MediaLibrary::find(dbSession, _mediaLibrary.id) }; // may be null if settings are updated in // => next scan will correct this
track.modify()->setMediaLibrary(mediaLibrary);
db::Directory::pointer directory{ utils::getOrCreateDirectory(dbSession, _file.parent_path(), mediaLibrary) };
track.modify()->setDirectory(directory);
track.modify()->clearArtistLinks();
// Do not fallback on artists with the same name but having a MBID for artist and releaseArtists, as it may be corrected by properly tagging files
for (const db::Artist::pointer& artist : getOrCreateArtists(dbSession, _parsedTrack->artists, false))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, artist, db::TrackArtistLinkType::Artist));
if (_parsedTrack->medium && _parsedTrack->medium->release)
{
for (const db::Artist::pointer& releaseArtist : getOrCreateArtists(dbSession, _parsedTrack->medium->release->artists, false))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, releaseArtist, db::TrackArtistLinkType::ReleaseArtist));
}
// Allow fallbacks on artists with the same name even if they have MBID, since there is no tag to indicate the MBID of these artists
// We could ask MusicBrainz to get all the information, but that would heavily slow down the import process
for (const db::Artist::pointer& conductor : getOrCreateArtists(dbSession, _parsedTrack->conductorArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, conductor, db::TrackArtistLinkType::Conductor));
for (const db::Artist::pointer& composer : getOrCreateArtists(dbSession, _parsedTrack->composerArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, composer, db::TrackArtistLinkType::Composer));
for (const db::Artist::pointer& lyricist : getOrCreateArtists(dbSession, _parsedTrack->lyricistArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, lyricist, db::TrackArtistLinkType::Lyricist));
for (const db::Artist::pointer& mixer : getOrCreateArtists(dbSession, _parsedTrack->mixerArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, mixer, db::TrackArtistLinkType::Mixer));
for (const auto& [role, performers] : _parsedTrack->performerArtists)
{
for (const db::Artist::pointer& performer : getOrCreateArtists(dbSession, performers, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, performer, db::TrackArtistLinkType::Performer, role));
}
for (const db::Artist::pointer& producer : getOrCreateArtists(dbSession, _parsedTrack->producerArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, producer, db::TrackArtistLinkType::Producer));
for (const db::Artist::pointer& remixer : getOrCreateArtists(dbSession, _parsedTrack->remixerArtists, true))
track.modify()->addArtistLink(db::TrackArtistLink::create(dbSession, track, remixer, db::TrackArtistLinkType::Remixer));
track.modify()->setScanVersion(_settings.scanVersion);
if (_parsedTrack->medium && _parsedTrack->medium->release)
track.modify()->setRelease(getOrCreateRelease(dbSession, *_parsedTrack->medium->release, directory));
else
track.modify()->setRelease({});
track.modify()->setTotalTrack(_parsedTrack->medium ? _parsedTrack->medium->trackCount : std::nullopt);
track.modify()->setReleaseReplayGain(_parsedTrack->medium ? _parsedTrack->medium->replayGain : std::nullopt);
track.modify()->setDiscSubtitle(_parsedTrack->medium ? _parsedTrack->medium->name : "");
track.modify()->setClusters(getOrCreateClusters(dbSession, *_parsedTrack));
track.modify()->setName(title);
track.modify()->setTrackNumber(_parsedTrack->position);
track.modify()->setDiscNumber(_parsedTrack->medium ? _parsedTrack->medium->position : std::nullopt);
track.modify()->setDate(_parsedTrack->date);
track.modify()->setOriginalDate(_parsedTrack->originalDate);
if (!track->getOriginalDate().isValid() && _parsedTrack->originalYear)
track.modify()->setOriginalDate(core::PartialDateTime{ *_parsedTrack->originalYear });
// If a file has an OriginalDate but no date, set it to ease filtering
if (!_parsedTrack->date.isValid() && _parsedTrack->originalDate.isValid())
track.modify()->setDate(_parsedTrack->originalDate);
track.modify()->setRecordingMBID(_parsedTrack->recordingMBID);
track.modify()->setTrackMBID(_parsedTrack->mbid);
if (auto trackFeatures{ db::TrackFeatures::find(dbSession, track->getId()) })
trackFeatures.remove(); // TODO: only if MBID changed?
track.modify()->setCopyright(_parsedTrack->copyright);
track.modify()->setCopyrightURL(_parsedTrack->copyrightURL);
track.modify()->setAdvisory(getAdvisory(_parsedTrack->advisory));
track.modify()->setComment(!_parsedTrack->comments.empty() ? _parsedTrack->comments.front() : ""); // only take the first one for now
track.modify()->setTrackReplayGain(_parsedTrack->replayGain);
track.modify()->setArtistDisplayName(_parsedTrack->artistDisplayName);
track.modify()->clearEmbeddedLyrics();
for (const metadata::Lyrics& lyricsInfo : _parsedTrack->lyrics)
track.modify()->addLyrics(createLyrics(dbSession, lyricsInfo));
updateEmbeddedImages(dbSession, track, _parsedImages);
if (added)
{
LMS_LOG(DBUPDATER, DEBUG, "Added audio file " << _file);
stats.additions++;
}
else
{
LMS_LOG(DBUPDATER, DEBUG, "Updated audio file " << _file);
stats.updates++;
}
}
metadata::ParserReadStyle getParserReadStyle()
{
std::string_view readStyle{ core::Service<core::IConfig>::get()->getString("scanner-parser-read-style", "average") };
@@ -838,6 +55,7 @@ namespace lms::scanner
params.userExtraTags = settings.extraTags;
params.artistTagDelimiters = settings.artistTagDelimiters;
params.defaultTagDelimiters = settings.defaultTagDelimiters;
params.artistsToNotSplit.insert(settings.artistsToNotSplit.cbegin(), settings.artistsToNotSplit.end());
params.backend = metadata::ParserBackend::TagLib;
params.readStyle = getParserReadStyle();
@@ -845,7 +63,6 @@ namespace lms::scanner
}
} // namespace
AudioFileScanner::AudioFileScanner(db::Db& db, const ScannerSettings& settings)
: _db{ db }
, _settings{ settings }
@@ -890,7 +107,7 @@ namespace lms::scanner
const db::Track::pointer track{ db::Track::findByPath(dbSession, file.file) };
if (track
&& track->getLastWriteTime() == lastWriteTime
&& track->getScanVersion() == _settings.scanVersion)
&& track->getScanVersion() == _settings.audioScanVersion)
{
// this file may have been moved from one library to another, then we just need to update the media library id instead of a full rescan
const auto trackMediaLibrary{ track->getMediaLibrary() };
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2024 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 "MediaLibraryInfo.hpp"
namespace lms::scanner
{
struct FileToScan
{
std::filesystem::path file;
MediaLibraryInfo mediaLibrary;
};
} // namespace lms::scanner
@@ -24,7 +24,7 @@
#include "core/LiteralString.hpp"
#include "MediaLibraryInfo.hpp"
#include "FileToScan.hpp"
namespace lms::scanner
{
@@ -32,12 +32,6 @@ namespace lms::scanner
struct ScanContext;
struct ScannerSettings;
struct FileToScan
{
std::filesystem::path file;
MediaLibraryInfo mediaLibrary;
};
class IFileScanner
{
public:
@@ -32,6 +32,7 @@ namespace lms::scanner
virtual ScanStep getStep() const = 0;
virtual core::LiteralString getStepName() const = 0;
virtual bool needProcess(const ScanContext& context) const = 0;
virtual void process(ScanContext& context) = 0;
};
} // namespace lms::scanner
@@ -0,0 +1,234 @@
/*
* Copyright (C) 2024 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 "ScanStepArtistReconciliation.hpp"
#include <cassert>
#include <ostream>
#include "core/ILogger.hpp"
#include "database/Artist.hpp"
#include "database/ArtistInfo.hpp"
#include "database/Db.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackList.hpp"
#include "metadata/Types.hpp"
#include "ScannerSettings.hpp"
#include "helpers/ArtistHelpers.hpp"
namespace lms::scanner
{
namespace
{
std::ostream& operator<<(std::ostream& os, const db::Artist::pointer& artist)
{
os << artist->getName();
if (const auto mbid{ artist->getMBID() })
os << " [" << mbid->getAsString() << "]";
return os;
}
void recomputeArtist(db::Session& session, db::TrackArtistLink::pointer link, bool allowArtistMBIDFallback)
{
assert(!link->isArtistMBIDMatched());
metadata::Artist artistInfo{ std::nullopt, link->getArtistName(), link->getArtistSortName().empty() ? std::nullopt : std::make_optional<std::string>(link->getArtistSortName()) };
db::Artist::pointer newArtist{ helpers::getOrCreateArtistByName(session, artistInfo, helpers::AllowFallbackOnMBIDEntry{ allowArtistMBIDFallback }) };
LMS_LOG(DB, INFO, "Reconcile artist link for track " << link->getTrack()->getAbsoluteFilePath() << ", type " << static_cast<int>(link->getType()) << " from " << link->getArtist() << " to " << newArtist);
assert(newArtist != link->getArtist());
link.modify()->setArtist(newArtist);
}
void recomputeArtist(db::Session& session, db::ArtistInfo::pointer artistInfo, bool allowArtistMBIDFallback)
{
assert(!artistInfo->isMBIDMatched());
const metadata::Artist artistMetadata{ std::nullopt, artistInfo->getName(), artistInfo->getSortName().empty() ? std::nullopt : std::make_optional<std::string>(artistInfo->getSortName()) };
db::Artist::pointer newArtist{ helpers::getOrCreateArtistByName(session, artistMetadata, helpers::AllowFallbackOnMBIDEntry{ allowArtistMBIDFallback }) };
LMS_LOG(DB, INFO, "Reconcile artist link for artist info " << artistInfo->getAbsoluteFilePath() << " from " << artistInfo->getArtist() << " to " << newArtist);
assert(newArtist != artistInfo->getArtist());
artistInfo.modify()->setArtist(newArtist);
}
} // namespace
bool ScanStepArtistReconciliation::needProcess([[maybe_unused]] const ScanContext& context) const
{
// Since this step is very fast in case there is nothing to do, no need to skip if nothing has changed
return true;
}
void ScanStepArtistReconciliation::process(ScanContext& context)
{
// Reconcile artist links
{
// Order is important
updateLinksForArtistNameNoLongerMatch(context);
updateLinksWithArtistNameAmbiguity(context);
}
// Reconcile artist info
{
// Order is important
updateArtistInfoForArtistNameNoLongerMatch(context);
updateArtistInfoWithArtistNameAmbiguity(context);
}
}
void ScanStepArtistReconciliation::updateArtistInfoForArtistNameNoLongerMatch(ScanContext& context)
{
static constexpr std::size_t batchSize{ 50 };
const bool allowArtistMBIDFallback{ _settings.allowArtistMBIDFallback };
db::Session& session{ _db.getTLSSession() };
std::vector<db::ArtistInfo::pointer> artistInfo;
while (!_abortScan)
{
artistInfo.clear();
{
auto transaction{ session.createReadTransaction() };
db::ArtistInfo::findArtistNameNoLongerMatch(session, db::Range{ .offset = 0, .size = batchSize }, [&](const db::ArtistInfo::pointer& link) {
artistInfo.push_back(link);
});
}
if (artistInfo.empty())
break;
{
auto transaction{ session.createWriteTransaction() };
for (db::ArtistInfo::pointer& info : artistInfo)
{
recomputeArtist(session, info, allowArtistMBIDFallback);
context.currentStepStats.processedElems++;
}
_progressCallback(context.currentStepStats);
}
}
}
void ScanStepArtistReconciliation::updateArtistInfoWithArtistNameAmbiguity(ScanContext& context)
{
static constexpr std::size_t batchSize{ 50 };
const bool allowArtistMBIDFallback{ _settings.allowArtistMBIDFallback };
db::Session& session{ _db.getTLSSession() };
std::vector<db::ArtistInfo::pointer> artistInfo;
while (!_abortScan)
{
artistInfo.clear();
{
auto transaction{ session.createReadTransaction() };
db::ArtistInfo::findWithArtistNameAmbiguity(session, db::Range{ .offset = 0, .size = batchSize }, allowArtistMBIDFallback, [&](const db::ArtistInfo::pointer& info) {
artistInfo.push_back(info);
});
}
if (artistInfo.empty())
break;
{
auto transaction{ session.createWriteTransaction() };
for (db::ArtistInfo::pointer& info : artistInfo)
{
recomputeArtist(session, info, allowArtistMBIDFallback);
context.currentStepStats.processedElems++;
}
_progressCallback(context.currentStepStats);
}
}
}
void ScanStepArtistReconciliation::updateLinksForArtistNameNoLongerMatch(ScanContext& context)
{
static constexpr std::size_t batchSize{ 50 };
const bool allowArtistMBIDFallback{ _settings.allowArtistMBIDFallback };
db::Session& session{ _db.getTLSSession() };
std::vector<db::TrackArtistLink::pointer> links;
while (!_abortScan)
{
links.clear();
{
auto transaction{ session.createReadTransaction() };
db::TrackArtistLink::findArtistNameNoLongerMatch(session, db::Range{ .offset = 0, .size = batchSize }, [&](const db::TrackArtistLink::pointer& link) {
links.push_back(link);
});
}
if (links.empty())
break;
{
auto transaction{ session.createWriteTransaction() };
for (db::TrackArtistLink::pointer& link : links)
{
recomputeArtist(session, link, allowArtistMBIDFallback);
context.currentStepStats.processedElems++;
}
_progressCallback(context.currentStepStats);
}
}
}
void ScanStepArtistReconciliation::updateLinksWithArtistNameAmbiguity(ScanContext& context)
{
static constexpr std::size_t batchSize{ 50 };
const bool allowArtistMBIDFallback{ _settings.allowArtistMBIDFallback };
db::Session& session{ _db.getTLSSession() };
std::vector<db::TrackArtistLink::pointer> links;
while (!_abortScan)
{
links.clear();
{
auto transaction{ session.createReadTransaction() };
db::TrackArtistLink::findWithArtistNameAmbiguity(session, db::Range{ .offset = 0, .size = batchSize }, allowArtistMBIDFallback, [&](const db::TrackArtistLink::pointer& link) {
links.push_back(link);
});
}
if (links.empty())
break;
{
auto transaction{ session.createWriteTransaction() };
for (db::TrackArtistLink::pointer& link : links)
{
recomputeArtist(session, link, allowArtistMBIDFallback);
context.currentStepStats.processedElems++;
}
_progressCallback(context.currentStepStats);
}
}
}
} // namespace lms::scanner
@@ -0,0 +1,42 @@
/*
* 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 "ScanStepBase.hpp"
namespace lms::scanner
{
class ScanStepArtistReconciliation : public ScanStepBase
{
public:
using ScanStepBase::ScanStepBase;
private:
ScanStep getStep() const override { return ScanStep::ReconciliateArtists; }
core::LiteralString getStepName() const override { return "Artist reconciliation"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
void updateLinksForArtistNameNoLongerMatch(ScanContext& context);
void updateLinksWithArtistNameAmbiguity(ScanContext& context);
void updateArtistInfoForArtistNameNoLongerMatch(ScanContext& context);
void updateArtistInfoWithArtistNameAmbiguity(ScanContext& context);
};
} // namespace lms::scanner
@@ -51,7 +51,7 @@ namespace lms::scanner
};
using ArtistImageAssociationContainer = std::deque<ArtistImageAssociation>;
struct SearchImageContext
struct SearchArtistImageContext
{
db::Session& session;
db::ArtistId lastRetrievedArtistId;
@@ -59,7 +59,7 @@ namespace lms::scanner
std::span<const std::string> artistFileNames;
};
db::Image::pointer findImageInDirectory(SearchImageContext& searchContext, const std::filesystem::path& directoryPath, std::span<const std::string> fileStemsToSearch)
db::Image::pointer findImageInDirectory(SearchArtistImageContext& searchContext, const std::filesystem::path& directoryPath, std::span<const std::string> fileStemsToSearch)
{
db::Image::pointer image;
@@ -85,7 +85,7 @@ namespace lms::scanner
return image;
}
db::Image::pointer getImageFromMbid(SearchImageContext& searchContext, const core::UUID& mbid)
db::Image::pointer getImageFromMbid(SearchArtistImageContext& searchContext, const core::UUID& mbid)
{
db::Image::pointer image;
@@ -98,7 +98,7 @@ namespace lms::scanner
return image;
}
db::Image::pointer searchImageInArtistInfoDirectory(SearchImageContext& searchContext, db::ArtistId artistId)
db::Image::pointer searchImageInArtistInfoDirectory(SearchArtistImageContext& searchContext, db::ArtistId artistId)
{
db::Image::pointer image;
@@ -116,7 +116,7 @@ namespace lms::scanner
return image;
}
db::Image::pointer searchImageInDirectories(SearchImageContext& searchContext, db::ArtistId artistId)
db::Image::pointer searchImageInDirectories(SearchArtistImageContext& searchContext, db::ArtistId artistId)
{
db::Image::pointer image;
@@ -169,7 +169,7 @@ namespace lms::scanner
return image;
}
db::Image::pointer computeBestArtistImage(SearchImageContext& searchContext, const db::Artist::pointer& artist)
db::Image::pointer computeBestArtistImage(SearchArtistImageContext& searchContext, const db::Artist::pointer& artist)
{
db::Image::pointer image;
@@ -185,7 +185,7 @@ namespace lms::scanner
return image;
}
bool fetchNextArtistImagesToUpdate(SearchImageContext& searchContext, ArtistImageAssociationContainer& artistImageAssociations)
bool fetchNextArtistImagesToUpdate(SearchArtistImageContext& searchContext, ArtistImageAssociationContainer& artistImageAssociations)
{
const db::ArtistId artistId{ searchContext.lastRetrievedArtistId };
@@ -254,14 +254,16 @@ namespace lms::scanner
{
}
bool ScanStepAssociateArtistImages::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepAssociateArtistImages::process(ScanContext& context)
{
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() };
{
@@ -269,7 +271,7 @@ namespace lms::scanner
context.currentStepStats.totalElems = db::Artist::getCount(session);
}
SearchImageContext searchContext{
SearchArtistImageContext searchContext{
.session = session,
.lastRetrievedArtistId = {},
.artistFileNames = _artistFileNames,
@@ -37,6 +37,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::AssociateArtistImages; }
core::LiteralString getStepName() const override { return "Associate artist images"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
const std::vector<std::string> _artistFileNames;
@@ -32,9 +32,6 @@ namespace lms::scanner
{
namespace
{
constexpr std::size_t readBatchSize{ 100 };
constexpr std::size_t writeBatchSize{ 20 };
struct TrackLyricsAssociation
{
db::TrackLyricsId trackLyricsId;
@@ -83,6 +80,8 @@ namespace lms::scanner
bool fetchNextTrackLyricsToUpdate(SearchTrackLyricsContext& searchContext, TrackLyricsAssociationContainer& trackLyricsAssociations)
{
constexpr std::size_t readBatchSize{ 100 };
const db::TrackLyricsId trackLyricsId{ searchContext.lastRetrievedTrackLyricsId };
{
@@ -125,6 +124,8 @@ namespace lms::scanner
void updateTrackLyrics(db::Session& session, TrackLyricsAssociationContainer& lyricsAssociations)
{
constexpr std::size_t writeBatchSize{ 20 };
while (!lyricsAssociations.empty())
{
auto transaction{ session.createWriteTransaction() };
@@ -138,14 +139,16 @@ namespace lms::scanner
}
} // namespace
bool ScanStepAssociateExternalLyrics::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepAssociateExternalLyrics::process(ScanContext& context)
{
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() };
{
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::AssociateExternalLyrics; }
core::LiteralString getStepName() const override { return "Associate external lyrics"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -37,9 +37,6 @@ namespace lms::scanner
{
namespace
{
constexpr std::size_t readBatchSize{ 20 };
constexpr std::size_t writeBatchSize{ 5 };
struct TrackInfo
{
db::TrackId trackId;
@@ -118,6 +115,8 @@ namespace lms::scanner
const db::PlayListFileId playListFileIdId{ searchContext.lastRetrievedPlayListFileId };
{
constexpr std::size_t readBatchSize{ 20 };
auto transaction{ searchContext.session.createReadTransaction() };
db::PlayListFile::find(searchContext.session, searchContext.lastRetrievedPlayListFileId, readBatchSize, [&](const db::PlayListFile::pointer& playListFile) {
@@ -199,6 +198,8 @@ namespace lms::scanner
void updatePlayListFiles(db::Session& session, PlayListFileAssociationContainer& playListFileAssociations)
{
constexpr std::size_t writeBatchSize{ 5 };
while (!playListFileAssociations.empty())
{
auto transaction{ session.createWriteTransaction() };
@@ -212,14 +213,19 @@ namespace lms::scanner
}
} // namespace
bool ScanStepAssociatePlayListTracks::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
if (getLastScanSettings() && getLastScanSettings()->skipSingleReleasePlayLists != _settings.skipSingleReleasePlayLists)
return true;
return false;
}
void ScanStepAssociatePlayListTracks::process(ScanContext& context)
{
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() };
{
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::AssociatePlayListTracks; }
core::LiteralString getStepName() const override { return "Associate playlist tracks"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -38,9 +38,6 @@ namespace lms::scanner
{
namespace
{
constexpr std::size_t readBatchSize{ 100 };
constexpr std::size_t writeBatchSize{ 20 };
struct ReleaseImageAssociation
{
db::ReleaseId releaseId;
@@ -48,7 +45,7 @@ namespace lms::scanner
};
using ReleaseImageAssociationContainer = std::deque<ReleaseImageAssociation>;
struct SearchImageContext
struct SearchReleaseImageContext
{
db::Session& session;
db::ReleaseId lastRetrievedReleaseId;
@@ -56,7 +53,7 @@ namespace lms::scanner
const std::vector<std::string>& releaseFileNames;
};
db::Image::pointer findImageInDirectory(SearchImageContext& searchContext, const std::filesystem::path& directoryPath)
db::Image::pointer findImageInDirectory(SearchReleaseImageContext& searchContext, const std::filesystem::path& directoryPath)
{
db::Image::pointer image;
@@ -82,7 +79,7 @@ namespace lms::scanner
return image;
}
db::Image::pointer computeBestReleaseImage(SearchImageContext& searchContext, const db::Release::pointer& release)
db::Image::pointer computeBestReleaseImage(SearchReleaseImageContext& searchContext, const db::Release::pointer& release)
{
db::Image::pointer image;
@@ -130,11 +127,13 @@ namespace lms::scanner
return image;
}
bool fetchNextReleaseImagesToUpdate(SearchImageContext& searchContext, ReleaseImageAssociationContainer& releaseImageAssociations)
bool fetchNextReleaseImagesToUpdate(SearchReleaseImageContext& searchContext, ReleaseImageAssociationContainer& releaseImageAssociations)
{
const db::ReleaseId releaseId{ searchContext.lastRetrievedReleaseId };
{
constexpr std::size_t readBatchSize{ 100 };
auto transaction{ searchContext.session.createReadTransaction() };
db::Release::find(searchContext.session, searchContext.lastRetrievedReleaseId, readBatchSize, [&](const db::Release::pointer& release) {
@@ -166,6 +165,8 @@ namespace lms::scanner
void updateReleaseImages(db::Session& session, ReleaseImageAssociationContainer& imageAssociations)
{
constexpr std::size_t writeBatchSize{ 20 };
while (!imageAssociations.empty())
{
auto transaction{ session.createWriteTransaction() };
@@ -199,14 +200,16 @@ namespace lms::scanner
{
}
bool ScanStepAssociateReleaseImages::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepAssociateReleaseImages::process(ScanContext& context)
{
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() };
{
@@ -214,7 +217,7 @@ namespace lms::scanner
context.currentStepStats.totalElems = db::Release::getCount(session);
}
SearchImageContext searchContext{
SearchReleaseImageContext searchContext{
.session = session,
.lastRetrievedReleaseId = {},
.releaseFileNames = _releaseFileNames,
@@ -37,6 +37,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::AssociateReleaseImages; }
core::LiteralString getStepName() const override { return "Associate release images"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
const std::vector<std::string> _releaseFileNames;
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2023 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 "ScanStepBase.hpp"
namespace lms::scanner
{
ScanStepBase::ScanStepBase(InitParams& initParams)
: _settings{ initParams.settings }
, _progressCallback{ initParams.progressCallback }
, _abortScan{ initParams.abortScan }
, _db{ initParams.db }
, _fileScanners(std::cbegin(initParams.fileScanners), std::cend(initParams.fileScanners))
, _lastScanSettings{ initParams.lastScanSettings }
{
}
ScanStepBase::~ScanStepBase() = default;
} // namespace lms::scanner
@@ -44,29 +44,27 @@ namespace lms::scanner
struct InitParams
{
const ScannerSettings& settings;
const ScannerSettings* lastScanSettings{};
ProgressCallback progressCallback;
bool& abortScan;
db::Db& db;
std::span<IFileScanner*> fileScanners;
};
ScanStepBase(InitParams& initParams)
: _settings{ initParams.settings }
, _progressCallback{ initParams.progressCallback }
, _abortScan{ initParams.abortScan }
, _db{ initParams.db }
, _fileScanners(std::cbegin(initParams.fileScanners), std::cend(initParams.fileScanners))
{
}
protected:
~ScanStepBase() override = default;
ScanStepBase(InitParams& initParams);
~ScanStepBase() override;
ScanStepBase(const ScanStepBase&) = delete;
ScanStepBase& operator=(const ScanStepBase&) = delete;
protected:
const ScannerSettings* getLastScanSettings() const { return _lastScanSettings; }
const ScannerSettings& _settings;
ProgressCallback _progressCallback;
bool& _abortScan;
db::Db& _db;
std::vector<IFileScanner*> _fileScanners;
private:
const ScannerSettings* _lastScanSettings{};
};
} // namespace lms::scanner
@@ -26,13 +26,16 @@
namespace lms::scanner
{
bool ScanStepCheckForDuplicatedFiles::needProcess([[maybe_unused]] const ScanContext& context) const
{
// Always check for everything
return true;
}
void ScanStepCheckForDuplicatedFiles::process(ScanContext& context)
{
using namespace db;
if (_abortScan)
return;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
core::LiteralString getStepName() const override { return "Check for duplicated files"; }
ScanStep getStep() const override { return ScanStep::CheckForDuplicatedFiles; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -41,11 +41,14 @@ namespace lms::scanner
constexpr std::size_t batchSize = 100;
}
bool ScanStepCheckForRemovedFiles::needProcess([[maybe_unused]] const ScanContext& context) const
{
// always check for removed files
return true;
}
void ScanStepCheckForRemovedFiles::process(ScanContext& context)
{
if (_abortScan)
return;
db::Session& session{ _db.getTLSSession() };
{
@@ -34,6 +34,7 @@ namespace lms::scanner
private:
core::LiteralString getStepName() const override { return "Check for removed files"; }
ScanStep getStep() const override { return ScanStep::CheckForRemovedFiles; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
template<typename Object>
@@ -24,10 +24,14 @@
namespace lms::scanner
{
void ScanStepCompact::process(ScanContext& context)
bool ScanStepCompact::needProcess(const ScanContext& context) const
{
// Don't auto compact as it may be too annoying to block the whole application for very large databases
if (context.scanOptions.compact)
return context.scanOptions.compact;
}
void ScanStepCompact::process([[maybe_unused]] ScanContext& context)
{
_db.getTLSSession().vacuum();
}
} // namespace lms::scanner
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::Compact; }
core::LiteralString getStepName() const override { return "Compact"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -25,13 +25,18 @@
namespace lms::scanner
{
bool ScanStepComputeClusterStats::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepComputeClusterStats::process(ScanContext& context)
{
using namespace db;
if (context.stats.nbChanges() == 0)
return;
Session& dbSession{ _db.getTLSSession() };
const std::size_t clusterCount{ [&] {
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::ComputeClusterStats; }
core::LiteralString getStepName() const override { return "Compute cluster stats"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -28,6 +28,12 @@
namespace lms::scanner
{
bool ScanStepDiscoverFiles::needProcess([[maybe_unused]] const ScanContext& context) const
{
// always discover files
return true;
}
void ScanStepDiscoverFiles::process(ScanContext& context)
{
context.stats.totalFileCount = 0;
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::DiscoverFiles; }
core::LiteralString getStepName() const override { return "Discover files"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -25,11 +25,18 @@
namespace lms::scanner
{
void ScanStepOptimize::process(ScanContext& context)
bool ScanStepOptimize::needProcess(const ScanContext& context) const
{
ScanStats& stats{ context.stats };
if (context.scanOptions.forceOptimize)
return true;
if (context.scanOptions.forceOptimize || (stats.nbChanges() > (stats.nbFiles() / 10)))
if (context.stats.nbChanges() > (context.stats.nbFiles() / 10))
return true;
return false;
}
void ScanStepOptimize::process(ScanContext& context)
{
LMS_LOG(DBUPDATER, INFO, "Database analyze started");
@@ -52,5 +59,4 @@ namespace lms::scanner
LMS_LOG(DBUPDATER, INFO, "Database analyze complete");
}
}
} // namespace lms::scanner
@@ -31,6 +31,7 @@ namespace lms::scanner
private:
ScanStep getStep() const override { return ScanStep::Optimize; }
core::LiteralString getStepName() const override { return "Optimize"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -31,6 +31,12 @@
namespace lms::scanner
{
bool ScanStepRemoveOrphanedDbEntries::needProcess([[maybe_unused]] const ScanContext& context) const
{
// fast enough when there is nothing to do
return true;
}
void ScanStepRemoveOrphanedDbEntries::process(ScanContext& context)
{
removeOrphanedClusters(context);

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