Merge branch 'develop' for release v3.60.0
This commit is contained in:
+10
-2
@@ -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_.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()) };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ add_executable(test-database
|
||||
TrackBookmark.cpp
|
||||
TrackFeatures.cpp
|
||||
TrackList.cpp
|
||||
TrackLyrics.cpp
|
||||
User.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -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{}));
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
add_executable(bench-metadata
|
||||
LyricsBench.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(bench-metadata PRIVATE
|
||||
lmsmetadata
|
||||
benchmark
|
||||
)
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
include(GoogleTest)
|
||||
|
||||
add_executable(test-metadata
|
||||
Lyrics.cpp
|
||||
Metadata.cpp
|
||||
Parser.cpp
|
||||
Utils.cpp
|
||||
|
||||
@@ -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
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -200,7 +200,8 @@ namespace lms::api::subsonic
|
||||
// Media retrieval
|
||||
{ "/hls", { handleNotImplemented } },
|
||||
{ "/getCaptions", { handleNotImplemented } },
|
||||
{ "/getLyrics", { handleNotImplemented } },
|
||||
{ "/getLyrics", { handleGetLyrics } },
|
||||
{ "/getLyricsBySongId", { handleGetLyricsBySongId } },
|
||||
{ "/getAvatar", { handleNotImplemented } },
|
||||
|
||||
// Media annotation
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user