feat(bfr): Big Refactor: new scanner, lots of new fields and tags, improvements and DB schema changes (#2709)

* fix(server): more race conditions when updating artist/album from external sources

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): add .gitignore syntax to .ndignore. Resolves #1394

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): pass configfile option to child process

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): resume interrupted fullScans

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): remove old scanner code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): rename old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): move old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: tests

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): update Go to 1.23.4

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log level

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config for scanner watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: children playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: replace `interface{}` with `any`

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlists with genres

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow any tags in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist names in playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlist's sort by tags

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use generic JSONArray for OS arrays

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use https in test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add releaseTypes to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add recordLabels to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): rename JSONArray to Array

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): do not pre-populate smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement a simplified version of ArtistID3.

See https://github.com/opensubsonic/open-subsonic-api/discussions/120

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add contributors to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add albumArtists to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayArtist and displayAlbumArtist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayComposer to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add roles to ArtistID3

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): use " • " separator for displayComposer

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): respect `PreferSortTags` config option

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize purging non-unused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: don't run 'refresh artist stats' concurrently with other transactions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add Scanner.ScanOnStartup config option, default true

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: better json parsing error msg when importing NSPs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update album's imported_time when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: handle interrupted scans and full scans after migrations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `analyze` when migration requires a full rescan

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `PRAGMA optimize` at the end of the scan

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update artist's updated_at when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: handle multiple artists and roles in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): dim missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album missing logic

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error encoding in gob

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: separate warnings from errors

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: mark albums as missing if they were contained in a deleted folder

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add participant names to media_file and album tables

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use participations in criteria, instead of m2m relationship

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename participations to participants

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: albumartist role case

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): run scanner as an external process by default

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): show albumArtist names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): dim out missing albums

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): scrobble buffer mapping. fix #3583

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: more participations renaming

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: send release_group_mbid to listenbrainz

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement OpenSubsonic explicitStatus field (#3597)

* feat: implement OpenSubsonic explicitStatus field

* fix(subsonic): fix failing snapshot tests

* refactor: create helper for setting explicitStatus

* fix: store smaller values for explicit-status on database

* test: ToAlbum explicitStatus

* refactor: rename explicitStatus helper function

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>

* fix: handle album and track tags in the DB based on the mappings.yaml file

Signed-off-by: Deluan <deluan@navidrome.org>

* save similar artists as JSONB

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: getAlbumList byGenre

Signed-off-by: Deluan <deluan@navidrome.org>

* detect changes in PID configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* set default album PID to legacy_pid

Signed-off-by: Deluan <deluan@navidrome.org>

* fix tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix SIGSEGV

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't lose album stars/ratings when migrating

Signed-off-by: Deluan <deluan@navidrome.org>

* store full PID conf in properties

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: keep album annotations when changing PID.Album config

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reassign album annotations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: use (display) albumArtist and add links to each artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: not showing albums by albumartist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error msgs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: hide PID from Native API

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album cover art resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim participant names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reduce watcher log spam

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: panic when initializing the watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: various artists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't store empty lyrics in the DB

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused methods

Signed-off-by: Deluan <deluan@navidrome.org>

* drop full_text indexes, as they are not being used by SQLite

Signed-off-by: Deluan <deluan@navidrome.org>

* keep album created_at when upgrading

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null pointer

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album artwork cache

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't expose missing files in Subsonic API

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: searchable interface

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: filter out missing items from subsonic search

* fix: filter out missing items from playlists

* fix: filter out missing items from shares

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add filter by artist role

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): only return albumartists in getIndexes and getArtists endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* sort roles alphabetically

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist playcounts

Signed-off-by: Deluan <deluan@navidrome.org>

* change default Album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix albumartist link when it does not match any albumartists values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix `Ignoring filter not whitelisted` (role) message

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim any names/titles being imported

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused genre code

Signed-off-by: Deluan <deluan@navidrome.org>

* serialize calls to Last.fm's getArtist

Signed-off-by: Deluan <deluan@navidrome.org>

xxx

Signed-off-by: Deluan <deluan@navidrome.org>

