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) name: Multi-platform Builds (Alpine)
on: [push, pull_request] on: [pull_request]
jobs: jobs:
Build: Build:
strategy: strategy:
@@ -20,9 +20,9 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: 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 uses: docker/build-push-action@v3
with: with:
context: ./ context: ./
@@ -32,4 +32,4 @@ jobs:
push: false push: false
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/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) name: Basic Build (Arch)
on: [push, pull_request] on: [push]
jobs: jobs:
Build: Build:
strategy:
matrix:
BUILD_TYPE: [Release, Debug]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check Out Repo - name: Check Out Repo
@@ -20,7 +17,7 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Build - name: Build
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
@@ -28,7 +25,10 @@ jobs:
context: ./ context: ./
file: ./Dockerfile-build-arch file: ./Dockerfile-build-arch
builder: ${{ steps.buildx.outputs.name }} 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 push: false
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
+4 -6
View File
@@ -1,8 +1,6 @@
name: "CodeQL" name: "CodeQL"
on: on:
push:
branches: [ "master", "develop" ]
pull_request: pull_request:
branches: [ "master", "develop" ] branches: [ "master", "develop" ]
schedule: schedule:
@@ -42,24 +40,24 @@ jobs:
popd popd
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-and-quality queries: +security-and-quality
- if: matrix.language == 'javascript' - if: matrix.language == 'javascript'
name: Autobuild name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v3
- if: matrix.language == 'cpp' - if: matrix.language == 'cpp'
name: Build name: Build
run: | run: |
mkdir -p build mkdir -p build
cd 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) make -j$(nproc)
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ ARG LMS_BUILD_TYPE="Release"
RUN \ RUN \
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \ DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \
xx-info is-cross && export BUILD_TESTS=OFF || export BUILD_TESTS=ON && \ 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) && \ VERBOSE=1 make -j$(nproc) && \
xx-verify src/lms/lms && \ xx-verify src/lms/lms && \
(xx-info is-cross || make test) (xx-info is-cross || make test)
+13 -8
View File
@@ -12,18 +12,23 @@ ARG BUILD_PACKAGES="\
libconfig \ libconfig \
make \ make \
pkgconfig \ pkgconfig \
stb \
taglib \ taglib \
wt \ wt \
xxhash" xxhash"
RUN pacman -Syu --noconfirm RUN pacman -Syu --noconfirm ${BUILD_PACKAGES} && \
RUN pacman -S --noconfirm ${BUILD_PACKAGES} pacman -Scc --noconfirm && \
rm -rf /var/cache/pacman/pkg/*
# LMS # LMS
COPY . /tmp/lms/ COPY . /tmp/lms/
ARG LMS_BUILD_TYPE="Release" RUN mkdir -p /tmp/lms/build
RUN \ WORKDIR /tmp/lms/build
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 && \ ARG LMS_BUILD_TYPE=Release
VERBOSE=1 make -j$(nproc) && \ ARG LMS_UNITY_BUILD=ON
make test 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/ COPY . /tmp/lms/
RUN \ RUN \
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \ 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 -j$(nproc) install && \
LD_LIBRARY_PATH=${PREFIX}/lib make test && \ LD_LIBRARY_PATH=${PREFIX}/lib make test && \
mkdir -p ${PREFIX}/etc/ && \ mkdir -p ${PREFIX}/etc/ && \
+2 -3
View File
@@ -51,15 +51,14 @@ git clone https://github.com/epoupon/lms.git lms
cd lms cd lms
mkdir build mkdir build
cd 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__: __Notes__:
* you can customize the installation directory using `-DCMAKE_INSTALL_PREFIX=path` (defaults to `/usr/local`). * 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`) * you can customize the image library using `-DLMS_IMAGE_BACKEND=<stb|graphicsmagick>` (defaults to `stb`)
```sh ```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 ### Installation
__Note__: the commands of this section require root privileges. __Note__: the commands of this section require root privileges.
```sh ```sh
+20
View File
@@ -39,6 +39,15 @@
</label> </label>
${artist-tag-delimiter-container class="row gy-3"} ${artist-tag-delimiter-container class="row gy-3"}
</div> </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"> <div class="col-12">
<label class="form-label"> <label class="form-label">
${tr:Lms.Admin.Database.default-tag-delimiters} ${tr:Lms.Admin.Database.default-tag-delimiters}
@@ -58,6 +67,17 @@
</div> </div>
</div> </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"> <div class="col-12">
<label class="form-label" for="${id:similarity-engine-type}"> <label class="form-label" for="${id:similarity-engine-type}">
${tr:Lms.Admin.Database.similarity-engine-type} ${tr:Lms.Admin.Database.similarity-engine-type}
+5 -1
View File
@@ -33,6 +33,7 @@
<plural case="1">{1} tracks</plural> <plural case="1">{1} tracks</plural>
</message> </message>
<message id="Lms.user">User</message> <message id="Lms.user">User</message>
<message id="Lms.uuid-invalid">Invalid UUID</message>
<!--Locale--> <!--Locale-->
<message id="Lms.locale.decimal-point">.</message> <message id="Lms.locale.decimal-point">.</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Root directory</message> <message id="Lms.Admin.MediaLibrary.root-path">Root directory</message>
<!--Scan settings--> <!--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.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.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.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> <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.duplicates-header">{1} duplicate files:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} errors:</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.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.get-report">Get report</message>
<message id="Lms.Admin.ScannerController.last-scan">Last scan</message> <message id="Lms.Admin.ScannerController.last-scan">Last scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Not available</message> <message id="Lms.Admin.ScannerController.last-scan-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-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-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-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-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-removing-orphaned-entries">Removing orphaned entries: {1} entries...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scanning files: {1}/{2} ({3}%)...</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> <plural case="1">{1} pistes</plural>
</message> </message>
<message id="Lms.user">Utilisateur</message> <message id="Lms.user">Utilisateur</message>
<message id="Lms.uuid-invalid">UUID invalide</message>
<!--Locale--> <!--Locale-->
<message id="Lms.locale.decimal-point">,</message> <message id="Lms.locale.decimal-point">,</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Répertoire racine</message> <message id="Lms.Admin.MediaLibrary.root-path">Répertoire racine</message>
<!--Scan settings--> <!--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.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.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.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> <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.duplicates-header">{1} fichiers dupliqués :</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} erreurs :</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.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.get-report">Rapport</message>
<message id="Lms.Admin.ScannerController.last-scan">Dernier scan</message> <message id="Lms.Admin.ScannerController.last-scan">Dernier scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponible</message> <message id="Lms.Admin.ScannerController.last-scan-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-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-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-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-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-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> <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> <plural case="1">{1} tracce</plural>
</message> </message>
<message id="Lms.user">Utente</message> <message id="Lms.user">Utente</message>
<message id="Lms.uuid-invalid">UUID non valido</message>
<!--Locale--> <!--Locale-->
<message id="Lms.locale.decimal-point">,</message> <message id="Lms.locale.decimal-point">,</message>
@@ -72,7 +73,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Cartella principale</message> <message id="Lms.Admin.MediaLibrary.root-path">Cartella principale</message>
<!--Scan settings--> <!--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.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.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.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> <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-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-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-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-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-removing-orphaned-entries">Rimozione voci orfane: {1} voci...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">Scansione dei file: {1}/{2} ({3}%)...</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> <plural case="2">{1} ścieżek</plural>
</message> </message>
<message id="Lms.user">Użytkownik</message> <message id="Lms.user">Użytkownik</message>
<message id="Lms.uuid-invalid">Nieprawidłowy UUID</message>
<!--Locale--> <!--Locale-->
<message id="Lms.locale.decimal-point">,</message> <message id="Lms.locale.decimal-point">,</message>
@@ -73,7 +74,9 @@
<message id="Lms.Admin.MediaLibrary.root-path">Katalog główny</message> <message id="Lms.Admin.MediaLibrary.root-path">Katalog główny</message>
<!--Scan settings--> <!--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.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.daily">Codziennie</message>
<message id="Lms.Admin.Database.default-tag-delimiters">Znaki rozdzielające inne oznaczenia</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> <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> <plural case="2">{1} błędów:</plural>
</message> </message>
<message id="Lms.Admin.ScannerController.force-optimize">Wymuś optymalizację bazy danych</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.get-report">Pokaż raport</message>
<message id="Lms.Admin.ScannerController.last-scan">Ostatnie skanowanie</message> <message id="Lms.Admin.ScannerController.last-scan">Ostatnie skanowanie</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Niedostępne</message> <message id="Lms.Admin.ScannerController.last-scan-not-available">Niedostępne</message>
@@ -150,6 +153,7 @@
</message> </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-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-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-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-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> <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> <message id="Lms.user">用户</message>
<!--Locale--> <!--Locale-->
<message id="Lms.locale.decimal-point">.</message> <message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator">,</message> <message id="Lms.locale.group-separator">,</message>
@@ -73,6 +74,7 @@
<!--Scan settings--> <!--Scan settings-->
<message id="Lms.Admin.Database.daily">每日</message> <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-discovering-files">检索文件中: {1} 文件</message>
<message id="Lms.Admin.ScannerController.step-fetching-track-features">从 AcousticBrainz 获取音轨特征: {1}/{2} 音轨 ({3}%)...</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-reloading-similarity-engine">重载相似引擎中 {1}%...</message>
<message id="Lms.Admin.ScannerController.step-scanning-files">扫描文件中: {1}/{2} 个文件 ({3}%)...</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.ogg_vorbis">Ogg/Vorbis</message>
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message> <message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
<!--Wt--> <!--Wt-->
<message id="Wt.WMessageBox.Yes"></message> <message id="Wt.WMessageBox.Yes"></message>
<message id="Wt.WMessageBox.No"></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) if (_precision == Precision::Invalid)
return ""; return "";
+4 -1
View File
@@ -471,7 +471,7 @@ namespace lms::core::stringUtils
if (dateTime.isValid()) if (dateTime.isValid())
{ {
// assume UTC // 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 ""; return "";
@@ -491,6 +491,9 @@ namespace lms::core::stringUtils
Wt::WDateTime fromISO8601String(std::string_view dateTime) Wt::WDateTime fromISO8601String(std::string_view dateTime)
{ {
// assume UTC // 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"); 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 fromString(std::string_view str);
static PartialDateTime fromWtDateTime(const Wt::WDateTime& dateTime); static PartialDateTime fromWtDateTime(const Wt::WDateTime& dateTime);
std::string toISO8601String() const; std::string toString() const;
constexpr bool isValid() const { return _precision != Precision::Invalid; } constexpr bool isValid() const { return _precision != Precision::Invalid; }
@@ -38,4 +38,7 @@ namespace lms::core
private: private:
T _value{}; T _value{};
}; };
template<typename Tag>
using TaggedBool = TaggedType<Tag, bool>;
} // namespace lms::core } // namespace lms::core
+16 -16
View File
@@ -61,21 +61,21 @@ namespace lms::core::stringUtils::tests
TEST(PartialDateTime, stringComparison) TEST(PartialDateTime, stringComparison)
{ {
EXPECT_EQ((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3, 27 }.toISO8601String())); EXPECT_EQ((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3, 27 }.toString()));
EXPECT_EQ((PartialDateTime{ 1992, 3 }.toISO8601String()), (PartialDateTime{ 1992, 3 }.toISO8601String())); EXPECT_EQ((PartialDateTime{ 1992, 3 }.toString()), (PartialDateTime{ 1992, 3 }.toString()));
EXPECT_EQ(PartialDateTime{ 1992 }.toISO8601String(), PartialDateTime{ 1992 }.toISO8601String()); EXPECT_EQ(PartialDateTime{ 1992 }.toString(), PartialDateTime{ 1992 }.toString());
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3 }.toISO8601String()), (PartialDateTime{ 1992 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3 }.toString()), (PartialDateTime{ 1992 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 3, 28 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 3, 28 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1992, 4, 27 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1992, 4, 27 }.toString()));
EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String())); EXPECT_NE((PartialDateTime{ 1992, 3, 27 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1993, 3, 28 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String())); EXPECT_GT((PartialDateTime{ 1993, 3, 28 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1993, 4 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String())); EXPECT_GT((PartialDateTime{ 1993, 4 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_GT((PartialDateTime{ 1994 }.toISO8601String()), (PartialDateTime{ 1993, 3, 27 }.toISO8601String())); EXPECT_GT((PartialDateTime{ 1994 }.toString()), (PartialDateTime{ 1993, 3, 27 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 3, 28 }.toISO8601String())); EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1993, 3, 28 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1993, 4 }.toISO8601String())); EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1993, 4 }.toString()));
EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toISO8601String()), (PartialDateTime{ 1994 }.toISO8601String())); EXPECT_LT((PartialDateTime{ 1993, 3, 27 }.toString()), (PartialDateTime{ 1994 }.toString()));
} }
TEST(PartialDateTime, stringConversions) TEST(PartialDateTime, stringConversions)
@@ -120,7 +120,7 @@ namespace lms::core::stringUtils::tests
for (const TestCase& test : tests) for (const TestCase& test : tests)
{ {
const PartialDateTime dateTime{ PartialDateTime::fromString(test.input) }; 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 } // 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 } }; 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 } }; 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) 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-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("2020-01-03"), Wt::WDateTime{});
EXPECT_EQ(fromISO8601String(""), Wt::WDateTime{}); EXPECT_EQ(fromISO8601String(""), Wt::WDateTime{});
+1
View File
@@ -5,6 +5,7 @@ add_library(lmsdatabase STATIC
impl/Cluster.cpp impl/Cluster.cpp
impl/Db.cpp impl/Db.cpp
impl/Directory.cpp impl/Directory.cpp
impl/IdType.cpp
impl/Image.cpp impl/Image.cpp
impl/Listen.cpp impl/Listen.cpp
impl/MediaLibrary.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)); 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) RangeResults<ArtistId> Artist::findIds(Session& session, const FindParameters& params)
{ {
session.checkReadTransaction(); session.checkReadTransaction();
@@ -304,6 +280,41 @@ AND NOT EXISTS (
utils::forEachQueryRangeResult(query, params.range, func); 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 ObjectPtr<Image> Artist::getImage() const
{ {
return ObjectPtr<Image>{ _image }; 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) 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) 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 Artist::pointer ArtistInfo::getArtist() const
{ {
return _artist; 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 namespace
{ {
static constexpr Version LMS_DATABASE_VERSION{ 85 }; static constexpr Version LMS_DATABASE_VERSION{ 88 };
} }
VersionInfo::VersionInfo() VersionInfo::VersionInfo()
@@ -1159,6 +1159,36 @@ FROM tracklist)");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); 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) bool doDbMigration(Session& session)
{ {
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1220,6 +1250,9 @@ FROM tracklist)");
{ 82, migrateFromV82 }, { 82, migrateFromV82 },
{ 83, migrateFromV83 }, { 83, migrateFromV83 },
{ 84, migrateFromV84 }, { 84, migrateFromV84 },
{ 85, migrateFromV85 },
{ 86, migrateFromV86 },
{ 87, migrateFromV87 },
}; };
bool migrationPerformed{}; bool migrationPerformed{};
+47 -19
View File
@@ -26,24 +26,33 @@
#include "database/Session.hpp" #include "database/Session.hpp"
#include "Utils.hpp" #include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
#include "traits/StringViewTraits.hpp"
namespace lms::db 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(); 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 std::vector<std::string_view> ScanSettings::getExtraTagsToScan() const
@@ -65,13 +74,19 @@ namespace lms::db
return core::stringUtils::splitEscapedStrings(_defaultTagDelimiters, ';', '\\'); 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) void ScanSettings::setExtraTagsToScan(std::span<const std::string_view> extraTags)
{ {
std::string newTagsToScan{ core::stringUtils::joinStrings(extraTags, ";") }; std::string newTagsToScan{ core::stringUtils::joinStrings(extraTags, ";") };
if (newTagsToScan != _extraTagsToScan) if (newTagsToScan != _extraTagsToScan)
incScanVersion(); {
_extraTagsToScan.swap(newTagsToScan);
_extraTagsToScan = std::move(newTagsToScan); incAudioScanVersion();
}
} }
void ScanSettings::setArtistTagDelimiters(std::span<const std::string_view> delimiters) void ScanSettings::setArtistTagDelimiters(std::span<const std::string_view> delimiters)
@@ -80,7 +95,17 @@ namespace lms::db
if (tagDelimiters != _artistTagDelimiters) if (tagDelimiters != _artistTagDelimiters)
{ {
_artistTagDelimiters.swap(tagDelimiters); _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) if (tagDelimiters != _defaultTagDelimiters)
{ {
_defaultTagDelimiters.swap(tagDelimiters); _defaultTagDelimiters.swap(tagDelimiters);
incScanVersion(); incAudioScanVersion();
} }
} }
void ScanSettings::setSkipSingleReleasePlayLists(bool value) void ScanSettings::setSkipSingleReleasePlayLists(bool value)
{ {
if (_skipSingleReleasePlayLists != value) if (_skipSingleReleasePlayLists != value)
{
_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 } // namespace lms::db
+7 -3
View File
@@ -180,7 +180,9 @@ namespace lms::db
// TODO: move this elsewhere // TODO: move this elsewhere
{ {
auto uniqueTransaction{ createWriteTransaction() }; auto uniqueTransaction{ createWriteTransaction() };
ScanSettings::init(*this);
if (!ScanSettings::find(*this))
create<ScanSettings>();
} }
return migrationPerformed; return migrationPerformed;
@@ -195,13 +197,14 @@ namespace lms::db
auto transaction{ createWriteTransaction() }; 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_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_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_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_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_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_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_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_user_domain_idx ON auth_token(user_id, domain)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)");
@@ -296,8 +299,9 @@ 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 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_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_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_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)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id,type)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id,type)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_type_track_artist_idx ON track_artist_link(type, track_id, artist_id)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_type_track_artist_idx ON track_artist_link(type, track_id, artist_id)");
+71 -4
View File
@@ -55,28 +55,41 @@ namespace lms::db
} }
} // namespace } // 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 } : _type{ type }
, _subType{ subType } , _subType{ subType }
, _artistMBIDMatched{ artistMBIDMatched }
, _track{ getDboPtr(track) } , _track{ getDboPtr(track) }
, _artist{ getDboPtr(artist) } , _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(); 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(); session.getDboSession()->flush();
return res; 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) TrackArtistLink::pointer TrackArtistLink::find(Session& session, TrackArtistLinkId id)
{ {
session.checkReadTransaction(); 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) 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; 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 } // namespace lms::db
@@ -38,7 +38,7 @@ namespace Wt::Dbo
if (!dateTime.isValid()) if (!dateTime.isValid())
statement->bindNull(column); statement->bindNull(column);
else else
statement->bind(column, dateTime.toISO8601String()); statement->bind(column, dateTime.toString());
} }
static bool read(lms::core::PartialDateTime& dateTime, SqlStatement* statement, int column, int size) static bool read(lms::core::PartialDateTime& dateTime, SqlStatement* statement, int column, int size)
@@ -134,8 +134,10 @@ namespace lms::db
// Accessors // Accessors
const std::string& getName() const { return _name; } const std::string& getName() const { return _name; }
const std::string& getSortName() const { return _sortName; } 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; ObjectPtr<Image> getImage() const;
void visitLinks(std::function<void(const ObjectPtr<TrackArtistLink>& link)> visitor) const;
// No artistLinkTypes means get them all // No artistLinkTypes means get them all
RangeResults<ArtistId> findSimilarArtistIds(core::EnumSet<TrackArtistLinkType> artistLinkTypes = {}, std::optional<Range> range = std::nullopt) const; 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 void find(Session& session, ArtistId id, const std::function<void(const pointer&)>& func);
static pointer find(Session& session, const std::filesystem::path& path); 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 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 // getters
const std::filesystem::path& getAbsoluteFilePath() const { return _absoluteFilePath; } const std::filesystem::path& getAbsoluteFilePath() const { return _absoluteFilePath; }
@@ -58,20 +60,26 @@ namespace lms::db
ObjectPtr<Directory> getDirectory() const; ObjectPtr<Directory> getDirectory() const;
ObjectPtr<Artist> getArtist() const; ObjectPtr<Artist> getArtist() const;
DirectoryId getDirectoryId() const { return _directory.id(); } 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 getType() const { return _type; }
std::string_view getGender() const { return _gender; } std::string_view getGender() const { return _gender; }
std::string_view getDisambiguation() const { return _disambiguation; } std::string_view getDisambiguation() const { return _disambiguation; }
std::string_view getBiography() const { return _biography; } std::string_view getBiography() const { return _biography; }
bool isMBIDMatched() const { return _MBIDMatched; }
// setters // setters
void setAbsoluteFilePath(const std::filesystem::path& filePath); void setAbsoluteFilePath(const std::filesystem::path& filePath);
void setLastWriteTime(Wt::WDateTime time) { _fileLastWrite = time; } void setLastWriteTime(Wt::WDateTime time) { _fileLastWrite = time; }
void setDirectory(ObjectPtr<Directory> directory); void setDirectory(ObjectPtr<Directory> directory);
void setArtist(ObjectPtr<Artist> artist); 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 setType(std::string_view type) { _type = type; }
void setGender(std::string_view gender) { _gender = gender; } void setGender(std::string_view gender) { _gender = gender; }
void setDisambiguation(std::string_view disambiguation) { _disambiguation = disambiguation; } void setDisambiguation(std::string_view disambiguation) { _disambiguation = disambiguation; }
void setBiography(std::string_view biography) { _biography = biography; }; void setBiography(std::string_view biography) { _biography = biography; };
void setMBIDMatched(bool matched) { _MBIDMatched = matched; }
template<class Action> template<class Action>
void persist(Action& a) void persist(Action& a)
@@ -79,11 +87,15 @@ namespace lms::db
Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path"); Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path");
Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); 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, _type, "type");
Wt::Dbo::field(a, _gender, "gender"); Wt::Dbo::field(a, _gender, "gender");
Wt::Dbo::field(a, _disambiguation, "disambiguation"); Wt::Dbo::field(a, _disambiguation, "disambiguation");
Wt::Dbo::field(a, _biography, "biography"); 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, _directory, "directory", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade); Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade);
} }
@@ -97,11 +109,18 @@ namespace lms::db
std::string _fileStem; std::string _fileStem;
Wt::WDateTime _fileLastWrite; 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 _type;
std::string _gender; std::string _gender;
std::string _disambiguation; std::string _disambiguation;
std::string _biography; std::string _biography;
bool _MBIDMatched{};
Wt::Dbo::ptr<Directory> _directory; Wt::Dbo::ptr<Directory> _directory;
Wt::Dbo::ptr<Artist> _artist; Wt::Dbo::ptr<Artist> _artist;
}; };
@@ -20,6 +20,7 @@
#pragma once #pragma once
#include <span> #include <span>
#include <vector>
#include "database/ClusterId.hpp" #include "database/ClusterId.hpp"
#include "database/LabelId.hpp" #include "database/LabelId.hpp"
+6 -14
View File
@@ -19,9 +19,6 @@
#pragma once #pragma once
#include <Wt/Dbo/ptr.h>
#include <cassert>
#include <functional>
#include <string> #include <string>
namespace lms::db namespace lms::db
@@ -29,24 +26,19 @@ namespace lms::db
class IdType class IdType
{ {
public: public:
using ValueType = Wt::Dbo::dbo_default_traits::IdType; using ValueType = long long;
IdType() = default; IdType();
IdType(ValueType id) IdType(ValueType id);
: _id{ id } {}
bool isValid() const { return _id != Wt::Dbo::dbo_default_traits::invalidId(); } bool isValid() const;
std::string toString() const std::string toString() const;
{
assert(isValid());
return std::to_string(_id);
}
ValueType getValue() const { return _id; } ValueType getValue() const { return _id; }
auto operator<=>(const IdType& other) const = default; auto operator<=>(const IdType& other) const = default;
private: private:
Wt::Dbo::dbo_default_traits::IdType _id{ Wt::Dbo::dbo_default_traits::invalidId() }; ValueType _id;
}; };
#define LMS_DECLARE_IDTYPE(name) \ #define LMS_DECLARE_IDTYPE(name) \
@@ -19,7 +19,6 @@
#pragma once #pragma once
#include <filesystem>
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
@@ -58,19 +57,22 @@ namespace lms::db
None, 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 // Getters
std::size_t getScanVersion() const { return _scanVersion; } std::size_t getAudioScanVersion() const { return _audioScanVersion; }
Wt::WTime getUpdateStartTime() const { return _startTime; } Wt::WTime getUpdateStartTime() const { return _startTime; }
UpdatePeriod getUpdatePeriod() const { return _updatePeriod; } UpdatePeriod getUpdatePeriod() const { return _updatePeriod; }
std::vector<std::string_view> getExtraTagsToScan() const; std::vector<std::string_view> getExtraTagsToScan() const;
SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; } SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; }
std::vector<std::string> getArtistTagDelimiters() const; std::vector<std::string> getArtistTagDelimiters() const;
std::vector<std::string> getDefaultTagDelimiters() const; std::vector<std::string> getDefaultTagDelimiters() const;
std::vector<std::string> getArtistsToNotSplit() const;
bool getSkipSingleReleasePlayLists() const { return _skipSingleReleasePlayLists; } bool getSkipSingleReleasePlayLists() const { return _skipSingleReleasePlayLists; }
bool getAllowMBIDArtistMerge() const { return _allowMBIDArtistMerge; }
// Setters // Setters
void setUpdateStartTime(Wt::WTime t) { _startTime = t; } void setUpdateStartTime(Wt::WTime t) { _startTime = t; }
@@ -78,31 +80,45 @@ namespace lms::db
void setExtraTagsToScan(std::span<const std::string_view> extraTags); void setExtraTagsToScan(std::span<const std::string_view> extraTags);
void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; } void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; }
void setArtistTagDelimiters(std::span<const std::string_view> delimiters); 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 setDefaultTagDelimiters(std::span<const std::string_view> delimiters);
void setSkipSingleReleasePlayLists(bool value); void setSkipSingleReleasePlayLists(bool value);
void incScanVersion(); void setAllowMBIDArtistMerge(bool value);
template<class Action> template<class Action>
void persist(Action& a) 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, _startTime, "start_time");
Wt::Dbo::field(a, _updatePeriod, "update_period"); Wt::Dbo::field(a, _updatePeriod, "update_period");
Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type"); Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type");
Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan"); Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan");
Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters"); 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, _defaultTagDelimiters, "default_tag_delimiters");
Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists"); Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists");
Wt::Dbo::field(a, _allowMBIDArtistMerge, "allow_mbid_artist_merge");
} }
private: 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 }; Wt::WTime _startTime = Wt::WTime{ 0, 0, 0 };
UpdatePeriod _updatePeriod{ UpdatePeriod::Never }; UpdatePeriod _updatePeriod{ UpdatePeriod::Never };
SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters }; SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters };
std::string _extraTagsToScan; std::string _extraTagsToScan;
std::string _artistTagDelimiters; std::string _artistTagDelimiters;
std::string _artistsToNotSplit;
std::string _defaultTagDelimiters; std::string _defaultTagDelimiters;
bool _skipSingleReleasePlayLists{ false }; bool _skipSingleReleasePlayLists{};
bool _allowMBIDArtistMerge{};
}; };
} // namespace lms::db } // namespace lms::db
@@ -80,32 +80,51 @@ namespace lms::db
}; };
TrackArtistLink() = default; 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, 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 TrackArtistLink::pointer&)>&); static void find(Session& session, const FindParameters& parameters, const std::function<void(const pointer&)>& func);
static pointer find(Session& session, TrackArtistLinkId linkId); 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 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<Track> getTrack() const { return _track; }
ObjectPtr<Artist> getArtist() const { return _artist; } ObjectPtr<Artist> getArtist() const { return _artist; }
TrackArtistLinkType getType() const { return _type; } TrackArtistLinkType getType() const { return _type; }
std::string_view getSubType() const { return _subType; } 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> template<class Action>
void persist(Action& a) void persist(Action& a)
{ {
Wt::Dbo::field(a, _type, "type"); Wt::Dbo::field(a, _type, "type");
Wt::Dbo::field(a, _subType, "subtype"); 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, _track, "track", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade); Wt::Dbo::belongsTo(a, _artist, "artist", Wt::Dbo::OnDeleteCascade);
} }
private: private:
TrackArtistLinkType _type; TrackArtistLinkType _type{ TrackArtistLinkType::Artist };
std::string _subType; 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<Track> _track;
Wt::Dbo::ptr<Artist> _artist; Wt::Dbo::ptr<Artist> _artist;
+99
View File
@@ -109,4 +109,103 @@ namespace lms::db::tests
EXPECT_TRUE(visited); 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 } // namespace lms::db::tests
+2
View File
@@ -15,10 +15,12 @@ add_executable(test-database
RatedRelease.cpp RatedRelease.cpp
RatedTrack.cpp RatedTrack.cpp
Release.cpp Release.cpp
ScanSettings.cpp
StarredArtist.cpp StarredArtist.cpp
StarredRelease.cpp StarredRelease.cpp
StarredTrack.cpp StarredTrack.cpp
Track.cpp Track.cpp
TrackArtistLink.cpp
TrackBookmark.cpp TrackBookmark.cpp
TrackEmbeddedImage.cpp TrackEmbeddedImage.cpp
TrackFeatures.cpp TrackFeatures.cpp
+4
View File
@@ -31,9 +31,11 @@
#include "database/RatedArtist.hpp" #include "database/RatedArtist.hpp"
#include "database/RatedRelease.hpp" #include "database/RatedRelease.hpp"
#include "database/RatedTrack.hpp" #include "database/RatedTrack.hpp"
#include "database/ScanSettings.hpp"
#include "database/StarredArtist.hpp" #include "database/StarredArtist.hpp"
#include "database/StarredRelease.hpp" #include "database/StarredRelease.hpp"
#include "database/StarredTrack.hpp" #include "database/StarredTrack.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackEmbeddedImage.hpp" #include "database/TrackEmbeddedImage.hpp"
#include "database/TrackEmbeddedImageLink.hpp" #include "database/TrackEmbeddedImageLink.hpp"
#include "database/TrackLyrics.hpp" #include "database/TrackLyrics.hpp"
@@ -361,10 +363,12 @@ VALUES
EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{})); EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{}));
EXPECT_FALSE(Release::find(session, ReleaseId{})); EXPECT_FALSE(Release::find(session, ReleaseId{}));
EXPECT_FALSE(ReleaseType::find(session, ReleaseTypeId{})); EXPECT_FALSE(ReleaseType::find(session, ReleaseTypeId{}));
EXPECT_FALSE(ScanSettings::find(session, ScanSettingsId{}));
EXPECT_FALSE(StarredArtist::find(session, StarredArtistId{})); EXPECT_FALSE(StarredArtist::find(session, StarredArtistId{}));
EXPECT_FALSE(StarredRelease::find(session, StarredReleaseId{})); EXPECT_FALSE(StarredRelease::find(session, StarredReleaseId{}));
EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{})); EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{}));
EXPECT_FALSE(Track::find(session, TrackId{})); EXPECT_FALSE(Track::find(session, TrackId{}));
EXPECT_FALSE(TrackArtistLink::find(session, TrackArtistLinkId{}));
EXPECT_FALSE(TrackList::find(session, TrackListId{})); EXPECT_FALSE(TrackList::find(session, TrackListId{}));
EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{})); EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{}));
EXPECT_FALSE(UIState::find(session, UIStateId{})); 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 target_sources(lmsimage PRIVATE
impl/stb/Image.cpp impl/stb/Image.cpp
impl/stb/Exception.cpp
impl/stb/RawImage.cpp impl/stb/RawImage.cpp
impl/stb/StbImage.cpp impl/stb/StbImage.cpp
impl/stb/StbImageResize.cpp impl/stb/StbImageResize.cpp
impl/stb/StbImageWrite.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_compile_options(lmsimage PRIVATE "-DSTB_IMAGE_RESIZE_VERSION=${STB_IMAGE_RESIZE_VERSION}")
target_include_directories(lmsimage PRIVATE ${STB_IMAGE_INCLUDE_DIR}) 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 <array>
#include "Exception.hpp"
#include "StbImage.hpp" #include "StbImage.hpp"
#include "StbImageWrite.hpp" #include "StbImageWrite.hpp"
+1
View File
@@ -19,6 +19,7 @@
#include "RawImage.hpp" #include "RawImage.hpp"
#include "Exception.hpp"
#include "StbImage.hpp" #include "StbImage.hpp"
#include "StbImageResize.hpp" #include "StbImageResize.hpp"
-14
View File
@@ -19,17 +19,3 @@
#define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_IMPLEMENTATION
#include "StbImage.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
-16
View File
@@ -24,19 +24,3 @@
#define STBI_FAILURE_USERMSG #define STBI_FAILURE_USERMSG
#include <stb_image.h> #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/>. * along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/ */
#pragma once
#include <cstddef> #include <cstddef>
namespace lms::image namespace lms::image
+7 -6
View File
@@ -23,6 +23,7 @@
#include <boost/property_tree/xml_parser.hpp> #include <boost/property_tree/xml_parser.hpp>
#include "core/ILogger.hpp" #include "core/ILogger.hpp"
#include "core/String.hpp"
namespace lms::metadata namespace lms::metadata
{ {
@@ -43,12 +44,12 @@ namespace lms::metadata
const auto& artistNode{ root.get_child("artist") }; const auto& artistNode{ root.get_child("artist") };
artistInfo.mbid = core::UUID::fromString(artistNode.get_optional<std::string>("musicBrainzArtistID").value_or("")); artistInfo.mbid = core::UUID::fromString(core::stringUtils::stringTrim(artistNode.get_optional<std::string>("musicBrainzArtistID").value_or("")));
artistInfo.name = artistNode.get_optional<std::string>("name").value_or(""); artistInfo.name = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("name").value_or(""));
artistInfo.sortName = artistNode.get_optional<std::string>("sortname").value_or(""); artistInfo.sortName = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("sortname").value_or(""));
artistInfo.type = artistNode.get_optional<std::string>("type").value_or(""); artistInfo.type = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("type").value_or(""));
artistInfo.gender = artistNode.get_optional<std::string>("gender").value_or(""); artistInfo.gender = core::stringUtils::stringTrim(artistNode.get_optional<std::string>("gender").value_or(""));
artistInfo.disambiguation = artistNode.get_optional<std::string>("disambiguation").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(""); artistInfo.biography = artistNode.get_optional<std::string>("biography").value_or("");
return artistInfo; return artistInfo;
+25 -69
View File
@@ -62,36 +62,41 @@ namespace lms::metadata
} }
template<typename T> 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; std::vector<T> res;
for (const TagType tagType : tagTypes) 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) { tagReader.visitTagValues(tagType, [&](std::string_view value) {
for (std::string_view tagDelimiter : tagDelimiters) value = core::stringUtils::stringTrim(value);
if (!whitelist || !whitelist->contains(value))
{ {
if (value.find(tagDelimiter) != std::string_view::npos) for (std::string_view tagDelimiter : tagDelimiters)
{ {
for (std::string_view splitTag : core::stringUtils::splitString(value, tagDelimiters)) if (value.find(tagDelimiter) != std::string_view::npos)
addTagIfNonEmpty(splitTag); {
for (std::string_view splitTag : core::stringUtils::splitString(value, tagDelimiters))
addTagIfNonEmpty(res, splitTag);
return; return;
}
} }
} }
// no delimiter found, or no delimiter to be used // no delimiter found, or no delimiter to be used
addTagIfNonEmpty(value); addTagIfNonEmpty(res, value);
}); });
if (!res.empty()) if (!res.empty())
@@ -153,11 +158,11 @@ namespace lms::metadata
std::initializer_list<TagType> artistMBIDTagNames, std::initializer_list<TagType> artistMBIDTagNames,
const AudioFileParserParameters& params) 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()) if (artistNames.empty())
return {}; 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<core::UUID> artistMBIDs{ getTagValuesFirstMatchAs<core::UUID>(tagReader, artistMBIDTagNames, params.defaultTagDelimiters) };
std::vector<Artist> artists; std::vector<Artist> artists;
@@ -238,6 +243,7 @@ namespace lms::metadata
// Otherwise, we reconstruct the string using a standard, hardcoded, join // Otherwise, we reconstruct the string using a standard, hardcoded, join
if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames)) if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames))
{ {
// Limitation: this test does not take the whitelist into account
if (!strIsContainingAny(*artistTag, artistTagDelimiters)) if (!strIsContainingAny(*artistTag, artistTagDelimiters))
artistDisplayName = *artistTag; artistDisplayName = *artistTag;
} }
@@ -267,54 +273,6 @@ namespace lms::metadata
return std::nullopt; 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 } // namespace
std::unique_ptr<IAudioFileParser> createAudioFileParser(const AudioFileParserParameters& params) 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.remixerArtists = getArtists(tagReader, { TagType::Remixers, TagType::Remixer }, { TagType::RemixersSortOrder, TagType::RemixerSortOrder }, {}, _params);
track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported
fillMissingMbids(track);
// If a file has originalDate but no originalYear, set it // If a file has originalDate but no originalYear, set it
if (!track.originalYear) if (!track.originalYear)
track.originalYear = track.originalDate.getYear(); track.originalYear = track.originalDate.getYear();
+3 -3
View File
@@ -30,7 +30,7 @@ namespace lms::metadata
namespace namespace
{ {
// Mapping to internal avformat names and/or common alternative custom names // 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::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
{ TagType::Advisory, { "ITUNESADVISORY" } }, { TagType::Advisory, { "ITUNESADVISORY" } },
{ TagType::Album, { "ALBUM", "TALB", "WM/ALBUMTITLE" } }, { TagType::Album, { "ALBUM", "TALB", "WM/ALBUMTITLE" } },
@@ -171,8 +171,8 @@ namespace lms::metadata
void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
{ {
auto itTagNames{ tagMapping.find(tag) }; auto itTagNames{ avFormatTagMapping.find(tag) };
if (itTagNames == std::cend(tagMapping)) if (itTagNames == std::cend(avFormatTagMapping))
return; return;
for (const std::string& tagName : itTagNames->second) for (const std::string& tagName : itTagNames->second)
+2 -2
View File
@@ -48,7 +48,7 @@ namespace lms::metadata
{ {
namespace namespace
{ {
class ParsingFailedException : public Exception class ImageParsingFailedException : public Exception
{ {
}; };
@@ -362,7 +362,7 @@ namespace lms::metadata
if (_file.isNull()) if (_file.isNull())
{ {
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed"); LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw ParsingFailedException{}; throw ImageParsingFailedException{};
} }
} }
+6 -6
View File
@@ -55,12 +55,12 @@ namespace lms::metadata
{ {
namespace namespace
{ {
class ParsingFailedException : public Exception class TagParsingFailedException : public Exception
{ {
}; };
// Mapping to internal taglib names and/or common alternative custom names // 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::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
{ TagType::Advisory, { "ITUNESADVISORY" } }, { TagType::Advisory, { "ITUNESADVISORY" } },
{ TagType::Album, { "ALBUM" } }, { TagType::Album, { "ALBUM" } },
@@ -228,13 +228,13 @@ namespace lms::metadata
if (_file.isNull()) if (_file.isNull())
{ {
LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed"); LMS_LOG(METADATA, ERROR, "File " << p << ": parsing failed");
throw ParsingFailedException{}; throw TagParsingFailedException{};
} }
if (!_file.audioProperties()) if (!_file.audioProperties())
{ {
LMS_LOG(METADATA, ERROR, "File " << p << ": no audio properties"); LMS_LOG(METADATA, ERROR, "File " << p << ": no audio properties");
throw ParsingFailedException{}; throw TagParsingFailedException{};
} }
computeAudioProperties(); computeAudioProperties();
@@ -465,8 +465,8 @@ namespace lms::metadata
void TagLibTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const void TagLibTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
{ {
auto itTagNames{ tagMapping.find(tag) }; auto itTagNames{ tagLibTagMapping.find(tag) };
if (itTagNames == std::cend(tagMapping)) if (itTagNames == std::cend(tagLibTagMapping))
return; return;
for (const std::string& tagName : itTagNames->second) for (const std::string& tagName : itTagNames->second)
@@ -25,6 +25,7 @@
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <unordered_set>
#include <vector> #include <vector>
#include "core/PartialDateTime.hpp" #include "core/PartialDateTime.hpp"
@@ -213,11 +214,28 @@ namespace lms::metadata
Accurate, 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 struct AudioFileParserParameters
{ {
ParserBackend backend{ ParserBackend::TagLib }; ParserBackend backend{ ParserBackend::TagLib };
ParserReadStyle readStyle{ ParserReadStyle::Average }; ParserReadStyle readStyle{ ParserReadStyle::Average };
std::vector<std::string> artistTagDelimiters; std::vector<std::string> artistTagDelimiters;
WhiteList artistsToNotSplit;
std::vector<std::string> defaultTagDelimiters; std::vector<std::string> defaultTagDelimiters;
std::vector<std::string> userExtraTags; std::vector<std::string> userExtraTags;
bool debug{}; 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.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."); 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 } // namespace lms::metadata::tests
+51 -127
View File
@@ -263,9 +263,9 @@ namespace lms::metadata
const TestTagReader testTags{ const TestTagReader testTags{
{ {
{ TagType::Album, { "MyAlbum" } }, { TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtist, { "AlbumArtist1 / AlbumArtist2" } }, { TagType::AlbumArtist, { "AlbumArtist1 / AlbumArtist2" } },
{ TagType::Artist, { " Artist1 / Artist2 feat. Artist3 " } }, { TagType::Artist, { " Artist1 / Artist2 feat. Artist3 " } },
{ TagType::Genre, { "Genre1 ; Genre2" } }, { TagType::Genre, { "Genre1 ; Genre2" } },
{ TagType::Language, { " Lang1/Lang2 / Lang3" } }, { TagType::Language, { " Lang1/Lang2 / Lang3" } },
} }
}; };
@@ -295,11 +295,60 @@ namespace lms::metadata
// Release // Release
ASSERT_TRUE(track->medium->release.has_value()); ASSERT_TRUE(track->medium->release.has_value());
EXPECT_EQ(track->medium->release->name, "MyAlbum"); 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[0].name, "AlbumArtist1");
EXPECT_EQ(track->medium->release->artists[1].name, "AlbumArtist2"); EXPECT_EQ(track->medium->release->artists[1].name, "AlbumArtist2");
EXPECT_EQ(track->medium->release->artistDisplayName, "AlbumArtist1, 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) TEST(AudioFileParser, customDelimiters_foundInArtist)
{ {
const TestTagReader testTags{ const TestTagReader testTags{
@@ -605,131 +654,6 @@ namespace lms::metadata
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstruct the artist display name 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) TEST(AudioFileParser, release_sortNameFallback)
{ {
const TestTagReader testTags{ const TestTagReader testTags{
@@ -20,6 +20,8 @@
#pragma once #pragma once
#include <atomic> #include <atomic>
#include <cassert>
#include <memory>
#include <optional> #include <optional>
#include <shared_mutex> #include <shared_mutex>
#include <unordered_map> #include <unordered_map>
@@ -27,7 +29,6 @@
#include "database/ImageId.hpp" #include "database/ImageId.hpp"
#include "database/TrackEmbeddedImageId.hpp" #include "database/TrackEmbeddedImageId.hpp"
#include "database/TrackId.hpp"
#include "image/IEncodedImage.hpp" #include "image/IEncodedImage.hpp"
namespace lms::cover namespace lms::cover
@@ -19,6 +19,7 @@
#pragma once #pragma once
#include <memory>
#include <string_view> #include <string_view>
#include "database/UserId.hpp" #include "database/UserId.hpp"
@@ -19,6 +19,8 @@
#pragma once #pragma once
#include <memory>
#include "database/StarredArtistId.hpp" #include "database/StarredArtistId.hpp"
#include "database/StarredReleaseId.hpp" #include "database/StarredReleaseId.hpp"
#include "database/StarredTrackId.hpp" #include "database/StarredTrackId.hpp"
@@ -36,7 +36,7 @@ namespace lms::recommendation
{ {
auto transaction{ session.createReadTransaction() }; auto transaction{ session.createReadTransaction() };
return db::ScanSettings::get(session)->getSimilarityEngineType(); return db::ScanSettings::find(session)->getSimilarityEngineType();
} }
} // namespace } // namespace
+4
View File
@@ -1,15 +1,19 @@
add_library(lmsscanner STATIC add_library(lmsscanner STATIC
impl/helpers/ArtistHelpers.cpp
impl/scanners/ArtistInfoFileScanner.cpp impl/scanners/ArtistInfoFileScanner.cpp
impl/scanners/AudioFileScanOperation.cpp
impl/scanners/AudioFileScanner.cpp impl/scanners/AudioFileScanner.cpp
impl/scanners/ImageFileScanner.cpp impl/scanners/ImageFileScanner.cpp
impl/scanners/LyricsFileScanner.cpp impl/scanners/LyricsFileScanner.cpp
impl/scanners/PlayListFileScanner.cpp impl/scanners/PlayListFileScanner.cpp
impl/scanners/Utils.cpp impl/scanners/Utils.cpp
impl/steps/FileScanQueue.cpp impl/steps/FileScanQueue.cpp
impl/steps/ScanStepArtistReconciliation.cpp
impl/steps/ScanStepAssociateArtistImages.cpp impl/steps/ScanStepAssociateArtistImages.cpp
impl/steps/ScanStepAssociateExternalLyrics.cpp impl/steps/ScanStepAssociateExternalLyrics.cpp
impl/steps/ScanStepAssociatePlayListTracks.cpp impl/steps/ScanStepAssociatePlayListTracks.cpp
impl/steps/ScanStepAssociateReleaseImages.cpp impl/steps/ScanStepAssociateReleaseImages.cpp
impl/steps/ScanStepBase.cpp
impl/steps/ScanStepCheckForDuplicatedFiles.cpp impl/steps/ScanStepCheckForDuplicatedFiles.cpp
impl/steps/ScanStepCheckForRemovedFiles.cpp impl/steps/ScanStepCheckForRemovedFiles.cpp
impl/steps/ScanStepCompact.cpp impl/steps/ScanStepCompact.cpp
+110 -62
View File
@@ -28,6 +28,7 @@
#include "core/ITraceLogger.hpp" #include "core/ITraceLogger.hpp"
#include "database/MediaLibrary.hpp" #include "database/MediaLibrary.hpp"
#include "database/ScanSettings.hpp" #include "database/ScanSettings.hpp"
#include "database/Session.hpp"
#include "scanners/ArtistInfoFileScanner.hpp" #include "scanners/ArtistInfoFileScanner.hpp"
#include "scanners/AudioFileScanner.hpp" #include "scanners/AudioFileScanner.hpp"
@@ -35,6 +36,7 @@
#include "scanners/LyricsFileScanner.hpp" #include "scanners/LyricsFileScanner.hpp"
#include "scanners/PlayListFileScanner.hpp" #include "scanners/PlayListFileScanner.hpp"
#include "steps/ScanStepArtistReconciliation.hpp"
#include "steps/ScanStepAssociateArtistImages.hpp" #include "steps/ScanStepAssociateArtistImages.hpp"
#include "steps/ScanStepAssociateExternalLyrics.hpp" #include "steps/ScanStepAssociateExternalLyrics.hpp"
#include "steps/ScanStepAssociatePlayListTracks.hpp" #include "steps/ScanStepAssociatePlayListTracks.hpp"
@@ -55,6 +57,9 @@ namespace lms::scanner
namespace namespace
{ {
static constexpr std::string_view currentSettingsName{ "" };
static constexpr std::string_view lastScanSettingsName{ "last_scan" };
Wt::WDate getNextMonday(Wt::WDate current) Wt::WDate getNextMonday(Wt::WDate current)
{ {
do do
@@ -74,6 +79,61 @@ namespace lms::scanner
return current; 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 } // namespace
std::unique_ptr<IScannerService> createScannerService(Db& db) std::unique_ptr<IScannerService> createScannerService(Db& db)
@@ -282,26 +342,7 @@ namespace lms::scanner
ScanStats& stats{ scanContext.stats }; ScanStats& stats{ scanContext.stats };
stats.startTime = Wt::WDateTime::currentDateTime(); stats.startTime = Wt::WDateTime::currentDateTime();
std::size_t stepIndex{}; processScanSteps(scanContext);
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() << "'");
}
{ {
std::unique_lock lock{ _statusMutex }; std::unique_lock lock{ _statusMutex };
@@ -321,6 +362,10 @@ namespace lms::scanner
_lastCompleteScanStats = stats; _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!"); LMS_LOG(DBUPDATER, DEBUG, "Scan not aborted, scheduling next scan!");
scheduleNextScan(); 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() void ScannerService::refreshScanSettings()
{ {
ScannerSettings newSettings{ readSettings() }; std::optional<ScannerSettings> newSettings{ readScannerSettings(_db.getTLSSession(), currentSettingsName) };
if (_settings == newSettings) assert(newSettings.has_value());
if (_settings == *newSettings)
return; return;
LMS_LOG(DBUPDATER, DEBUG, "Scanner settings updated"); 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) { auto cbFunc{ [this](const ScanStepStats& stats) {
notifyInProgressIfNeeded(stats); notifyInProgressIfNeeded(stats);
} }; } };
_fileScanners.clear(); _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<AudioFileScanner>(_db, _settings));
_fileScanners.emplace_back(std::make_unique<ImageFileScanner>(_db)); _fileScanners.emplace_back(std::make_unique<ImageFileScanner>(_db));
_fileScanners.emplace_back(std::make_unique<LyricsFileScanner>(_db)); _fileScanners.emplace_back(std::make_unique<LyricsFileScanner>(_db));
@@ -359,6 +442,7 @@ namespace lms::scanner
ScanStepBase::InitParams params{ ScanStepBase::InitParams params{
.settings = _settings, .settings = _settings,
.lastScanSettings = _lastScanSettings.has_value() ? &(_lastScanSettings.value()) : nullptr,
.progressCallback = cbFunc, .progressCallback = cbFunc,
.abortScan = _abortScan, .abortScan = _abortScan,
.db = _db, .db = _db,
@@ -370,6 +454,7 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique<ScanStepDiscoverFiles>(params)); _scanSteps.emplace_back(std::make_unique<ScanStepDiscoverFiles>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepScanFiles>(params)); _scanSteps.emplace_back(std::make_unique<ScanStepScanFiles>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepCheckForRemovedFiles>(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<ScanStepAssociatePlayListTracks>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepUpdateLibraryFields>(params)); _scanSteps.emplace_back(std::make_unique<ScanStepUpdateLibraryFields>(params));
_scanSteps.emplace_back(std::make_unique<ScanStepAssociateArtistImages>(params)); _scanSteps.emplace_back(std::make_unique<ScanStepAssociateArtistImages>(params));
@@ -382,43 +467,6 @@ namespace lms::scanner
_scanSteps.emplace_back(std::make_unique<ScanStepCheckForDuplicatedFiles>(params)); _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) void ScannerService::notifyInProgress(const ScanStepStats& stepStats)
{ {
{ {
@@ -39,6 +39,9 @@ namespace lms::scanner
{ {
class IFileScanner; 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 class ScannerService : public IScannerService
{ {
public: public:
@@ -65,12 +68,12 @@ namespace lms::scanner
// Update database (scheduled callback) // Update database (scheduled callback)
void scan(const ScanOptions& scanOptions); void scan(const ScanOptions& scanOptions);
void processScanSteps(ScanContext& context);
void scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats); void scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats);
// Helpers // Helpers
void refreshScanSettings(); void refreshScanSettings();
ScannerSettings readSettings();
void notifyInProgressIfNeeded(const ScanStepStats& stats); void notifyInProgressIfNeeded(const ScanStepStats& stats);
void notifyInProgress(const ScanStepStats& stats); void notifyInProgress(const ScanStepStats& stats);
@@ -93,5 +96,6 @@ namespace lms::scanner
Wt::WDateTime _nextScheduledScan; Wt::WDateTime _nextScheduledScan;
ScannerSettings _settings; ScannerSettings _settings;
std::optional<ScannerSettings> _lastScanSettings;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -35,14 +35,16 @@ namespace lms::scanner
struct ScannerSettings struct ScannerSettings
{ {
std::size_t scanVersion{}; std::size_t audioScanVersion{};
Wt::WTime startTime; Wt::WTime startTime;
db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never }; db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never };
bool skipDuplicateMBID{}; bool skipDuplicateTrackMBID{};
std::vector<std::string> extraTags; std::vector<std::string> extraTags;
std::vector<std::string> artistTagDelimiters; std::vector<std::string> artistTagDelimiters;
std::vector<std::string> artistsToNotSplit;
std::vector<std::string> defaultTagDelimiters; std::vector<std::string> defaultTagDelimiters;
bool skipSingleReleasePlayLists{}; bool skipSingleReleasePlayLists{};
bool allowArtistMBIDFallback{ true }; // TODO false?
std::vector<MediaLibraryInfo> mediaLibraries; 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 "IFileScanOperation.hpp"
#include "ScanContext.hpp" #include "ScanContext.hpp"
#include "ScannerSettings.hpp"
#include "Utils.hpp" #include "Utils.hpp"
#include "helpers/ArtistHelpers.hpp"
#include "metadata/Types.hpp"
namespace lms::scanner namespace lms::scanner
{ {
@@ -41,10 +44,13 @@ namespace lms::scanner
class ArtistInfoFileScanOperation : public IFileScanOperation class ArtistInfoFileScanOperation : public IFileScanOperation
{ {
public: public:
ArtistInfoFileScanOperation(const FileToScan& file, db::Db& db) ArtistInfoFileScanOperation(const FileToScan& file, const ScannerSettings& settings, db::Db& db)
: _file{ file.file } : _file{ file.file }
, _mediaLibrary{ file.mediaLibrary } , _mediaLibrary{ file.mediaLibrary }
, _db{ db } {} , _settings{ settings }
, _db{ db }
{
}
~ArtistInfoFileScanOperation() override = default; ~ArtistInfoFileScanOperation() override = default;
ArtistInfoFileScanOperation(const ArtistInfoFileScanOperation&) = delete; ArtistInfoFileScanOperation(const ArtistInfoFileScanOperation&) = delete;
ArtistInfoFileScanOperation& operator=(const ArtistInfoFileScanOperation&) = delete; ArtistInfoFileScanOperation& operator=(const ArtistInfoFileScanOperation&) = delete;
@@ -59,6 +65,7 @@ namespace lms::scanner
const std::filesystem::path _file; const std::filesystem::path _file;
const MediaLibraryInfo _mediaLibrary; const MediaLibraryInfo _mediaLibrary;
const ScannerSettings& _settings;
db::Db& _db; db::Db& _db;
std::optional<metadata::ArtistInfo> _parsedArtistInfo; std::optional<metadata::ArtistInfo> _parsedArtistInfo;
@@ -76,12 +83,7 @@ namespace lms::scanner
} }
_parsedArtistInfo = metadata::parseArtistInfo(ifs); _parsedArtistInfo = metadata::parseArtistInfo(ifs);
if (!_parsedArtistInfo->mbid.has_value()) if (_parsedArtistInfo->name.empty())
{
LMS_LOG(DBUPDATER, DEBUG, "Discarding artist info in file " << _file << ": no mbid set");
_parsedArtistInfo.reset();
}
else if (_parsedArtistInfo->name.empty())
{ {
LMS_LOG(DBUPDATER, DEBUG, "Discarding artist info in file " << _file << ": no name set"); LMS_LOG(DBUPDATER, DEBUG, "Discarding artist info in file " << _file << ": no name set");
_parsedArtistInfo.reset(); _parsedArtistInfo.reset();
@@ -125,6 +127,8 @@ namespace lms::scanner
artistInfo.modify()->setAbsoluteFilePath(_file); artistInfo.modify()->setAbsoluteFilePath(_file);
} }
artistInfo.modify()->setName(_parsedArtistInfo->name);
artistInfo.modify()->setSortName(_parsedArtistInfo->sortName);
artistInfo.modify()->setLastWriteTime(fileInfo->lastWriteTime); artistInfo.modify()->setLastWriteTime(fileInfo->lastWriteTime);
artistInfo.modify()->setType(_parsedArtistInfo->type); artistInfo.modify()->setType(_parsedArtistInfo->type);
artistInfo.modify()->setGender(_parsedArtistInfo->gender); 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 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)); artistInfo.modify()->setDirectory(utils::getOrCreateDirectory(dbSession, _file.parent_path(), mediaLibrary));
db::Artist::pointer artist{ db::Artist::find(dbSession, *_parsedArtistInfo->mbid) }; const metadata::Artist artistMetadata{ _parsedArtistInfo->mbid, _parsedArtistInfo->name, _parsedArtistInfo->sortName.empty() ? std::nullopt : std::make_optional<std::string>(_parsedArtistInfo->sortName) };
if (!artist) db::Artist::pointer artist{ helpers::getOrCreateArtist(dbSession, artistMetadata, helpers::AllowFallbackOnMBIDEntry{ _settings.allowArtistMBIDFallback }) };
artist = dbSession.create<db::Artist>(_parsedArtistInfo->name, _parsedArtistInfo->mbid);
artist.modify()->setName(_parsedArtistInfo->name);
artist.modify()->setSortName(_parsedArtistInfo->sortName);
artistInfo.modify()->setArtist(artist); artistInfo.modify()->setArtist(artist);
if (added) if (added)
@@ -155,8 +155,9 @@ namespace lms::scanner
} }
} // namespace } // namespace
ArtistInfoFileScanner::ArtistInfoFileScanner(db::Db& db) ArtistInfoFileScanner::ArtistInfoFileScanner(const ScannerSettings& settings, db::Db& db)
: _db{ db } : _settings{ settings }
, _db{ db }
{ {
} }
@@ -202,6 +203,6 @@ namespace lms::scanner
std::unique_ptr<IFileScanOperation> ArtistInfoFileScanner::createScanOperation(const FileToScan& fileToScan) const 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 } // namespace lms::scanner
@@ -31,10 +31,12 @@ namespace lms
namespace lms::scanner namespace lms::scanner
{ {
struct ScannerSettings;
class ArtistInfoFileScanner : public IFileScanner class ArtistInfoFileScanner : public IFileScanner
{ {
public: public:
ArtistInfoFileScanner(db::Db& db); ArtistInfoFileScanner(const ScannerSettings& _settings, db::Db& db);
~ArtistInfoFileScanner() override = default; ~ArtistInfoFileScanner() override = default;
ArtistInfoFileScanner(const ArtistInfoFileScanner&) = delete; ArtistInfoFileScanner(const ArtistInfoFileScanner&) = delete;
ArtistInfoFileScanner& operator=(const ArtistInfoFileScanner&) = delete; ArtistInfoFileScanner& operator=(const ArtistInfoFileScanner&) = delete;
@@ -45,6 +47,7 @@ namespace lms::scanner
bool needsScan(ScanContext& context, const FileToScan& file) const override; bool needsScan(ScanContext& context, const FileToScan& file) const override;
std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override; std::unique_ptr<IFileScanOperation> createScanOperation(const FileToScan& fileToScan) const override;
const ScannerSettings& _settings;
db::Db& _db; db::Db& _db;
}; };
} // namespace lms::scanner } // 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 "AudioFileScanner.hpp"
#include "core/IConfig.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/Service.hpp"
#include "core/XxHash3.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Db.hpp" #include "database/Db.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp" #include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp" #include "database/Session.hpp"
#include "database/Track.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 "ScanContext.hpp"
#include "ScannerSettings.hpp" #include "ScannerSettings.hpp"
#include "Utils.hpp" #include "Utils.hpp"
#include "metadata/Types.hpp"
namespace lms::scanner namespace lms::scanner
{ {
namespace 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() metadata::ParserReadStyle getParserReadStyle()
{ {
std::string_view readStyle{ core::Service<core::IConfig>::get()->getString("scanner-parser-read-style", "average") }; 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.userExtraTags = settings.extraTags;
params.artistTagDelimiters = settings.artistTagDelimiters; params.artistTagDelimiters = settings.artistTagDelimiters;
params.defaultTagDelimiters = settings.defaultTagDelimiters; params.defaultTagDelimiters = settings.defaultTagDelimiters;
params.artistsToNotSplit.insert(settings.artistsToNotSplit.cbegin(), settings.artistsToNotSplit.end());
params.backend = metadata::ParserBackend::TagLib; params.backend = metadata::ParserBackend::TagLib;
params.readStyle = getParserReadStyle(); params.readStyle = getParserReadStyle();
@@ -845,7 +63,6 @@ namespace lms::scanner
} }
} // namespace } // namespace
AudioFileScanner::AudioFileScanner(db::Db& db, const ScannerSettings& settings) AudioFileScanner::AudioFileScanner(db::Db& db, const ScannerSettings& settings)
: _db{ db } : _db{ db }
, _settings{ settings } , _settings{ settings }
@@ -890,7 +107,7 @@ namespace lms::scanner
const db::Track::pointer track{ db::Track::findByPath(dbSession, file.file) }; const db::Track::pointer track{ db::Track::findByPath(dbSession, file.file) };
if (track if (track
&& track->getLastWriteTime() == lastWriteTime && 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 // 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() }; 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 "core/LiteralString.hpp"
#include "MediaLibraryInfo.hpp" #include "FileToScan.hpp"
namespace lms::scanner namespace lms::scanner
{ {
@@ -32,12 +32,6 @@ namespace lms::scanner
struct ScanContext; struct ScanContext;
struct ScannerSettings; struct ScannerSettings;
struct FileToScan
{
std::filesystem::path file;
MediaLibraryInfo mediaLibrary;
};
class IFileScanner class IFileScanner
{ {
public: public:
@@ -32,6 +32,7 @@ namespace lms::scanner
virtual ScanStep getStep() const = 0; virtual ScanStep getStep() const = 0;
virtual core::LiteralString getStepName() const = 0; virtual core::LiteralString getStepName() const = 0;
virtual bool needProcess(const ScanContext& context) const = 0;
virtual void process(ScanContext& context) = 0; virtual void process(ScanContext& context) = 0;
}; };
} // namespace lms::scanner } // 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>; using ArtistImageAssociationContainer = std::deque<ArtistImageAssociation>;
struct SearchImageContext struct SearchArtistImageContext
{ {
db::Session& session; db::Session& session;
db::ArtistId lastRetrievedArtistId; db::ArtistId lastRetrievedArtistId;
@@ -59,7 +59,7 @@ namespace lms::scanner
std::span<const std::string> artistFileNames; 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; db::Image::pointer image;
@@ -85,7 +85,7 @@ namespace lms::scanner
return image; 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; db::Image::pointer image;
@@ -98,7 +98,7 @@ namespace lms::scanner
return image; return image;
} }
db::Image::pointer searchImageInArtistInfoDirectory(SearchImageContext& searchContext, db::ArtistId artistId) db::Image::pointer searchImageInArtistInfoDirectory(SearchArtistImageContext& searchContext, db::ArtistId artistId)
{ {
db::Image::pointer image; db::Image::pointer image;
@@ -116,7 +116,7 @@ namespace lms::scanner
return image; return image;
} }
db::Image::pointer searchImageInDirectories(SearchImageContext& searchContext, db::ArtistId artistId) db::Image::pointer searchImageInDirectories(SearchArtistImageContext& searchContext, db::ArtistId artistId)
{ {
db::Image::pointer image; db::Image::pointer image;
@@ -169,7 +169,7 @@ namespace lms::scanner
return image; 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; db::Image::pointer image;
@@ -185,7 +185,7 @@ namespace lms::scanner
return image; return image;
} }
bool fetchNextArtistImagesToUpdate(SearchImageContext& searchContext, ArtistImageAssociationContainer& artistImageAssociations) bool fetchNextArtistImagesToUpdate(SearchArtistImageContext& searchContext, ArtistImageAssociationContainer& artistImageAssociations)
{ {
const db::ArtistId artistId{ searchContext.lastRetrievedArtistId }; 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) void ScanStepAssociateArtistImages::process(ScanContext& context)
{ {
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() }; auto& session{ _db.getTLSSession() };
{ {
@@ -269,7 +271,7 @@ namespace lms::scanner
context.currentStepStats.totalElems = db::Artist::getCount(session); context.currentStepStats.totalElems = db::Artist::getCount(session);
} }
SearchImageContext searchContext{ SearchArtistImageContext searchContext{
.session = session, .session = session,
.lastRetrievedArtistId = {}, .lastRetrievedArtistId = {},
.artistFileNames = _artistFileNames, .artistFileNames = _artistFileNames,
@@ -37,6 +37,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::AssociateArtistImages; } ScanStep getStep() const override { return ScanStep::AssociateArtistImages; }
core::LiteralString getStepName() const override { return "Associate artist images"; } core::LiteralString getStepName() const override { return "Associate artist images"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
const std::vector<std::string> _artistFileNames; const std::vector<std::string> _artistFileNames;
@@ -32,9 +32,6 @@ namespace lms::scanner
{ {
namespace namespace
{ {
constexpr std::size_t readBatchSize{ 100 };
constexpr std::size_t writeBatchSize{ 20 };
struct TrackLyricsAssociation struct TrackLyricsAssociation
{ {
db::TrackLyricsId trackLyricsId; db::TrackLyricsId trackLyricsId;
@@ -83,6 +80,8 @@ namespace lms::scanner
bool fetchNextTrackLyricsToUpdate(SearchTrackLyricsContext& searchContext, TrackLyricsAssociationContainer& trackLyricsAssociations) bool fetchNextTrackLyricsToUpdate(SearchTrackLyricsContext& searchContext, TrackLyricsAssociationContainer& trackLyricsAssociations)
{ {
constexpr std::size_t readBatchSize{ 100 };
const db::TrackLyricsId trackLyricsId{ searchContext.lastRetrievedTrackLyricsId }; const db::TrackLyricsId trackLyricsId{ searchContext.lastRetrievedTrackLyricsId };
{ {
@@ -125,6 +124,8 @@ namespace lms::scanner
void updateTrackLyrics(db::Session& session, TrackLyricsAssociationContainer& lyricsAssociations) void updateTrackLyrics(db::Session& session, TrackLyricsAssociationContainer& lyricsAssociations)
{ {
constexpr std::size_t writeBatchSize{ 20 };
while (!lyricsAssociations.empty()) while (!lyricsAssociations.empty())
{ {
auto transaction{ session.createWriteTransaction() }; auto transaction{ session.createWriteTransaction() };
@@ -138,14 +139,16 @@ namespace lms::scanner
} }
} // namespace } // namespace
bool ScanStepAssociateExternalLyrics::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepAssociateExternalLyrics::process(ScanContext& context) void ScanStepAssociateExternalLyrics::process(ScanContext& context)
{ {
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() }; auto& session{ _db.getTLSSession() };
{ {
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::AssociateExternalLyrics; } ScanStep getStep() const override { return ScanStep::AssociateExternalLyrics; }
core::LiteralString getStepName() const override { return "Associate external lyrics"; } core::LiteralString getStepName() const override { return "Associate external lyrics"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -37,9 +37,6 @@ namespace lms::scanner
{ {
namespace namespace
{ {
constexpr std::size_t readBatchSize{ 20 };
constexpr std::size_t writeBatchSize{ 5 };
struct TrackInfo struct TrackInfo
{ {
db::TrackId trackId; db::TrackId trackId;
@@ -118,6 +115,8 @@ namespace lms::scanner
const db::PlayListFileId playListFileIdId{ searchContext.lastRetrievedPlayListFileId }; const db::PlayListFileId playListFileIdId{ searchContext.lastRetrievedPlayListFileId };
{ {
constexpr std::size_t readBatchSize{ 20 };
auto transaction{ searchContext.session.createReadTransaction() }; auto transaction{ searchContext.session.createReadTransaction() };
db::PlayListFile::find(searchContext.session, searchContext.lastRetrievedPlayListFileId, readBatchSize, [&](const db::PlayListFile::pointer& playListFile) { 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) void updatePlayListFiles(db::Session& session, PlayListFileAssociationContainer& playListFileAssociations)
{ {
constexpr std::size_t writeBatchSize{ 5 };
while (!playListFileAssociations.empty()) while (!playListFileAssociations.empty())
{ {
auto transaction{ session.createWriteTransaction() }; auto transaction{ session.createWriteTransaction() };
@@ -212,14 +213,19 @@ namespace lms::scanner
} }
} // namespace } // 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) void ScanStepAssociatePlayListTracks::process(ScanContext& context)
{ {
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() }; auto& session{ _db.getTLSSession() };
{ {
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::AssociatePlayListTracks; } ScanStep getStep() const override { return ScanStep::AssociatePlayListTracks; }
core::LiteralString getStepName() const override { return "Associate playlist tracks"; } core::LiteralString getStepName() const override { return "Associate playlist tracks"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -38,9 +38,6 @@ namespace lms::scanner
{ {
namespace namespace
{ {
constexpr std::size_t readBatchSize{ 100 };
constexpr std::size_t writeBatchSize{ 20 };
struct ReleaseImageAssociation struct ReleaseImageAssociation
{ {
db::ReleaseId releaseId; db::ReleaseId releaseId;
@@ -48,7 +45,7 @@ namespace lms::scanner
}; };
using ReleaseImageAssociationContainer = std::deque<ReleaseImageAssociation>; using ReleaseImageAssociationContainer = std::deque<ReleaseImageAssociation>;
struct SearchImageContext struct SearchReleaseImageContext
{ {
db::Session& session; db::Session& session;
db::ReleaseId lastRetrievedReleaseId; db::ReleaseId lastRetrievedReleaseId;
@@ -56,7 +53,7 @@ namespace lms::scanner
const std::vector<std::string>& releaseFileNames; 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; db::Image::pointer image;
@@ -82,7 +79,7 @@ namespace lms::scanner
return image; 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; db::Image::pointer image;
@@ -130,11 +127,13 @@ namespace lms::scanner
return image; return image;
} }
bool fetchNextReleaseImagesToUpdate(SearchImageContext& searchContext, ReleaseImageAssociationContainer& releaseImageAssociations) bool fetchNextReleaseImagesToUpdate(SearchReleaseImageContext& searchContext, ReleaseImageAssociationContainer& releaseImageAssociations)
{ {
const db::ReleaseId releaseId{ searchContext.lastRetrievedReleaseId }; const db::ReleaseId releaseId{ searchContext.lastRetrievedReleaseId };
{ {
constexpr std::size_t readBatchSize{ 100 };
auto transaction{ searchContext.session.createReadTransaction() }; auto transaction{ searchContext.session.createReadTransaction() };
db::Release::find(searchContext.session, searchContext.lastRetrievedReleaseId, readBatchSize, [&](const db::Release::pointer& release) { 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) void updateReleaseImages(db::Session& session, ReleaseImageAssociationContainer& imageAssociations)
{ {
constexpr std::size_t writeBatchSize{ 20 };
while (!imageAssociations.empty()) while (!imageAssociations.empty())
{ {
auto transaction{ session.createWriteTransaction() }; 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) void ScanStepAssociateReleaseImages::process(ScanContext& context)
{ {
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() }; auto& session{ _db.getTLSSession() };
{ {
@@ -214,7 +217,7 @@ namespace lms::scanner
context.currentStepStats.totalElems = db::Release::getCount(session); context.currentStepStats.totalElems = db::Release::getCount(session);
} }
SearchImageContext searchContext{ SearchReleaseImageContext searchContext{
.session = session, .session = session,
.lastRetrievedReleaseId = {}, .lastRetrievedReleaseId = {},
.releaseFileNames = _releaseFileNames, .releaseFileNames = _releaseFileNames,
@@ -37,6 +37,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::AssociateReleaseImages; } ScanStep getStep() const override { return ScanStep::AssociateReleaseImages; }
core::LiteralString getStepName() const override { return "Associate release images"; } core::LiteralString getStepName() const override { return "Associate release images"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
const std::vector<std::string> _releaseFileNames; 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 struct InitParams
{ {
const ScannerSettings& settings; const ScannerSettings& settings;
const ScannerSettings* lastScanSettings{};
ProgressCallback progressCallback; ProgressCallback progressCallback;
bool& abortScan; bool& abortScan;
db::Db& db; db::Db& db;
std::span<IFileScanner*> fileScanners; std::span<IFileScanner*> fileScanners;
}; };
ScanStepBase(InitParams& initParams) ScanStepBase(InitParams& initParams);
: _settings{ initParams.settings } ~ScanStepBase() override;
, _progressCallback{ initParams.progressCallback }
, _abortScan{ initParams.abortScan }
, _db{ initParams.db }
, _fileScanners(std::cbegin(initParams.fileScanners), std::cend(initParams.fileScanners))
{
}
protected:
~ScanStepBase() override = default;
ScanStepBase(const ScanStepBase&) = delete; ScanStepBase(const ScanStepBase&) = delete;
ScanStepBase& operator=(const ScanStepBase&) = delete; ScanStepBase& operator=(const ScanStepBase&) = delete;
protected:
const ScannerSettings* getLastScanSettings() const { return _lastScanSettings; }
const ScannerSettings& _settings; const ScannerSettings& _settings;
ProgressCallback _progressCallback; ProgressCallback _progressCallback;
bool& _abortScan; bool& _abortScan;
db::Db& _db; db::Db& _db;
std::vector<IFileScanner*> _fileScanners; std::vector<IFileScanner*> _fileScanners;
private:
const ScannerSettings* _lastScanSettings{};
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -26,13 +26,16 @@
namespace lms::scanner namespace lms::scanner
{ {
bool ScanStepCheckForDuplicatedFiles::needProcess([[maybe_unused]] const ScanContext& context) const
{
// Always check for everything
return true;
}
void ScanStepCheckForDuplicatedFiles::process(ScanContext& context) void ScanStepCheckForDuplicatedFiles::process(ScanContext& context)
{ {
using namespace db; using namespace db;
if (_abortScan)
return;
Session& session{ _db.getTLSSession() }; Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() }; auto transaction{ session.createReadTransaction() };
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
core::LiteralString getStepName() const override { return "Check for duplicated files"; } core::LiteralString getStepName() const override { return "Check for duplicated files"; }
ScanStep getStep() const override { return ScanStep::CheckForDuplicatedFiles; } ScanStep getStep() const override { return ScanStep::CheckForDuplicatedFiles; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -41,11 +41,14 @@ namespace lms::scanner
constexpr std::size_t batchSize = 100; 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) void ScanStepCheckForRemovedFiles::process(ScanContext& context)
{ {
if (_abortScan)
return;
db::Session& session{ _db.getTLSSession() }; db::Session& session{ _db.getTLSSession() };
{ {
@@ -34,6 +34,7 @@ namespace lms::scanner
private: private:
core::LiteralString getStepName() const override { return "Check for removed files"; } core::LiteralString getStepName() const override { return "Check for removed files"; }
ScanStep getStep() const override { return ScanStep::CheckForRemovedFiles; } ScanStep getStep() const override { return ScanStep::CheckForRemovedFiles; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
template<typename Object> template<typename Object>
@@ -24,10 +24,14 @@
namespace lms::scanner 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 // 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;
_db.getTLSSession().vacuum(); }
void ScanStepCompact::process([[maybe_unused]] ScanContext& context)
{
_db.getTLSSession().vacuum();
} }
} // namespace lms::scanner } // namespace lms::scanner
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::Compact; } ScanStep getStep() const override { return ScanStep::Compact; }
core::LiteralString getStepName() const override { return "Compact"; } core::LiteralString getStepName() const override { return "Compact"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -25,13 +25,18 @@
namespace lms::scanner namespace lms::scanner
{ {
bool ScanStepComputeClusterStats::needProcess(const ScanContext& context) const
{
if (context.stats.nbChanges() > 0)
return true;
return false;
}
void ScanStepComputeClusterStats::process(ScanContext& context) void ScanStepComputeClusterStats::process(ScanContext& context)
{ {
using namespace db; using namespace db;
if (context.stats.nbChanges() == 0)
return;
Session& dbSession{ _db.getTLSSession() }; Session& dbSession{ _db.getTLSSession() };
const std::size_t clusterCount{ [&] { const std::size_t clusterCount{ [&] {
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::ComputeClusterStats; } ScanStep getStep() const override { return ScanStep::ComputeClusterStats; }
core::LiteralString getStepName() const override { return "Compute cluster stats"; } core::LiteralString getStepName() const override { return "Compute cluster stats"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -28,6 +28,12 @@
namespace lms::scanner namespace lms::scanner
{ {
bool ScanStepDiscoverFiles::needProcess([[maybe_unused]] const ScanContext& context) const
{
// always discover files
return true;
}
void ScanStepDiscoverFiles::process(ScanContext& context) void ScanStepDiscoverFiles::process(ScanContext& context)
{ {
context.stats.totalFileCount = 0; context.stats.totalFileCount = 0;
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::DiscoverFiles; } ScanStep getStep() const override { return ScanStep::DiscoverFiles; }
core::LiteralString getStepName() const override { return "Discover files"; } core::LiteralString getStepName() const override { return "Discover files"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -25,32 +25,38 @@
namespace lms::scanner namespace lms::scanner
{ {
bool ScanStepOptimize::needProcess(const ScanContext& context) const
{
if (context.scanOptions.forceOptimize)
return true;
if (context.stats.nbChanges() > (context.stats.nbFiles() / 10))
return true;
return false;
}
void ScanStepOptimize::process(ScanContext& context) void ScanStepOptimize::process(ScanContext& context)
{ {
ScanStats& stats{ context.stats }; LMS_LOG(DBUPDATER, INFO, "Database analyze started");
if (context.scanOptions.forceOptimize || (stats.nbChanges() > (stats.nbFiles() / 10))) auto& session{ _db.getTLSSession() };
std::vector<std::string> entries;
session.retrieveEntriesToAnalyze(entries);
context.currentStepStats.totalElems = entries.size();
_progressCallback(context.currentStepStats);
for (const std::string& entry : entries)
{ {
LMS_LOG(DBUPDATER, INFO, "Database analyze started"); if (_abortScan)
break;
auto& session{ _db.getTLSSession() }; _db.getTLSSession().analyzeEntry(entry);
context.currentStepStats.processedElems++;
std::vector<std::string> entries;
session.retrieveEntriesToAnalyze(entries);
context.currentStepStats.totalElems = entries.size();
_progressCallback(context.currentStepStats); _progressCallback(context.currentStepStats);
for (const std::string& entry : entries)
{
if (_abortScan)
break;
_db.getTLSSession().analyzeEntry(entry);
context.currentStepStats.processedElems++;
_progressCallback(context.currentStepStats);
}
LMS_LOG(DBUPDATER, INFO, "Database analyze complete");
} }
LMS_LOG(DBUPDATER, INFO, "Database analyze complete");
} }
} // namespace lms::scanner } // namespace lms::scanner
@@ -31,6 +31,7 @@ namespace lms::scanner
private: private:
ScanStep getStep() const override { return ScanStep::Optimize; } ScanStep getStep() const override { return ScanStep::Optimize; }
core::LiteralString getStepName() const override { return "Optimize"; } core::LiteralString getStepName() const override { return "Optimize"; }
bool needProcess(const ScanContext& context) const override;
void process(ScanContext& context) override; void process(ScanContext& context) override;
}; };
} // namespace lms::scanner } // namespace lms::scanner
@@ -31,6 +31,12 @@
namespace lms::scanner 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) void ScanStepRemoveOrphanedDbEntries::process(ScanContext& context)
{ {
removeOrphanedClusters(context); removeOrphanedClusters(context);

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