Merge branch 'develop' for release v3.60.0

This commit is contained in:
emeric
2024-10-30 23:07:23 +01:00
80 changed files with 3028 additions and 381 deletions
+10 -2
View File
@@ -8,10 +8,11 @@
+ [Upgrade](#upgrade)
- [Deployment](#deployment)
* [Configuration](#configuration)
* [Authentication backend](#authentication-backend)
* [Authentication backends](#authentication-backends)
* [Deploy on non root path](#deploy-on-non-root-path)
* [Reverse proxy settings](#reverse-proxy-settings)
- [Run](#run)
- [First launch](#first-launch)
# Installation
## Docker
_Docker_ images are available, please see detailed instructions on https://hub.docker.com/r/epoupon/lms.
@@ -100,7 +101,7 @@ __Note__: don't forget to give the _lms_ user read access to the music directory
_LMS_ uses a configuration file, installed by default in `/etc/lms.conf`. It is recommended to edit this file and change relevant settings (listen address, listen port, working directory, Subsonic API activation, deployment path, ...).
All other settings are set using the web interface (user management, scan settings, transcode settings, ...).
If a setting is not present in the configuration file, a hardcoded default value is used (the same as in the [default configuration file](conf/lms.conf))
## Authentication backend
## Authentication backends
You can define which authentication backend to be used thanks to the `authentication-backend` option:
* `internal` (default): _LMS_ uses an internal database to store users and their associated passwords (salted and hashed using [Bcrypt](https://en.wikipedia.org/wiki/Bcrypt)). Only the admin user can create, edit or remove other users.
* `PAM`: the user/password authentication request is forwarded to PAM (see the default [PAM configuration file](conf/pam/lms) provided).
@@ -170,3 +171,10 @@ Log traces can be accessed using journactl:
journalctl -u lms.service
```
To connect to _LMS_, just open your favorite browser and go to `http://localhost:5082`
# First launch
At the first launch, a setup assistant will guide you through creating the administrator account (username and password). Once this information is saved, you can refresh the page to access the _LMS_ login screen.
__Note__: If you are using `PAM` or `http-header` authentication, this setup assistant will not appear (see [Authentication backends](#authentication-backends)).
Once logged in as an administrator, you will need to define your libraries—that is, select the music folders you want to scan. Then, check that the scan settings meet your needs (scan frequency, tag delimiters, etc.).
You can now initiate the initial scan to import your music into _LMS_.
+1
View File
@@ -51,3 +51,4 @@ The following extra fields are implemented:
## Supported extensions
* [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/)
* [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/)
+12 -2
View File
@@ -34,6 +34,13 @@
</message>
<message id="Lms.user">User</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator">,</message>
<message id="Lms.locale.date-format">MM/dd/yyyy</message>
<message id="Lms.locale.time-format">HH:mm:ss</message>
<message id="Lms.locale.date-time-format">MM/dd/yyyy HH:mm:ss</message>
<!--Errors-->
<message id="Lms.Error.artist-not-found">Artist not found</message>
<message id="Lms.Error.error-occurred">Error occured!</message>
@@ -89,6 +96,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Cannot get track duration</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Cannot parse audio file</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Cannot parse image file</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Cannot parse lyrics file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Cannot read file</message>
<message id="Lms.Admin.ScannerController.compact">Compact the database. <strong>Caution:</strong> this may take a while and will block the whole application during the compact step!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} duplicate files:</message>
@@ -98,7 +106,7 @@
<message id="Lms.Admin.ScannerController.get-report">Get report</message>
<message id="Lms.Admin.ScannerController.last-scan">Last scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Not available</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates)</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} files were scanned in {2} at {4} on {3} (UTC) - {5} errors, {6} duplicates</message>
<message id="Lms.Admin.ScannerController.no-audio-track">No audio track</message>
<message id="Lms.Admin.ScannerController.same-hash">Duplicated file hash</message>
<message id="Lms.Admin.ScannerController.same-mbid">Duplicated track MBID</message>
@@ -107,9 +115,10 @@
<message id="Lms.Admin.ScannerController.scanner">Scanner</message>
<message id="Lms.Admin.ScannerController.status">Status</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Not scheduled</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Scheduled on {1}</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Scheduled at {2} on {1} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Scanning: step {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Associating artist images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associating external lyrics: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associating release images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Checking for duplicate files... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Checking for removed files... {1}%</message>
@@ -182,6 +191,7 @@
<message id="Lms.Explore.star">Star</message>
<message id="Lms.Explore.starred">Starred</message>
<message id="Lms.Explore.track-info">Track information</message>
<message id="Lms.Explore.track-lyrics">Lyrics</message>
<message id="Lms.Explore.tracklists">Playlists</message>
<message id="Lms.Explore.tracks">Tracks</message>
<message id="Lms.Explore.type">Type</message>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<messages xmlns:if="Wt.WTemplate.conditions"
nplurals="2" plural="n > 1 ? 1 : 0">
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator">,</message>
<message id="Lms.locale.date-format">MM/dd/yyyy</message>
<message id="Lms.locale.time-format">hh:mm:ss a</message>
<message id="Lms.locale.date-time-format">MM/dd/yyyy hh:mm:ss a</message>
</messages>
+42 -2
View File
@@ -34,6 +34,13 @@
</message>
<message id="Lms.user">Utilisateur</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">Cet artiste n'existe pas</message>
<message id="Lms.Error.error-occurred">Une erreur est survenue!</message>
@@ -89,6 +96,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Impossible de récupérer la durée de la piste</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossible d'analyser le fichier audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossible d'analyser le fichier image</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossible d'analyser le fichier de paroles</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Impossible de lire le fichier</message>
<message id="Lms.Admin.ScannerController.compact">Compacter la base de données. <strong>Attention :</strong> cette opération peut prendre du temps et va vérouiller l'application pendant toute l'étape de compactage!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} fichiers dupliqués :</message>
@@ -98,7 +106,7 @@
<message id="Lms.Admin.ScannerController.get-report">Rapport</message>
<message id="Lms.Admin.ScannerController.last-scan">Dernier scan</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponible</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas)</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} fichiers analysés en {2} à {4} le {3} (UTC) - {5} erreurs, {6} duplicatas</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Pas de piste audio</message>
<message id="Lms.Admin.ScannerController.same-hash">Hash dupliqué</message>
<message id="Lms.Admin.ScannerController.same-mbid">Track MBID dupliqué</message>
@@ -107,9 +115,10 @@
<message id="Lms.Admin.ScannerController.scanner">Scanner</message>
<message id="Lms.Admin.ScannerController.status">Statut</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Non planifié</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Planifié le {1}</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Planifié à {2} le {1} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">En cours de scan : étape {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-release-images">Association des images des albums: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Vérification des fichiers dupliqués... {1} fichiers</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Vérification des fichiers supprimés... {1}%</message>
@@ -182,6 +191,7 @@
<message id="Lms.Explore.star">Ajouter aux favoris</message>
<message id="Lms.Explore.starred">Favoris</message>
<message id="Lms.Explore.track-info">Informations sur la piste</message>
<message id="Lms.Explore.track-lyrics">Paroles</message>
<message id="Lms.Explore.tracklists">Playlists</message>
<message id="Lms.Explore.tracks">Pistes</message>
<message id="Lms.Explore.type">Type</message>
@@ -328,6 +338,36 @@
<message id="Lms.Settings.settings-saved">Paramètres sauvegardés !</message>
<!--Wt-->
<message id="Wt.WDateTime.LessThanASecond">moins d'une seconde</message>
<message id="Wt.WDateTime.seconds">
<plural case="0">une second</plural>
<plural case="1">{1} secondes</plural>
</message>
<message id="Wt.WDateTime.minutes">
<plural case="0">une minute</plural>
<plural case="1">{1} minutes</plural>
</message>
<message id="Wt.WDateTime.hours">
<plural case="0">une houre</plural>
<plural case="1">{1} houres</plural>
</message>
<message id="Wt.WDateTime.days">
<plural case="0">un jour</plural>
<plural case="1">{1} jours</plural>
</message>
<message id="Wt.WDateTime.weeks">
<plural case="0">une semaine</plural>
<plural case="1">{1} semaines</plural>
</message>
<message id="Wt.WDateTime.months">
<plural case="0">un mois</plural>
<plural case="1">{1} mois</plural>
</message>
<message id="Wt.WDateTime.years">
<plural case="0">une année</plural>
<plural case="1">{1} années</plural>
</message>
<message id="Wt.WDateTime.null"></message>
<message id="Wt.WMessageBox.Yes">Oui</message>
<message id="Wt.WMessageBox.No">Non</message>
<message id="Wt.WValidator.Invalid">Ce champ ne peut pas être vide</message>
+42 -2
View File
@@ -34,6 +34,13 @@
</message>
<message id="Lms.user">Utente</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">Artista non trovato</message>
<message id="Lms.Error.error-occurred">C'è stato un errore!</message>
@@ -89,6 +96,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Non sono stato in grado di determinare la durata della traccia</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossibile analizzare il file audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossibile analizzare il file immagine</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossibile analizzare il file dei testi</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Non in grado di leggere il file</message>
<message id="Lms.Admin.ScannerController.compact">Compatta il database. <strong>Attenzione:</strong> ciò potrebbe richiedere del tempo e bloccherà l'intera applicazione durante il passaggio di compattazione!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} file duplicati:</message>
@@ -98,7 +106,7 @@
<message id="Lms.Admin.ScannerController.get-report">Scarica un resoconto</message>
<message id="Lms.Admin.ScannerController.last-scan">Ultima scansione</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Non disponibile</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Scansionati {1} files in {2} su {3} ({4} errori, {5} duplicati)</message>
<message id="Lms.Admin.ScannerController.last-scan-status">{1} file scansionati in {2} alle {3} il {4} (UTC) - {5} errori, {6} duplicati</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Nessuna traccia audio</message>
<message id="Lms.Admin.ScannerController.same-hash">Hash doppio</message>
<message id="Lms.Admin.ScannerController.same-mbid">Track MBID doppio</message>
@@ -107,9 +115,10 @@
<message id="Lms.Admin.ScannerController.scanner">Scanner</message>
<message id="Lms.Admin.ScannerController.status">Stato</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Non pianificato</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Pianificato il {1}</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Programmato alle {2} del {1} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Scansione: passo {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Associando immagini degli artisti: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associazione dei testi esterni: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associando immagini degli album: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Controllo duplicati... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Controllo file... {1}%</message>
@@ -182,6 +191,7 @@
<message id="Lms.Explore.star">Aggiungi ai preferiti</message>
<message id="Lms.Explore.starred">Preferiti</message>
<message id="Lms.Explore.track-info">Informazioni sulla traccia</message>
<message id="Lms.Explore.track-lyrics">Testo</message>
<message id="Lms.Explore.tracklists">Playlist</message>
<message id="Lms.Explore.tracks">Tracce</message>
<message id="Lms.Explore.type">Tipo</message>
@@ -328,6 +338,36 @@
<message id="Lms.Settings.settings-saved">Nuove impostazioni salvate!</message>
<!--Wt-->
<message id="Wt.WDateTime.LessThanASecond">meno di un secondo</message>
<message id="Wt.WDateTime.seconds">
<plural case="0">un secondo</plural>
<plural case="1">{1} secondi</plural>
</message>
<message id="Wt.WDateTime.minutes">
<plural case="0">un minuto</plural>
<plural case="1">{1} minuti</plural>
</message>
<message id="Wt.WDateTime.hours">
<plural case="0">un'ora</plural>
<plural case="1">{1} ore</plural>
</message>
<message id="Wt.WDateTime.days">
<plural case="0">un giorno</plural>
<plural case="1">{1} giorni</plural>
</message>
<message id="Wt.WDateTime.weeks">
<plural case="0">una settimana</plural>
<plural case="1">{1} settimane</plural>
</message>
<message id="Wt.WDateTime.months">
<plural case="0">un mese</plural>
<plural case="1">{1} mesi</plural>
</message>
<message id="Wt.WDateTime.years">
<plural case="0">un anno</plural>
<plural case="1">{1} anni</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">Questo campo non può essere vuoto</message>
+50 -2
View File
@@ -35,6 +35,13 @@
</message>
<message id="Lms.user">Użytkownik</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">Artysta nie odnaleziony</message>
<message id="Lms.Error.error-occurred">Wystąpił błąd!</message>
@@ -90,6 +97,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Nie udało się ustalić długości ścieżki</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Nie można przeanalizować pliku audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Nie można przeanalizować pliku obrazu</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Nie można przetworzyć pliku z tekstem</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Nie udało się odczytać pliku</message>
<message id="Lms.Admin.ScannerController.compact">Sprasuj bazę danych. <strong>Uwaga:</strong> może to trochę zająć, a cała aplikacja będzie w tym czasie zablokowana!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">
@@ -107,7 +115,7 @@
<message id="Lms.Admin.ScannerController.get-report">Pokaż raport</message>
<message id="Lms.Admin.ScannerController.last-scan">Ostatnie skanowanie</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">Niedostępne</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Przeskanowano {1} plików w {2} o {3} ({4} błędów, {5} duplikatów)</message>
<message id="Lms.Admin.ScannerController.last-scan-status">Przeskanowano {1} plików w {2} o {4} dnia {3} (UTC) - {5} błędów, {6} duplikatów</message>
<message id="Lms.Admin.ScannerController.no-audio-track">Brak ścieżki dźwiękowej</message>
<message id="Lms.Admin.ScannerController.same-hash">Zduplikowny hash pliku</message>
<message id="Lms.Admin.ScannerController.same-mbid">Zduplikowany MBID pliku</message>
@@ -116,9 +124,10 @@
<message id="Lms.Admin.ScannerController.scanner">Skaner</message>
<message id="Lms.Admin.ScannerController.status">Status</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">Nie zaplanowano</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Zaplanowano na {1}</message>
<message id="Lms.Admin.ScannerController.status-scheduled">Zaplanowano o {2} dnia {1} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Skanowanie: krok {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Kojarzenie obrazów artystów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Kojarzenie zewnętrznych tekstów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Kojarzenie obrazów albumów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">
<plural case="0">Sprawdzanie duplikatów... {1} plik</plural>
@@ -199,6 +208,7 @@
<message id="Lms.Explore.star">Wyróżnij</message>
<message id="Lms.Explore.starred">Wyróżnione</message>
<message id="Lms.Explore.track-info">Informacje o ścieżce</message>
<message id="Lms.Explore.track-lyrics">Tekst</message>
<message id="Lms.Explore.tracklists">Listy odtwarzania</message>
<message id="Lms.Explore.tracks">Ścieżki</message>
<message id="Lms.Explore.type">Rodzaj</message>
@@ -353,4 +363,42 @@
<message id="Lms.Settings.transcoding-mode.never">Nigdy</message>
<message id="Lms.Settings.settings-saved">Ustawienia zapisane!</message>
<!--Wt-->
<message id="Wt.WDateTime.LessThanASecond">mniej niż sekunda</message>
<message id="Wt.WDateTime.seconds">
<plural case="0">jedna sekunda</plural>
<plural case="1">{1} sekundy</plural>
<plural case="2">{1} sekund</plural>
</message>
<message id="Wt.WDateTime.minutes">
<plural case="0">jedna minuta</plural>
<plural case="1">{1} minuty</plural>
<plural case="2">{1} minut</plural>
</message>
<message id="Wt.WDateTime.hours">
<plural case="0">jedna godzina</plural>
<plural case="1">{1} godziny</plural>
<plural case="2">{1} godzin</plural>
</message>
<message id="Wt.WDateTime.days">
<plural case="0">jeden dzień</plural>
<plural case="1">{1} dni</plural>
<plural case="2">{1} dni</plural>
</message>
<message id="Wt.WDateTime.weeks">
<plural case="0">jeden tydzień</plural>
<plural case="1">{1} tygodnie</plural>
<plural case="2">{1} tygodni</plural>
</message>
<message id="Wt.WDateTime.months">
<plural case="0">jeden miesiąc</plural>
<plural case="1">{1} miesiące</plural>
<plural case="2">{1} miesięcy</plural>
</message>
<message id="Wt.WDateTime.years">
<plural case="0">jeden rok</plural>
<plural case="1">{1} lata</plural>
<plural case="2">{1} lat</plural>
</message>
<message id="Wt.WDateTime.null"></message>
</messages>
+12 -3
View File
@@ -34,6 +34,13 @@
<message id="Lms.user">用户</message>
<!--Locale-->
<message id="Lms.locale.decimal-point">.</message>
<message id="Lms.locale.group-separator">,</message>
<message id="Lms.locale.date-format">yyyy/MM/dd</message>
<message id="Lms.locale.time-format">HH:mm:ss</message>
<message id="Lms.locale.date-time-format">yyyy/MM/dd HH:mm:ss</message>
<!--Errors-->
<message id="Lms.Error.artist-not-found">歌手未找到</message>
<message id="Lms.Error.error-occurred">发生错误!</message>
@@ -85,13 +92,13 @@
<message id="Lms.Admin.Database.update-start-time">更新开始时间</message>
<message id="Lms.Admin.Database.weekly">每周</message>
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">无法获得音轨时间</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">无法解析文件</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">无法读取文件</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} 个重复文件:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} 个错误:</message>
@@ -99,7 +106,7 @@
<message id="Lms.Admin.ScannerController.get-report">生成报告</message>
<message id="Lms.Admin.ScannerController.last-scan">最后扫描</message>
<message id="Lms.Admin.ScannerController.last-scan-not-available">不可用</message>
<message id="Lms.Admin.ScannerController.last-scan-status">在 {2} 内扫描了 {3} 的 {1} 个文件({4} 个错误, {5} 个副本)</message>
<message id="Lms.Admin.ScannerController.last-scan-status">已扫描 {1} 个文件,用时 {2},在 {3} 的 {4} (UTC) - {5} 个错误,{6} 个重复项</message>
<message id="Lms.Admin.ScannerController.no-audio-track">没有音轨</message>
<message id="Lms.Admin.ScannerController.same-hash">相同文件哈希值</message>
<message id="Lms.Admin.ScannerController.same-mbid">相同 MBID</message>
@@ -108,11 +115,12 @@
<message id="Lms.Admin.ScannerController.scanner">扫描器</message>
<message id="Lms.Admin.ScannerController.status">状态</message>
<message id="Lms.Admin.ScannerController.status-not-scheduled">无计划</message>
<message id="Lms.Admin.ScannerController.status-scheduled">计划于 {1}</message>
<message id="Lms.Admin.ScannerController.status-scheduled">计划于 {2} 在 {1} (UTC)</message>
<message id="Lms.Admin.ScannerController.status-in-progress">扫描中: 阶段 {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">检查文件中... {1}%</message>
@@ -183,6 +191,7 @@
<message id="Lms.Explore.star">收藏</message>
<message id="Lms.Explore.starred">已收藏</message>
<message id="Lms.Explore.tracklists">播放列表</message>
<message id="Lms.Explore.tracks">音轨</message>
<message id="Lms.Explore.type">类型</message>
+1
View File
@@ -102,6 +102,7 @@
<li>${star class="dropdown-item"}</li>
<li>${download class="dropdown-item"}</li>
<li>${track-info class="dropdown-item"}</li>
${<if-has-lyrics>}<li>${track-lyrics class="dropdown-item"}</li>${</if-has-lyrics>}
</ul>
</div>
</div>
+21
View File
@@ -60,6 +60,7 @@
<li>${star class="dropdown-item"}</li>
<li>${download class="dropdown-item"}</li>
<li>${track-info class="dropdown-item"}</li>
${<if-has-lyrics>}<li>${track-lyrics class="dropdown-item"}</li>${</if-has-lyrics>}
</ul>
</div>
</div>
@@ -139,4 +140,24 @@
</div>
</message>
<message id="Lms.Explore.Tracks.template.track-lyrics">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${tr:Lms.Explore.track-lyrics}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
${lyrics}
</div>
<div class="modal-footer">
${ok-btn class="btn btn-primary"}
</div>
</div>
</div>
</div>
</message>
</messages>
+1 -1
View File
@@ -133,7 +133,7 @@ namespace lms::core::pathUtils
return true;
}
bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector<std::filesystem::path>& supportedExtensions)
bool hasFileAnyExtension(const std::filesystem::path& file, std::span<const std::filesystem::path> supportedExtensions)
{
const std::filesystem::path extension{ stringUtils::stringToLower(file.extension().string()) };
+15 -2
View File
@@ -149,9 +149,9 @@ namespace lms::core::stringUtils
template<>
std::optional<bool> readAs(std::string_view str)
{
if (str == "1" || str == "true")
if (str == "1" || stringCaseInsensitiveEqual(str, "true"))
return true;
else if (str == "0" || str == "false")
else if (str == "0" || stringCaseInsensitiveEqual(str, "false"))
return false;
return std::nullopt;
@@ -463,4 +463,17 @@ namespace lms::core::stringUtils
// assume UTC
return date.toString("yyyy-MM-dd").toUTF8();
}
std::string formatTimestamp(std::chrono::milliseconds timestamp)
{
using namespace std::chrono;
const auto mins{ duration_cast<minutes>(timestamp).count() };
timestamp -= duration_cast<milliseconds>(minutes{ mins });
const auto secs{ duration_cast<seconds>(timestamp).count() };
timestamp -= duration_cast<milliseconds>(seconds{ secs });
const auto millis{ timestamp.count() };
return "[" + std::to_string(mins) + ":" + (secs < 10 ? "0" : "") + std::to_string(secs) + "." + (millis < 100 ? (millis < 10 ? "00" : "0") : "") + std::to_string(millis) + "]";
}
} // namespace lms::core::stringUtils
+2 -2
View File
@@ -21,8 +21,8 @@
#include <filesystem>
#include <functional>
#include <span>
#include <string>
#include <vector>
#include <Wt/WDateTime.h>
@@ -41,7 +41,7 @@ namespace lms::core::pathUtils
bool exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> cb, const std::filesystem::path* excludeDirFileName = {});
// Check if file's extension is one of provided extensions
bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector<std::filesystem::path>& extensions);
bool hasFileAnyExtension(const std::filesystem::path& file, std::span<const std::filesystem::path> extensions);
// Check if a path is within a directory (excludeDirFileName is a relative can be used to exclude a whole directory and its subdirectory, must not have parent_path)
// Caller responsibility to call with normalized paths
+6 -2
View File
@@ -19,6 +19,7 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <optional>
#include <span>
@@ -51,8 +52,8 @@ namespace lms::core::stringUtils
[[nodiscard]] std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar);
[[nodiscard]] std::vector<std::string> splitEscapedStrings(std::string_view string, char delimiter, char escapeChar);
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t");
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t");
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t\r");
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t\r");
[[nodiscard]] std::string stringToLower(std::string_view str);
void stringToLower(std::string& str);
@@ -113,4 +114,7 @@ namespace lms::core::stringUtils
[[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime);
[[nodiscard]] std::string toISO8601String(const Wt::WDate& date);
// to "[minutes:seconds.milliseconds]"
std::string formatTimestamp(std::chrono::milliseconds timestamp);
} // namespace lms::core::stringUtils
+1
View File
@@ -23,6 +23,7 @@ add_library(lmsdatabase SHARED
impl/SqlQuery.cpp
impl/Track.cpp
impl/TrackBookmark.cpp
impl/TrackLyrics.cpp
impl/Types.cpp
impl/UIState.cpp
impl/User.cpp
+1 -1
View File
@@ -46,7 +46,7 @@ namespace lms::db
{
session.checkWriteTransaction();
session.getDboSession()->execute("DELETE FROM auth_token WHERE expiry < ?").bind(now);
utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ?", now);
}
AuthToken::pointer AuthToken::find(Session& session, std::string_view value)
+2
View File
@@ -169,9 +169,11 @@ namespace lms::db
query.leftJoin("directory d_child ON d.id = d_child.parent_directory_id");
query.leftJoin("track t ON d.id = t.directory_id");
query.leftJoin("image i ON d.id = i.directory_id");
query.leftJoin("track_lyrics l_lrc ON d.id = l_lrc.directory_id");
query.where("d_child.id IS NULL");
query.where("t.directory_id IS NULL");
query.where("i.directory_id IS NULL");
query.where("l_lrc.directory_id IS NULL");
return utils::execRangeQuery<DirectoryId>(query, range);
}
+177 -147
View File
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 70 };
static constexpr Version LMS_DATABASE_VERSION{ 71 };
}
VersionInfo::VersionInfo()
@@ -92,7 +92,7 @@ namespace lms::db::Migration
{
// remove name from track_artist_link
// Drop Auth mode
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "track_artist_link_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -103,49 +103,49 @@ CREATE TABLE IF NOT EXISTS "track_artist_link_backup" (
constraint "fk_track_artist_link_artist" foreign key ("artist_id") references "artist" ("id") on delete cascade deferrable initially deferred
);
))");
session.getDboSession()->execute("INSERT INTO track_artist_link_backup SELECT id, version, type, track_id, artist_id FROM track_artist_link");
session.getDboSession()->execute("DROP TABLE track_artist_link");
session.getDboSession()->execute("ALTER TABLE track_artist_link_backup RENAME TO track_artist_link");
utils::executeCommand(*session.getDboSession(), "INSERT INTO track_artist_link_backup SELECT id, version, type, track_id, artist_id FROM track_artist_link");
utils::executeCommand(*session.getDboSession(), "DROP TABLE track_artist_link");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link_backup RENAME TO track_artist_link");
}
void migrateFromV34(Session& session)
{
// Add scrobbling state
// By default, everything needs to be sent
session.getDboSession()->execute("ALTER TABLE starred_artist ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
session.getDboSession()->execute("ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
session.getDboSession()->execute("ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
}
void migrateFromV35(Session& session)
{
// Add creattion/last modif date time for tracklists
session.getDboSession()->execute("ALTER TABLE tracklist ADD creation_date_time TEXT");
session.getDboSession()->execute("ALTER TABLE tracklist ADD last_modified_date_time TEXT");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE tracklist ADD creation_date_time TEXT");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE tracklist ADD last_modified_date_time TEXT");
}
void migrateFromV36(Session& session)
{
// Increased precision for track durations (now in milliseconds instead of secodns)
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV37(Session& session)
{
// Support Performer tags (via subtypes)
session.getDboSession()->execute("ALTER TABLE track_artist_link ADD subtype TEXT");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link ADD subtype TEXT");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV38(Session& session)
{
// migrate release-specific tags from Track to Release
session.getDboSession()->execute("ALTER TABLE release ADD total_disc INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD total_disc INTEGER");
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "track_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -172,158 +172,158 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
constraint "fk_track_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred
);
))");
session.getDboSession()->execute("INSERT INTO track_backup SELECT id, version, scan_version, track_number, disc_number, total_track, disc_subtitle, name, duration, date, original_date, file_path, file_last_write, file_added, has_cover, mbid, recording_mbid, copyright, copyright_url, track_replay_gain, release_replay_gain, release_id FROM track");
session.getDboSession()->execute("DROP TABLE track");
session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track");
utils::executeCommand(*session.getDboSession(), "INSERT INTO track_backup SELECT id, version, scan_version, track_number, disc_number, total_track, disc_subtitle, name, duration, date, original_date, file_path, file_last_write, file_added, has_cover, mbid, recording_mbid, copyright, copyright_url, track_replay_gain, release_replay_gain, release_id FROM track");
utils::executeCommand(*session.getDboSession(), "DROP TABLE track");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV39(Session& session)
{
// add release type
session.getDboSession()->execute("ALTER TABLE release ADD primary_type INTEGER");
session.getDboSession()->execute("ALTER TABLE release ADD secondary_types INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD primary_type INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD secondary_types INTEGER");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV40(Session& session)
{
// add artist_display_name in Release and Track
session.getDboSession()->execute("ALTER TABLE release ADD artist_display_name TEXT NOT NULL DEFAULT ''");
session.getDboSession()->execute("ALTER TABLE track ADD artist_display_name TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD artist_display_name TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD artist_display_name TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV41(Session& session)
{
// add artist_display_name in Release and Track
session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN subsonic_transcode_format TO subsonic_default_transcode_format");
session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN subsonic_transcode_bitrate TO subsonic_default_transcode_bitrate");
session.getDboSession()->execute("ALTER TABLE user DROP COLUMN subsonic_transcode_enable");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN subsonic_transcode_format TO subsonic_default_transcode_format");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN subsonic_transcode_bitrate TO subsonic_default_transcode_bitrate");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN subsonic_transcode_enable");
}
void migrateFromV42(Session& session)
{
session.getDboSession()->execute("DROP INDEX IF EXISTS listen_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_track_scrobbler_date_time_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_artist_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_artist_artist_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_release_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_release_release_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_track_user_scrobbler_idx");
session.getDboSession()->execute("DROP INDEX IF EXISTS starred_track_track_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_track_scrobbler_date_time_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_artist_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_artist_artist_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_release_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_release_release_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_track_user_scrobbler_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_track_track_user_scrobbler_idx");
// New feedback service that now handles the star/unstar stuff (that was previously handled by the scrobbling service)
session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN scrobbler TO scrobbling_backend");
session.getDboSession()->execute("ALTER TABLE user ADD feedback_backend INTEGER");
session.getDboSession()->execute("ALTER TABLE listen RENAME COLUMN scrobbler TO backend");
session.getDboSession()->execute("ALTER TABLE listen RENAME COLUMN scrobbling_state TO sync_state");
session.getDboSession()->execute("ALTER TABLE starred_artist RENAME COLUMN scrobbler TO backend");
session.getDboSession()->execute("ALTER TABLE starred_artist RENAME COLUMN scrobbling_state TO sync_state");
session.getDboSession()->execute("ALTER TABLE starred_release RENAME COLUMN scrobbler TO backend");
session.getDboSession()->execute("ALTER TABLE starred_release RENAME COLUMN scrobbling_state TO sync_state");
session.getDboSession()->execute("ALTER TABLE starred_track RENAME COLUMN scrobbler TO backend");
session.getDboSession()->execute("ALTER TABLE starred_track RENAME COLUMN scrobbling_state TO sync_state");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN scrobbler TO scrobbling_backend");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD feedback_backend INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE listen RENAME COLUMN scrobbler TO backend");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE listen RENAME COLUMN scrobbling_state TO sync_state");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist RENAME COLUMN scrobbler TO backend");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist RENAME COLUMN scrobbling_state TO sync_state");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release RENAME COLUMN scrobbler TO backend");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release RENAME COLUMN scrobbling_state TO sync_state");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track RENAME COLUMN scrobbler TO backend");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track RENAME COLUMN scrobbling_state TO sync_state");
session.getDboSession()->execute("UPDATE user SET feedback_backend = scrobbling_backend");
utils::executeCommand(*session.getDboSession(), "UPDATE user SET feedback_backend = scrobbling_backend");
}
void migrateFromV43(Session& session)
{
// add counts in genre table
session.getDboSession()->execute("ALTER TABLE cluster ADD track_count INTEGER");
session.getDboSession()->execute("ALTER TABLE cluster ADD release_count INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster ADD track_count INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster ADD release_count INTEGER");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV44(Session& session)
{
// add bitrate
session.getDboSession()->execute("ALTER TABLE track ADD bitrate INTEGER NOT NULL DEFAULT 0");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD bitrate INTEGER NOT NULL DEFAULT 0");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV45(Session& session)
{
// add subsonic_enable_transcoding_by_default, default is disabled
session.getDboSession()->execute("ALTER TABLE user ADD subsonic_enable_transcoding_by_default INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*User::defaultSubsonicEnableTranscodingByDefault*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD subsonic_enable_transcoding_by_default INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*User::defaultSubsonicEnableTranscodingByDefault*/ 0)) + ")");
}
void migrateFromV46(Session& session)
{
// add extra tags to parse
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "cluster_type_backup" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "cluster_type_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null
);)");
session.getDboSession()->execute("INSERT INTO cluster_type_backup SELECT id, version, name FROM cluster_type");
session.getDboSession()->execute("DROP TABLE cluster_type");
session.getDboSession()->execute("ALTER TABLE cluster_type_backup RENAME TO cluster_type");
utils::executeCommand(*session.getDboSession(), "INSERT INTO cluster_type_backup SELECT id, version, name FROM cluster_type");
utils::executeCommand(*session.getDboSession(), "DROP TABLE cluster_type");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster_type_backup RENAME TO cluster_type");
session.getDboSession()->execute("ALTER TABLE scan_settings ADD COLUMN extra_tags_to_scan TEXT");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN extra_tags_to_scan TEXT");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV47(Session& session)
{
// release type, new way
session.getDboSession()->execute("ALTER TABLE release DROP primary_type");
session.getDboSession()->execute("ALTER TABLE release DROP secondary_types");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release DROP primary_type");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release DROP secondary_types");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_type" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_type" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null))");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_release_type" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_release_type" (
"release_type_id" bigint,
"release_id" bigint,
primary key ("release_type_id", "release_id"),
constraint "fk_release_release_type_key1" foreign key ("release_type_id") references "release_type" ("id") on delete cascade deferrable initially deferred,
constraint "fk_release_release_type_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred
))");
session.getDboSession()->execute(R"(CREATE INDEX "release_release_type_release_type" on "release_release_type" ("release_type_id"))");
session.getDboSession()->execute(R"(CREATE INDEX "release_release_type_release" on "release_release_type" ("release_id"))");
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_release_type_release_type" on "release_release_type" ("release_type_id"))");
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_release_type_release" on "release_release_type" ("release_id"))");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV48(Session& session)
{
// Regression for the extra tags not being parsed
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV49(Session& session)
{
// Add year / originalYear fields, as date / originalDate are not enough (we don't want a wrong date but year or nothing)
session.getDboSession()->execute("ALTER TABLE track ADD year INTEGER");
session.getDboSession()->execute("ALTER TABLE track ADD original_year INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD year INTEGER");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD original_year INTEGER");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV50(Session& session)
{
// MediaLibrary support
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "media_library" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "media_library" (
"id" integer primary key autoincrement,
"version" integer not null,
"path" text not null,
@@ -333,17 +333,17 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
const int scanSettingsId{ session.getDboSession()->query<int>("SELECT id FROM scan_settings") };
// Convert the existing media_directory in the scan_settings table to a media_library with id '1'
session.getDboSession()->execute(R"(INSERT INTO "media_library" ("id", "version", "path", "name")
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO "media_library" ("id", "version", "path", "name")
SELECT 1, 0, s_s.media_directory, "Main"
FROM scan_settings s_s
WHERE id = ?)")
.bind(scanSettingsId);
WHERE id = ?)",
scanSettingsId);
// Remove the outdated column in scan_settings
session.getDboSession()->execute("ALTER TABLE scan_settings DROP media_directory");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings DROP media_directory");
// Add the media_library column in tracks, with id '1'
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "track_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -377,7 +377,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
))");
// Migrate data, with the new media_library_id field set to 1
session.getDboSession()->execute(R"(INSERT INTO track_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup
SELECT
id,
version,
@@ -407,55 +407,55 @@ SELECT
release_id,
1
FROM track)");
session.getDboSession()->execute("DROP TABLE track");
session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track");
utils::executeCommand(*session.getDboSession(), "DROP TABLE track");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track");
}
void migrateFromV51(Session& session)
{
// Add custom artist tag delimiters, no need to rescan since it has no effect when empty
session.getDboSession()->execute("ALTER TABLE scan_settings ADD artist_tag_delimiters TEXT NOT NULL DEFAULT ''");
session.getDboSession()->execute("ALTER TABLE scan_settings ADD default_tag_delimiters TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD artist_tag_delimiters TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD default_tag_delimiters TEXT NOT NULL DEFAULT ''");
}
void migrateFromV52(Session& session)
{
// Add sort name for releases
session.getDboSession()->execute("ALTER TABLE release ADD sort_name TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD sort_name TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV53(Session& session)
{
// Add release group mbid
session.getDboSession()->execute("ALTER TABLE release ADD group_mbid TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD group_mbid TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV54(Session& session)
{
// Add file size + relative file path
session.getDboSession()->execute("ALTER TABLE track RENAME COLUMN file_path TO absolute_file_path");
session.getDboSession()->execute("ALTER TABLE track ADD file_size BIGINT NOT NULL DEFAULT(0)");
session.getDboSession()->execute("ALTER TABLE track ADD relative_file_path TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track RENAME COLUMN file_path TO absolute_file_path");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD file_size BIGINT NOT NULL DEFAULT(0)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD relative_file_path TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV55(Session& session)
{
// Add bitsPerSample, channelCount and sampleRate
session.getDboSession()->execute("ALTER TABLE track ADD bits_per_sample INTEGER NOT NULL DEFAULT(0)");
session.getDboSession()->execute("ALTER TABLE track ADD channel_count INTEGER NOT NULL DEFAULT(0)");
session.getDboSession()->execute("ALTER TABLE track ADD sample_rate INTEGER NOT NULL DEFAULT(0)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD bits_per_sample INTEGER NOT NULL DEFAULT(0)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD channel_count INTEGER NOT NULL DEFAULT(0)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD sample_rate INTEGER NOT NULL DEFAULT(0)");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV56(Session& session)
@@ -463,25 +463,25 @@ SELECT
// Make sure we remove all the previoulsy created index, the createIndexesIfNeeded will recreate them all
std::vector<std::string> indexeNames{ utils::fetchQueryResults(session.getDboSession()->query<std::string>(R"(SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE '%_idx')")) };
for (const auto& indexName : indexeNames)
session.getDboSession()->execute("DROP INDEX " + indexName);
utils::executeCommand(*session.getDboSession(), "DROP INDEX " + indexName);
}
void migrateFromV57(Session& session)
{
// useless index, may have been already removed in the previous step
session.getDboSession()->execute("DROP INDEX IF EXISTS cluster_name_idx");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS cluster_name_idx");
}
void migrateFromV58(Session& session)
{
// DSF support
session.getDboSession()->execute("UPDATE scan_settings SET audio_file_extensions = audio_file_extensions || ' .dsf'");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_file_extensions = audio_file_extensions || ' .dsf'");
}
void migrateFromV59(Session& session)
{
// Dedicated image table
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "image" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "image" (
"id" integer primary key autoincrement,
"version" integer not null,
"path" text not null,
@@ -495,13 +495,13 @@ SELECT
))");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV60(Session& session)
{
// Dedicated directory table
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "directory" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "directory" (
"id" integer primary key autoincrement,
"version" integer not null,
"absolute_path" text not null,
@@ -511,7 +511,7 @@ SELECT
))");
// Add a ref in track, need to recreate a new table
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "track_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -551,7 +551,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
constraint "fk_track_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred
))");
// Migrate data, with the new directory_id field set to null
session.getDboSession()->execute(R"(INSERT INTO track_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup
SELECT
id,
version,
@@ -587,11 +587,11 @@ SELECT
media_library_id,
NULL
FROM track)");
session.getDboSession()->execute("DROP TABLE track");
session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track");
utils::executeCommand(*session.getDboSession(), "DROP TABLE track");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track");
// Add a ref in image + rename path to absolute_file_path, need to recreate a new table
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "image_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -608,7 +608,7 @@ SELECT
))");
// Migrate data, with the new directory_id field set to null
session.getDboSession()->execute(R"(INSERT INTO image_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO image_backup
SELECT
id,
version,
@@ -622,11 +622,11 @@ SELECT
NULL
FROM image
)");
session.getDboSession()->execute("DROP TABLE image");
session.getDboSession()->execute("ALTER TABLE image_backup RENAME TO image");
utils::executeCommand(*session.getDboSession(), "DROP TABLE image");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE image_backup RENAME TO image");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
} // namespace
@@ -634,7 +634,7 @@ SELECT
void migrateFromV61(Session& session)
{
// Added a media_library_id in Directory
session.getDboSession()->execute(R"(
utils::executeCommand(*session.getDboSession(), R"(
CREATE TABLE IF NOT EXISTS "directory_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
@@ -647,7 +647,7 @@ CREATE TABLE IF NOT EXISTS "directory_backup" (
))");
// Migrate data, with the new directory_id field set to null
session.getDboSession()->execute(R"(INSERT INTO directory_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO directory_backup
SELECT
id,
version,
@@ -657,27 +657,27 @@ SELECT
NULL
FROM directory)");
session.getDboSession()->execute("DROP TABLE directory");
session.getDboSession()->execute("ALTER TABLE directory_backup RENAME TO directory");
utils::executeCommand(*session.getDboSession(), "DROP TABLE directory");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE directory_backup RENAME TO directory");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV62(Session& session)
{
// Add a new column comment
session.getDboSession()->execute("ALTER TABLE track ADD comment TEXT NOT NULL DEFAULT ''");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD comment TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV63(Session& session)
{
// Add a rated entities
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_artist" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_artist" (
"id" integer primary key autoincrement,
"version" integer not null,
"rating" integer not null,
@@ -688,7 +688,7 @@ SELECT
constraint "fk_rated_artist_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred
))");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_release" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_release" (
"id" integer primary key autoincrement,
"version" integer not null,
"rating" integer not null,
@@ -699,7 +699,7 @@ SELECT
constraint "fk_rated_release_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred
))");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_track" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_track" (
"id" integer primary key autoincrement,
"version" integer not null,
"rating" bigint not null,
@@ -711,43 +711,43 @@ SELECT
))");
// Drop badly named index, will be recreated
session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_backend_date_time");
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_backend_date_time");
}
void migrateFromV64(Session& session)
{
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "label" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "label" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null
))");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_label" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_label" (
"label_id" bigint,
"release_id" bigint,
primary key ("label_id", "release_id"),
constraint "fk_release_label_key1" foreign key ("label_id") references "label" ("id") on delete cascade deferrable initially deferred,
constraint "fk_release_label_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred
))");
session.getDboSession()->execute(R"(CREATE INDEX "release_label_label" on "release_label" ("label_id"))");
session.getDboSession()->execute(R"(CREATE INDEX "release_label_release" on "release_label" ("release_id"))");
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_label_label" on "release_label" ("label_id"))");
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_label_release" on "release_label" ("release_id"))");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV65(Session& session)
{
session.getDboSession()->execute("ALTER TABLE release ADD is_compilation BOOLEAN NOT NULL DEFAULT(false)");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD is_compilation BOOLEAN NOT NULL DEFAULT(false)");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV66(Session& session)
{
// New way of handling UI settings
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "ui_state" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "ui_state" (
"id" integer primary key autoincrement,
"version" integer not null,
"item" text not null,
@@ -756,15 +756,15 @@ SELECT
constraint "fk_ui_state_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred
))");
session.getDboSession()->execute("ALTER TABLE user DROP COLUMN repeat_all");
session.getDboSession()->execute("ALTER TABLE user DROP COLUMN radio");
session.getDboSession()->execute("ALTER TABLE user DROP COLUMN cur_playing_track_pos");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN repeat_all");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN radio");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN cur_playing_track_pos");
}
void migrateFromV67(Session& session)
{
// Add a ref to release in image
session.getDboSession()->execute(R"(CREATE TABLE "image_backup" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "image_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"absolute_file_path" text not null,
@@ -782,7 +782,7 @@ SELECT
))");
// Migrate data, with the new release_id field set to null
session.getDboSession()->execute(R"(INSERT INTO image_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO image_backup
SELECT
id,
version,
@@ -797,22 +797,22 @@ SELECT
directory_id
FROM image
)");
session.getDboSession()->execute("DROP TABLE image");
session.getDboSession()->execute("ALTER TABLE image_backup RENAME TO image");
utils::executeCommand(*session.getDboSession(), "DROP TABLE image");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE image_backup RENAME TO image");
// Changed some indexes for the image table -> remove all the previoulsy created index, the createIndexesIfNeeded will recreate them all
std::vector<std::string> indexeNames{ utils::fetchQueryResults(session.getDboSession()->query<std::string>(R"(SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE '%_idx')")) };
for (const auto& indexName : indexeNames)
session.getDboSession()->execute("DROP INDEX " + indexName);
utils::executeCommand(*session.getDboSession(), "DROP INDEX " + indexName);
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV68(Session& session)
{
// Changed the way we ref images from release and artists (several releases and artist can now share the same image)
session.getDboSession()->execute(R"(CREATE TABLE "release_backup" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "release_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null,
@@ -826,7 +826,7 @@ SELECT
constraint "fk_release_image" foreign key ("image_id") references "image" ("id") on delete set null deferrable initially deferred))");
// Migrate data, with the new image_id field set to null
session.getDboSession()->execute(R"(INSERT INTO release_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO release_backup
SELECT
id,
version,
@@ -840,10 +840,10 @@ SELECT
NULL
FROM release
)");
session.getDboSession()->execute("DROP TABLE release");
session.getDboSession()->execute("ALTER TABLE release_backup RENAME TO release");
utils::executeCommand(*session.getDboSession(), "DROP TABLE release");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release_backup RENAME TO release");
session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "artist_backup" (
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "artist_backup" (
"id" integer primary key autoincrement,
"version" integer not null,
"name" text not null,
@@ -854,7 +854,7 @@ SELECT
))");
// Migrate data, with the new image_id field set to null
session.getDboSession()->execute(R"(INSERT INTO artist_backup
utils::executeCommand(*session.getDboSession(), R"(INSERT INTO artist_backup
SELECT
id,
version,
@@ -865,17 +865,46 @@ SELECT
FROM artist
)");
session.getDboSession()->execute("DROP TABLE artist");
session.getDboSession()->execute("ALTER TABLE artist_backup RENAME TO artist");
utils::executeCommand(*session.getDboSession(), "DROP TABLE artist");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_backup RENAME TO artist");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV69(Session& session)
{
// Add a field in UI settings
session.getDboSession()->execute("ALTER TABLE user ADD COLUMN ui_artist_release_sort_method NOT NULL DEFAULT 7"); // 7 = ReleaseSortMethod::OriginalDateDesc
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD COLUMN ui_artist_release_sort_method NOT NULL DEFAULT 7"); // 7 = ReleaseSortMethod::OriginalDateDesc
}
void migrateFromV70(Session& session)
{
// Add a file name/stem in tracks
utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD COLUMN file_stem TEXT NOT NULL DEFAULT ''");
// New table TrackLyrics
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_lyrics" (
"id" integer primary key autoincrement,
"version" integer not null,
"absolute_file_path" text not null,
"stem" text not null,
"file_last_write" text,
"file_size" integer not null,
"lines" text not null,
"language" text not null,
"offset" integer,
"display_artist" text not null,
"display_title" text not null,
"synchronized" boolean not null,
"track_id" bigint,
"directory_id" bigint,
constraint "fk_track_lyrics_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred,
constraint "fk_track_lyrics_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred
))");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
}
bool doDbMigration(Session& session)
@@ -924,6 +953,7 @@ SELECT
{ 67, migrateFromV67 },
{ 68, migrateFromV68 },
{ 69, migrateFromV69 },
{ 70, migrateFromV70 },
};
bool migrationPerformed{};
+26 -10
View File
@@ -229,6 +229,23 @@ namespace lms::db
return query;
}
template<typename ResultType>
Wt::Dbo::Query<ResultType> createArtistQuery(Wt::Dbo::Session& session, std::string_view itemToSelect, ReleaseId releaseId, TrackArtistLinkType linkType)
{
auto query{ session.query<ResultType>("SELECT " + std::string{ itemToSelect } + " from artist a")
.join("track_artist_link t_a_l ON t_a_l.artist_id = a.id")
.join("track t ON t.id = t_a_l.track_id")
.where("t.release_id = ?")
.bind(releaseId)
.where("+t_a_l.type = ?")
.bind(linkType) // adding + since the query planner does not a good job when analyze is not performed
.groupBy("a.id")
.orderBy("t_a_l.id") };
return query;
};
} // namespace
Label::Label(std::string_view name)
@@ -530,19 +547,18 @@ namespace lms::db
{
assert(session());
const auto query{ session()->query<Wt::Dbo::ptr<Artist>>(
"SELECT a FROM artist a"
" INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id"
" INNER JOIN track t ON t.id = t_a_l.track_id")
.where("t.release_id = ?")
.bind(getId())
.where("+t_a_l.type = ?")
.bind(linkType) // adding + since the query planner does not a good job when analyze is not performed
.groupBy("a.id") };
const auto query{ createArtistQuery<Wt::Dbo::ptr<Artist>>(*session(), "a", getId(), linkType) };
return utils::fetchQueryResults<Artist::pointer>(query);
}
std::vector<ArtistId> Release::getArtistIds(TrackArtistLinkType linkType) const
{
assert(session());
const auto query{ createArtistQuery<ArtistId>(*session(), "a.id", getId(), linkType) };
return utils::fetchQueryResults(query);
}
std::vector<Release::pointer> Release::getSimilarReleases(std::optional<std::size_t> offset, std::optional<std::size_t> count) const
{
assert(session());
+78 -72
View File
@@ -43,6 +43,7 @@
#include "database/TrackBookmark.hpp"
#include "database/TrackFeatures.hpp"
#include "database/TrackList.hpp"
#include "database/TrackLyrics.hpp"
#include "database/TransactionChecker.hpp"
#include "database/UIState.hpp"
#include "database/User.hpp"
@@ -118,6 +119,7 @@ namespace lms::db
_session.mapClass<TrackFeatures>("track_features");
_session.mapClass<TrackList>("tracklist");
_session.mapClass<TrackListEntry>("tracklist_entry");
_session.mapClass<TrackLyrics>("track_lyrics");
_session.mapClass<UIState>("ui_state");
_session.mapClass<User>("user");
}
@@ -134,7 +136,7 @@ namespace lms::db
void Session::execute(std::string_view statement)
{
_session.execute(std::string{ statement });
utils::executeCommand(_session, std::string{ statement });
}
void Session::prepareTablesIfNeeded()
@@ -178,92 +180,97 @@ namespace lms::db
LMS_LOG(DB, INFO, "Creating indexes... This may take a while...");
auto transaction{ createWriteTransaction() };
_session.execute("CREATE INDEX IF NOT EXISTS artist_id_idx ON artist(id)");
_session.execute("CREATE INDEX IF NOT EXISTS artist_image_idx ON artist(image_id)");
_session.execute("CREATE INDEX IF NOT EXISTS artist_name_idx ON artist(name)");
_session.execute("CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_id_idx ON artist(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_image_idx ON artist(image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_name_idx ON artist(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)");
_session.execute("CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)");
_session.execute("CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)");
_session.execute("CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)");
_session.execute("CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)");
_session.execute("CREATE INDEX IF NOT EXISTS directory_id_idx ON directory(id)");
_session.execute("CREATE INDEX IF NOT EXISTS directory_parent_directory_idx ON directory(parent_directory_id)");
_session.execute("CREATE INDEX IF NOT EXISTS directory_path_idx ON directory(absolute_path)");
_session.execute("CREATE INDEX IF NOT EXISTS directory_media_library_idx ON directory(media_library_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_id_idx ON directory(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_parent_directory_idx ON directory(parent_directory_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_path_idx ON directory(absolute_path)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_media_library_idx ON directory(media_library_id)");
_session.execute("CREATE INDEX IF NOT EXISTS image_directory_stem_idx ON image(directory_id, stem COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS image_id_idx ON image(id)");
_session.execute("CREATE INDEX IF NOT EXISTS image_path_idx ON image(absolute_file_path)");
_session.execute("CREATE INDEX IF NOT EXISTS image_stem_idx ON image(stem COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_directory_stem_idx ON image(directory_id, stem COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_id_idx ON image(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_path_idx ON image(absolute_file_path)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_stem_idx ON image(stem COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS label_name_idx ON label(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS label_name_idx ON label(name)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_backend_idx ON listen(backend)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_id_idx ON listen(id)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_idx ON listen(user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_date_time_idx ON listen(user_id, backend, date_time DESC)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_track_user_backend_idx ON listen(track_id,user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS listen_user_track_backend_date_time_idx ON listen(user_id,track_id,backend,date_time)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_backend_idx ON listen(backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_id_idx ON listen(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_backend_idx ON listen(user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_backend_date_time_idx ON listen(user_id, backend, date_time DESC)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_track_user_backend_idx ON listen(track_id,user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_track_backend_date_time_idx ON listen(user_id,track_id,backend,date_time)");
_session.execute("CREATE INDEX IF NOT EXISTS media_library_id_idx ON media_library(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS media_library_id_idx ON media_library(id)");
_session.execute("CREATE INDEX IF NOT EXISTS rated_artist_user_artist_idx ON rated_artist(user_id,artist_id)");
_session.execute("CREATE INDEX IF NOT EXISTS rated_release_user_release_idx ON rated_release(user_id,release_id)");
_session.execute("CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_artist_user_artist_idx ON rated_artist(user_id,artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_release_user_release_idx ON rated_release(user_id,release_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)");
_session.execute("CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)");
_session.execute("CREATE INDEX IF NOT EXISTS release_image_idx ON release(image_id)");
_session.execute("CREATE INDEX IF NOT EXISTS release_mbid_idx ON release(mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)");
_session.execute("CREATE INDEX IF NOT EXISTS release_name_nocase_idx ON release(name COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS release_type_name_idx ON release_type(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_image_idx ON release(image_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_mbid_idx ON release(mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_name_nocase_idx ON release(name COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_type_name_idx ON release_type(name)");
_session.execute("CREATE INDEX IF NOT EXISTS track_id_idx ON track(id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_absolute_path_idx ON track(absolute_file_path)");
_session.execute("CREATE INDEX IF NOT EXISTS track_date_idx ON track(date)");
_session.execute("CREATE INDEX IF NOT EXISTS track_directory_release_idx ON track(directory_id, release_id);");
_session.execute("CREATE INDEX IF NOT EXISTS track_file_last_write_idx ON track(file_last_write)");
_session.execute("CREATE INDEX IF NOT EXISTS track_media_library_idx ON track(media_library_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_media_library_release_idx ON track(media_library_id, release_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)");
_session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)");
_session.execute("CREATE INDEX IF NOT EXISTS track_original_date_idx ON track(original_date)");
_session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)");
_session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)");
_session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_release_file_last_write_idx ON track(release_id, file_last_write)");
_session.execute("CREATE INDEX IF NOT EXISTS track_release_year_idx ON track(release_id, year)");
_session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_id_idx ON track(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_absolute_path_idx ON track(absolute_file_path)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_date_idx ON track(date)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_directory_release_idx ON track(directory_id, release_id);");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_directory_file_stem_idx ON track(directory_id, file_stem);");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_file_last_write_idx ON track(file_last_write)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_media_library_idx ON track(media_library_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_media_library_release_idx ON track(media_library_id, release_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_original_date_idx ON track(original_date)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_file_last_write_idx ON track(release_id, file_last_write)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_year_idx ON track(release_id, year)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)");
_session.execute("CREATE INDEX IF NOT EXISTS tracklist_name_idx ON tracklist(name)");
_session.execute("CREATE INDEX IF NOT EXISTS tracklist_user_idx ON tracklist(user_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_name_idx ON tracklist(name)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_user_idx ON tracklist(user_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(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_track_idx ON track_artist_link(artist_id, track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_type_idx ON track_artist_link(artist_id,type)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)");
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)");
_session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_idx ON track_artist_link(artist_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_track_idx ON track_artist_link(artist_id, track_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_type_idx ON track_artist_link(artist_id,type)");
_session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id,type)");
_session.execute("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_features_track_idx ON track_features(track_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)");
_session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_id_idx ON track_lyrics(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_absolute_file_path_idx ON track_lyrics(absolute_file_path)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_track_idx ON track_lyrics(track_id)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_artist_user_backend_idx ON starred_artist(user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_artist_artist_user_backend_idx ON starred_artist(artist_id,user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_release_user_backend_idx ON starred_release(user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_release_release_user_backend_idx ON starred_release(release_id,user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_artist_user_backend_idx ON starred_artist(user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_artist_artist_user_backend_idx ON starred_artist(artist_id,user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_track_user_backend_idx ON starred_track(user_id,backend)");
_session.execute("CREATE INDEX IF NOT EXISTS starred_track_track_user_backend_idx ON starred_track(track_id,user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_release_user_backend_idx ON starred_release(user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_release_release_user_backend_idx ON starred_release(release_id,user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_track_user_backend_idx ON starred_track(user_id,backend)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_track_track_user_backend_idx ON starred_track(track_id,user_id,backend)");
LMS_LOG(DB, INFO, "Indexes created!");
}
@@ -341,11 +348,10 @@ namespace lms::db
void Session::analyzeEntry(const std::string& entry)
{
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "AnalyzeEntry", "Entry", entry);
LMS_LOG(DB, DEBUG, "Analyzing " << entry);
{
auto transaction{ createWriteTransaction() };
_session.execute("ANALYZE " + entry);
utils::executeCommand(_session, "ANALYZE " + entry);
}
LMS_LOG(DB, DEBUG, "Analyzing " << entry << ": done!");
}
+28
View File
@@ -30,6 +30,7 @@
#include "database/Session.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackFeatures.hpp"
#include "database/TrackLyrics.hpp"
#include "database/User.hpp"
#include "IdTypeTraits.hpp"
@@ -53,6 +54,9 @@ namespace lms::db
for (std::string_view keyword : params.keywords)
query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%");
if (!params.stem.empty())
query.where("t.file_stem = ?").bind(params.stem);
if (!params.name.empty())
query.where("t.name = ?").bind(params.name);
@@ -372,11 +376,15 @@ namespace lms::db
{
assert(filePath.is_absolute());
_absoluteFilePath = filePath;
_fileStem = filePath.stem();
}
void Track::setRelativeFilePath(const std::filesystem::path& filePath)
{
assert(filePath.is_relative());
assert(_absoluteFilePath.filename() == filePath.filename()); // must be compatible with previous setAbsoluteFilePath call
_fileStem = filePath.stem(); // lazy migration (_fileStem added later, could be set only with setAbsoluteFilePath)
_relativeFilePath = filePath;
}
@@ -418,6 +426,26 @@ namespace lms::db
_clusters.insert(getDboPtr(cluster));
}
void Track::clearLyrics()
{
_trackLyrics.clear();
}
void Track::clearEmbeddedLyrics()
{
utils::executeCommand(*session(), "DELETE FROM track_lyrics WHERE absolute_file_path = '' AND track_id = ?", getId());
}
void Track::addLyrics(const ObjectPtr<TrackLyrics>& lyrics)
{
_trackLyrics.insert(getDboPtr(lyrics));
}
bool Track::hasLyrics() const
{
return !_trackLyrics.empty();
}
std::optional<std::string> Track::getCopyright() const
{
return _copyright != "" ? std::make_optional<std::string>(_copyright) : std::nullopt;
+212
View File
@@ -0,0 +1,212 @@
/*
* 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 "database/TrackLyrics.hpp"
#include <Wt/Dbo/SqlTraits.h>
#include <Wt/Dbo/WtSqlTraits.h>
#include "database/Artist.hpp"
#include "database/Directory.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "IdTypeTraits.hpp"
#include "PathTraits.hpp"
#include "Utils.hpp"
namespace lms::db
{
namespace
{
Wt::Dbo::Query<Wt::Dbo::ptr<TrackLyrics>> createQuery(Session& session, const TrackLyrics::FindParameters& params)
{
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc") };
if (params.track.isValid())
query.where("t_lrc.track_id = ?").bind(params.track);
if (params.external.has_value())
query.where("t_lrc.absolute_file_path " + std::string{ *params.external ? "<>" : "=" } + " ''");
switch (params.sortMethod)
{
case TrackLyricsSortMethod::None:
break;
case TrackLyricsSortMethod::ExternalFirst:
query.orderBy("CASE WHEN absolute_file_path <> '' THEN 0 ELSE 1 END");
break;
case TrackLyricsSortMethod::EmbeddedFirst:
query.orderBy("CASE WHEN absolute_file_path = '' THEN 0 ELSE 1 END");
break;
}
return query;
}
} // namespace
TrackLyrics::pointer TrackLyrics::create(Session& session)
{
return session.getDboSession()->add(std::unique_ptr<TrackLyrics>{ new TrackLyrics{} });
}
std::size_t TrackLyrics::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM track_lyrics"));
}
std::size_t TrackLyrics::getExternalLyricsCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(t_lrc.id) FROM track_lyrics t_lrc").where("t_lrc.absolute_file_path <> ''"));
}
TrackLyrics::pointer TrackLyrics::find(Session& session, TrackLyricsId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.id = ?").bind(id));
}
TrackLyrics::pointer TrackLyrics::find(Session& session, const std::filesystem::path& path)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.absolute_file_path = ?").bind(path));
}
void TrackLyrics::find(Session& session, const FindParameters& params, const std::function<void(const TrackLyrics::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ createQuery(session, params) };
utils::forEachQueryRangeResult(query, params.range, func);
}
void TrackLyrics::find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").orderBy("t_lrc.id").where("t_lrc.id > ?").bind(lastRetrievedId).limit(static_cast<int>(count)) };
utils::forEachQueryResult(query, [&](const TrackLyrics::pointer& lyrics) {
func(lyrics);
lastRetrievedId = lyrics->getId();
});
}
RangeResults<TrackLyricsId> TrackLyrics::findOrphanIds(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<TrackLyricsId>("select t_lrc.id from track_lyrics t_lrc") };
query.leftJoin("track t ON t_lrc.track_id = t.id");
query.where("t.id IS NULL");
return utils::execRangeQuery<TrackLyricsId>(query, range);
}
TrackLyrics::SynchronizedLines TrackLyrics::getSynchronizedLines() const
{
SynchronizedLines synchronizedLines;
assert(_synchronized);
{
Wt::Json::Object root;
Wt::Json::parse(_lines, root);
assert(root.type("lines") == Wt::Json::Type::Array);
const Wt::Json::Array& linesArray = root.get("lines");
for (const Wt::Json::Value& line : linesArray)
{
assert(line.type() == Wt::Json::Type::Object);
const Wt::Json::Object& entry = line;
std::chrono::milliseconds timestamp{ entry.get("timestamp") };
std::string lineText{ static_cast<std::string>(entry.get("value")) };
synchronizedLines.emplace(timestamp, std::move(lineText));
}
}
return synchronizedLines;
}
std::vector<std::string> TrackLyrics::getUnsynchronizedLines() const
{
std::vector<std::string> unsynchronizedLines;
assert(!_synchronized);
{
Wt::Json::Object root;
Wt::Json::parse(_lines, root);
assert(root.type("lines") == Wt::Json::Type::Array);
const Wt::Json::Array& linesArray = root.get("lines");
for (const Wt::Json::Value& line : linesArray)
unsynchronizedLines.push_back(line.toString());
}
return unsynchronizedLines;
}
void TrackLyrics::setAbsoluteFilePath(const std::filesystem::path& p)
{
assert(p.is_absolute());
_fileAbsolutePath = p;
_fileStem = p.stem().string();
}
void TrackLyrics::setSynchronizedLines(const SynchronizedLines& synchronizedLines)
{
Wt::Json::Object root;
Wt::Json::Array lines;
for (const auto& [timestamp, lineText] : synchronizedLines)
{
Wt::Json::Object line;
line["timestamp"] = Wt::Json::Value{ timestamp.count() };
line["value"] = Wt::Json::Value{ lineText };
lines.push_back(std::move(line));
}
root["lines"] = std::move(lines);
_synchronized = true;
_lines = Wt::Json::serialize(root);
}
void TrackLyrics::setUnsynchronizedLines(std::span<const std::string> unsynchronizedLines)
{
Wt::Json::Object root;
Wt::Json::Array lines;
for (const auto& lineText : unsynchronizedLines)
lines.push_back(Wt::Json::Value{ lineText });
root["lines"] = std::move(lines);
_synchronized = false;
_lines = Wt::Json::serialize(root);
}
} // namespace lms::db
+9
View File
@@ -172,5 +172,14 @@ namespace lms::db::utils
}
}
template<typename... Args>
void executeCommand(Wt::Dbo::Session& session, std::string_view command, const Args&... args)
{
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "ExecuteCommand", "Command", command);
Wt::Dbo::Call call{ session.execute(std::string{ command }) };
(call.bind(args), ...);
}
Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime);
} // namespace lms::db::utils
@@ -270,6 +270,7 @@ namespace lms::db
// Get the artists of this release
std::vector<ObjectPtr<Artist>> getArtists(TrackArtistLinkType type = TrackArtistLinkType::Artist) const;
std::vector<ArtistId> getArtistIds(TrackArtistLinkType type = TrackArtistLinkType::Artist) const;
std::vector<ObjectPtr<Artist>> getReleaseArtists() const { return getArtists(TrackArtistLinkType::ReleaseArtist); }
bool hasVariousArtists() const;
std::vector<pointer> getSimilarReleases(std::optional<std::size_t> offset = {}, std::optional<std::size_t> count = {}) const;
@@ -56,6 +56,7 @@ namespace lms::db
class Release;
class Session;
class TrackArtistLink;
class TrackLyrics;
class TrackStats;
class User;
@@ -67,6 +68,7 @@ namespace lms::db
std::vector<ClusterId> clusters; // if non empty, tracks that belong to these clusters
std::vector<std::string_view> keywords; // if non empty, name must match all of these keywords
std::string name; // if non empty, must match this name
std::string stem; // if non empty, must match this stem
TrackSortMethod sortMethod{ TrackSortMethod::None };
std::optional<Range> range;
Wt::WDateTime writtenAfter;
@@ -100,6 +102,11 @@ namespace lms::db
name = _name;
return *this;
}
FindParameters& setStem(std::string_view _stem)
{
stem = _stem;
return *this;
}
FindParameters& setSortMethod(TrackSortMethod _method)
{
sortMethod = _method;
@@ -239,6 +246,9 @@ namespace lms::db
void addArtistLink(const ObjectPtr<TrackArtistLink>& artistLink);
void setRelease(ObjectPtr<Release> release) { _release = getDboPtr(release); }
void setClusters(const std::vector<ObjectPtr<Cluster>>& clusters);
void clearLyrics();
void clearEmbeddedLyrics();
void addLyrics(const ObjectPtr<TrackLyrics>& lyrics);
void setMediaLibrary(ObjectPtr<MediaLibrary> mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); }
void setDirectory(ObjectPtr<Directory> directory) { _directory = getDboPtr(directory); }
@@ -264,6 +274,7 @@ namespace lms::db
const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; }
const Wt::WDateTime& getAddedTime() const { return _fileAdded; }
bool hasCover() const { return _hasCover; }
bool hasLyrics() const;
std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); }
std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); }
std::optional<std::string> getCopyright() const;
@@ -305,6 +316,7 @@ namespace lms::db
Wt::Dbo::field(a, _originalYear, "original_year");
Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path");
Wt::Dbo::field(a, _relativeFilePath, "relative_file_path");
Wt::Dbo::field(a, _fileStem, "file_stem");
Wt::Dbo::field(a, _fileSize, "file_size");
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
Wt::Dbo::field(a, _fileAdded, "file_added");
@@ -323,6 +335,7 @@ namespace lms::db
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track");
Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::hasMany(a, _trackLyrics, Wt::Dbo::ManyToOne, "track");
}
private:
@@ -350,6 +363,7 @@ namespace lms::db
std::optional<int> _originalYear;
std::filesystem::path _absoluteFilePath; // full path
std::filesystem::path _relativeFilePath; // relative to root (that may be deleted)
std::filesystem::path _fileStem;
long long _fileSize{};
Wt::WDateTime _fileLastWrite;
Wt::WDateTime _fileAdded;
@@ -368,6 +382,7 @@ namespace lms::db
Wt::Dbo::ptr<Directory> _directory;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackArtistLink>> _trackArtistLinks;
Wt::Dbo::collection<Wt::Dbo::ptr<Cluster>> _clusters;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackLyrics>> _trackLyrics;
};
namespace Debug
@@ -0,0 +1,151 @@
/*
* 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 <chrono>
#include <filesystem>
#include <map>
#include <span>
#include <string>
#include <Wt/Dbo/Dbo.h>
#include "database/IdType.hpp"
#include "database/Object.hpp"
#include "database/TrackId.hpp"
#include "database/Types.hpp"
LMS_DECLARE_IDTYPE(TrackLyricsId)
namespace lms::db
{
class Directory;
class Session;
class Track;
class TrackLyrics final : public Object<TrackLyrics, TrackLyricsId>
{
public:
TrackLyrics() = default;
struct FindParameters
{
std::optional<Range> range;
TrackId track;
std::optional<bool> external; // if set, true means external, false means embedded
TrackLyricsSortMethod sortMethod{ TrackLyricsSortMethod::None };
FindParameters& setRange(std::optional<Range> _range)
{
range = _range;
return *this;
}
FindParameters& setTrack(TrackId _track)
{
track = _track;
return *this;
}
FindParameters& setExternal(std::optional<bool> _external)
{
external = _external;
return *this;
}
FindParameters& setSortMethod(TrackLyricsSortMethod _sortMethod)
{
sortMethod = _sortMethod;
return *this;
}
};
// Find utilities
static std::size_t getCount(Session& session);
static std::size_t getExternalLyricsCount(Session& session);
static pointer find(Session& session, TrackLyricsId id);
static pointer find(Session& session, const std::filesystem::path& file);
static void find(Session& session, const FindParameters& params, const std::function<void(const TrackLyrics::pointer&)>& func);
static void find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func);
static RangeResults<TrackLyricsId> findOrphanIds(Session& session, std::optional<Range> range);
using SynchronizedLines = std::map<std::chrono::milliseconds, std::string>;
// Readers
const std::filesystem::path& getAbsoluteFilePath() const { return _fileAbsolutePath; }
std::string_view getFileStem() const { return _fileStem; }
const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; }
std::size_t getFileSize() const { return _fileSize; }
std::string_view getLanguage() const { return _language; }
std::string_view getDisplayArtist() const { return _displayArtist; }
std::string_view getDisplayTitle() const { return _displayTitle; }
std::chrono::milliseconds getOffset() const { return _offset; }
bool isSynchronized() const { return _synchronized; }
SynchronizedLines getSynchronizedLines() const;
std::vector<std::string> getUnsynchronizedLines() const;
Wt::Dbo::ptr<Track> getTrack() const { return _track; }
Wt::Dbo::ptr<Directory> getDirectory() const { return _directory; }
// Writers
void setAbsoluteFilePath(const std::filesystem::path& path);
void setLastWriteTime(const Wt::WDateTime& fileLastWrite) { _fileLastWrite = fileLastWrite; }
void setFileSize(std::size_t fileSize) { _fileSize = fileSize; }
void setLanguage(std::string_view language) { _language = language; }
void setOffset(std::chrono::milliseconds offset) { _offset = offset; }
void setDisplayArtist(std::string_view displayArtist) { _displayArtist = displayArtist; }
void setDisplayTitle(std::string_view displayTitle) { _displayTitle = displayTitle; }
void setSynchronizedLines(const SynchronizedLines& lines);
void setUnsynchronizedLines(std::span<const std::string> lines);
void setTrack(const ObjectPtr<Track>& track) { _track = getDboPtr(track); }
void setDirectory(const ObjectPtr<Directory>& directory) { _directory = getDboPtr(directory); }
template<class Action>
void persist(Action& a)
{
Wt::Dbo::field(a, _fileAbsolutePath, "absolute_file_path");
Wt::Dbo::field(a, _fileStem, "stem");
Wt::Dbo::field(a, _fileLastWrite, "file_last_write");
Wt::Dbo::field(a, _fileSize, "file_size");
Wt::Dbo::field(a, _lines, "lines");
Wt::Dbo::field(a, _language, "language");
Wt::Dbo::field(a, _offset, "offset");
Wt::Dbo::field(a, _displayArtist, "display_artist");
Wt::Dbo::field(a, _displayTitle, "display_title");
Wt::Dbo::field(a, _synchronized, "synchronized");
Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade);
Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade);
}
private:
friend class Session;
static pointer create(Session& session);
std::filesystem::path _fileAbsolutePath; // optional (if embedded in media file)
std::string _fileStem; // optional (if embedded in media file)
Wt::WDateTime _fileLastWrite; // optional (if embedded in media file)
int _fileSize{}; // optional (if embedded in media file)
std::string _lines; // a json encoded array of lines (with possibly offsets)
std::string _language;
std::chrono::duration<int, std::milli> _offset{};
std::string _displayArtist;
std::string _displayTitle;
bool _synchronized{};
Wt::Dbo::ptr<Track> _track;
Wt::Dbo::ptr<Directory> _directory;
};
} // namespace lms::db
@@ -166,6 +166,13 @@ namespace lms::db
TrackList, // order by asc order in tracklist
};
enum class TrackLyricsSortMethod
{
None,
ExternalFirst,
EmbeddedFirst,
};
enum class TrackArtistLinkType
{
Artist = 0, // regular track artist
+1
View File
@@ -19,6 +19,7 @@ add_executable(test-database
TrackBookmark.cpp
TrackFeatures.cpp
TrackList.cpp
TrackLyrics.cpp
User.cpp
)
+2
View File
@@ -29,6 +29,7 @@
#include "database/StarredArtist.hpp"
#include "database/StarredRelease.hpp"
#include "database/StarredTrack.hpp"
#include "database/TrackLyrics.hpp"
#include "database/UIState.hpp"
#include "database/User.hpp"
@@ -351,6 +352,7 @@ VALUES
EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{}));
EXPECT_FALSE(Track::find(session, TrackId{}));
EXPECT_FALSE(TrackList::find(session, TrackListId{}));
EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{}));
EXPECT_FALSE(UIState::find(session, UIStateId{}));
EXPECT_FALSE(User::find(session, UserId{}));
}
+52
View File
@@ -670,6 +670,58 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, Release_releaseArtist)
{
ScopedRelease release{ session, "MyRelease" };
ScopedTrack track{ session };
ScopedArtist artist{ session, "MyArtist" };
{
auto transaction{ session.createReadTransaction() };
const auto releases{ Release::findIds(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })) };
EXPECT_EQ(releases.results.size(), 0);
EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })), 0);
EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId())), 0);
EXPECT_EQ(release->getArtists(TrackArtistLinkType::ReleaseArtist).size(), 0);
EXPECT_EQ(release->getArtistIds(TrackArtistLinkType::ReleaseArtist).size(), 0);
}
{
auto transaction{ session.createWriteTransaction() };
track.get().modify()->setRelease(release.get());
TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist);
}
{
auto transaction{ session.createReadTransaction() };
auto artists{ release->getArtists(TrackArtistLinkType::ReleaseArtist) };
ASSERT_EQ(artists.size(), 1);
EXPECT_EQ(artists.front()->getId(), artist.getId());
}
{
auto transaction{ session.createReadTransaction() };
auto artists{ release->getArtistIds(TrackArtistLinkType::ReleaseArtist) };
ASSERT_EQ(artists.size(), 1);
EXPECT_EQ(artists.front(), artist.getId());
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(Release::getCount(session, Release::FindParameters{}), 1);
const auto releases{ Release::findIds(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })) };
ASSERT_EQ(releases.results.size(), 1);
EXPECT_EQ(releases.results.front(), release.getId());
EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })), 1);
EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId())), 1);
}
}
TEST_F(DatabaseFixture, Release_getDiscCount)
{
ScopedRelease release{ session, "MyRelease" };
+203
View File
@@ -0,0 +1,203 @@
/*
* 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 "Common.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
namespace lms::db::tests
{
using ScopedTrackLyrics = ScopedEntity<db::TrackLyrics>;
TEST_F(DatabaseFixture, TrackLyrics_synchronized)
{
using namespace std::chrono_literals;
ScopedTrack track{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 0);
}
ScopedTrackLyrics lyrics{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 1);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0);
const TrackLyrics::pointer dbLyrics{ lyrics.get() };
EXPECT_FALSE(dbLyrics->isSynchronized());
EXPECT_FALSE(dbLyrics->getLastWriteTime().isValid());
EXPECT_TRUE(dbLyrics->getLastWriteTime().isNull());
}
{
auto transaction{ session.createWriteTransaction() };
TrackLyrics::pointer dbLyrics{ lyrics.get() };
dbLyrics.modify()->setSynchronizedLines({ { 1s + 300ms, "First line\nSecondLine" }, { 2s + 351ms, "ThirdLine" } });
EXPECT_TRUE(dbLyrics->isSynchronized());
}
{
auto transaction{ session.createReadTransaction() };
const TrackLyrics::pointer dbLyrics{ lyrics.get() };
EXPECT_TRUE(dbLyrics->isSynchronized());
const auto synchronizedLines{ dbLyrics->getSynchronizedLines() };
ASSERT_EQ(synchronizedLines.size(), 2);
ASSERT_TRUE(synchronizedLines.contains(1s + 300ms));
EXPECT_EQ(synchronizedLines.find(1s + 300ms)->second, "First line\nSecondLine");
ASSERT_TRUE(synchronizedLines.contains(2s + 351ms));
EXPECT_EQ(synchronizedLines.find(2s + 351ms)->second, "ThirdLine");
}
}
TEST_F(DatabaseFixture, TrackLyrics_unsynchronized)
{
using namespace std::chrono_literals;
ScopedTrack track{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 0);
}
ScopedTrackLyrics lyrics{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 1);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0);
const TrackLyrics::pointer dbLyrics{ lyrics.get() };
EXPECT_FALSE(dbLyrics->isSynchronized());
EXPECT_FALSE(dbLyrics->getLastWriteTime().isValid());
EXPECT_TRUE(dbLyrics->getLastWriteTime().isNull());
}
{
auto transaction{ session.createWriteTransaction() };
TrackLyrics::pointer dbLyrics{ lyrics.get() };
dbLyrics.modify()->setUnsynchronizedLines(std::array<std::string, 2>{ "First line\nSecondLine", "ThirdLine" });
EXPECT_FALSE(dbLyrics->isSynchronized());
}
{
auto transaction{ session.createReadTransaction() };
const TrackLyrics::pointer dbLyrics{ lyrics.get() };
EXPECT_FALSE(dbLyrics->isSynchronized());
const std::vector<std::string> lines{ dbLyrics->getUnsynchronizedLines() };
ASSERT_EQ(lines.size(), 2);
EXPECT_EQ(lines[0], "First line\nSecondLine");
EXPECT_EQ(lines[1], "ThirdLine");
}
}
TEST_F(DatabaseFixture, TrackLyrics_external)
{
using namespace std::chrono_literals;
ScopedTrack track{ session };
ScopedTrackLyrics internalLyrics{ session };
ScopedTrackLyrics externalLyrics{ session };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 2);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0);
}
{
auto transaction{ session.createWriteTransaction() };
externalLyrics.get().modify()->setAbsoluteFilePath("/tmp/test.lrc");
externalLyrics.get().modify()->setTrack(track.get());
internalLyrics.get().modify()->setTrack(track.get());
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 2);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1);
}
{
auto transaction{ session.createWriteTransaction() };
track.get().modify()->clearEmbeddedLyrics();
}
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackLyrics::getCount(session), 1);
EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1);
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setTrack(track.getId()), [&](const TrackLyrics::pointer& lyrics) {
EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc");
visited = true;
});
EXPECT_TRUE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(true), [&](const TrackLyrics::pointer& lyrics) {
EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc");
visited = true;
});
EXPECT_TRUE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(false), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_FALSE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::EmbeddedFirst), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_TRUE(visited);
}
{
bool visited{};
TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::ExternalFirst), [&](const TrackLyrics::pointer&) {
visited = true;
});
EXPECT_TRUE(visited);
}
}
}
} // namespace lms::db::tests
+5
View File
@@ -4,8 +4,13 @@ if(BUILD_TESTING)
add_subdirectory(test)
endif()
if (BUILD_BENCHMARKS)
add_subdirectory(bench)
endif()
add_library(lmsmetadata SHARED
impl/AvFormatTagReader.cpp
impl/Lyrics.cpp
impl/Parser.cpp
impl/TagLibTagReader.cpp
impl/Utils.cpp
+9
View File
@@ -0,0 +1,9 @@
add_executable(bench-metadata
LyricsBench.cpp
)
target_link_libraries(bench-metadata PRIVATE
lmsmetadata
benchmark
)
+94
View File
@@ -0,0 +1,94 @@
/*
* 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 <iostream>
#include <sstream>
#include <benchmark/benchmark.h>
#include "metadata/Lyrics.hpp"
namespace lms::metadata::benchmarks
{
static void BM_Lyrics(benchmark::State& state)
{
std::istringstream lyricsStream{ R"(
[id: rkrzmqos]
[ar: Billie Eilish]
[al: HIT ME HARD AND SOFT]
[ti: WILDFLOWER]
[length: 04:21]
[00:15.16]Things fall apart and time breaks your heart
[00:21.57]I wasn't there, but I know
[00:28.01]She was your girl, you showed her the world
[00:34.18]You fell out of love and you both let go
[00:40.33]She was cryin' on my shoulder, all I could do was hold her
[00:46.71]Only made us closer until July
[00:53.47]Now I know that you love me, you don't need to remind me
[00:59.86]I should put it all behind me, shouldn't I?
[01:04.72]But I see her in the back of my mind
[01:11.50]All the time
[01:17.75]Like a fever, like I'm burning alive
[01:24.31]Like a sign
[01:32.67]Did I cross the line?
[01:37.72]Mm, hm
[01:48.97]Well, good things don't last (good things don't last)
[01:52.28]And life moves so fast (life moves so fast)
[01:55.50]I'd never ask who was better (I'd never ask who was better)
[02:01.68]'Cause she couldn't be (she couldn't be)
[02:05.20]More different from me (more different)
[02:08.53]Happy and free (happy and free) in leather
[02:14.51]And I know that you love me (you love me)
[02:18.01]You don't need to remind me (remind me)
[02:20.88]Wanna put it all behind me, but baby
[02:26.41]I see her in the back of my mind (back of my mind)
[02:32.66]All the time (all the time)
[02:38.95]Feels like a fever (like a fever)
[02:42.06]Like I'm burning alive (burning alive)
[02:45.66]Like a sign
[02:53.68]Did I cross the line?
[02:58.03]You say no one knows you so well (oh)
[03:02.63]But every time you touch me, I just wonder how she felt
[03:08.54]Valentine's Day, cryin' in the hotel
[03:14.48]I know you didn't mean to hurt me, so I kept it to myself
[03:21.13]And I wonder
[03:24.41]Do you see her in the back of your mind?
[03:31.13]In my eyes?
[03:51.94]You say no one knows you so well
[03:56.88]But every time you touch me, I just wonder how she felt
[04:03.87]Valentine's Day, cryin' in the hotel
[04:09.16]I know you didn't mean to hurt me, so I kept it to myself
[04:15.59]
)" };
for (auto _ : state)
{
lyricsStream.clear();
lyricsStream.seekg(0, std::ios::beg);
const Lyrics lyrics{ parseLyrics(lyricsStream) };
assert(lyrics.synchronizedLines.size() == 41);
}
}
BENCHMARK(BM_Lyrics);
} // namespace lms::metadata::benchmarks
BENCHMARK_MAIN();
+19 -1
View File
@@ -79,7 +79,6 @@ namespace lms::metadata
{ TagType::LyricistSortOrder, { "LYRICISTSORT" } },
{ TagType::Lyricists, { "LYRICISTS" } },
{ TagType::LyricistsSortOrder, { "LYRICISTSSORT" } },
{ TagType::Lyrics, { "LYRICS" } },
{ TagType::Media, { "TMED", "MEDIA", "WM/MEDIA" } },
{ TagType::MixDJ, { "DJMIXER" } },
{ TagType::Mixer, { "MIXER" } },
@@ -207,4 +206,23 @@ namespace lms::metadata
visitor("", value);
});
}
void AvFormatTagReader::visitLyricsTags(LyricsVisitor visitor) const
{
// MPEG files: need to visit LYRICS-language entries
for (const auto& [tag, value] : _metaDataMap)
{
constexpr std::string_view lyricsPrefix{ "LYRICS-" };
if (tag.starts_with(lyricsPrefix))
{
const std::string language{ core::stringUtils::stringToLower(tag.substr(lyricsPrefix.size())) };
visitor(language, value);
}
}
// otherwise, just visit regular LYRICS tag with no language
visitTagValues("LYRICS", [&](std::string_view value) {
visitor("", value);
});
}
} // namespace lms::metadata
@@ -40,6 +40,7 @@ namespace lms::metadata
void visitTagValues(TagType tag, TagValueVisitor visitor) const override;
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override;
void visitPerformerTags(PerformerVisitor visitor) const override;
void visitLyricsTags(LyricsVisitor visitor) const override;
bool hasEmbeddedCover() const override { return _hasEmbeddedCover; }
const AudioProperties& getAudioProperties() const override { return _audioProperties; }
+5 -1
View File
@@ -76,7 +76,7 @@ namespace lms::metadata
LyricistSortOrder, // non standard
Lyricists, // non standard
LyricistsSortOrder, // non standard
Lyrics,
// Lyrics, Handled separately
Media,
MixDJ,
Mixer,
@@ -104,6 +104,7 @@ namespace lms::metadata
OriginalFilename,
OriginalReleaseDate,
OriginalReleaseYear,
// Performers, Handled separately
Podcast,
PodcastURL,
Producer,
@@ -154,6 +155,9 @@ namespace lms::metadata
using PerformerVisitor = std::function<void(std::string_view role, std::string_view artist)>;
virtual void visitPerformerTags(PerformerVisitor visitor) const = 0;
using LyricsVisitor = std::function<void(std::string_view language, std::string_view lyrics)>;
virtual void visitLyricsTags(LyricsVisitor visitor) const = 0;
virtual bool hasEmbeddedCover() const = 0;
virtual const AudioProperties& getAudioProperties() const = 0;
};
+216
View File
@@ -0,0 +1,216 @@
/*
* 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 "metadata/Lyrics.hpp"
#include <cassert>
#include <regex>
#include "core/String.hpp"
namespace lms::metadata
{
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions()
{
static const std::array<std::filesystem::path, 1> fileExtensions{ ".lrc" }; // TODO handle ".txt" and ".elrc"
return fileExtensions;
}
namespace
{
std::string_view getSubmatchString(const std::csub_match& submatch)
{
assert(submatch.matched);
return std::string_view{ submatch.first, static_cast<std::string_view::size_type>(submatch.length()) };
}
// Parse a single line with ID tags like [ar: Artist] and set the appropriate fields in the Lyrics object
bool parseIDTag(std::string_view line, Lyrics& lyrics)
{
static const std::regex idTagRegex{ R"(^\[([a-zA-Z_]+):(.+?)\])" };
std::cmatch match;
if (std::regex_search(line.data(), line.data() + line.size(), match, idTagRegex))
{
std::string_view tagType{ getSubmatchString(match[1]) };
std::string_view tagValue{ core::stringUtils::stringTrim(getSubmatchString(match[2])) };
if (tagType == "ar")
{
lyrics.displayArtist = tagValue;
}
else if (tagType == "al")
{
lyrics.displayAlbum = tagValue;
}
else if (tagType == "ti")
{
lyrics.displayTitle = tagValue;
}
else if (tagType == "la")
{
lyrics.language = tagValue;
}
else if (tagType == "offset")
{
if (const auto value{ core::stringUtils::readAs<int>(tagValue) })
lyrics.offset = std::chrono::milliseconds{ *value };
}
// not interrested by other tags like 'duration', 'id', etc.
return true;
}
return false;
}
// Parse timestamps from a line and return the associated times in milliseconds
void extractTimestamps(std::string_view line, std::vector<std::chrono::milliseconds>& timestamps)
{
timestamps.clear();
static const std::regex timeTagRegex{ R"(\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\])" };
std::cregex_iterator regexIt(line.begin(), line.end(), timeTagRegex);
std::cregex_iterator regexEnd;
while (regexIt != regexEnd)
{
std::cmatch match{ *regexIt };
int hour{ match[1].matched ? std::stoi(match[1].str()) : 0 };
int minute{ std::stoi(match[2].str()) };
int second{ std::stoi(match[3].str()) };
int fractional{ match[4].matched ? std::stoi(match[4].str()) : 0 };
std::chrono::milliseconds currentTimestamp{ std::chrono::hours{ hour } + std::chrono::minutes{ minute } + std::chrono::seconds{ second } };
if (match[4].length() == 2) // Centiseconds
{
currentTimestamp += std::chrono::milliseconds{ fractional * 10 };
}
else // Milliseconds
{
currentTimestamp += std::chrono::milliseconds{ fractional };
}
timestamps.push_back(currentTimestamp);
++regexIt;
}
}
// Extract the lyric text from a line, removing any timestamps
std::string_view extractLyricText(std::string_view line)
{
return line.substr(line.find_last_of(']') + 1);
}
} // namespace
// Main function to parse lyrics from an input stream
Lyrics parseLyrics(std::istream& is)
{
Lyrics lyrics;
enum class State
{
None,
SynchronizedLyrics,
UnsynchronizedLyrics,
};
State currentState{ State::None };
std::vector<std::chrono::milliseconds> lastTimestamps;
std::vector<std::chrono::milliseconds> timestamps;
std::string accumulatedLyrics;
auto applyAccumulatedLyrics = [&](bool skipTrailingEmptyLines = false) {
if (lastTimestamps.empty())
return;
if (skipTrailingEmptyLines)
accumulatedLyrics.resize(core::stringUtils::stringTrimEnd(accumulatedLyrics, " \t\r\n").size());
if (accumulatedLyrics.empty())
return;
for (std::chrono::milliseconds timestamp : lastTimestamps)
{
std::string& synchronizedLine{ lyrics.synchronizedLines.find(timestamp)->second };
synchronizedLine += accumulatedLyrics;
}
accumulatedLyrics.clear();
};
std::string line;
while (std::getline(is, line))
{
std::string_view trimmedLine{ core::stringUtils::stringTrimEnd(line) };
// Remove potential UTF8 BOM
constexpr std::string_view utf8BOM{ "\xEF\xBB\xBF" };
if (trimmedLine.starts_with(utf8BOM))
trimmedLine = trimmedLine.substr(utf8BOM.size());
// Skip comments
if (!trimmedLine.empty() && trimmedLine.front() == '#')
continue;
// Skip empty lines before actual lyrics
if (currentState == State::None && trimmedLine.empty())
continue;
if (parseIDTag(trimmedLine, lyrics))
continue;
extractTimestamps(trimmedLine, timestamps);
// If there are timestamps, add as synchronized lyrics
if (!timestamps.empty())
{
if (currentState == State::UnsynchronizedLyrics)
lyrics.unsynchronizedLines.clear(); // choice: discard all lyrics parsed so far
currentState = State::SynchronizedLyrics;
applyAccumulatedLyrics();
std::string_view lyricText{ extractLyricText(trimmedLine) };
for (std::chrono::milliseconds timestamp : timestamps)
lyrics.synchronizedLines.emplace(timestamp, lyricText);
lastTimestamps = timestamps;
}
else
{
if (!lastTimestamps.empty())
{
accumulatedLyrics += '\n';
accumulatedLyrics += trimmedLine;
}
else
{
assert(currentState != State::SynchronizedLyrics); // should be handled
currentState = State::UnsynchronizedLyrics;
lyrics.unsynchronizedLines.push_back(std::string{ trimmedLine });
}
}
}
if (currentState == State::SynchronizedLyrics)
applyAccumulatedLyrics(true);
return lyrics;
}
} // namespace lms::metadata
+30 -16
View File
@@ -121,6 +121,29 @@ namespace lms::metadata
return getTagValueFirstMatchAs<T>(tagReader, { tagType });
}
std::vector<Lyrics> getLyrics(const ITagReader& tagReader)
{
std::vector<Lyrics> res;
tagReader.visitLyricsTags([&](std::string_view language, std::string_view lyricsText) {
std::istringstream iss{ std::string{ lyricsText } }; // TODO avoid copies (ispanstream?)
try
{
Lyrics lyrics{ parseLyrics(iss) };
if (lyrics.language.empty())
lyrics.language = language;
res.emplace_back(std::move(lyrics));
}
catch (const LyricsException& e)
{
LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() });
}
});
return res;
}
std::vector<Artist> getArtists(const ITagReader& tagReader,
std::initializer_list<TagType> artistTagNames,
std::initializer_list<TagType> artistSortTagNames,
@@ -198,7 +221,7 @@ namespace lms::metadata
return std::any_of(std::cbegin(subStrs), std::cend(subStrs), [&str](const std::string& subStr) { return str.find(subStr) != std::string_view::npos; });
}
std::string computeArtistDisplayName(std::span<const Artist> artists, const std::optional<std::string> artistTag, std::span<const std::string> artistsTag, std::span<const std::string> artistTagDelimiters)
std::string computeArtistDisplayName(std::span<const Artist> artists, const std::optional<std::string> artistTag, std::span<const std::string> artistTagDelimiters)
{
std::string artistDisplayName;
@@ -209,22 +232,12 @@ namespace lms::metadata
std::vector<std::string_view> artistNames;
std::transform(std::cbegin(artists), std::cend(artists), std::back_inserter(artistNames), [](const Artist& artist) -> std::string_view { return artist.name; });
// Picard use case: if we manage to match all artists in the "artist" tag (considered single-valued), and if no custom delimiter was used, we use it as the display name
// Picard use case: if we manage to match all artists in the "artist" tag (considered single-valued), and if no custom delimiter is hit, we use it as the display name
// Otherwise, we reconstruct the string using a standard, hardcoded, join
if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames))
{
if (artistsTag.size() == artists.size())
{
// artists was used
if (std::none_of(std::begin(artistsTag), std::cend(artistsTag), [&](std::string_view tag) { return strIsContainingAny(tag, artistTagDelimiters); }))
artistDisplayName = *artistTag;
}
else
{
// artist was used
if (!strIsContainingAny(*artistTag, artistTagDelimiters))
artistDisplayName = *artistTag;
}
if (!strIsContainingAny(*artistTag, artistTagDelimiters))
artistDisplayName = *artistTag;
}
if (artistDisplayName.empty())
@@ -331,6 +344,7 @@ namespace lms::metadata
track.originalYear = utils::parseYear(*dateStr);
}
track.lyrics = getLyrics(tagReader); // no custom delimiter on lyrics
track.comments = getTagValuesAs<std::string>(tagReader, TagType::Comment, {} /* no custom delimiter on comments */);
track.copyright = getTagValueAs<std::string>(tagReader, TagType::Copyright).value_or("");
track.copyrightURL = getTagValueAs<std::string>(tagReader, TagType::CopyrightURL).value_or("");
@@ -354,7 +368,7 @@ namespace lms::metadata
track.medium = getMedium(tagReader);
track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _artistTagDelimiters, _defaultTagDelimiters);
track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs<std::string>(tagReader, TagType::Artist), getTagValuesAs<std::string>(tagReader, TagType::Artists, {}), _artistTagDelimiters);
track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs<std::string>(tagReader, TagType::Artist), _artistTagDelimiters);
track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, {}, _artistTagDelimiters, _defaultTagDelimiters);
track.composerArtists = getArtists(tagReader, { TagType::Composers, TagType::Composer }, { TagType::ComposersSortOrder, TagType::ComposerSortOrder }, {}, _artistTagDelimiters, _defaultTagDelimiters);
@@ -415,7 +429,7 @@ namespace lms::metadata
release->name = std::move(*releaseName);
release->sortName = getTagValueAs<std::string>(tagReader, TagType::AlbumSortOrder).value_or("");
release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _artistTagDelimiters, _defaultTagDelimiters);
release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs<std::string>(tagReader, TagType::AlbumArtist), getTagValuesAs<std::string>(tagReader, TagType::AlbumArtists, {}), _artistTagDelimiters);
release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs<std::string>(tagReader, TagType::AlbumArtist), _artistTagDelimiters);
release->mbid = getTagValueAs<core::UUID>(tagReader, TagType::MusicBrainzReleaseID);
release->groupMBID = getTagValueAs<core::UUID>(tagReader, TagType::MusicBrainzReleaseGroupID);
release->mediumCount = getTagValueAs<std::size_t>(tagReader, TagType::TotalDiscs);
+79 -25
View File
@@ -35,8 +35,10 @@
#include <taglib/mpcfile.h>
#include <taglib/mpegfile.h>
#include <taglib/opusfile.h>
#include <taglib/synchronizedlyricsframe.h>
#include <taglib/tag.h>
#include <taglib/tpropertymap.h>
#include <taglib/unsynchronizedlyricsframe.h>
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/wavpackfile.h>
@@ -100,7 +102,6 @@ namespace lms::metadata
{ TagType::LyricistSortOrder, { "LYRICISTSORT" } },
{ TagType::Lyricists, { "LYRICISTS" } },
{ TagType::LyricistsSortOrder, { "LYRICISTSSORT" } },
{ TagType::Lyrics, { "LYRICS" } },
{ TagType::Media, { "MEDIA" } },
{ TagType::MixDJ, { "DJMIXER" } },
{ TagType::Mixer, { "MIXER" } },
@@ -220,8 +221,63 @@ namespace lms::metadata
mergeTagMaps(_propertyMap, apeTag->properties());
};
// Not that good embedded pictures handling
// + get some extra tags that may not be known by taglib
auto processID3v2Tags = [&](TagLib::ID3v2::Tag& id3v2Tags) {
const auto& frameListMap{ id3v2Tags.frameListMap() };
// Not that good embedded pictures handling
if (!frameListMap["APIC"].isEmpty())
_hasEmbeddedCover = true;
// Get some extra tags that may not be known by taglib
if (!frameListMap["TSST"].isEmpty() && !_propertyMap.contains("DISCSUBTITLE"))
_propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() };
// consider each frame hold a different set of lyrics
// Synchronized lyrics frames
for (const TagLib::ID3v2::Frame* frame : frameListMap["SYLT"])
{
const auto* lyricsFrame{ dynamic_cast<const TagLib::ID3v2::SynchronizedLyricsFrame*>(frame) };
if (!lyricsFrame)
continue; // TODO log or assert?
const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() };
std::string lyrics;
for (const TagLib::ID3v2::SynchronizedLyricsFrame::SynchedText& synchedText : lyricsFrame->synchedText())
{
std::chrono::milliseconds timestamp{};
switch (lyricsFrame->timestampFormat())
{
case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds:
timestamp = std::chrono::milliseconds{ synchedText.time };
break;
case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames:
timestamp = std::chrono::milliseconds{ _audioProperties.sampleRate ? (synchedText.time * 1000) / _audioProperties.sampleRate : 0 };
break;
case TagLib::ID3v2::SynchronizedLyricsFrame::Unknown:
break;
}
if (!lyrics.empty())
lyrics += '\n';
lyrics += core::stringUtils::formatTimestamp(timestamp);
lyrics += synchedText.text.to8Bit(true);
}
_id3v2Lyrics.emplace(language, std::move(lyrics));
}
// Unsynchronized lyrics frames
for (const TagLib::ID3v2::Frame* frame : frameListMap["USLT"])
{
const auto* lyricsFrame{ dynamic_cast<const TagLib::ID3v2::UnsynchronizedLyricsFrame*>(frame) };
if (!lyricsFrame)
continue; // TODO log or assert?
const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() };
_id3v2Lyrics.emplace(language, lyricsFrame->text().to8Bit(true));
}
};
// WMA
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.file()) })
@@ -255,16 +311,8 @@ namespace lms::metadata
// MP3
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_file.file()) })
{
if (mp3File->ID3v2Tag())
{
const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() };
if (!frameListMap["APIC"].isEmpty())
_hasEmbeddedCover = true;
if (!frameListMap["TSST"].isEmpty() && !_propertyMap.contains("DISCSUBTITLE"))
_propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() };
}
if (mp3File->hasID3v2Tag())
processID3v2Tags(*mp3File->ID3v2Tag());
getAPETags(mp3File->APETag());
}
@@ -326,22 +374,12 @@ namespace lms::metadata
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File*>(_file.file()) })
{
if (aiffFile->hasID3v2Tag())
{
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
if (!frameListMap["APIC"].isEmpty())
_hasEmbeddedCover = true;
}
processID3v2Tags(*aiffFile->tag());
}
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(_file.file()) })
{
if (wavFile->hasID3v2Tag())
{
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
if (!frameListMap["APIC"].isEmpty())
_hasEmbeddedCover = true;
}
processID3v2Tags(*wavFile->ID3v2Tag());
}
if (debug && core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
@@ -442,4 +480,20 @@ namespace lms::metadata
}
}
}
void TagLibTagReader::visitLyricsTags(LyricsVisitor visitor) const
{
if (!_id3v2Lyrics.empty())
{
for (const auto& [language, lyrics] : _id3v2Lyrics)
visitor(language, lyrics);
}
else
{
// otherwise, just visit regular LYRICS tag with no language
visitTagValues("LYRICS", [&](std::string_view value) {
visitor("", value);
});
}
}
} // namespace lms::metadata
@@ -20,6 +20,8 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
#include <taglib/fileref.h>
#include <taglib/tpropertymap.h>
@@ -43,6 +45,7 @@ namespace lms::metadata
void visitTagValues(TagType tag, TagValueVisitor visitor) const override;
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override;
void visitPerformerTags(PerformerVisitor visitor) const override;
void visitLyricsTags(LyricsVisitor visitor) const override;
bool hasEmbeddedCover() const override { return _hasEmbeddedCover; }
const AudioProperties& getAudioProperties() const override { return _audioProperties; }
@@ -51,5 +54,6 @@ namespace lms::metadata
AudioProperties _audioProperties;
TagLib::PropertyMap _propertyMap; // case-insensitive keys
bool _hasEmbeddedCover{};
std::multimap<std::string /* language*/, std::string /* lyrics */> _id3v2Lyrics;
};
} // namespace lms::metadata
@@ -0,0 +1,55 @@
/*
* 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 <chrono>
#include <filesystem>
#include <iosfwd>
#include <map>
#include <memory>
#include <span>
#include <string>
#include <vector>
#include "metadata/Exception.hpp"
namespace lms::metadata
{
struct Lyrics
{
std::string language;
std::chrono::milliseconds offset{};
std::string displayArtist;
std::string displayAlbum;
std::string displayTitle;
std::map<std::chrono::milliseconds, std::string> synchronizedLines;
std::vector<std::string> unsynchronizedLines;
};
class LyricsException : public metadata::Exception
{
public:
using metadata::Exception::Exception;
};
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions();
Lyrics parseLyrics(std::istream& is);
} // namespace lms::metadata
@@ -30,6 +30,8 @@
#include "core/UUID.hpp"
#include "Lyrics.hpp"
namespace lms::metadata
{
using Tags = std::map<std::string /* type */, std::vector<std::string> /* values */>;
@@ -119,6 +121,7 @@ namespace lms::metadata
std::string copyright;
std::string copyrightURL;
std::vector<std::string> comments;
std::vector<Lyrics> lyrics;
std::optional<float> replayGain;
std::string artistDisplayName;
std::vector<Artist> artists;
+1
View File
@@ -1,6 +1,7 @@
include(GoogleTest)
add_executable(test-metadata
Lyrics.cpp
Metadata.cpp
Parser.cpp
Utils.cpp
+334
View File
@@ -0,0 +1,334 @@
/*
* 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 <gtest/gtest.h>
#include "metadata/Lyrics.hpp"
namespace lms::metadata::tests
{
using namespace std::chrono_literals;
TEST(Lyrics, synchronized)
{
std::istringstream is{ R"([id: dqsxdkbu]
[ar: Lady Gaga]
[al: Lady Gaga]
[ti: Die With A Smile]
[la: eng]
[length: 04:12]
[offset: -34]
[00:03.30]Ooh, ooh
[00:06.75]
[00:09.16]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.displayArtist, "Lady Gaga");
EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga");
EXPECT_EQ(lyrics.displayTitle, "Die With A Smile");
EXPECT_EQ(lyrics.language, "eng");
EXPECT_EQ(lyrics.offset, -34ms);
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 3);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, tagInMidleOfLyrics)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[id: dqsxdkbu]
[00:09.16]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, tagAtTheEndOfLyrics)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[00:09.16]I, I just woke up from a dream
[id: dqsxdkbu])" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, skipEmptyBeginLines)
{
std::istringstream is{ R"(
[00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipEmptyEndLines)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipLeadingUnsynchronizedLyrics)
{
std::istringstream is{ R"(
Some unsynchronized lyrics
[00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, skipComments)
{
std::istringstream is{ R"(###
[00:03.30]Ooh, ooh
## just dance
[00:09.16]I, I just woke up from a dream
##end)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
}
TEST(Lyrics, synchronized_notags)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 1);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
}
TEST(Lyrics, synchronized_timestampFormats)
{
std::istringstream is{ R"([00:03.30]First line
[00:01.301]in milliseconds
[0:02.301]leading with only one digit
[61:01.30]more than 60 minutes
[02:01:01.30]With hours
[3:01:01.30]With hours with only one digit)" };
const Lyrics lyrics{ parseLyrics(is) };
ASSERT_EQ(lyrics.synchronizedLines.size(), 6);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(1s + 301ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(2s + 301ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(61min + 1s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(2h + 1min + 1s + 300ms));
ASSERT_TRUE(lyrics.synchronizedLines.contains(3h + 1min + 1s + 300ms));
}
TEST(Lyrics, synchronized_keepBlankLinesExceptEOF)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[00:06.75]Foo
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\n\n");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo");
}
TEST(Lyrics, synchronized_blankLinesEnd)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
SecondLine
Even a third line!!
[00:06.75]Foo
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecondLine\n Even a third line!!");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo");
}
TEST(Lyrics, synchronized_multitimestamps)
{
std::istringstream is{ R"([00:03.30][00:09.16] [00:15.16]Ooh, ooh
[00:06.75]I, I just woke up from a dream)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 4);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(15s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(15s + 160ms)->second, "Ooh, ooh");
}
TEST(Lyrics, synchronized_multitimestamps_blank)
{
std::istringstream is{ R"([00:03.30]Ooh, ooh
[00:06.75]
[00:09.16]I, I just woke up from a dream
[00:10.16]
)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 4);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream");
ASSERT_TRUE(lyrics.synchronizedLines.contains(10s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(10s + 160ms)->second, "");
}
TEST(Lyrics, synchronized_multitimestamps_multilines)
{
std::istringstream is{ R"([00:03.30][00:09.16]Ooh, ooh
Second line
Third line
Fifth line after an empty one...
[00:06.75]I, I just woke up from a dream
Cool)" };
const Lyrics lyrics{ parseLyrics(is) };
EXPECT_TRUE(lyrics.displayArtist.empty());
EXPECT_TRUE(lyrics.displayAlbum.empty());
EXPECT_TRUE(lyrics.displayTitle.empty());
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
ASSERT_EQ(lyrics.synchronizedLines.size(), 3);
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one...");
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream\nCool");
ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms));
EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one...");
}
TEST(Lyrics, unsynchronized)
{
std::istringstream is{ R"([id: dqsxdkbu]
[ar: Lady Gaga]
[al: Lady Gaga]
[ti: Die With A Smile]
[length: 04:12]
[offset: -34]
Ooh, ooh
I, I just woke up from a dream
)" };
Lyrics lyrics{ parseLyrics(is) };
EXPECT_EQ(lyrics.displayArtist, "Lady Gaga");
EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga");
EXPECT_EQ(lyrics.displayTitle, "Die With A Smile");
EXPECT_EQ(lyrics.offset, -34ms);
ASSERT_EQ(lyrics.unsynchronizedLines.size(), 5);
EXPECT_EQ(lyrics.unsynchronizedLines[0], "Ooh, ooh");
EXPECT_EQ(lyrics.unsynchronizedLines[1], "");
EXPECT_EQ(lyrics.unsynchronizedLines[2], "");
EXPECT_EQ(lyrics.unsynchronizedLines[3], "I, I just woke up from a dream");
EXPECT_EQ(lyrics.unsynchronizedLines[4], "");
}
} // namespace lms::metadata::tests
+97 -10
View File
@@ -75,16 +75,17 @@ namespace lms::metadata
{ TagType::TrackNumber, { "7" } },
{ TagType::TotalTracks, { "12" } },
{ TagType::TotalDiscs, { "3" } },
},
{ { "RoleA", { "MyPerformer1ForRoleA", "MyPerformer2ForRoleA" } },
{ "RoleB", { "MyPerformer1ForRoleB", "MyPerformer2ForRoleB" } } },
{ { "MY_AWESOME_TAG_A", { "MyTagValue1ForTagA", "MyTagValue2ForTagA" } },
{ "MY_AWESOME_TAG_B", { "MyTagValue1ForTagB", "MyTagValue2ForTagB" } } }
}
};
testTags.setExtraUserTags({ { "MY_AWESOME_TAG_A", { "MyTagValue1ForTagA", "MyTagValue2ForTagA" } },
{ "MY_AWESOME_TAG_B", { "MyTagValue1ForTagB", "MyTagValue2ForTagB" } } });
testTags.setPerformersTags({ { "RoleA", { "MyPerformer1ForRoleA", "MyPerformer2ForRoleA" } },
{ "RoleB", { "MyPerformer1ForRoleB", "MyPerformer2ForRoleB" } } });
testTags.setLyricsTags({ { "eng", "[00:00.00]First line\n[00:01.00]Second line" } });
static_cast<IParser&>(parser).setUserExtraTags(std::vector<std::string>{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" });
std::unique_ptr<Track> track{ parser.parse(testTags) };
const std::unique_ptr<Track> track{ parser.parse(testTags) };
// Audio properties
{
@@ -135,6 +136,13 @@ namespace lms::metadata
ASSERT_EQ(track->lyricistArtists.size(), 2);
EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1");
EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2");
ASSERT_EQ(track->lyrics.size(), 1);
EXPECT_EQ(track->lyrics.front().language, "eng");
ASSERT_EQ(track->lyrics.front().synchronizedLines.size(), 2);
ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 0 }));
EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 0 })->second, "First line");
ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 1000 }));
EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 1000 })->second, "Second line");
ASSERT_TRUE(track->mbid.has_value());
EXPECT_EQ(track->mbid.value(), core::UUID::fromString("0afb190a-6735-46df-a16d-199f48206e4a"));
ASSERT_EQ(track->mixerArtists.size(), 2);
@@ -273,7 +281,47 @@ namespace lms::metadata
EXPECT_EQ(track->medium->release->artistDisplayName, "AlbumArtist1, AlbumArtist2");
}
TEST(Parser, customDelimitersNotForDisplayString)
TEST(Parser, customDelimiters_foundInArtist)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "Artist1; Artist2" } },
{ TagType::Artists, { "Artist1", "Artist2" } },
}
};
Parser parser;
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ "; " });
std::unique_ptr<Track> track{ parser.parse(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
EXPECT_EQ(track->artists[1].name, "Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstruct the display name since we hit a custom delimiter in Artist
}
TEST(Parser, customDelimiters_foundInArtists)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "Artist1 feat. Artist2" } },
{ TagType::Artists, { "Artist1; Artist2" } },
}
};
Parser parser;
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ "; " });
std::unique_ptr<Track> track{ parser.parse(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
EXPECT_EQ(track->artists[1].name, "Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1 feat. Artist2");
}
TEST(Parser, customDelimiters_notUsed)
{
const TestTagReader testTags{
{
@@ -282,6 +330,25 @@ namespace lms::metadata
}
};
Parser parser;
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ "; " });
std::unique_ptr<Track> track{ parser.parse(testTags) };
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
EXPECT_EQ(track->artists[1].name, "Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1 & Artist2");
}
TEST(Parser, customDelimiters_onlyInArtist)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "Artist1 & Artist2" } },
}
};
Parser parser;
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ " & " });
@@ -290,14 +357,14 @@ namespace lms::metadata
ASSERT_EQ(track->artists.size(), 2);
EXPECT_EQ(track->artists[0].name, "Artist1");
EXPECT_EQ(track->artists[1].name, "Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1 & Artist2");
EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstructed since a custom delimiter was hit for parsing
}
TEST(Parser, customDelimitersUsedForArtist)
TEST(Parser, customDelimitersUsedForArtists)
{
const TestTagReader testTags{
{
{ TagType::Artist, { "Artist1 & Artist2" } },
{ TagType::Artists, { "Artist1 & Artist2" } },
}
};
@@ -432,6 +499,26 @@ namespace lms::metadata
EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1, Artist2"); // reconstruct artist display name since multiple entries are found
}
TEST(Parser, multipleArtistsInAlbumArtists_displayName)
{
const TestTagReader testTags{
{
{ TagType::Album, { "MyAlbum" } },
{ TagType::AlbumArtist, { "Artist1 & Artist2" } },
{ TagType::AlbumArtists, { "Artist1", "Artist2" } },
}
};
std::unique_ptr<Track> track{ Parser{}.parse(testTags) };
ASSERT_TRUE(track->medium);
ASSERT_TRUE(track->medium->release);
ASSERT_EQ(track->medium->release->artists.size(), 2);
EXPECT_EQ(track->medium->release->artists[0].name, "Artist1");
EXPECT_EQ(track->medium->release->artists[1].name, "Artist2");
EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1 & Artist2");
}
TEST(Parser, multipleArtistsInAlbumArtists)
{
const TestTagReader testTags{
+27 -6
View File
@@ -38,15 +38,29 @@ namespace lms::metadata
};
using Tags = std::unordered_map<TagType, std::vector<std::string_view>>;
using Performers = std::unordered_map<std::string_view, std::vector<std::string_view>>;
using Performers = std::unordered_map<std::string_view /*role*/, std::vector<std::string_view> /*names*/>;
using ExtraUserTags = std::unordered_map<std::string_view, std::vector<std::string_view>>;
TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {})
using LyricsTags = std::unordered_map<std::string_view /*language*/, std::string_view /*contents*/>;
TestTagReader(Tags&& tags)
: _tags{ std::move(tags) }
, _performers{ std::move(performers) }
, _extraUserTags{ std::move(extraUserTags) }
{
}
void setPerformersTags(Performers&& performers)
{
_performers = std::move(performers);
}
void setExtraUserTags(ExtraUserTags&& extraUserTags)
{
_extraUserTags = std::move(extraUserTags);
}
void setLyricsTags(LyricsTags&& lyricsTags)
{
_lyricsTags = std::move(lyricsTags);
}
void visitTagValues(TagType tag, TagValueVisitor visitor) const override
{
auto itValues{ _tags.find(tag) };
@@ -75,13 +89,20 @@ namespace lms::metadata
}
}
void visitLyricsTags(LyricsVisitor visitor) const override
{
for (const auto& [language, lyrics] : _lyricsTags)
visitor(language, lyrics);
}
bool hasEmbeddedCover() const override { return false; };
const AudioProperties& getAudioProperties() const override { return audioProperties; }
private:
const Tags _tags;
const Performers _performers;
const ExtraUserTags _extraUserTags;
Performers _performers;
ExtraUserTags _extraUserTags;
LyricsTags _lyricsTags;
};
} // namespace lms::metadata
+1
View File
@@ -4,6 +4,7 @@ add_library(lmsscanner SHARED
impl/ScannerService.cpp
impl/ScannerStats.cpp
impl/ScanStepAssociateArtistImages.cpp
impl/ScanStepAssociateExternalLyrics.cpp
impl/ScanStepAssociateReleaseImages.cpp
impl/ScanStepCheckForDuplicatedFiles.cpp
impl/ScanStepCheckForRemovedFiles.cpp
@@ -19,6 +19,8 @@
#include "FileScanQueue.hpp"
#include <fstream>
#include "core/Exception.hpp"
#include "core/IConfig.hpp"
#include "core/ILogger.hpp"
@@ -62,6 +64,10 @@ namespace lms::scanner
break;
case ScanRequestType::ImageFile:
result.scanData = scanImageFile(path);
break;
case ScanRequestType::LyricsFile:
result.scanData = scanLyricsFile(path);
break;
}
{
@@ -114,6 +120,28 @@ namespace lms::scanner
return optInfo;
}
LyricsFileScanData FileScanQueue::scanLyricsFile(const std::filesystem::path& path)
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ScanLyricsFile");
LyricsFileScanData lyrics;
try
{
std::ifstream ifs{ path.string() };
if (!ifs)
LMS_LOG(DBUPDATER, ERROR, "Cannot open file '" << path.string() << "'");
else
lyrics = metadata::parseLyrics(ifs);
}
catch (const std::exception& e)
{
LMS_LOG(DBUPDATER, ERROR, "Cannot read lyrics in file '" << path.string() << "': " << e.what());
}
return lyrics;
}
std::size_t FileScanQueue::getResultsCount() const
{
std::scoped_lock lock{ _mutex };
@@ -29,6 +29,7 @@
#include "core/IOContextRunner.hpp"
#include "metadata/IParser.hpp"
#include "metadata/Lyrics.hpp"
namespace lms::scanner
{
@@ -40,10 +41,11 @@ namespace lms::scanner
using AudioFileScanData = std::unique_ptr<metadata::Track>;
using ImageFileScanData = std::optional<ImageInfo>;
using LyricsFileScanData = std::optional<metadata::Lyrics>;
struct FileScanResult
{
std::filesystem::path path;
std::variant<std::monostate, AudioFileScanData, ImageFileScanData> scanData;
std::variant<std::monostate, AudioFileScanData, ImageFileScanData, LyricsFileScanData> scanData;
};
class FileScanQueue
@@ -57,6 +59,7 @@ namespace lms::scanner
{
AudioFile,
ImageFile,
LyricsFile,
};
void pushScanRequest(const std::filesystem::path& path, ScanRequestType type);
@@ -68,6 +71,7 @@ namespace lms::scanner
private:
AudioFileScanData scanAudioFile(const std::filesystem::path& path);
ImageFileScanData scanImageFile(const std::filesystem::path& path);
LyricsFileScanData scanLyricsFile(const std::filesystem::path& path);
metadata::IParser& _metadataParser;
boost::asio::io_context _scanContext;
@@ -19,8 +19,6 @@
#pragma once
#include <vector>
#include "core/LiteralString.hpp"
#include "services/scanner/ScannerOptions.hpp"
#include "services/scanner/ScannerStats.hpp"
@@ -54,6 +54,7 @@ namespace lms::scanner
{
db::Session& session;
db::ArtistId lastRetrievedArtistId;
std::size_t processedArtistCount{};
const std::vector<std::string>& artistFileNames;
};
@@ -107,27 +108,44 @@ namespace lms::scanner
releasePaths.insert(directory->getAbsolutePath());
});
// Expect layout like this:
// ReleaseArtist/Release/Tracks'
// /artist.jpg
// /someOtherUserConfiguredArtistFile.jpg
if (!releasePaths.empty())
{
const std::filesystem::path artistPath{ releasePaths.size() == 1 ? releasePaths.begin()->parent_path() : core::pathUtils::getLongestCommonPath(std::cbegin(releasePaths), std::cend(releasePaths)) };
image = findImageInDirectory(searchContext, artistPath);
}
if (!image)
{
// Expect layout like this:
// ReleaseArtist/Release/Tracks'
// /artist.jpg
// /someOtherUserConfiguredArtistFile.jpg
for (const std::filesystem::path& releasePath : releasePaths)
// /artist.jpg
// /someOtherUserConfiguredArtistFile.jpg
//
// Or:
// ReleaseArtist/SomeGrouping/Release/Tracks'
// /artist.jpg
// /someOtherUserConfiguredArtistFile.jpg
//
std::filesystem::path directoryToInspect{ core::pathUtils::getLongestCommonPath(std::cbegin(releasePaths), std::cend(releasePaths)) };
while (true)
{
image = findImageInDirectory(searchContext, releasePath);
image = findImageInDirectory(searchContext, directoryToInspect);
if (image)
break;
std::filesystem::path parentPath{ directoryToInspect.parent_path() };
if (parentPath == directoryToInspect)
break;
directoryToInspect = parentPath;
}
if (!image)
{
// Expect layout like this:
// ReleaseArtist/Release/Tracks'
// /artist.jpg
// /someOtherUserConfiguredArtistFile.jpg
for (const std::filesystem::path& releasePath : releasePaths)
{
image = findImageInDirectory(searchContext, releasePath);
if (image)
break;
}
}
}
}
@@ -150,6 +168,7 @@ namespace lms::scanner
LMS_LOG(DBUPDATER, DEBUG, "Updating artist image for artist '" << artist->getName() << "', using '" << (image ? image->getAbsoluteFilePath().c_str() : "<none>") << "'");
artistImageAssociations.push_back(ArtistImageAssociation{ artist->getId(), image ? image->getId() : db::ImageId{} });
}
searchContext.processedArtistCount++;
});
}
@@ -231,7 +250,7 @@ namespace lms::scanner
return;
updateArtistImages(session, artistImageAssociations);
context.currentStepStats.processedElems += readBatchSize;
context.currentStepStats.processedElems = searchContext.processedArtistCount;
_progressCallback(context.currentStepStats);
}
}
@@ -0,0 +1,172 @@
/*
* 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 "ScanStepAssociateExternalLyrics.hpp"
#include <deque>
#include "core/ILogger.hpp"
#include "database/Db.hpp"
#include "database/Directory.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
namespace lms::scanner
{
namespace
{
constexpr std::size_t readBatchSize{ 100 };
constexpr std::size_t writeBatchSize{ 20 };
struct TrackLyricsAssociation
{
db::TrackLyricsId trackLyricsId;
db::TrackId trackId;
};
using TrackLyricsAssociationContainer = std::deque<TrackLyricsAssociation>;
struct SearchTrackLyricsContext
{
db::Session& session;
db::TrackLyricsId lastRetrievedTrackLyricsId;
std::size_t processedLyricsCount{};
};
db::Track::pointer getMatchingTrack(db::Session& session, const db::TrackLyrics::pointer& lyrics)
{
db::Track::pointer matchingTrack;
auto tryMatch = [&](std::string_view stem) {
db::Track::FindParameters params;
assert(lyrics->getDirectory()->getId().isValid());
assert(!lyrics->getFileStem().empty());
params.setDirectory(lyrics->getDirectory()->getId());
params.setStem(stem);
db::Track::find(session, params, [&](const db::Track::pointer track) {
if (matchingTrack)
LMS_LOG(DBUPDATER, DEBUG, "External lyrics '" << lyrics->getAbsoluteFilePath() << "' already matched with '" << matchingTrack->getAbsoluteFilePath() << "', replaced by '" << track->getAbsoluteFilePath() << "'");
matchingTrack = track;
});
};
// First try with the stem. If it does not match, try again with the parent steam, if it exists, to handle the file.laguagecode.lrc case
tryMatch(lyrics->getFileStem());
if (!matchingTrack)
{
std::filesystem::path stem{ lyrics->getFileStem() };
if (stem.has_extension())
tryMatch(stem.stem().string());
}
return matchingTrack;
}
bool fetchNextTrackLyricsToUpdate(SearchTrackLyricsContext& searchContext, TrackLyricsAssociationContainer& trackLyricsAssociations)
{
const db::TrackLyricsId trackLyricsId{ searchContext.lastRetrievedTrackLyricsId };
{
auto transaction{ searchContext.session.createReadTransaction() };
db::TrackLyrics::find(searchContext.session, searchContext.lastRetrievedTrackLyricsId, readBatchSize, [&](const db::TrackLyrics::pointer& trackLyrics) {
// Only iterate over external lyrics
if (trackLyrics->getAbsoluteFilePath().empty())
return;
db::Track::pointer track{ getMatchingTrack(searchContext.session, trackLyrics) };
if (track != trackLyrics->getTrack())
{
LMS_LOG(DBUPDATER, DEBUG, "Updating track for external lyrics " << trackLyrics->getAbsoluteFilePath() << ", using " << (track ? track->getAbsoluteFilePath() : "<none>"));
trackLyricsAssociations.push_back(TrackLyricsAssociation{ .trackLyricsId = trackLyrics->getId(), .trackId = (track ? track->getId() : db::TrackId{}) });
}
else if (!track)
{
LMS_LOG(DBUPDATER, DEBUG, "No track found for external lyrics " << trackLyrics->getAbsoluteFilePath() << "'");
}
searchContext.processedLyricsCount++;
});
}
return trackLyricsId != searchContext.lastRetrievedTrackLyricsId;
}
void updateTrackLyrics(db::Session& session, const TrackLyricsAssociation& trackLyricsAssociation)
{
db::TrackLyrics::pointer lyrics{ db::TrackLyrics::find(session, trackLyricsAssociation.trackLyricsId) };
assert(lyrics);
db::Track::pointer track;
if (trackLyricsAssociation.trackId.isValid())
track = db::Track::find(session, trackLyricsAssociation.trackId);
lyrics.modify()->setTrack(track);
}
void updateTrackLyrics(db::Session& session, TrackLyricsAssociationContainer& lyricsAssociations)
{
while (!lyricsAssociations.empty())
{
auto transaction{ session.createWriteTransaction() };
for (std::size_t i{}; !lyricsAssociations.empty() && i < writeBatchSize; ++i)
{
updateTrackLyrics(session, lyricsAssociations.front());
lyricsAssociations.pop_front();
}
}
}
} // namespace
void ScanStepAssociateExternalLyrics::process(ScanContext& context)
{
if (_abortScan)
return;
if (context.stats.nbChanges() == 0)
return;
auto& session{ _db.getTLSSession() };
{
auto transaction{ session.createReadTransaction() };
context.currentStepStats.totalElems = db::TrackLyrics::getExternalLyricsCount(session);
}
SearchTrackLyricsContext searchContext{
.session = session,
.lastRetrievedTrackLyricsId = {},
};
TrackLyricsAssociationContainer trackLyricsAssociations;
while (fetchNextTrackLyricsToUpdate(searchContext, trackLyricsAssociations))
{
if (_abortScan)
return;
updateTrackLyrics(session, trackLyricsAssociations);
context.currentStepStats.processedElems = searchContext.processedLyricsCount;
_progressCallback(context.currentStepStats);
}
}
} // namespace lms::scanner
@@ -0,0 +1,36 @@
/*
* 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 "ScanStepBase.hpp"
namespace lms::scanner
{
class ScanStepAssociateExternalLyrics : public ScanStepBase
{
public:
using ScanStepBase::ScanStepBase;
private:
ScanStep getStep() const override { return ScanStep::AssociateExternalLyrics; }
core::LiteralString getStepName() const override { return "Associate external lyrics"; }
void process(ScanContext& context) override;
};
} // namespace lms::scanner
@@ -54,6 +54,7 @@ namespace lms::scanner
{
db::Session& session;
db::ReleaseId lastRetrievedReleaseId;
std::size_t processedReleaseCount{};
const std::vector<std::string>& releaseFileNames;
};
@@ -146,6 +147,7 @@ namespace lms::scanner
LMS_LOG(DBUPDATER, DEBUG, "Updating release image for release '" << release->getName() << "', using '" << (image ? image->getAbsoluteFilePath().c_str() : "<none>") << "'");
releaseImageAssociations.push_back(ReleaseImageAssociation{ release->getId(), image ? image->getId() : db::ImageId{} });
}
searchContext.processedReleaseCount++;
});
}
@@ -227,7 +229,7 @@ namespace lms::scanner
return;
updateReleaseImages(session, releaseImageAssociations);
context.currentStepStats.processedElems += readBatchSize;
context.currentStepStats.processedElems = searchContext.processedReleaseCount;
_progressCallback(context.currentStepStats);
}
}
@@ -32,7 +32,7 @@ namespace lms::scanner
ScanStepAssociateReleaseImages(InitParams& initParams);
private:
ScanStep getStep() const override { return ScanStep::AssociateArtistImages; }
ScanStep getStep() const override { return ScanStep::AssociateReleaseImages; }
core::LiteralString getStepName() const override { return "Associate release images"; }
void process(ScanContext& context) override;
@@ -25,6 +25,7 @@
#include "database/Image.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
namespace lms::scanner
{
@@ -45,15 +46,17 @@ namespace lms::scanner
context.currentStepStats.totalElems = 0;
context.currentStepStats.totalElems += db::Track::getCount(session);
context.currentStepStats.totalElems += db::Image::getCount(session);
context.currentStepStats.totalElems += db::TrackLyrics::getExternalLyricsCount(session);
}
LMS_LOG(DBUPDATER, DEBUG, context.currentStepStats.totalElems << " files to be checked...");
checkForRemovedFiles<db::Track>(context, _settings.supportedAudioFileExtensions);
checkForRemovedFiles<db::Image>(context, _settings.supportedImageFileExtensions);
checkForRemovedFiles<db::TrackLyrics>(context, _settings.supportedLyricsFileExtensions);
}
template<typename Object>
void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, const std::vector<std::filesystem::path>& supportedFileExtensions)
void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, std::span<const std::filesystem::path> supportedFileExtensions)
{
using namespace db;
@@ -79,6 +82,13 @@ namespace lms::scanner
Object::find(session, lastCheckedId, batchSize, [&](const typename Object::pointer& object) {
endReached = false;
// special case for track lyrics, only check external lyrics
if constexpr (std::is_same_v<Object, TrackLyrics>)
{
if (object->getAbsoluteFilePath().empty())
return;
}
if (!checkFile(object->getAbsoluteFilePath(), supportedFileExtensions))
objectsToRemove.push_back(object);
@@ -101,7 +111,7 @@ namespace lms::scanner
}
}
bool ScanStepCheckForRemovedFiles::checkFile(const std::filesystem::path& p, const std::vector<std::filesystem::path>& allowedExtensions)
bool ScanStepCheckForRemovedFiles::checkFile(const std::filesystem::path& p, std::span<const std::filesystem::path> allowedExtensions)
{
try
{
@@ -109,7 +119,7 @@ namespace lms::scanner
// and still belongs to a media directory
if (!std::filesystem::exists(p) || !std::filesystem::is_regular_file(p))
{
LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': missing");
LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': missing");
return false;
}
@@ -118,13 +128,13 @@ namespace lms::scanner
return core::pathUtils::isPathInRootPath(p, libraryInfo.rootDirectory, &excludeDirFileName);
}))
{
LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': out of media directory");
LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': out of media directory");
return false;
}
if (!core::pathUtils::hasFileAnyExtension(p, allowedExtensions))
{
LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': file format no longer handled");
LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': file format no longer handled");
return false;
}
@@ -20,6 +20,7 @@
#pragma once
#include <filesystem>
#include <span>
#include "ScanStepBase.hpp"
@@ -36,8 +37,8 @@ namespace lms::scanner
void process(ScanContext& context) override;
template<typename Object>
void checkForRemovedFiles(ScanContext& context, const std::vector<std::filesystem::path>& supportedFileExtensions);
void checkForRemovedFiles(ScanContext& context, std::span<const std::filesystem::path> supportedFileExtensions);
bool checkFile(const std::filesystem::path& p, const std::vector<std::filesystem::path>& allowedExtensions);
bool checkFile(const std::filesystem::path& p, std::span<const std::filesystem::path> allowedExtensions);
};
} // namespace lms::scanner
@@ -26,7 +26,7 @@ namespace lms::scanner
{
void ScanStepCompact::process(ScanContext& context)
{
// Don't auto compact as it may be too annoying to block the whole application
// Don't auto compact as it may be too annoying to block the whole application for very large databases
if (context.scanOptions.compact)
_db.getTLSSession().vacuum();
}
@@ -35,6 +35,7 @@
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackFeatures.hpp"
#include "database/TrackLyrics.hpp"
#include "metadata/Exception.hpp"
#include "metadata/IParser.hpp"
@@ -122,6 +123,22 @@ namespace lms::scanner
return directory;
}
db::TrackLyrics::pointer createLyrics(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;
}
Artist::pointer createArtist(Session& session, const metadata::Artist& artistInfo)
{
Artist::pointer artist{ session.create<Artist>(artistInfo.name) };
@@ -448,6 +465,12 @@ namespace lms::scanner
if (checkImageFileNeedScan(context, path))
_fileScanQueue.pushScanRequest(path, FileScanQueue::ScanRequestType::ImageFile);
}
else if (core::pathUtils::hasFileAnyExtension(path, _settings.supportedLyricsFileExtensions))
{
fileMatched = true;
if (checkLyricsFileNeedScan(context, path))
_fileScanQueue.pushScanRequest(path, FileScanQueue::ScanRequestType::LyricsFile);
}
if (fileMatched)
{
@@ -555,6 +578,34 @@ namespace lms::scanner
return true; // need to scan
}
bool ScanStepScanFiles::checkLyricsFileNeedScan(ScanContext& context, const std::filesystem::path& file)
{
ScanStats& stats{ context.stats };
const Wt::WDateTime lastWriteTime{ retrieveFileGetLastWrite(file) };
// Should rarely fail as we are currently iterating it
if (!lastWriteTime.isValid())
{
stats.skips++;
return false;
}
if (!context.scanOptions.fullScan)
{
db::Session& dbSession{ _db.getTLSSession() };
auto transaction{ _db.getTLSSession().createReadTransaction() };
const db::TrackLyrics::pointer lyrics{ db::TrackLyrics::find(dbSession, file) };
if (lyrics && lyrics->getLastWriteTime() == lastWriteTime)
{
stats.skips++;
return false;
}
}
return true; // need to scan
}
void ScanStepScanFiles::processFileScanResults(ScanContext& context, std::span<const FileScanResult> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo)
{
LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ProcessScanResults");
@@ -577,6 +628,11 @@ namespace lms::scanner
context.stats.scans++;
processImageFileScanData(context, scanResult.path, scanData->has_value() ? &scanData->value() : nullptr, libraryInfo);
}
else if (const LyricsFileScanData * scanData{ std::get_if<LyricsFileScanData>(&scanResult.scanData) })
{
context.stats.scans++;
processLyricsFileScanData(context, scanResult.path, scanData->has_value() ? &scanData->value() : nullptr, libraryInfo);
}
}
}
@@ -783,6 +839,13 @@ namespace lms::scanner
track.modify()->setTrackReplayGain(trackMetadata->replayGain);
track.modify()->setArtistDisplayName(trackMetadata->artistDisplayName);
track.modify()->clearEmbeddedLyrics();
for (const metadata::Lyrics& lyricsInfo : trackMetadata->lyrics)
{
db::TrackLyrics::pointer lyrics{ createLyrics(dbSession, lyricsInfo) };
track.modify()->addLyrics(lyrics);
}
if (added)
{
LMS_LOG(DBUPDATER, DEBUG, "Added audio file '" << file.string() << "'");
@@ -851,4 +914,70 @@ namespace lms::scanner
stats.updates++;
}
}
void ScanStepScanFiles::processLyricsFileScanData(ScanContext& context, const std::filesystem::path& file, const metadata::Lyrics* lyricsInfo, const ScannerSettings::MediaLibraryInfo& libraryInfo)
{
LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessImageScanData");
ScanStats& stats{ context.stats };
const std::optional<FileInfo> fileInfo{ retrieveFileInfo(file, libraryInfo.rootDirectory) };
if (!fileInfo)
{
stats.skips++;
return;
}
db::Session& dbSession{ _db.getTLSSession() };
db::TrackLyrics::pointer trackLyrics{ db::TrackLyrics::find(dbSession, file) };
if (!lyricsInfo)
{
if (trackLyrics)
{
trackLyrics.remove();
stats.deletions++;
}
context.stats.errors.emplace_back(file, ScanErrorType::CannotReadLyricsFile);
return;
}
bool added;
if (!trackLyrics)
{
trackLyrics = dbSession.create<db::TrackLyrics>();
trackLyrics.modify()->setAbsoluteFilePath(file);
added = true;
}
else
{
added = false;
}
trackLyrics.modify()->setLastWriteTime(fileInfo->lastWriteTime);
trackLyrics.modify()->setFileSize(fileInfo->fileSize);
trackLyrics.modify()->setLanguage(!lyricsInfo->language.empty() ? lyricsInfo->language : "xxx");
trackLyrics.modify()->setOffset(lyricsInfo->offset);
trackLyrics.modify()->setDisplayTitle(lyricsInfo->displayTitle);
trackLyrics.modify()->setDisplayArtist(lyricsInfo->displayArtist);
if (!lyricsInfo->synchronizedLines.empty())
trackLyrics.modify()->setSynchronizedLines(lyricsInfo->synchronizedLines);
else
trackLyrics.modify()->setUnsynchronizedLines(lyricsInfo->unsynchronizedLines);
MediaLibrary::pointer mediaLibrary{ MediaLibrary::find(dbSession, libraryInfo.id) }; // may be null if settings are updated in // => next scan will correct this
trackLyrics.modify()->setDirectory(getOrCreateDirectory(dbSession, file.parent_path(), mediaLibrary));
if (added)
{
LMS_LOG(DBUPDATER, DEBUG, "Added external lyrics '" << file.string() << "'");
stats.additions++;
}
else
{
LMS_LOG(DBUPDATER, DEBUG, "Updated external lyrics '" << file.string() << "'");
stats.updates++;
}
}
} // namespace lms::scanner
@@ -44,10 +44,12 @@ namespace lms::scanner
bool checkAudioFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo);
bool checkImageFileNeedScan(ScanContext& context, const std::filesystem::path& file);
bool checkLyricsFileNeedScan(ScanContext& context, const std::filesystem::path& file);
void processFileScanResults(ScanContext& context, std::span<const FileScanResult> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo);
void processAudioFileScanData(ScanContext& context, const std::filesystem::path& path, const metadata::Track* trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo);
void processImageFileScanData(ScanContext& context, const std::filesystem::path& path, const ImageInfo* imageInfo, const ScannerSettings::MediaLibraryInfo& libraryInfo);
void processLyricsFileScanData(ScanContext& context, const std::filesystem::path& path, const metadata::Lyrics* lyrics, const ScannerSettings::MediaLibraryInfo& libraryInfo);
std::unique_ptr<metadata::IParser> _metadataParser;
const std::vector<std::string> _extraTagsToParse;
@@ -32,6 +32,7 @@
#include "image/Image.hpp"
#include "ScanStepAssociateArtistImages.hpp"
#include "ScanStepAssociateExternalLyrics.hpp"
#include "ScanStepAssociateReleaseImages.hpp"
#include "ScanStepCheckForDuplicatedFiles.hpp"
#include "ScanStepCheckForRemovedFiles.hpp"
@@ -271,7 +272,8 @@ namespace lms::scanner
refreshScanSettings();
IScanStep::ScanContext scanContext{ scanOptions, ScanStats{}, ScanStepStats{} };
IScanStep::ScanContext scanContext;
scanContext.scanOptions = scanOptions;
ScanStats& stats{ scanContext.stats };
stats.startTime = Wt::WDateTime::currentDateTime();
@@ -281,7 +283,14 @@ namespace lms::scanner
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() };
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);
@@ -341,7 +350,7 @@ namespace lms::scanner
_db
};
// Order is important, steps are sequential
// Order is important: steps are sequential
_scanSteps.clear();
_scanSteps.push_back(std::make_unique<ScanStepDiscoverFiles>(params));
_scanSteps.push_back(std::make_unique<ScanStepScanFiles>(params));
@@ -349,6 +358,7 @@ namespace lms::scanner
_scanSteps.push_back(std::make_unique<ScanStepUpdateLibraryFields>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateArtistImages>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateReleaseImages>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateExternalLyrics>(params));
_scanSteps.push_back(std::make_unique<ScanStepRemoveOrphanedDbEntries>(params));
_scanSteps.push_back(std::make_unique<ScanStepCompact>(params));
_scanSteps.push_back(std::make_unique<ScanStepOptimize>(params));
@@ -373,14 +383,21 @@ namespace lms::scanner
{
const auto audioFileExtensions{ scanSettings->getAudioFileExtensions() };
newSettings.supportedAudioFileExtensions.reserve(audioFileExtensions.size());
std::transform(std::cbegin(audioFileExtensions), std::end(audioFileExtensions), std::back_inserter(newSettings.supportedAudioFileExtensions),
std::transform(std::cbegin(audioFileExtensions), std::cend(audioFileExtensions), std::back_inserter(newSettings.supportedAudioFileExtensions),
[](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; });
}
{
const auto imageFileExtensions{ image::getSupportedFileExtensions() };
newSettings.supportedImageFileExtensions.reserve(imageFileExtensions.size());
std::transform(std::cbegin(imageFileExtensions), std::end(imageFileExtensions), std::back_inserter(newSettings.supportedImageFileExtensions),
std::transform(std::cbegin(imageFileExtensions), std::cend(imageFileExtensions), std::back_inserter(newSettings.supportedImageFileExtensions),
[](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; });
}
{
const auto lyricsFileExtensions{ metadata::getSupportedLyricsFileExtensions() };
newSettings.supportedLyricsFileExtensions.reserve(lyricsFileExtensions.size());
std::transform(std::cbegin(lyricsFileExtensions), std::cend(lyricsFileExtensions), std::back_inserter(newSettings.supportedLyricsFileExtensions),
[](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; });
}
@@ -410,7 +427,7 @@ namespace lms::scanner
}
const std::chrono::system_clock::time_point now{ std::chrono::system_clock::now() };
_events.scanInProgress(stepStats);
_events.scanInProgress.emit(stepStats);
_lastScanInProgressEmit = now;
}
@@ -418,7 +435,7 @@ namespace lms::scanner
{
std::chrono::system_clock::time_point now{ std::chrono::system_clock::now() };
if (std::chrono::duration_cast<std::chrono::seconds>(now - _lastScanInProgressEmit).count() > 1)
if (now - _lastScanInProgressEmit >= std::chrono::seconds{ 1 })
notifyInProgress(stepStats);
}
} // namespace lms::scanner
@@ -37,6 +37,7 @@ namespace lms::scanner
db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never };
std::vector<std::filesystem::path> supportedAudioFileExtensions;
std::vector<std::filesystem::path> supportedImageFileExtensions;
std::vector<std::filesystem::path> supportedLyricsFileExtensions;
bool skipDuplicateMBID{};
std::vector<std::string> extraTags;
std::vector<std::string> artistTagDelimiters;
@@ -40,6 +40,8 @@ namespace lms::scanner
unsigned ScanStepStats::progress() const
{
return (processedElems / static_cast<float>(totalElems ? totalElems : 1)) * 100;
const unsigned res{ static_cast<unsigned>((processedElems / static_cast<float>(totalElems ? totalElems : 1)) * 100) };
// can technically be above 100% since we may add files while iterating the filesystem
return res;
}
} // namespace lms::scanner
@@ -30,11 +30,12 @@ namespace lms::scanner
{
enum class ScanErrorType
{
CannotReadFile, // cannot read file
CannotReadAudioFile, // cannot parse audio file
CannotReadImageFile, // cannot parse image file
NoAudioTrack, // no audio track found
BadDuration, // bad duration
CannotReadFile, // cannot read file
CannotReadAudioFile, // cannot parse audio file
CannotReadImageFile, // cannot parse image file
CannotReadLyricsFile, // cannot parse lyrics file
NoAudioTrack, // no audio track found
BadDuration, // bad duration
};
enum class DuplicateReason
@@ -62,6 +63,7 @@ namespace lms::scanner
enum class ScanStep
{
AssociateArtistImages,
AssociateExternalLyrics,
AssociateReleaseImages,
CheckForDuplicatedFiles,
CheckForRemovedFiles,
+1
View File
@@ -18,6 +18,7 @@ add_library(lmssubsonic SHARED
impl/responses/ItemDate.cpp
impl/responses/ItemGenre.cpp
impl/responses/Genre.cpp
impl/responses/Lyrics.cpp
impl/responses/Playlist.cpp
impl/responses/RecordLabel.cpp
impl/responses/ReplayGain.cpp
+2 -1
View File
@@ -200,7 +200,8 @@ namespace lms::api::subsonic
// Media retrieval
{ "/hls", { handleNotImplemented } },
{ "/getCaptions", { handleNotImplemented } },
{ "/getLyrics", { handleNotImplemented } },
{ "/getLyrics", { handleGetLyrics } },
{ "/getLyricsBySongId", { handleGetLyricsBySongId } },
{ "/getAvatar", { handleNotImplemented } },
// Media annotation
+5 -1
View File
@@ -196,7 +196,11 @@ namespace lms::api::subsonic
auto valueToPropertyTree = [](const Node::ValueType& value) {
boost::property_tree::ptree res;
std::visit([&](const auto& rawValue) {
res.put_value(rawValue);
using RawValueType = std::decay_t<decltype(rawValue)>;
if constexpr (std::is_same_v<RawValueType, Node::string>)
res.put_value(core::stringUtils::replaceInString(rawValue, "\n", "\\n"));
else
res.put_value(rawValue);
},
value);
@@ -31,11 +31,13 @@
#include "core/Utils.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
#include "database/User.hpp"
#include "services/artwork/IArtworkService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Lyrics.hpp"
namespace lms::api::subsonic
{
@@ -189,6 +191,74 @@ namespace lms::api::subsonic
}
} // namespace
Response handleGetLyrics(RequestContext& context)
{
std::string artistName{ getParameterAs<std::string>(context.parameters, "artist").value_or("") };
std::string titleName{ getParameterAs<std::string>(context.parameters, "title").value_or("") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
// best effort search, as this API is really limited
auto transaction{ context.dbSession.createReadTransaction() };
db::Track::FindParameters params;
params.name = titleName;
params.artistName = artistName;
params.range = Range{ 0, 2 };
// Choice: we return nothing if there are too many results
const auto tracks{ db::Track::findIds(context.dbSession, params) };
if (tracks.results.size() == 1)
{
// Choice: we return only the first lyrics if the track has many lyrics
db::TrackLyrics::FindParameters lyricsParams;
lyricsParams.setTrack(tracks.results[0]);
lyricsParams.setSortMethod(TrackLyricsSortMethod::ExternalFirst);
lyricsParams.setRange(db::Range{ 0, 1 });
db::TrackLyrics::find(context.dbSession, lyricsParams, [&](const db::TrackLyrics::pointer& lyrics) {
response.addNode("lyrics", createLyricsNode(context, lyrics));
});
}
return response;
}
Response handleGetLyricsBySongId(RequestContext& context)
{
// mandatory params
db::TrackId id{ getMandatoryParameterAs<db::TrackId>(context.parameters, "id") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& lyricsList{ response.createNode("lyricsList") };
lyricsList.createEmptyArrayChild("structuredLyrics");
auto transaction{ context.dbSession.createReadTransaction() };
const db::Track::pointer track{ db::Track::find(context.dbSession, id) };
if (track)
{
db::TrackLyrics::FindParameters params;
params.setTrack(track->getId());
params.setExternal(true); // First try to only report external lyrics as they are often duplicate of embedded lyrics and support more features
bool hasExternalLyrics{};
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
hasExternalLyrics = true;
});
if (!hasExternalLyrics)
{
params.setExternal(false);
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
});
}
}
return response;
}
void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response)
{
std::shared_ptr<IResourceHandler> resourceHandler;
@@ -26,6 +26,9 @@
namespace lms::api::subsonic
{
Response handleGetLyrics(RequestContext& context);
Response handleGetLyricsBySongId(RequestContext& context);
void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
@@ -35,6 +35,12 @@ namespace lms::api::subsonic
formPostNode.addArrayValue("versions", 1);
}
{
Response::Node& songLyricsNode{ response.createArrayNode("openSubsonicExtensions") };
songLyricsNode.setAttribute("name", "songLyrics");
songLyricsNode.addArrayValue("versions", 1);
}
return response;
};
} // namespace lms::api::subsonic
+121
View File
@@ -0,0 +1,121 @@
/*
* 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 "responses/Lyrics.hpp"
#include "database/TrackLyrics.hpp"
#include "RequestContext.hpp"
namespace lms::api::subsonic
{
Response::Node createLyricsNode(RequestContext& context, const db::TrackLyrics::pointer& lyrics)
{
Response::Node lyricsNode;
if (!lyrics->getDisplayArtist().empty())
lyricsNode.setAttribute("artist", lyrics->getDisplayArtist());
if (!lyrics->getDisplayTitle().empty())
lyricsNode.setAttribute("title", lyrics->getDisplayTitle());
std::string lyricsText;
auto addLine{ [&](std::string&& line) {
if (!lyricsText.empty())
lyricsText += "\n";
lyricsText += std::move(line);
} };
if (!lyrics->isSynchronized())
{
std::vector<std::string> lines{ lyrics->getUnsynchronizedLines() };
for (std::string& line : lines)
addLine(std::move(line));
}
else
{
// Reconstruct the lyrics without timestamp if needed
db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() };
for (auto& [timestamp, line] : lines)
addLine(std::move(line));
}
switch (context.responseFormat)
{
case ResponseFormat::json:
lyricsNode.setAttribute("value", lyricsText);
break;
case ResponseFormat::xml:
lyricsNode.setValue(lyricsText);
break;
}
return lyricsNode;
}
Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr<db::TrackLyrics>& lyrics)
{
Response::Node lyricsNode;
if (!lyrics->getDisplayArtist().empty())
lyricsNode.setAttribute("artist", lyrics->getDisplayArtist());
if (!lyrics->getDisplayTitle().empty())
lyricsNode.setAttribute("title", lyrics->getDisplayTitle());
lyricsNode.setAttribute("lang", lyrics->getLanguage());
lyricsNode.setAttribute("synced", lyrics->isSynchronized());
if (lyrics->getOffset() != std::chrono::milliseconds{})
lyricsNode.setAttribute("offset", lyrics->getOffset().count());
lyricsNode.createEmptyArrayChild("lines");
auto addLine{ [&](std::string&& line, std::optional<std::chrono::milliseconds> timestamp = std::nullopt) {
Response::Node lineNode;
if (timestamp)
lineNode.setAttribute("start", std::chrono::duration_cast<std::chrono::milliseconds>(*timestamp).count());
switch (context.responseFormat)
{
case ResponseFormat::json:
lineNode.setAttribute("value", std::move(line));
break;
case ResponseFormat::xml:
lineNode.setValue(std::move(line));
break;
}
lyricsNode.addArrayChild("lines", std::move(lineNode));
} };
if (!lyrics->isSynchronized())
{
std::vector<std::string> lines{ lyrics->getUnsynchronizedLines() };
for (std::string& line : lines)
addLine(std::move(line));
}
else
{
db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() };
for (auto& [timestamp, line] : lines)
addLine(std::move(line), timestamp);
}
return lyricsNode;
}
} // namespace lms::api::subsonic
@@ -0,0 +1,37 @@
/*
* 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 "database/Object.hpp"
#include "SubsonicResponse.hpp"
namespace lms::db
{
class TrackLyrics;
}
namespace lms::api::subsonic
{
struct RequestContext;
Response::Node createLyricsNode(RequestContext& context, const db::ObjectPtr<db::TrackLyrics>& lyrics);
Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr<db::TrackLyrics>& lyrics);
} // namespace lms::api::subsonic
+14 -1
View File
@@ -111,6 +111,18 @@ namespace lms::ui
return res;
}
Wt::WLocale createLocale(const std::string& name)
{
Wt::WLocale locale{ name };
locale.setDecimalPoint(Wt::WString::tr("Lms.locale.decimal-point").toUTF8());
locale.setGroupSeparator(Wt::WString::tr("Lms.locale.group-separator").toUTF8());
locale.setDateFormat(Wt::WString::tr("Lms.locale.date-format").toUTF8());
locale.setTimeFormat(Wt::WString::tr("Lms.locale.time-format").toUTF8());
locale.setDateTimeFormat(Wt::WString::tr("Lms.locale.date-time-format").toUTF8());
return locale;
}
enum IdxRoot
{
IdxExplore = 0,
@@ -274,6 +286,7 @@ namespace lms::ui
setTitle();
setLocalizedStrings(getOrCreateMessageBundle());
setLocale(createLocale(Wt::WLocale::currentLocale().name()));
// Handle Media Scanner events and other session events
enableUpdates(true);
@@ -362,7 +375,7 @@ namespace lms::ui
setUserInfo(userId, strongAuth);
LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent());
LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent() << ", locale = '" << locale().name() << "'");
_appManager.registerApplication(*this);
_appManager.applicationRegistered.connect(this, [this](LmsApplication& otherApplication) {
+8 -13
View File
@@ -239,29 +239,24 @@ namespace lms::ui::utils
{
using namespace db;
Artist::FindParameters params;
params.setRelease(release->getId());
params.setLinkType(TrackArtistLinkType::ReleaseArtist);
if (const auto releaseArtists{ Artist::findIds(LmsApp->getDbSession(), params) }; !releaseArtists.results.empty())
if (const std::vector<ArtistId> releaseArtists{ release->getArtistIds(TrackArtistLinkType::ReleaseArtist) }; !releaseArtists.empty())
{
if (releaseArtists.results.size() == 1 && releaseArtists.results.front() == omitIfMatchThisArtist)
if (releaseArtists.size() == 1 && releaseArtists.front() == omitIfMatchThisArtist)
return {};
return createArtistDisplayNameWithAnchors(release->getArtistDisplayName(), releaseArtists.results, cssAnchorClass);
return createArtistDisplayNameWithAnchors(release->getArtistDisplayName(), releaseArtists, cssAnchorClass);
}
params.setLinkType(TrackArtistLinkType::Artist);
const auto artists{ Artist::findIds(LmsApp->getDbSession(), params) };
if (artists.results.size() == 1)
const auto artists{ release->getArtistIds(TrackArtistLinkType::Artist) };
if (artists.size() == 1)
{
if (artists.results.front() == omitIfMatchThisArtist)
if (artists.front() == omitIfMatchThisArtist)
return {};
return createArtistAnchorList({ artists.results.front() }, cssAnchorClass);
return createArtistAnchorList({ artists.front() }, cssAnchorClass);
}
if (artists.results.size() > 1)
if (artists.size() > 1)
{
auto res{ std::make_unique<Wt::WContainerWidget>() };
res->addNew<Wt::WText>(Wt::WString::tr("Lms.Explore.various-artists"));
+13 -13
View File
@@ -24,6 +24,7 @@
#include <Wt/Http/Response.h>
#include <Wt/WCheckBox.h>
#include <Wt/WDateTime.h>
#include <Wt/WLocale.h>
#include <Wt/WPushButton.h>
#include <Wt/WResource.h>
@@ -40,17 +41,7 @@ namespace lms::ui
{
std::string durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end)
{
const auto secs{ std::chrono::duration_cast<std::chrono::seconds>(end.toTimePoint() - begin.toTimePoint()).count() };
std::ostringstream oss;
if (secs >= 3600)
oss << secs / 3600 << "h";
if (secs >= 60)
oss << std::setw(2) << std::setfill('0') << (secs % 3600) / 60 << "m";
oss << std::setw(2) << std::setfill('0') << (secs % 60) << "s";
return oss.str();
return begin.timeTo(end).toUTF8();
}
} // namespace
@@ -123,6 +114,8 @@ namespace lms::ui
return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-audio-file");
case scanner::ScanErrorType::CannotReadImageFile:
return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-image-file");
case scanner::ScanErrorType::CannotReadLyricsFile:
return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-lyrics-file");
case scanner::ScanErrorType::NoAudioTrack:
return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track");
case scanner::ScanErrorType::BadDuration:
@@ -220,7 +213,8 @@ namespace lms::ui
_lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status")
.arg(status.lastCompleteScanStats->nbFiles())
.arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime))
.arg(status.lastCompleteScanStats->stopTime.toString())
.arg(status.lastCompleteScanStats->stopTime.date().toString(Wt::WLocale::currentLocale().dateFormat()))
.arg(status.lastCompleteScanStats->stopTime.time().toString(Wt::WLocale::currentLocale().timeFormat()))
.arg(status.lastCompleteScanStats->errors.size())
.arg(status.lastCompleteScanStats->duplicates.size()));
@@ -247,7 +241,8 @@ namespace lms::ui
case IScannerService::State::Scheduled:
_status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled")
.arg(status.nextScheduledScan.toString()));
.arg(status.nextScheduledScan.date().toString(Wt::WLocale::currentLocale().dateFormat()))
.arg(status.nextScheduledScan.time().toString(Wt::WLocale::currentLocale().timeFormat())));
_stepStatus->setText("");
break;
@@ -274,6 +269,11 @@ namespace lms::ui
.arg(stepStats.progress()));
break;
case ScanStep::AssociateExternalLyrics:
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-external-lyrics")
.arg(stepStats.progress()));
break;
case ScanStep::AssociateReleaseImages:
_stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-release-images")
.arg(stepStats.progress()));
+8
View File
@@ -466,6 +466,14 @@ namespace lms::ui
entry->bindNew<Wt::WPushButton>("track-info", Wt::WString::tr("Lms.Explore.track-info"))
->clicked()
.connect([this, trackId] { TrackListHelpers::showTrackInfoModal(trackId, _filters); });
if (track->hasLyrics())
{
entry->setCondition("if-has-lyrics", true);
entry->bindNew<Wt::WPushButton>("track-lyrics", Wt::WString::tr("Lms.Explore.track-lyrics"))
->clicked()
.connect([trackId] { TrackListHelpers::showTrackLyricsModal(trackId); });
}
}
entry->bindString("duration", utils::durationToString(track->getDuration()), Wt::TextFormat::Plain);
+55
View File
@@ -33,6 +33,7 @@
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackArtistLink.hpp"
#include "database/TrackLyrics.hpp"
#include "database/User.hpp"
#include "services/feedback/IFeedbackService.hpp"
#include "services/scrobbling/IScrobblingService.hpp"
@@ -155,6 +156,52 @@ namespace lms::ui::TrackListHelpers
LmsApp->getModalManager().show(std::move(trackInfo));
}
void showTrackLyricsModal(db::TrackId trackId)
{
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
auto trackLyrics{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.track-lyrics")) };
Wt::WWidget* trackLyricsPtr{ trackLyrics.get() };
trackLyrics->addFunction("tr", &Wt::WTemplate::Functions::tr);
Wt::WContainerWidget* lyricsContainer{ trackLyrics->bindNew<Wt::WContainerWidget>("lyrics") };
// limitation: display only first lyrics for now
db::TrackLyrics::FindParameters params;
params.setTrack(trackId);
params.setSortMethod(db::TrackLyricsSortMethod::ExternalFirst);
params.setRange(db::Range{ 0, 1 });
db::TrackLyrics::find(LmsApp->getDbSession(), params, [&](const db::TrackLyrics::pointer& lyrics) {
std::string text;
auto addLine = [&](std::string_view line) {
if (!text.empty())
text += '\n';
text += line;
};
if (lyrics->isSynchronized())
{
for (const auto& [timestamp, line] : lyrics->getSynchronizedLines())
addLine(line);
}
else
{
for (const auto& line : lyrics->getUnsynchronizedLines())
addLine(line);
}
lyricsContainer->addNew<Wt::WText>(Wt::WString::fromUTF8(text), Wt::TextFormat::Plain);
});
Wt::WPushButton* okBtn{ trackLyrics->bindNew<Wt::WPushButton>("ok-btn", Wt::WString::tr("Lms.ok")) };
okBtn->clicked().connect([=] {
LmsApp->getModalManager().dispose(trackLyricsPtr);
});
LmsApp->getModalManager().show(std::move(trackLyrics));
}
std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters)
{
auto entry{ std::make_unique<Template>(Wt::WString::tr("Lms.Explore.Tracks.template.entry")) };
@@ -240,6 +287,14 @@ namespace lms::ui::TrackListHelpers
->clicked()
.connect([trackId, &filters] { showTrackInfoModal(trackId, filters); });
if (track->hasLyrics())
{
entry->setCondition("if-has-lyrics", true);
entry->bindNew<Wt::WPushButton>("track-lyrics", Wt::WString::tr("Lms.Explore.track-lyrics"))
->clicked()
.connect([trackId] { showTrackLyricsModal(trackId); });
}
LmsApp->getMediaPlayer().trackLoaded.connect(entryPtr, [=](db::TrackId loadedTrackId) {
entryPtr->toggleStyleClass("Lms-entry-playing", loadedTrackId == trackId);
});
+1
View File
@@ -40,5 +40,6 @@ namespace lms::ui
namespace lms::ui::TrackListHelpers
{
void showTrackInfoModal(db::TrackId trackId, Filters& filters);
void showTrackLyricsModal(db::TrackId trackId);
std::unique_ptr<Wt::WWidget> createEntry(const db::ObjectPtr<db::Track>& track, PlayQueueController& playQueueController, Filters& filters);
} // namespace lms::ui::TrackListHelpers
+47 -2
View File
@@ -18,6 +18,7 @@
*/
#include <chrono>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <optional>
@@ -35,6 +36,22 @@
namespace lms::metadata
{
std::ostream& operator<<(std::ostream& os, const Lyrics& lyrics)
{
os << "\tTitle display name: " << lyrics.displayTitle << std::endl;
os << "\tArtist display name: " << lyrics.displayArtist << std::endl;
os << "\tAlbum display name: " << lyrics.displayAlbum << std::endl;
os << "\tOffset: " << lyrics.offset.count() << "ms" << std::endl;
os << "\tLanguage: " << lyrics.language << std::endl;
os << "\tSynchronized: " << !lyrics.synchronizedLines.empty() << std::endl;
for (const auto& [timestamp, line] : lyrics.synchronizedLines)
os << "\t" << core::stringUtils::formatTimestamp(timestamp) << " '" << line << "'" << std::endl;
for (const auto& line : lyrics.unsynchronizedLines)
os << "\t'" << line << "'" << std::endl;
return os;
}
std::ostream& operator<<(std::ostream& os, const AudioProperties& audioProperties)
{
os << "\tBitrate: " << audioProperties.bitrate << " bps" << std::endl;
@@ -221,6 +238,10 @@ namespace lms::metadata
if (!track->copyright.empty())
std::cout << "Copyright: " << track->copyright << std::endl;
for (const Lyrics& lyrics : track->lyrics)
std::cout << "Lyrics:\n"
<< lyrics << std::endl;
for (const auto& comment : track->comments)
std::cout << "Comment: '" << comment << "'" << std::endl;
@@ -244,7 +265,7 @@ int main(int argc, char* argv[])
("help,h", "Display this help message")
("tag-delimiter", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{}, "[]"), "Tag delimiters (multiple allowed)")
("artist-tag-delimiter", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{}, "[]"), "Artist tag delimiters (multiple allowed)")
("parser", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{ "taglib" }, "[taglib]"), "Parser to be used (value can be \"taglib\" or \"ffmpeg\")");
("parser", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{ "taglib" }, "[taglib]"), "Parser to be used (value can be \"taglib\", \"ffmpeg\" or \"lyrics\")");
// clang-format on
program_options::options_description hiddenOptions{ "Hidden options" };
@@ -286,6 +307,7 @@ int main(int argc, char* argv[])
enum class Parser
{
Lyrics,
Taglib,
Ffmpeg,
};
@@ -299,7 +321,9 @@ int main(int argc, char* argv[])
core::EnumSet<Parser> parsers;
for (const std::string& strParser : vm["parser"].as<std::vector<std::string>>())
{
if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "taglib"))
if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "lyrics"))
parsers.insert(Parser::Lyrics);
else if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "taglib"))
parsers.insert(Parser::Taglib);
else if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "ffmpeg"))
parsers.insert(Parser::Ffmpeg);
@@ -333,6 +357,27 @@ int main(int argc, char* argv[])
std::cout << "Parsing file '" << file << "'" << std::endl;
if (parsers.contains(Parser::Lyrics))
{
try
{
std::cout << "Using Lyrics:" << std::endl;
std::ifstream ifs{ file.string() };
if (ifs)
{
const metadata::Lyrics lyrics{ metadata::parseLyrics(ifs) };
std::cout << lyrics << std::endl;
}
else
std::cerr << "Cannot open file '" << file.string() << "'";
}
catch (metadata::Exception& e)
{
std::cerr << "Parsing failed: " << e.what() << std::endl;
}
}
if (parsers.contains(Parser::Ffmpeg))
{
try