* add counters to genres

Signed-off-by: Deluan <deluan@navidrome.org>

* nit: fix migration `notice` message

Signed-off-by: Deluan <deluan@navidrome.org>

* optimize similar artists query

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: last.fm.getInfo when mbid does not exist

Signed-off-by: Deluan <deluan@navidrome.org>

* ui only show missing items for admins

Signed-off-by: Deluan <deluan@navidrome.org>

* don't allow interaction with missing items

Signed-off-by: Deluan <deluan@navidrome.org>

* Add Missing Files view (WIP)

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: merged tag_counts into tag table

Signed-off-by: Deluan <deluan@navidrome.org>

* add option to completely disable automatic scanner

Signed-off-by: Deluan <deluan@navidrome.org>

* add delete missing files functionality

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: playlists not showing for regular users

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce updateLastAccess frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce update player frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* add timeout when updating player

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dead code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix duplicated roles in stats

Signed-off-by: Deluan <deluan@navidrome.org>

* add `; ` to artist splitters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix stats query

Signed-off-by: Deluan <deluan@navidrome.org>

* more logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* add record label filter

Signed-off-by: Deluan <deluan@navidrome.org>

* add release type filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix purgeUnused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* add grouping filter to albums

Signed-off-by: Deluan <deluan@navidrome.org>

* allow any album tags to be used in as filters in the API

Signed-off-by: Deluan <deluan@navidrome.org>

* remove empty tags from album info

Signed-off-by: Deluan <deluan@navidrome.org>

* comments in the migration

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: Cannot read properties of undefined

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling (#3640)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove duplicated tag values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't ignore the taglib folder!

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: show track subtitle tag

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: show artists stats based on selected role

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: inspect

Signed-off-by: Deluan <deluan@navidrome.org>

* add media type to album info/filters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: change format of subtitle in the UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in Subsonic API and search

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in UI's player

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: split strings should be case-insensitive

Signed-off-by: Deluan <deluan@navidrome.org>

* disable ScanSchedule

Signed-off-by: Deluan <deluan@navidrome.org>

* increase default sessiontimeout

Signed-off-by: Deluan <deluan@navidrome.org>

* add sqlite command line tool to docker image

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resources override

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* change migration to mark current artists as albumArtists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Allow filtering on multiple genres (#3679)

* feat(ui): Allow filtering on multiple genres

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>

* add multi-genre filter in Album list

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>

* add more multi-valued tag filters to Album and Song views

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): unselect missing files after removing

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): song filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix sharing tracks. fix #3687

Signed-off-by: Deluan <deluan@navidrome.org>

* use rowids when using search for sync (ex: Symfonium)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients for search

Signed-off-by: Deluan <deluan@navidrome.org>

* add libraryPath to Native API /songs endpoint

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add album version

Signed-off-by: Deluan <deluan@navidrome.org>

