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

@ -9,6 +9,8 @@ import (
"net/http"
"strings"
"time"
"github.com/navidrome/navidrome/log"
)
const cacheSizeLimit = 100
@ -41,7 +43,10 @@ func NewHTTPClient(wrapped httpDoer, ttl time.Duration) *HTTPClient {
func (c *HTTPClient) Do(req *http.Request) (*http.Response, error) {
key := c.serializeReq(req)
cached := true
start := time.Now()
respStr, err := c.cache.GetWithLoader(key, func(key string) (string, time.Duration, error) {
cached = false
req, err := c.deserializeReq(key)
if err != nil {
return "", 0, err
@ -53,6 +58,7 @@ func (c *HTTPClient) Do(req *http.Request) (*http.Response, error) {
defer resp.Body.Close()
return c.serializeResponse(resp), c.ttl, nil
})
log.Trace(req.Context(), "CachedHTTPClient.Do", "key", key, "cached", cached, "elapsed", time.Since(start))
if err != nil {
return nil, err
}

29
utils/chain/chain.go Normal file
View file

@ -0,0 +1,29 @@
package chain
import "golang.org/x/sync/errgroup"
// RunSequentially runs the given functions sequentially,
// If any function returns an error, it stops the execution and returns that error.
// If all functions return nil, it returns nil.
func RunSequentially(fs ...func() error) error {
for _, f := range fs {
if err := f(); err != nil {
return err
}
}
return nil
}
// RunParallel runs the given functions in parallel,
// It waits for all functions to finish and returns the first error encountered.
func RunParallel(fs ...func() error) func() error {
return func() error {
g := errgroup.Group{}
for _, f := range fs {
g.Go(func() error {
return f()
})
}
return g.Wait()
}
}

51
utils/chain/chain_test.go Normal file
View file

@ -0,0 +1,51 @@
package chain_test
import (
"errors"
"testing"
"github.com/navidrome/navidrome/utils/chain"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestChain(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "chain Suite")
}
var _ = Describe("RunSequentially", func() {
It("should return nil if no functions are provided", func() {
err := chain.RunSequentially()
Expect(err).To(BeNil())
})
It("should return nil if all functions succeed", func() {
err := chain.RunSequentially(
func() error { return nil },
func() error { return nil },
)
Expect(err).To(BeNil())
})
It("should return the error from the first failing function", func() {
expectedErr := errors.New("error in function 2")
err := chain.RunSequentially(
func() error { return nil },
func() error { return expectedErr },
func() error { return errors.New("error in function 3") },
)
Expect(err).To(Equal(expectedErr))
})
It("should not run functions after the first failing function", func() {
expectedErr := errors.New("error in function 1")
var runCount int
err := chain.RunSequentially(
func() error { runCount++; return expectedErr },
func() error { runCount++; return nil },
)
Expect(err).To(Equal(expectedErr))
Expect(runCount).To(Equal(1))
})
})

34
utils/chrono/meter.go Normal file
View file

@ -0,0 +1,34 @@
package chrono
import (
"time"
. "github.com/navidrome/navidrome/utils/gg"
)
// Meter is a simple stopwatch
type Meter struct {
elapsed time.Duration
mark *time.Time
}
func (m *Meter) Start() {
m.mark = P(time.Now())
}
func (m *Meter) Stop() time.Duration {
if m.mark == nil {
return m.elapsed
}
m.elapsed += time.Since(*m.mark)
m.mark = nil
return m.elapsed
}
func (m *Meter) Elapsed() time.Duration {
elapsed := m.elapsed
if m.mark != nil {
elapsed += time.Since(*m.mark)
}
return elapsed
}

View file

@ -0,0 +1,70 @@
package chrono_test
import (
"testing"
"time"
"github.com/navidrome/navidrome/tests"
. "github.com/navidrome/navidrome/utils/chrono"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestChrono(t *testing.T) {
tests.Init(t, false)
RegisterFailHandler(Fail)
RunSpecs(t, "Chrono Suite")
}
// Note: These tests may be flaky due to the use of time.Sleep.
var _ = Describe("Meter", func() {
var meter *Meter
BeforeEach(func() {
meter = &Meter{}
})
Describe("Stop", func() {
It("should return the elapsed time", func() {
meter.Start()
time.Sleep(20 * time.Millisecond)
elapsed := meter.Stop()
Expect(elapsed).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
})
It("should accumulate elapsed time on multiple starts and stops", func() {
meter.Start()
time.Sleep(20 * time.Millisecond)
meter.Stop()
meter.Start()
time.Sleep(20 * time.Millisecond)
elapsed := meter.Stop()
Expect(elapsed).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
})
})
Describe("Elapsed", func() {
It("should return the total elapsed time", func() {
meter.Start()
time.Sleep(20 * time.Millisecond)
meter.Stop()
// Should not count the time the meter was stopped
time.Sleep(20 * time.Millisecond)
meter.Start()
time.Sleep(20 * time.Millisecond)
meter.Stop()
Expect(meter.Elapsed()).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
})
It("should include the current running time if started", func() {
meter.Start()
time.Sleep(20 * time.Millisecond)
Expect(meter.Elapsed()).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
})
})
})

View file

@ -41,7 +41,6 @@ func Decrypt(ctx context.Context, encKey []byte, encData string) (value string,
// Recover from any panics
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Panic during decryption", r)
err = errors.New("decryption panicked")
}
}()

View file

@ -2,11 +2,18 @@ package utils
import (
"os"
"path"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/navidrome/navidrome/model/id"
)
func TempFileName(prefix, suffix string) string {
return filepath.Join(os.TempDir(), prefix+uuid.NewString()+suffix)
return filepath.Join(os.TempDir(), prefix+id.NewRandom()+suffix)
}
func BaseName(filePath string) string {
p := path.Base(filePath)
return strings.TrimSuffix(p, path.Ext(p))
}

View file

@ -14,3 +14,10 @@ func V[T any](p *T) T {
}
return *p
}
func If[T any](cond bool, v1, v2 T) T {
if cond {
return v1
}
return v2
}

View file

@ -39,4 +39,24 @@ var _ = Describe("GG", func() {
Expect(gg.V(v)).To(Equal(0))
})
})
Describe("If", func() {
It("returns the first value if the condition is true", func() {
Expect(gg.If(true, 1, 2)).To(Equal(1))
})
It("returns the second value if the condition is false", func() {
Expect(gg.If(false, 1, 2)).To(Equal(2))
})
It("works with string values", func() {
Expect(gg.If(true, "a", "b")).To(Equal("a"))
Expect(gg.If(false, "a", "b")).To(Equal("b"))
})
It("works with different types", func() {
Expect(gg.If(true, 1.1, 2.2)).To(Equal(1.1))
Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2))
})
})
})