* made all tags lowercase as they are case-insensitive anyways.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Show full paths, extended properties for album/song (#3691)

* feat(ui): Show full paths, extended properties for album/song

- uses library path + os separator + path
- show participants (album/song) and tags (song)
- make album/participant clickable in show info

* add source to path

* fix pathSeparator in UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix local artist artwork (#3695)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: parse vorbis performers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: clean function into smaller functions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix translations for en and pt

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow performers without instrument/subrole

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: metadata clean function again

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize split function

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: split function is now a method of TagConf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: humanize Artist total size

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version to album details

Signed-off-by: Deluan <deluan@navidrome.org>

* don't display album-level tags in SongInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* fix genre clicking in Album Page

Signed-off-by: Deluan <deluan@navidrome.org>

* don't use mbids in Last.fm api calls.

From 1337574018:

With MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&mbid=a41ac10f-0a56-4672-9161-b83f9b223559&method=artist.getInfo

{
artist: {
name: "Bee Gees",
mbid: "bf0f7e29-dfe1-416c-b5c6-f9ebc19ea810",
url: "https://www.last.fm/music/Bee+Gees",
}
```

Without MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&method=artist.getInfo

{
artist: {
name: "Van Morrison",
mbid: "a41ac10f-0a56-4672-9161-b83f9b223559",
url: "https://www.last.fm/music/Van+Morrison",
}
```

Signed-off-by: Deluan <deluan@navidrome.org>

* better logging for when the artist folder is not found

Signed-off-by: Deluan <deluan@navidrome.org>

* fix various issues with artist image resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* hide "Additional Tags" header if there are none.

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify tag rendering

Signed-off-by: Deluan <deluan@navidrome.org>

* enhance logging for artist folder detection

Signed-off-by: Deluan <deluan@navidrome.org>

* make folderID consistent for relative and absolute folderPaths

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more folder paths scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

* filter out other roles when SubsonicArtistParticipations = true

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Cannot read properties of undefined"

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lyrics and comments being truncated (#3701)

* fix lyrics and comments being truncated

* specifically test for lyrics and comment length

* reorder assertions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* fix(server): Expose library_path for playlist (#3705)

Allows showing absolute path for UI, and makes "report real path" work for playlists (Subsonic)

* fix BFR on Windows (#3704)

* fix potential reflected cross-site scripting vulnerability

Signed-off-by: Deluan <deluan@navidrome.org>

* hack to make it work on Windows

* ignore windows executables

* try fixing the pipeline

Signed-off-by: Deluan <deluan@navidrome.org>

* allow MusicFolder in other drives

* move windows local drive logic to local storage implementation

---------

Signed-off-by: Deluan <deluan@navidrome.org>

* increase pagination sizes for missing files

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce level of "already scanning" watcher log message

Signed-off-by: Deluan <deluan@navidrome.org>

* only count folders with audio files in it

See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-11990930

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version and catalog number to search

Signed-off-by: Deluan <deluan@navidrome.org>

* add `organization` alias for `recordlabel`

Signed-off-by: Deluan <deluan@navidrome.org>

* remove mbid from Last.fm agent

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: support inspect in ui (#3726)

* inspect in ui

* address round 1

* add catalogNum to AlbumInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dependency on metadata_old (deprecated) package

Signed-off-by: Deluan <deluan@navidrome.org>

* add `RawTags` to model

Signed-off-by: Deluan <deluan@navidrome.org>

* support parsing MBIDs for roles (from the https://github.com/kgarner7/picard-all-mbids plugin) (#3698)


* parse standard roles, vorbis/m4a work for now

* fix djmixer

* working roles, use DJ-mix

* add performers to file

* map mbids

* add a few more tests

* add test

Signed-off-by: Deluan <deluan@navidrome.org>

* try to simplify the performers logic

Signed-off-by: Deluan <deluan@navidrome.org>

* stylistic changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* remove param mutation

Signed-off-by: Deluan <deluan@navidrome.org>

* run automated SQLite optimizations

Signed-off-by: Deluan <deluan@navidrome.org>

* fix playlists import/export on Windows

* fix import playlists

* fix export playlists

* better handling of Windows volumes

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more album ID reassignments

Signed-off-by: Deluan <deluan@navidrome.org>

* allow adding/overriding tags in the config file

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): Fix playlist track id, handle missing tracks better (#3734)

- Use `mediaFileId` instead of `id` for playlist tracks
- Only fetch if the file is not missing
- If extractor fails to get the file, also error (rather than panic)

* optimize DB after each scan.

Signed-off-by: Deluan <deluan@navidrome.org>

* remove sortable from AlbumSongs columns

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify query to get missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* mark Scanner.Extractor as deprecated

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
This commit is contained in:
Deluan Quintão 2025-02-19 17:35:17 -08:00 committed by GitHub
parent 46a963a02a
commit c795bcfcf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
329 changed files with 16586 additions and 5852 deletions

View file

@ -24,16 +24,21 @@ func (c Criteria) OrderBy() string {
if c.Sort == "" {
c.Sort = "title"
}
f := fieldMap[strings.ToLower(c.Sort)]
sortField := strings.ToLower(c.Sort)
f := fieldMap[sortField]
var mapped string
if f == nil {
log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort)
mapped = fieldMap["title"].field
} else {
if f.order == "" {
mapped = f.field
} else {
if f.order != "" {
mapped = f.order
} else if f.isTag {
mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
} else if f.isRole {
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
} else {
mapped = f.field
}
}
if c.Order != "" {
@ -46,23 +51,20 @@ func (c Criteria) OrderBy() string {
return mapped
}
func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
func (c Criteria) ToSql() (sql string, args []any, err error) {
return c.Expression.ToSql()
}
func (c Criteria) ChildPlaylistIds() (ids []string) {
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return ids
return nil
}
switch rules := c.Expression.(type) {
case Any:
ids = rules.ChildPlaylistIds()
case All:
ids = rules.ChildPlaylistIds()
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
return parent.ChildPlaylistIds()
}
return ids
return nil
}
func (c Criteria) MarshalJSON() ([]byte, error) {

View file

@ -12,5 +12,6 @@ import (
func TestCriteria(t *testing.T) {
log.SetLevel(log.LevelFatal)
gomega.RegisterFailHandler(Fail)
// Register `genre` as a tag name, so we can use it in tests
RunSpecs(t, "Criteria Suite")
}

View file

@ -12,28 +12,30 @@ import (
var _ = Describe("Criteria", func() {
var goObj Criteria
var jsonObj string
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Contains{"title": "love"},
NotContains{"title": "hate"},
Any{
IsNot{"artist": "u2"},
Is{"album": "best of"},
Context("with a complex criteria", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Contains{"title": "love"},
NotContains{"title": "hate"},
Any{
IsNot{"artist": "u2"},
Is{"album": "best of"},
},
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "Rock"},
},
},
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "test"},
},
},
Sort: "title",
Order: "asc",
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
err := json.Compact(&b, []byte(`
Sort: "title",
Order: "asc",
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
err := json.Compact(&b, []byte(`
{
"all": [
{ "contains": {"title": "love"} },
@ -46,7 +48,7 @@ var _ = Describe("Criteria", func() {
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "test" }}
{ "isNot": { "genre": "Rock" }}
]
}
],
@ -56,128 +58,150 @@ var _ = Describe("Criteria", func() {
"offset": 10
}
`))
if err != nil {
panic(err)
}
jsonObj = b.String()
if err != nil {
panic(err)
}
jsonObj = b.String()
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
`AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` +
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
`AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock"))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("is reversible to/from JSON", func() {
var newObj Criteria
err := json.Unmarshal([]byte(jsonObj), &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
j, err := json.Marshal(newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
Describe("OrderBy", func() {
It("sorts by regular fields", func() {
gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
goObj.Sort = "genre"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
),
)
})
It("sorts by role fields", func() {
goObj.Sort = "artist"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
),
)
})
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
})
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND COALESCE(genre.name, '') <> ?))"))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("is reversible to/from JSON", func() {
var newObj Criteria
err := json.Unmarshal([]byte(jsonObj), &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
j, err := json.Marshal(newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("allows sort by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
It("extracts all child smart playlist IDs from All expression criteria", func() {
topLevelInPlaylistID := uuid.NewString()
topLevelNotInPlaylistID := uuid.NewString()
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
goObj := Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
Context("with artist roles", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Is{"artist": "The Beatles"},
Contains{"composer": "Lennon"},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
}
})
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
))
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
It("extracts all child smart playlist IDs from Any expression criteria", func() {
topLevelInPlaylistID := uuid.NewString()
topLevelNotInPlaylistID := uuid.NewString()
Context("with child playlists", func() {
var (
topLevelInPlaylistID string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
)
BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString()
topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString()
goObj := Criteria{
Expression: Any{
InPlaylist{"id": topLevelInPlaylistID},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
goObj := Criteria{
Expression: Any{
Any{
goObj = Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
},
All{
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
})
It("extracts all child smart playlist IDs from expression criteria", func() {
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{
Expression: Any{
Any{
All{
Any{
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
Any{
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
},
},
},
},
},
}
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("returns empty list when no child playlist IDs are present", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
})
})

View file

@ -0,0 +1,5 @@
package criteria
var StartOfPeriod = startOfPeriod
type UnmarshalConjunctionType = unmarshalConjunctionType

View file

@ -1,21 +1,22 @@
package criteria
import (
"fmt"
"reflect"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"artist": {field: "media_file.artist"},
"albumartist": {field: "media_file.album_artist"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
@ -31,31 +32,37 @@ var fieldMap = map[string]*mappedField{
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumtype": {field: "media_file.mbz_album_type"},
"albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"genre": {field: "COALESCE(genre.name, '')"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"random": {field: "", order: "random()"},
// special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
"value": {field: "value"}, // pseudo-field for tag and roles values
}
type mappedField struct {
field string
order string
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
}
func mapFields(expr map[string]interface{}) map[string]interface{} {
m := make(map[string]interface{})
func mapFields(expr map[string]any) map[string]any {
m := make(map[string]any)
for f, v := range expr {
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
m[dbf.field] = v
@ -65,3 +72,136 @@ func mapFields(expr map[string]interface{}) map[string]interface{} {
}
return m
}
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
// This is required because tags are handled differently than other fields,
// as they are stored as a JSON column in the database.
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
rv := reflect.ValueOf(expr)
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
}
// Extract into a generic map
var k string
m := make(map[string]any, rv.Len())
for _, key := range rv.MapKeys() {
// Save the key to build the expression, and use the provided keyName as the key
k = key.String()
m["value"] = rv.MapIndex(key).Interface()
break // only one key is expected (and supported)
}
// Clear the original map
for _, key := range rv.MapKeys() {
rv.SetMapIndex(key, reflect.Value{})
}
// Write the updated map back into the original variable
for key, val := range m {
rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val))
}
return exprFunc(k, expr, negate)
}
// mapTagExpr maps a normal field expression to a tag expression.
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, tagExpr)
}
// mapRoleExpr maps a normal field expression to an artist role expression.
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, roleExpr)
}
func isTagExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
return true
}
}
return false
}
func isRoleExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
return true
}
}
return false
}
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return tagCond{tag: tag, cond: cond, not: negate}
}
type tagCond struct {
tag string
cond squirrel.Sqlizer
not bool
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
e.tag, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return roleCond{role: role, cond: cond, not: negate}
}
type roleCond struct {
role string
cond squirrel.Sqlizer
not bool
}
func (e roleCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`,
e.role, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = &mappedField{field: name, isRole: true}
}
}
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
// file to the field map, so they can be used in smart playlists.
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
func AddTagNames(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
if _, ok := fieldMap[name]; ok {
continue
}
for _, fm := range fieldMap {
if fm.alias == name {
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
fieldMap[name] = &mappedField{field: name, isTag: true}
}
}
}

View file

@ -8,7 +8,7 @@ import (
var _ = Describe("fields", func() {
Describe("mapFields", func() {
It("ignores random fields", func() {
m := map[string]interface{}{"random": "123"}
m := map[string]any{"random": "123"}
m = mapFields(m)
gomega.Expect(m).To(gomega.BeEmpty())
})

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
)
type unmarshalConjunctionType []Expression
@ -24,7 +23,7 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
expr = unmarshalConjunction(k, v)
}
if expr == nil {
return fmt.Errorf(`invalid expression key %s`, k)
return fmt.Errorf(`invalid expression key '%s'`, k)
}
es = append(es, expr)
}
@ -34,7 +33,7 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
}
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
m := make(map[string]interface{})
m := make(map[string]any)
err := json.Unmarshal(rawValue, &m)
if err != nil {
return nil
@ -89,7 +88,7 @@ func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression
return nil
}
func marshalExpression(name string, value map[string]interface{}) ([]byte, error) {
func marshalExpression(name string, value map[string]any) ([]byte, error) {
if len(value) != 1 {
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
}
@ -120,10 +119,3 @@ func marshalConjunction(name string, conj []Expression) ([]byte, error) {
}
return json.Marshal(aux)
}
type date time.Time
func (t date) MarshalJSON() ([]byte, error) {
stamp := fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02"))
return []byte(stamp), nil
}

View file

@ -15,7 +15,7 @@ type (
And = All
)
func (all All) ToSql() (sql string, args []interface{}, err error) {
func (all All) ToSql() (sql string, args []any, err error) {
return squirrel.And(all).ToSql()
}
@ -32,7 +32,7 @@ type (
Or = Any
)
func (any Any) ToSql() (sql string, args []interface{}, err error) {
func (any Any) ToSql() (sql string, args []any, err error) {
return squirrel.Or(any).ToSql()
}
@ -47,7 +47,13 @@ func (any Any) ChildPlaylistIds() (ids []string) {
type Is squirrel.Eq
type Eq = Is
func (is Is) ToSql() (sql string, args []interface{}, err error) {
func (is Is) ToSql() (sql string, args []any, err error) {
if isRoleExpr(is) {
return mapRoleExpr(is, false).ToSql()
}
if isTagExpr(is) {
return mapTagExpr(is, false).ToSql()
}
return squirrel.Eq(mapFields(is)).ToSql()
}
@ -57,7 +63,13 @@ func (is Is) MarshalJSON() ([]byte, error) {
type IsNot squirrel.NotEq
func (in IsNot) ToSql() (sql string, args []interface{}, err error) {
func (in IsNot) ToSql() (sql string, args []any, err error) {
if isRoleExpr(in) {
return mapRoleExpr(squirrel.Eq(in), true).ToSql()
}
if isTagExpr(in) {
return mapTagExpr(squirrel.Eq(in), true).ToSql()
}
return squirrel.NotEq(mapFields(in)).ToSql()
}
@ -67,7 +79,10 @@ func (in IsNot) MarshalJSON() ([]byte, error) {
type Gt squirrel.Gt
func (gt Gt) ToSql() (sql string, args []interface{}, err error) {
func (gt Gt) ToSql() (sql string, args []any, err error) {
if isTagExpr(gt) {
return mapTagExpr(gt, false).ToSql()
}
return squirrel.Gt(mapFields(gt)).ToSql()
}
@ -77,7 +92,10 @@ func (gt Gt) MarshalJSON() ([]byte, error) {
type Lt squirrel.Lt
func (lt Lt) ToSql() (sql string, args []interface{}, err error) {
func (lt Lt) ToSql() (sql string, args []any, err error) {
if isTagExpr(lt) {
return mapTagExpr(squirrel.Lt(lt), false).ToSql()
}
return squirrel.Lt(mapFields(lt)).ToSql()
}
@ -87,31 +105,37 @@ func (lt Lt) MarshalJSON() ([]byte, error) {
type Before squirrel.Lt
func (bf Before) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Lt(mapFields(bf)).ToSql()
func (bf Before) ToSql() (sql string, args []any, err error) {
return Lt(bf).ToSql()
}
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
type After squirrel.Gt
type After Gt
func (af After) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Gt(mapFields(af)).ToSql()
func (af After) ToSql() (sql string, args []any, err error) {
return Gt(af).ToSql()
}
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
type Contains map[string]interface{}
type Contains map[string]any
func (ct Contains) ToSql() (sql string, args []interface{}, err error) {
func (ct Contains) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(ct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(ct) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(ct) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
@ -119,13 +143,19 @@ func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
type NotContains map[string]interface{}
type NotContains map[string]any
func (nct NotContains) ToSql() (sql string, args []interface{}, err error) {
func (nct NotContains) ToSql() (sql string, args []any, err error) {
lk := squirrel.NotLike{}
for f, v := range mapFields(nct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(nct) {
return mapRoleExpr(squirrel.Like(lk), true).ToSql()
}
if isTagExpr(nct) {
return mapTagExpr(squirrel.Like(lk), true).ToSql()
}
return lk.ToSql()
}
@ -133,13 +163,19 @@ func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
type StartsWith map[string]interface{}
type StartsWith map[string]any
func (sw StartsWith) ToSql() (sql string, args []interface{}, err error) {
func (sw StartsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%s%%", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
@ -147,13 +183,19 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
type EndsWith map[string]interface{}
type EndsWith map[string]any
func (sw EndsWith) ToSql() (sql string, args []interface{}, err error) {
func (sw EndsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%%%s", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
@ -161,10 +203,10 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", sw)
}
type InTheRange map[string]interface{}
type InTheRange map[string]any
func (itr InTheRange) ToSql() (sql string, args []interface{}, err error) {
var and squirrel.And
func (itr InTheRange) ToSql() (sql string, args []any, err error) {
and := squirrel.And{}
for f, v := range mapFields(itr) {
s := reflect.ValueOf(v)
if s.Kind() != reflect.Slice || s.Len() != 2 {
@ -182,9 +224,9 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
type InTheLast map[string]interface{}
type InTheLast map[string]any
func (itl InTheLast) ToSql() (sql string, args []interface{}, err error) {
func (itl InTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(itl, false)
if err != nil {
return "", nil, err
@ -196,9 +238,9 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
type NotInTheLast map[string]interface{}
type NotInTheLast map[string]any
func (nitl NotInTheLast) ToSql() (sql string, args []interface{}, err error) {
func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(nitl, true)
if err != nil {
return "", nil, err
@ -210,9 +252,9 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
func inPeriod(m map[string]interface{}, negate bool) (Expression, error) {
func inPeriod(m map[string]any, negate bool) (Expression, error) {
var field string
var value interface{}
var value any
for f, v := range mapFields(m) {
field, value = f, v
break
@ -237,9 +279,9 @@ func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
type InPlaylist map[string]interface{}
type InPlaylist map[string]any
func (ipl InPlaylist) ToSql() (sql string, args []interface{}, err error) {
func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, false)
}
@ -247,9 +289,9 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
}
type NotInPlaylist map[string]interface{}
type NotInPlaylist map[string]any
func (ipl NotInPlaylist) ToSql() (sql string, args []interface{}, err error) {
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, true)
}
@ -257,7 +299,7 @@ func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("notInPlaylist", ipl)
}
func inList(m map[string]interface{}, negate bool) (sql string, args []interface{}, err error) {
func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
var playlistid string
var ok bool
if playlistid, ok = m["id"].(string); !ok {
@ -284,7 +326,7 @@ func inList(m map[string]interface{}, negate bool) (sql string, args []interface
}
}
func extractPlaylistIds(inputRule interface{}) (ids []string) {
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool

View file

@ -1,17 +1,23 @@
package criteria
package criteria_test
import (
"encoding/json"
"fmt"
"time"
. "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = BeforeSuite(func() {
AddRoles([]string{"artist", "composer"})
AddTagNames([]string{"genre"})
})
var _ = Describe("Operators", func() {
rangeStart := date(time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local))
rangeEnd := date(time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local))
rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
DescribeTable("ToSQL",
func(op Expression, expectedSql string, expectedArgs ...any) {
@ -30,18 +36,73 @@ var _ = Describe("Operators", func() {
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []date{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// TODO These may be flaky
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", startOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())),
// InPlaylist and NotInPlaylist are special cases
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
// TODO These may be flaky
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Tag tests
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
// Artist roles tests
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
Describe("Custom Tags", func() {
It("generates valid SQL", func() {
AddTagNames([]string{"mood"})
op := EndsWith{"mood": "Soft"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("skips unknown tag names", func() {
op := EndsWith{"unknown": "value"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
})
Describe("Custom Roles", func() {
It("generates valid SQL", func() {
AddRoles([]string{"producer"})
op := EndsWith{"producer": "Eno"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
})
It("skips unknown roles", func() {
op := Contains{"groupie": "Penny Lane"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
})
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
@ -49,7 +110,7 @@ var _ = Describe("Operators", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString)))
var unmarshalObj unmarshalConjunctionType
var unmarshalObj UnmarshalConjunctionType
js := "[" + jsonString + "]"
err = json.Unmarshal([]byte(js), &unmarshalObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
@ -64,8 +125,8 @@ var _ = Describe("Operators", func() {
Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`),
Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`),
Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`),
Entry("inTheRange [number]", InTheRange{"year": []interface{}{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []interface{}{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
Entry("inTheRange [number]", InTheRange{"year": []any{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []any{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`),
Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`),
Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`),