View file

@ -3,7 +3,6 @@ package gravatar_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gravatar"
. "github.com/onsi/ginkgo/v2"
@ -12,7 +11,6 @@ import (
func TestGravatar(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Gravatar Test Suite")
}

26
utils/limiter.go Normal file
View file

@ -0,0 +1,26 @@
package utils
import (
"cmp"
"sync"
"time"
"golang.org/x/time/rate"
)
// Limiter is a rate limiter that allows a function to be executed at most once per ID and per interval.
type Limiter struct {
Interval time.Duration
sm sync.Map
}
// Do executes the provided function `f` if the rate limiter for the given `id` allows it.
// It uses the interval specified in the Limiter struct or defaults to 1 minute if not set.
func (m *Limiter) Do(id string, f func()) {
interval := cmp.Or(
m.Interval,
time.Minute, // Default every 1 minute
)
limiter, _ := m.sm.LoadOrStore(id, &rate.Sometimes{Interval: interval})
limiter.(*rate.Sometimes).Do(f)
}

View file

@ -5,8 +5,7 @@ import (
"sync/atomic"
"testing"
"github.com/google/uuid"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/singleton"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -22,7 +21,7 @@ var _ = Describe("GetInstance", func() {
var numInstancesCreated int
constructor := func() *T {
numInstancesCreated++
return &T{id: uuid.NewString()}
return &T{id: id.NewRandom()}
}
It("calls the constructor to create a new instance", func() {
@ -43,7 +42,7 @@ var _ = Describe("GetInstance", func() {
instance := singleton.GetInstance(constructor)
newInstance := singleton.GetInstance(func() T {
numInstancesCreated++
return T{id: uuid.NewString()}
return T{id: id.NewRandom()}
})
Expect(instance).To(BeAssignableToTypeOf(&T{}))

View file

@ -3,8 +3,12 @@ package slice
import (
"bufio"
"bytes"
"cmp"
"io"
"iter"
"slices"
"golang.org/x/exp/maps"
)
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
@ -30,25 +34,46 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T {
return m
}
func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V {
m := make(map[K]V, len(s))
for _, item := range s {
k, v := transformFunc(item)
m[k] = v
}
return m
}
func CompactByFrequency[T comparable](list []T) []T {
counters := make(map[T]int)
for _, item := range list {
counters[item]++
}
sorted := maps.Keys(counters)
slices.SortFunc(sorted, func(i, j T) int {
return cmp.Compare(counters[j], counters[i])
})
return sorted
}
func MostFrequent[T comparable](list []T) T {
var zero T
if len(list) == 0 {
var zero T
return zero
}
counters := make(map[T]int)
var topItem T
var topCount int
counters := map[T]int{}
if len(list) == 1 {
topItem = list[0]
} else {
for _, id := range list {
c := counters[id] + 1
counters[id] = c
if c > topCount {
topItem = id
topCount = c
}
for _, value := range list {
if value == zero {
continue
}
counters[value]++
if counters[value] > topCount {
topItem = value
topCount = counters[value]
}
}
@ -68,6 +93,18 @@ func Move[T any](slice []T, srcIndex int, dstIndex int) []T {
return Insert(Remove(slice, srcIndex), value, dstIndex)
}
func Unique[T comparable](list []T) []T {
seen := make(map[T]struct{})
var result []T
for _, item := range list {
if _, ok := seen[item]; !ok {
seen[item] = struct{}{}
result = append(result, item)
}
}
return result
}
// LinesFrom returns a Seq that reads lines from the given reader
func LinesFrom(reader io.Reader) iter.Seq[string] {
return func(yield func(string) bool) {

View file

@ -63,6 +63,34 @@ var _ = Describe("Slice Utils", func() {
})
})
Describe("ToMap", func() {
It("returns empty map for an empty input", func() {
transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) }
result := slice.ToMap([]int{}, transformFunc)
Expect(result).To(BeEmpty())
})
It("returns a map with the result of the transform function", func() {
transformFunc := func(v int) (int, string) { return v * 2, strconv.Itoa(v * 2) }
result := slice.ToMap([]int{1, 2, 3, 4}, transformFunc)
Expect(result).To(HaveLen(4))
Expect(result).To(HaveKeyWithValue(2, "2"))
Expect(result).To(HaveKeyWithValue(4, "4"))
Expect(result).To(HaveKeyWithValue(6, "6"))
Expect(result).To(HaveKeyWithValue(8, "8"))
})
})
Describe("CompactByFrequency", func() {
It("returns empty slice for an empty input", func() {
Expect(slice.CompactByFrequency([]int{})).To(BeEmpty())
})
It("groups by frequency", func() {
Expect(slice.CompactByFrequency([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(2, 1, 3))
})
})
Describe("MostFrequent", func() {
It("returns zero value if no arguments are passed", func() {
Expect(slice.MostFrequent([]int{})).To(BeZero())
@ -74,6 +102,9 @@ var _ = Describe("Slice Utils", func() {
It("returns the item that appeared more times", func() {
Expect(slice.MostFrequent([]string{"1", "2", "1", "2", "3", "2"})).To(Equal("2"))
})
It("ignores zero values", func() {
Expect(slice.MostFrequent([]int{0, 0, 0, 2, 2})).To(Equal(2))
})
})
Describe("Move", func() {
@ -88,6 +119,16 @@ var _ = Describe("Slice Utils", func() {
})
})
Describe("Unique", func() {
It("returns empty slice for an empty input", func() {
Expect(slice.Unique([]int{})).To(BeEmpty())
})
It("returns the unique elements", func() {
Expect(slice.Unique([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(1, 2, 3))
})
})
DescribeTable("LinesFrom",
func(path string, expected int) {
count := 0

View file

@ -3,7 +3,7 @@ package str
import (
"html"
"regexp"
"sort"
"slices"
"strings"
"github.com/deluan/sanitize"
@ -11,27 +11,28 @@ import (
"github.com/navidrome/navidrome/conf"
)
var quotesRegex = regexp.MustCompile("[“”‘’'\"\\[({\\])}]")
var ignoredCharsRegex = regexp.MustCompile("[“”‘’'\"\\[({\\])},]")
var slashRemover = strings.NewReplacer("\\", " ", "/", " ")
func SanitizeStrings(text ...string) string {
// Concatenate all strings, removing extra spaces
sanitizedText := strings.Builder{}
for _, txt := range text {
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
sanitizedText.WriteString(strings.TrimSpace(txt))
sanitizedText.WriteByte(' ')
}
words := make(map[string]struct{})
for _, w := range strings.Fields(sanitizedText.String()) {
words[w] = struct{}{}
}
var fullText []string
for w := range words {
w = quotesRegex.ReplaceAllString(w, "")
w = slashRemover.Replace(w)
if w != "" {
fullText = append(fullText, w)
}
}
sort.Strings(fullText)
// Remove special symbols, accents, extra spaces and slashes
sanitizedStrings := slashRemover.Replace(Clear(sanitizedText.String()))
sanitizedStrings = sanitize.Accents(strings.ToLower(sanitizedStrings))
sanitizedStrings = ignoredCharsRegex.ReplaceAllString(sanitizedStrings, "")
fullText := strings.Fields(sanitizedStrings)
// Remove duplicated words
slices.Sort(fullText)
fullText = slices.Compact(fullText)
// Returns the sanitized text as a single string
return strings.Join(fullText, " ")
}
@ -44,12 +45,12 @@ func SanitizeText(text string) string {
func SanitizeFieldForSorting(originalValue string) string {
v := strings.TrimSpace(sanitize.Accents(originalValue))
return strings.ToLower(v)
return Clear(strings.ToLower(v))
}
func SanitizeFieldForSortingNoArticle(originalValue string) string {
v := strings.TrimSpace(sanitize.Accents(originalValue))
return strings.ToLower(RemoveArticle(v))
return Clear(strings.ToLower(strings.TrimSpace(RemoveArticle(v))))
}
func RemoveArticle(name string) string {

View file

@ -18,11 +18,11 @@ var _ = Describe("Sanitize Strings", func() {
})
It("remove extra spaces", func() {
Expect(str.SanitizeStrings(" some text ")).To(Equal("some text"))
Expect(str.SanitizeStrings(" some text ", "text some")).To(Equal("some text"))
})
It("remove duplicated words", func() {
Expect(str.SanitizeStrings("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
Expect(str.SanitizeStrings("legião urbana", "urbana legiÃo")).To(Equal("legiao urbana"))
})
It("remove symbols", func() {
@ -32,8 +32,20 @@ var _ = Describe("Sanitize Strings", func() {
It("remove opening brackets", func() {
Expect(str.SanitizeStrings("[Five Years]")).To(Equal("five years"))
})
It("remove slashes", func() {
Expect(str.SanitizeStrings("folder/file\\yyyy")).To(Equal("folder file yyyy"))
Expect(str.SanitizeStrings("folder/file\\yyyy")).To(Equal("file folder yyyy"))
})
It("normalizes utf chars", func() {
// These uses different types of hyphens
Expect(str.SanitizeStrings("k—os", "k−os")).To(Equal("k-os"))
})
It("remove commas", func() {
// This is specially useful for handling cases where the Sort field uses comma.
// It reduces the size of the resulting string, thus reducing the size of the DB table and indexes.
Expect(str.SanitizeStrings("Bob Marley", "Marley, Bob")).To(Equal("bob marley"))
})
})

View file

@ -4,14 +4,21 @@ import (
"strings"
)
var utf8ToAscii = strings.NewReplacer(
"–", "-",
"‐", "-",
"“", `"`,
"”", `"`,
"‘", `'`,
"’", `'`,
)
var utf8ToAscii = func() *strings.Replacer {
var utf8Map = map[string]string{
"'": `‘’‛′`,
`"`: `"〃ˮײ᳓″‶˶ʺ“”˝‟`,
"-": `‐–—−―`,
}
list := make([]string, 0, len(utf8Map)*2)
for ascii, utf8 := range utf8Map {
for _, r := range utf8 {
list = append(list, string(r), ascii)
}
}
return strings.NewReplacer(list...)
}()
func Clear(name string) string {
return utf8ToAscii.Replace(name)

View file

@ -23,6 +23,13 @@ var _ = Describe("String Utils", func() {
It("finds the longest common prefix", func() {
Expect(str.LongestCommonPrefix(testPaths)).To(Equal("/Music/iTunes 1/iTunes Media/Music/"))
})
It("does NOT handle partial prefixes", func() {
albums := []string{
"/artist/albumOne",
"/artist/albumTwo",
}
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
})
})
})