mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 04:27:37 +03:00
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:
parent
46a963a02a
commit
c795bcfcf7
329 changed files with 16586 additions and 5852 deletions
|
@ -4,13 +4,17 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -21,36 +25,68 @@ type albumRepository struct {
|
|||
type dbAlbum struct {
|
||||
*model.Album `structs:",flatten"`
|
||||
Discs string `structs:"-" json:"discs"`
|
||||
Participants string `structs:"-" json:"-"`
|
||||
Tags string `structs:"-" json:"-"`
|
||||
FolderIDs string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (a *dbAlbum) PostScan() error {
|
||||
var err error
|
||||
if a.Discs != "" {
|
||||
return json.Unmarshal([]byte(a.Discs), &a.Album.Discs)
|
||||
if err = json.Unmarshal([]byte(a.Discs), &a.Album.Discs); err != nil {
|
||||
return fmt.Errorf("parsing album discs from db: %w", err)
|
||||
}
|
||||
}
|
||||
a.Album.Participants, err = unmarshalParticipants(a.Participants)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing album from db: %w", err)
|
||||
}
|
||||
if a.Tags != "" {
|
||||
a.Album.Tags, err = unmarshalTags(a.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing album from db: %w", err)
|
||||
}
|
||||
a.Genre, a.Genres = a.Album.Tags.ToGenres()
|
||||
}
|
||||
if a.FolderIDs != "" {
|
||||
var ids []string
|
||||
if err = json.Unmarshal([]byte(a.FolderIDs), &ids); err != nil {
|
||||
return fmt.Errorf("parsing album folder_ids from db: %w", err)
|
||||
}
|
||||
a.Album.FolderIDs = ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *dbAlbum) PostMapArgs(m map[string]any) error {
|
||||
if len(a.Album.Discs) == 0 {
|
||||
m["discs"] = "{}"
|
||||
return nil
|
||||
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
|
||||
fullText = append(fullText, a.Album.Participants.AllNames()...)
|
||||
fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...)
|
||||
fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...)
|
||||
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
|
||||
args["tags"] = marshalTags(a.Album.Tags)
|
||||
args["participants"] = marshalParticipants(a.Album.Participants)
|
||||
|
||||
folderIDs, err := json.Marshal(a.Album.FolderIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling album folder_ids: %w", err)
|
||||
}
|
||||
args["folder_ids"] = string(folderIDs)
|
||||
|
||||
b, err := json.Marshal(a.Album.Discs)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("marshalling album discs: %w", err)
|
||||
}
|
||||
m["discs"] = string(b)
|
||||
args["discs"] = string(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dbAlbums []dbAlbum
|
||||
|
||||
func (dba dbAlbums) toModels() model.Albums {
|
||||
res := make(model.Albums, len(dba))
|
||||
for i := range dba {
|
||||
res[i] = *dba[i].Album
|
||||
}
|
||||
return res
|
||||
func (as dbAlbums) toModels() model.Albums {
|
||||
return slice.Map(as, func(a dbAlbum) model.Album { return *a.Album })
|
||||
}
|
||||
|
||||
func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumRepository {
|
||||
|
@ -58,17 +94,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "album"
|
||||
r.registerModel(&model.Album{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
r.registerModel(&model.Album{}, albumFilters())
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_album_name, order_album_artist_name",
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
|
@ -78,10 +104,29 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("album"),
|
||||
"name": fullTextFilter("album"),
|
||||
"compilation": booleanFilter,
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
"missing": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.AlbumLevelTags() {
|
||||
filters[string(tag)] = tagIDFilter
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
func recentlyAddedSort() string {
|
||||
if conf.Server.RecentlyAddedByModTime {
|
||||
return "updated_at"
|
||||
|
@ -108,98 +153,187 @@ func yearFilter(_ string, value interface{}) Sqlizer {
|
|||
}
|
||||
}
|
||||
|
||||
// BFR: Support other roles
|
||||
func artistFilter(_ string, value interface{}) Sqlizer {
|
||||
return Like{"all_artist_ids": fmt.Sprintf("%%%s%%", value)}
|
||||
return Or{
|
||||
Exists("json_tree(Participants, '$.albumartist')", Eq{"value": value}),
|
||||
Exists("json_tree(Participants, '$.artist')", Eq{"value": value}),
|
||||
}
|
||||
// For any role:
|
||||
//return Like{"Participants": fmt.Sprintf(`%%"%s"%%`, value)}
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := r.newSelectWithAnnotation("album.id")
|
||||
sql = r.withGenres(sql) // Required for filtering by genre
|
||||
sql := r.newSelect()
|
||||
sql = r.withAnnotation(sql, "album.id")
|
||||
// BFR WithParticipants (for filtering by name)?
|
||||
return r.count(sql, options...)
|
||||
}
|
||||
|
||||
func (r *albumRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"album.id": id}))
|
||||
return r.exists(Eq{"album.id": id})
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelectWithAnnotation("album.id", options...).Columns("album.*")
|
||||
if len(options) > 0 && options[0].Filters != nil {
|
||||
s, _, _ := options[0].Filters.ToSql()
|
||||
// If there's any reference of genre in the filter, joins with genre
|
||||
if strings.Contains(s, "genre") {
|
||||
sql = r.withGenres(sql)
|
||||
// If there's no filter on genre_id, group the results by media_file.id
|
||||
if !strings.Contains(s, "genre_id") {
|
||||
sql = sql.GroupBy("album.id")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
sq := r.selectAlbum().Where(Eq{"album.id": id})
|
||||
var dba dbAlbums
|
||||
if err := r.queryAll(sq, &dba); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dba) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
res := dba.toModels()
|
||||
err := loadAllGenres(r, res)
|
||||
return &res[0], err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Put(m *model.Album) error {
|
||||
_, err := r.put(m.ID, &dbAlbum{Album: m})
|
||||
func (r *albumRepository) Put(al *model.Album) error {
|
||||
al.ImportedAt = time.Now()
|
||||
id, err := r.put(al.ID, &dbAlbum{Album: al})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateGenres(m.ID, m.Genres)
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
res, err := r.GetAllWithoutGenres(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = loadAllGenres(r, res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) {
|
||||
r.resetSeededRandom(options)
|
||||
sq := r.selectAlbum(options...)
|
||||
var dba dbAlbums
|
||||
err := r.queryAll(sq, &dba)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dba.toModels(), err
|
||||
}
|
||||
|
||||
func (r *albumRepository) purgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c)
|
||||
al.ID = id
|
||||
if len(al.Participants) > 0 {
|
||||
err = r.updateParticipants(al.ID, al.Participants)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) {
|
||||
var dba dbAlbums
|
||||
err := r.doSearch(q, offset, size, &dba, "name")
|
||||
// TODO Move external metadata to a separated table
|
||||
func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
|
||||
_, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at")
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelect(options...).Columns("album.*")
|
||||
return r.withAnnotation(sql, "album.id")
|
||||
}
|
||||
|
||||
func (r *albumRepository) Get(id string) (*model.Album, error) {
|
||||
res, err := r.GetAll(model.QueryOptions{Filters: Eq{"album.id": id}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := dba.toModels()
|
||||
err = loadAllGenres(r, res)
|
||||
return res, err
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
sq := r.selectAlbum(options...)
|
||||
var res dbAlbums
|
||||
err := r.queryAll(sq, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.toModels(), err
|
||||
}
|
||||
|
||||
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
var from dbx.NullStringMap
|
||||
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting album to copy fields from: %w", err)
|
||||
}
|
||||
to := make(map[string]interface{})
|
||||
for _, col := range columns {
|
||||
to[col] = from[col]
|
||||
}
|
||||
_, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID}))
|
||||
return err
|
||||
}
|
||||
|
||||
// Touch flags an album as being scanned by the scanner, but not necessarily updated.
|
||||
// This is used for when missing tracks are detected for an album during scan.
|
||||
func (r *albumRepository) Touch(ids ...string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
for ids := range slices.Chunk(ids, 200) {
|
||||
upd := Update(r.tableName).Set("imported_at", time.Now()).Where(Eq{"id": ids})
|
||||
c, err := r.executeSQL(upd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error touching albums: %w", err)
|
||||
}
|
||||
log.Debug(r.ctx, "Touching albums", "ids", ids, "updated", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchByMissingFolder touches all albums that have missing folders
|
||||
func (r *albumRepository) TouchByMissingFolder() (int64, error) {
|
||||
upd := Update(r.tableName).Set("imported_at", time.Now()).
|
||||
Where(And{
|
||||
NotEq{"folder_ids": nil},
|
||||
ConcatExpr("EXISTS (SELECT 1 FROM json_each(folder_ids) AS je JOIN main.folder AS f ON je.value = f.id WHERE f.missing = true)"),
|
||||
})
|
||||
c, err := r.executeSQL(upd)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error touching albums by missing folder: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// GetTouchedAlbums returns all albums that were touched by the scanner for a given library, in the
|
||||
// current library scan run.
|
||||
// It does not need to load participants, as they are not used by the scanner.
|
||||
func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
||||
query := r.selectAlbum().
|
||||
Join("library on library.id = album.library_id").
|
||||
Where(And{
|
||||
Eq{"library.id": libID},
|
||||
ConcatExpr("album.imported_at > library.last_scan_at"),
|
||||
})
|
||||
cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(model.Album, error) bool) {
|
||||
for a, err := range cursor {
|
||||
if a.Album == nil {
|
||||
yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a))
|
||||
return
|
||||
}
|
||||
if !yield(*a.Album, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all albums, based
|
||||
// on the media files associated with them.
|
||||
func (r *albumRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
with play_counts as (
|
||||
select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
join annotation on item_id = media_file.id
|
||||
group by user_id, album_id
|
||||
)
|
||||
insert into annotation (user_id, item_id, item_type, play_count, play_date)
|
||||
select user_id, album_id, 'album', total_play_count, last_play_date
|
||||
from play_counts
|
||||
where total_play_count > 0
|
||||
on conflict (user_id, item_id, item_type) do update
|
||||
set play_count = excluded.play_count,
|
||||
play_date = excluded.play_date;
|
||||
`)
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
func (r *albumRepository) purgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purging empty albums: %w", err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) {
|
||||
var res dbAlbums
|
||||
err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.toModels(), err
|
||||
}
|
||||
|
||||
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
|
|
|
@ -4,12 +4,11 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
@ -24,22 +23,37 @@ var _ = Describe("AlbumRepository", func() {
|
|||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
var Get = func(id string) (*model.Album, error) {
|
||||
album, err := repo.Get(id)
|
||||
if album != nil {
|
||||
album.ImportedAt = time.Time{}
|
||||
}
|
||||
return album, err
|
||||
}
|
||||
It("returns an existent album", func() {
|
||||
Expect(repo.Get("103")).To(Equal(&albumRadioactivity))
|
||||
Expect(Get("103")).To(Equal(&albumRadioactivity))
|
||||
})
|
||||
It("returns ErrNotFound when the album does not exist", func() {
|
||||
_, err := repo.Get("666")
|
||||
_, err := Get("666")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) {
|
||||
albums, err := repo.GetAll(opts...)
|
||||
for i := range albums {
|
||||
albums[i].ImportedAt = time.Time{}
|
||||
}
|
||||
return albums, err
|
||||
}
|
||||
|
||||
It("returns all records", func() {
|
||||
Expect(repo.GetAll()).To(Equal(testAlbums))
|
||||
Expect(GetAll()).To(Equal(testAlbums))
|
||||
})
|
||||
|
||||
It("returns all records sorted", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
|
@ -47,7 +61,7 @@ var _ = Describe("AlbumRepository", func() {
|
|||
})
|
||||
|
||||
It("returns all records sorted desc", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumAbbeyRoad,
|
||||
|
@ -55,107 +69,179 @@ var _ = Describe("AlbumRepository", func() {
|
|||
})
|
||||
|
||||
It("paginates the result", func() {
|
||||
Expect(repo.GetAll(model.QueryOptions{Offset: 1, Max: 1})).To(Equal(model.Albums{
|
||||
Expect(GetAll(model.QueryOptions{Offset: 1, Max: 1})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album.PlayCount", func() {
|
||||
// Implementation is in withAnnotation() method
|
||||
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
|
||||
func(songCount, playCount, expected int) {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
album, err := repo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.PlayCount).To(Equal(int64(expected)))
|
||||
},
|
||||
Entry("1 song, 0 plays", 1, 0, 0),
|
||||
Entry("1 song, 4 plays", 1, 4, 4),
|
||||
Entry("3 songs, 6 plays", 3, 6, 6),
|
||||
Entry("10 songs, 6 plays", 10, 6, 6),
|
||||
Entry("70 songs, 70 plays", 70, 70, 70),
|
||||
Entry("10 songs, 50 plays", 10, 50, 50),
|
||||
Entry("120 songs, 121 plays", 120, 121, 121),
|
||||
)
|
||||
|
||||
DescribeTable("normalizes play count when AlbumPlayCountMode is normalized",
|
||||
func(songCount, playCount, expected int) {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
album, err := repo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.PlayCount).To(Equal(int64(expected)))
|
||||
},
|
||||
Entry("1 song, 0 plays", 1, 0, 0),
|
||||
Entry("1 song, 4 plays", 1, 4, 4),
|
||||
Entry("3 songs, 6 plays", 3, 6, 2),
|
||||
Entry("10 songs, 6 plays", 10, 6, 1),
|
||||
Entry("70 songs, 70 plays", 70, 70, 1),
|
||||
Entry("10 songs, 50 plays", 10, 50, 5),
|
||||
Entry("120 songs, 121 plays", 120, 121, 1),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("dbAlbum mapping", func() {
|
||||
Describe("Album.Discs", func() {
|
||||
var a *model.Album
|
||||
BeforeEach(func() {
|
||||
a = &model.Album{ID: "1", Name: "name", ArtistID: "2"}
|
||||
})
|
||||
It("maps empty discs field", func() {
|
||||
a.Discs = model.Discs{}
|
||||
dba := dbAlbum{Album: a}
|
||||
var (
|
||||
a model.Album
|
||||
dba *dbAlbum
|
||||
args map[string]any
|
||||
)
|
||||
|
||||
m := structs.Map(dba)
|
||||
Expect(dba.PostMapArgs(m)).To(Succeed())
|
||||
Expect(m).To(HaveKeyWithValue("discs", `{}`))
|
||||
|
||||
other := dbAlbum{Album: &model.Album{ID: "1", Name: "name"}, Discs: "{}"}
|
||||
Expect(other.PostScan()).To(Succeed())
|
||||
|
||||
Expect(other.Album.Discs).To(Equal(a.Discs))
|
||||
})
|
||||
It("maps the discs field", func() {
|
||||
a.Discs = model.Discs{1: "disc1", 2: "disc2"}
|
||||
dba := dbAlbum{Album: a}
|
||||
|
||||
m := structs.Map(dba)
|
||||
Expect(dba.PostMapArgs(m)).To(Succeed())
|
||||
Expect(m).To(HaveKeyWithValue("discs", `{"1":"disc1","2":"disc2"}`))
|
||||
|
||||
other := dbAlbum{Album: &model.Album{ID: "1", Name: "name"}, Discs: m["discs"].(string)}
|
||||
Expect(other.PostScan()).To(Succeed())
|
||||
|
||||
Expect(other.Album.Discs).To(Equal(a.Discs))
|
||||
})
|
||||
})
|
||||
Describe("Album.PlayCount", func() {
|
||||
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
|
||||
func(songCount, playCount, expected int) {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute
|
||||
|
||||
id := uuid.NewString()
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
album, err := repo.Get(id)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.PlayCount).To(Equal(int64(expected)))
|
||||
},
|
||||
Entry("1 song, 0 plays", 1, 0, 0),
|
||||
Entry("1 song, 4 plays", 1, 4, 4),
|
||||
Entry("3 songs, 6 plays", 3, 6, 6),
|
||||
Entry("10 songs, 6 plays", 10, 6, 6),
|
||||
Entry("70 songs, 70 plays", 70, 70, 70),
|
||||
Entry("10 songs, 50 plays", 10, 50, 50),
|
||||
Entry("120 songs, 121 plays", 120, 121, 121),
|
||||
)
|
||||
|
||||
DescribeTable("normalizes play count when AlbumPlayCountMode is normalized",
|
||||
func(songCount, playCount, expected int) {
|
||||
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized
|
||||
|
||||
id := uuid.NewString()
|
||||
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
album, err := repo.Get(id)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.PlayCount).To(Equal(int64(expected)))
|
||||
},
|
||||
Entry("1 song, 0 plays", 1, 0, 0),
|
||||
Entry("1 song, 4 plays", 1, 4, 4),
|
||||
Entry("3 songs, 6 plays", 3, 6, 2),
|
||||
Entry("10 songs, 6 plays", 10, 6, 1),
|
||||
Entry("70 songs, 70 plays", 70, 70, 1),
|
||||
Entry("10 songs, 50 plays", 10, 50, 5),
|
||||
Entry("120 songs, 121 plays", 120, 121, 1),
|
||||
)
|
||||
BeforeEach(func() {
|
||||
a = al(model.Album{ID: "1", Name: "name"})
|
||||
dba = &dbAlbum{Album: &a, Participants: "{}"}
|
||||
args = make(map[string]any)
|
||||
})
|
||||
|
||||
Describe("dbAlbums.toModels", func() {
|
||||
It("converts dbAlbums to model.Albums", func() {
|
||||
dba := dbAlbums{
|
||||
{Album: &model.Album{ID: "1", Name: "name", SongCount: 2, Annotations: model.Annotations{PlayCount: 4}}},
|
||||
{Album: &model.Album{ID: "2", Name: "name2", SongCount: 3, Annotations: model.Annotations{PlayCount: 6}}},
|
||||
}
|
||||
albums := dba.toModels()
|
||||
for i := range dba {
|
||||
Expect(albums[i].ID).To(Equal(dba[i].Album.ID))
|
||||
Expect(albums[i].Name).To(Equal(dba[i].Album.Name))
|
||||
Expect(albums[i].SongCount).To(Equal(dba[i].Album.SongCount))
|
||||
Expect(albums[i].PlayCount).To(Equal(dba[i].Album.PlayCount))
|
||||
Describe("PostScan", func() {
|
||||
It("parses Discs correctly", func() {
|
||||
dba.Discs = `{"1":"disc1","2":"disc2"}`
|
||||
Expect(dba.PostScan()).To(Succeed())
|
||||
Expect(dba.Album.Discs).To(Equal(model.Discs{1: "disc1", 2: "disc2"}))
|
||||
})
|
||||
|
||||
It("parses Participants correctly", func() {
|
||||
dba.Participants = `{"composer":[{"id":"1","name":"Composer 1"}],` +
|
||||
`"artist":[{"id":"2","name":"Artist 2"},{"id":"3","name":"Artist 3","subRole":"subRole"}]}`
|
||||
Expect(dba.PostScan()).To(Succeed())
|
||||
Expect(dba.Album.Participants).To(HaveLen(2))
|
||||
Expect(dba.Album.Participants).To(HaveKeyWithValue(
|
||||
model.RoleFromString("composer"),
|
||||
model.ParticipantList{{Artist: model.Artist{ID: "1", Name: "Composer 1"}}},
|
||||
))
|
||||
Expect(dba.Album.Participants).To(HaveKeyWithValue(
|
||||
model.RoleFromString("artist"),
|
||||
model.ParticipantList{{Artist: model.Artist{ID: "2", Name: "Artist 2"}}, {Artist: model.Artist{ID: "3", Name: "Artist 3"}, SubRole: "subRole"}},
|
||||
))
|
||||
})
|
||||
|
||||
It("parses Tags correctly", func() {
|
||||
dba.Tags = `{"genre":[{"id":"1","value":"rock"},{"id":"2","value":"pop"}],"mood":[{"id":"3","value":"happy"}]}`
|
||||
Expect(dba.PostScan()).To(Succeed())
|
||||
Expect(dba.Album.Tags).To(HaveKeyWithValue(
|
||||
model.TagName("mood"), []string{"happy"},
|
||||
))
|
||||
Expect(dba.Album.Tags).To(HaveKeyWithValue(
|
||||
model.TagName("genre"), []string{"rock", "pop"},
|
||||
))
|
||||
Expect(dba.Album.Genre).To(Equal("rock"))
|
||||
Expect(dba.Album.Genres).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses Paths correctly", func() {
|
||||
dba.FolderIDs = `["folder1","folder2"]`
|
||||
Expect(dba.PostScan()).To(Succeed())
|
||||
Expect(dba.Album.FolderIDs).To(Equal([]string{"folder1", "folder2"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostMapArgs", func() {
|
||||
It("maps full_text correctly", func() {
|
||||
Expect(dba.PostMapArgs(args)).To(Succeed())
|
||||
Expect(args).To(HaveKeyWithValue("full_text", " name"))
|
||||
})
|
||||
|
||||
It("maps tags correctly", func() {
|
||||
dba.Album.Tags = model.Tags{"genre": {"rock", "pop"}, "mood": {"happy"}}
|
||||
Expect(dba.PostMapArgs(args)).To(Succeed())
|
||||
Expect(args).To(HaveKeyWithValue("tags",
|
||||
`{"genre":[{"id":"5qDZoz1FBC36K73YeoJ2lF","value":"rock"},{"id":"4H0KjnlS2ob9nKLL0zHOqB",`+
|
||||
`"value":"pop"}],"mood":[{"id":"1F4tmb516DIlHKFT1KzE1Z","value":"happy"}]}`,
|
||||
))
|
||||
})
|
||||
|
||||
It("maps participants correctly", func() {
|
||||
dba.Album.Participants = model.Participants{
|
||||
model.RoleAlbumArtist: model.ParticipantList{_p("AA1", "AlbumArtist1")},
|
||||
model.RoleComposer: model.ParticipantList{{Artist: model.Artist{ID: "C1", Name: "Composer1"}, SubRole: "composer"}},
|
||||
}
|
||||
Expect(dba.PostMapArgs(args)).To(Succeed())
|
||||
Expect(args).To(HaveKeyWithValue(
|
||||
"participants",
|
||||
`{"albumartist":[{"id":"AA1","name":"AlbumArtist1"}],`+
|
||||
`"composer":[{"id":"C1","name":"Composer1","subRole":"composer"}]}`,
|
||||
))
|
||||
})
|
||||
|
||||
It("maps discs correctly", func() {
|
||||
dba.Album.Discs = model.Discs{1: "disc1", 2: "disc2"}
|
||||
Expect(dba.PostMapArgs(args)).To(Succeed())
|
||||
Expect(args).To(HaveKeyWithValue("discs", `{"1":"disc1","2":"disc2"}`))
|
||||
})
|
||||
|
||||
It("maps paths correctly", func() {
|
||||
dba.Album.FolderIDs = []string{"folder1", "folder2"}
|
||||
Expect(dba.PostMapArgs(args)).To(Succeed())
|
||||
Expect(args).To(HaveKeyWithValue("folder_ids", `["folder1","folder2"]`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("dbAlbums.toModels", func() {
|
||||
It("converts dbAlbums to model.Albums", func() {
|
||||
dba := dbAlbums{
|
||||
{Album: &model.Album{ID: "1", Name: "name", SongCount: 2, Annotations: model.Annotations{PlayCount: 4}}},
|
||||
{Album: &model.Album{ID: "2", Name: "name2", SongCount: 3, Annotations: model.Annotations{PlayCount: 6}}},
|
||||
}
|
||||
albums := dba.toModels()
|
||||
for i := range dba {
|
||||
Expect(albums[i].ID).To(Equal(dba[i].Album.ID))
|
||||
Expect(albums[i].Name).To(Equal(dba[i].Album.Name))
|
||||
Expect(albums[i].SongCount).To(Equal(dba[i].Album.SongCount))
|
||||
Expect(albums[i].PlayCount).To(Equal(dba[i].Album.PlayCount))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func _p(id, name string, sortName ...string) model.Participant {
|
||||
p := model.Participant{Artist: model.Artist{ID: id, Name: name}}
|
||||
if len(sortName) > 0 {
|
||||
p.Artist.SortArtistName = sortName[0]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
|
|
@ -3,18 +3,19 @@ package persistence
|
|||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
@ -26,35 +27,84 @@ type artistRepository struct {
|
|||
|
||||
type dbArtist struct {
|
||||
*model.Artist `structs:",flatten"`
|
||||
SimilarArtists string `structs:"-" json:"similarArtists"`
|
||||
SimilarArtists string `structs:"-" json:"-"`
|
||||
Stats string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
type dbSimilarArtist struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
func (a *dbArtist) PostScan() error {
|
||||
var stats map[string]map[string]int64
|
||||
if err := json.Unmarshal([]byte(a.Stats), &stats); err != nil {
|
||||
return fmt.Errorf("parsing artist stats from db: %w", err)
|
||||
}
|
||||
a.Artist.Stats = make(map[model.Role]model.ArtistStats)
|
||||
for key, c := range stats {
|
||||
if key == "total" {
|
||||
a.Artist.Size = c["s"]
|
||||
a.Artist.SongCount = int(c["m"])
|
||||
a.Artist.AlbumCount = int(c["a"])
|
||||
}
|
||||
role := model.RoleFromString(key)
|
||||
if role == model.RoleInvalid {
|
||||
continue
|
||||
}
|
||||
a.Artist.Stats[role] = model.ArtistStats{
|
||||
SongCount: int(c["m"]),
|
||||
AlbumCount: int(c["a"]),
|
||||
Size: c["s"],
|
||||
}
|
||||
}
|
||||
a.Artist.SimilarArtists = nil
|
||||
if a.SimilarArtists == "" {
|
||||
return nil
|
||||
}
|
||||
for _, s := range strings.Split(a.SimilarArtists, ";") {
|
||||
fields := strings.Split(s, ":")
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
name, _ := url.QueryUnescape(fields[1])
|
||||
var sa []dbSimilarArtist
|
||||
if err := json.Unmarshal([]byte(a.SimilarArtists), &sa); err != nil {
|
||||
return fmt.Errorf("parsing similar artists from db: %w", err)
|
||||
}
|
||||
for _, s := range sa {
|
||||
a.Artist.SimilarArtists = append(a.Artist.SimilarArtists, model.Artist{
|
||||
ID: fields[0],
|
||||
Name: name,
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
||||
var sa []string
|
||||
sa := make([]dbSimilarArtist, 0)
|
||||
for _, s := range a.Artist.SimilarArtists {
|
||||
sa = append(sa, fmt.Sprintf("%s:%s", s.ID, url.QueryEscape(s.Name)))
|
||||
sa = append(sa, dbSimilarArtist{ID: s.ID, Name: s.Name})
|
||||
}
|
||||
similarArtists, _ := json.Marshal(sa)
|
||||
m["similar_artists"] = string(similarArtists)
|
||||
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
||||
|
||||
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
||||
// BFR: Better way to handle this?
|
||||
if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" {
|
||||
delete(m, "sort_artist_name")
|
||||
}
|
||||
if v, ok := m["mbz_artist_id"]; !ok || v.(string) == "" {
|
||||
delete(m, "mbz_artist_id")
|
||||
}
|
||||
m["similar_artists"] = strings.Join(sa, ";")
|
||||
return nil
|
||||
}
|
||||
|
||||
type dbArtists []dbArtist
|
||||
|
||||
func (dba dbArtists) toModels() model.Artists {
|
||||
res := make(model.Artists, len(dba))
|
||||
for i := range dba {
|
||||
res[i] = *dba[i].Artist
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistRepository {
|
||||
r := &artistRepository{}
|
||||
r.ctx = ctx
|
||||
|
@ -62,80 +112,82 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
|
||||
r.tableName = "artist" // To be used by the idFilter below
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter(r.tableName),
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"song_count": "stats->>'total'->>'m'",
|
||||
"album_count": "stats->>'total'->>'a'",
|
||||
"size": "stats->>'total'->>'s'",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func roleFilter(_ string, role any) Sqlizer {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelectWithAnnotation("artist.id", options...).Columns("artist.*")
|
||||
return r.withGenres(sql).GroupBy("artist.id")
|
||||
query := r.newSelect(options...).Columns("artist.*")
|
||||
query = r.withAnnotation(query, "artist.id")
|
||||
// BFR How to handle counts and sizes (per role)?
|
||||
return query
|
||||
}
|
||||
|
||||
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := r.newSelectWithAnnotation("artist.id")
|
||||
sql = r.withGenres(sql) // Required for filtering by genre
|
||||
return r.count(sql, options...)
|
||||
query := r.newSelect()
|
||||
query = r.withAnnotation(query, "artist.id")
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"artist.id": id}))
|
||||
return r.exists(Eq{"artist.id": id})
|
||||
}
|
||||
|
||||
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
|
||||
a.FullText = getFullText(a.Name, a.SortArtistName)
|
||||
dba := &dbArtist{Artist: a}
|
||||
dba.CreatedAt = P(time.Now())
|
||||
dba.UpdatedAt = dba.CreatedAt
|
||||
_, err := r.put(dba.ID, dba, colsToUpdate...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.ID == consts.VariousArtistsID {
|
||||
return r.updateGenres(a.ID, nil)
|
||||
}
|
||||
return r.updateGenres(a.ID, a.Genres)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error {
|
||||
dba := &dbArtist{Artist: a}
|
||||
_, err := r.put(a.ID, dba,
|
||||
"biography", "small_image_url", "medium_image_url", "large_image_url",
|
||||
"similar_artists", "external_url", "external_info_updated_at")
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artistRepository) Get(id string) (*model.Artist, error) {
|
||||
sel := r.selectArtist().Where(Eq{"artist.id": id})
|
||||
var dba []dbArtist
|
||||
var dba dbArtists
|
||||
if err := r.queryAll(sel, &dba); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dba) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
res := r.toModels(dba)
|
||||
err := loadAllGenres(r, res)
|
||||
return &res[0], err
|
||||
res := dba.toModels()
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
sel := r.selectArtist(options...)
|
||||
var dba []dbArtist
|
||||
var dba dbArtists
|
||||
err := r.queryAll(sel, &dba)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := r.toModels(dba)
|
||||
err = loadAllGenres(r, res)
|
||||
res := dba.toModels()
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
|
||||
res := model.Artists{}
|
||||
for i := range dba {
|
||||
res = append(res, *dba[i].Artist)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
source := a.OrderArtistName
|
||||
if conf.Server.PreferSortTags {
|
||||
|
@ -151,8 +203,15 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
|||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
artists, err := r.GetAll(model.QueryOptions{Sort: "name"})
|
||||
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
options := model.QueryOptions{Sort: "name"}
|
||||
if len(roles) > 0 {
|
||||
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
|
||||
return roleFilter("role", r)
|
||||
})
|
||||
options.Filters = And(roleFilters)
|
||||
}
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -167,23 +226,119 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
|||
}
|
||||
|
||||
func (r *artistRepository) purgeEmpty() error {
|
||||
del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)")
|
||||
del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)")
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("purging empty artists: %w", err)
|
||||
}
|
||||
return err
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) {
|
||||
var dba []dbArtist
|
||||
err := r.doSearch(q, offset, size, &dba, "name")
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
|
||||
// on the media files associated with them.
|
||||
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
|
||||
query := rawSQL(`
|
||||
with play_counts as (
|
||||
select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
|
||||
from media_file
|
||||
join annotation on item_id = media_file.id
|
||||
left join json_tree(participants, '$.artist') as jt
|
||||
where atom is not null and key = 'id'
|
||||
group by user_id, atom
|
||||
)
|
||||
insert into annotation (user_id, item_id, item_type, play_count, play_date)
|
||||
select user_id, artist_id, 'artist', total_play_count, last_play_date
|
||||
from play_counts
|
||||
where total_play_count > 0
|
||||
on conflict (user_id, item_id, item_type) do update
|
||||
set play_count = excluded.play_count,
|
||||
play_date = excluded.play_date;
|
||||
`)
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
// RefreshStats updates the stats field for all artists, based on the media files associated with them.
|
||||
// BFR Maybe filter by "touched" artists?
|
||||
func (r *artistRepository) RefreshStats() (int64, error) {
|
||||
// First get all counters, one query groups by artist/role, and another with totals per artist.
|
||||
// Union both queries and group by artist to get a single row of counters per artist/role.
|
||||
// Then format the counters in a JSON object, one key for each role.
|
||||
// Finally update the artist table with the new counters
|
||||
// In all queries, atom is the artist ID and path is the role (or "total" for the totals)
|
||||
query := rawSQL(`
|
||||
-- CTE to get counters for each artist, grouped by role
|
||||
with artist_role_counters as (
|
||||
-- Get counters for each artist, grouped by role
|
||||
-- (remove the index from the role: composer[0] => composer
|
||||
select atom as artist_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
case when instr(replace(jt.path, '$.', ''), '[') > 0
|
||||
then instr(replace(jt.path, '$.', ''), '[') - 1
|
||||
else length(replace(jt.path, '$.', ''))
|
||||
end
|
||||
) as role,
|
||||
count(distinct album_id) as album_count,
|
||||
count(mf.id) as count,
|
||||
sum(size) as size
|
||||
from media_file mf
|
||||
left join json_tree(participants) jt
|
||||
where atom is not null and key = 'id'
|
||||
group by atom, role
|
||||
),
|
||||
|
||||
-- CTE to get the totals for each artist
|
||||
artist_total_counters as (
|
||||
select mfa.artist_id,
|
||||
'total' as role,
|
||||
count(distinct mf.album) as album_count,
|
||||
count(distinct mf.id) as count,
|
||||
sum(mf.size) as size
|
||||
from (select distinct artist_id, media_file_id
|
||||
from main.media_file_artists) as mfa
|
||||
join main.media_file mf on mfa.media_file_id = mf.id
|
||||
group by mfa.artist_id
|
||||
),
|
||||
|
||||
-- CTE to combine role and total counters
|
||||
combined_counters as (
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_role_counters
|
||||
union
|
||||
select artist_id, role, album_count, count, size
|
||||
from artist_total_counters
|
||||
),
|
||||
|
||||
-- CTE to format the counters in a JSON object
|
||||
artist_counters as (
|
||||
select artist_id as id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) as counters
|
||||
from combined_counters
|
||||
group by artist_id
|
||||
)
|
||||
|
||||
-- Update the artist table with the new counters
|
||||
update artist
|
||||
set stats = coalesce((select counters from artist_counters where artist_counters.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
where id <> ''; -- always true, to avoid warnings`)
|
||||
return r.executeSQL(query)
|
||||
}
|
||||
|
||||
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) {
|
||||
var dba dbArtists
|
||||
err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &dba, "json_extract(stats, '$.total.m') desc", "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModels(dba), nil
|
||||
return dba.toModels(), nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
|
@ -195,6 +350,15 @@ func (r *artistRepository) Read(id string) (interface{}, error) {
|
|||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
role := "total"
|
||||
if len(options) > 0 {
|
||||
if v, ok := options[0].Filters["role"].(string); ok {
|
||||
role = v
|
||||
}
|
||||
}
|
||||
r.sortMappings["song_count"] = "stats->>'" + role + "'->>'m'"
|
||||
r.sortMappings["album_count"] = "stats->>'" + role + "'->>'a'"
|
||||
r.sortMappings["size"] = "stats->>'" + role + "'->>'s'"
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
)
|
||||
|
||||
var _ = Describe("ArtistRepository", func() {
|
||||
|
@ -41,7 +40,9 @@ var _ = Describe("ArtistRepository", func() {
|
|||
|
||||
Describe("Get", func() {
|
||||
It("saves and retrieves data", func() {
|
||||
Expect(repo.Get("2")).To(Equal(&artistKraftwerk))
|
||||
artist, err := repo.Get("2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artist.Name).To(Equal(artistKraftwerk.Name))
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -86,83 +87,67 @@ var _ = Describe("ArtistRepository", func() {
|
|||
Describe("GetIndex", func() {
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
// Set SortArtistName to "Foo" for Beatles
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
|
||||
// Restore the original value
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
// BFR Empty SortArtistName is not saved in the DB anymore
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
})
|
||||
})
|
||||
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
It("returns the index when SortArtistName is NOT empty", func() {
|
||||
// Set SortArtistName to "Foo" for Beatles
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
|
||||
// Restore the original value
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
@ -170,53 +155,86 @@ var _ = Describe("ArtistRepository", func() {
|
|||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("dbArtist mapping", func() {
|
||||
var a *model.Artist
|
||||
var (
|
||||
artist *model.Artist
|
||||
dba *dbArtist
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
a = &model.Artist{ID: "1", Name: "Van Halen", SimilarArtists: []model.Artist{
|
||||
{ID: "2", Name: "AC/DC"}, {ID: "-1", Name: "Test;With:Sep,Chars"},
|
||||
}}
|
||||
artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"}
|
||||
dba = &dbArtist{Artist: artist}
|
||||
})
|
||||
It("maps fields", func() {
|
||||
dba := &dbArtist{Artist: a}
|
||||
m := structs.Map(dba)
|
||||
Expect(dba.PostMapArgs(m)).To(Succeed())
|
||||
Expect(m).To(HaveKeyWithValue("similar_artists", "2:AC%2FDC;-1:Test%3BWith%3ASep%2CChars"))
|
||||
|
||||
other := dbArtist{SimilarArtists: m["similar_artists"].(string), Artist: &model.Artist{
|
||||
ID: "1", Name: "Van Halen",
|
||||
}}
|
||||
Expect(other.PostScan()).To(Succeed())
|
||||
Describe("PostScan", func() {
|
||||
It("parses stats and similar artists correctly", func() {
|
||||
stats := map[string]map[string]int64{
|
||||
"total": {"s": 1000, "m": 10, "a": 2},
|
||||
"composer": {"s": 500, "m": 5, "a": 1},
|
||||
}
|
||||
statsJSON, _ := json.Marshal(stats)
|
||||
dba.Stats = string(statsJSON)
|
||||
dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`
|
||||
|
||||
actual := other.Artist
|
||||
Expect(*actual).To(MatchFields(IgnoreExtras, Fields{
|
||||
"ID": Equal(a.ID),
|
||||
"Name": Equal(a.Name),
|
||||
}))
|
||||
Expect(actual.SimilarArtists).To(HaveLen(2))
|
||||
Expect(actual.SimilarArtists[0].ID).To(Equal("2"))
|
||||
Expect(actual.SimilarArtists[0].Name).To(Equal("AC/DC"))
|
||||
Expect(actual.SimilarArtists[1].ID).To(Equal("-1"))
|
||||
Expect(actual.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
|
||||
err := dba.PostScan()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(dba.Artist.Size).To(Equal(int64(1000)))
|
||||
Expect(dba.Artist.SongCount).To(Equal(10))
|
||||
Expect(dba.Artist.AlbumCount).To(Equal(2))
|
||||
Expect(dba.Artist.Stats).To(HaveLen(1))
|
||||
Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500)))
|
||||
Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5))
|
||||
Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1))
|
||||
Expect(dba.Artist.SimilarArtists).To(HaveLen(2))
|
||||
Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2"))
|
||||
Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC"))
|
||||
Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty())
|
||||
Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PostMapArgs", func() {
|
||||
It("maps empty similar artists correctly", func() {
|
||||
m := make(map[string]any)
|
||||
err := dba.PostMapArgs(m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).To(HaveKeyWithValue("similar_artists", "[]"))
|
||||
})
|
||||
|
||||
It("maps similar artists and full text correctly", func() {
|
||||
artist.SimilarArtists = []model.Artist{
|
||||
{ID: "2", Name: "AC/DC"},
|
||||
{Name: "Test;With:Sep,Chars"},
|
||||
}
|
||||
m := make(map[string]any)
|
||||
err := dba.PostMapArgs(m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`))
|
||||
Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van"))
|
||||
})
|
||||
|
||||
It("does not override empty sort_artist_name and mbz_artist_id", func() {
|
||||
m := map[string]any{
|
||||
"sort_artist_name": "",
|
||||
"mbz_artist_id": "",
|
||||
}
|
||||
err := dba.PostMapArgs(m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).ToNot(HaveKey("sort_artist_name"))
|
||||
Expect(m).ToNot(HaveKey("mbz_artist_id"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
package persistence
|
||||
|
||||
// Definitions for testing private methods
|
||||
|
||||
var GetIndexKey = (*artistRepository).getIndexKey
|
||||
|
|
167
persistence/folder_repository.go
Normal file
167
persistence/folder_repository.go
Normal file
|
@ -0,0 +1,167 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type folderRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
type dbFolder struct {
|
||||
*model.Folder `structs:",flatten"`
|
||||
ImageFiles string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (f *dbFolder) PostScan() error {
|
||||
var err error
|
||||
if f.ImageFiles != "" {
|
||||
if err = json.Unmarshal([]byte(f.ImageFiles), &f.Folder.ImageFiles); err != nil {
|
||||
return fmt.Errorf("parsing folder image files from db: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *dbFolder) PostMapArgs(args map[string]any) error {
|
||||
if f.Folder.ImageFiles == nil {
|
||||
args["image_files"] = "[]"
|
||||
} else {
|
||||
imgFiles, err := json.Marshal(f.Folder.ImageFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling image files: %w", err)
|
||||
}
|
||||
args["image_files"] = string(imgFiles)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type dbFolders []dbFolder
|
||||
|
||||
func (fs dbFolders) toModels() []model.Folder {
|
||||
return slice.Map(fs, func(f dbFolder) model.Folder { return *f.Folder })
|
||||
}
|
||||
|
||||
func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderRepository {
|
||||
r := &folderRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "folder"
|
||||
return r
|
||||
}
|
||||
|
||||
func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).Columns("folder.*", "library.path as library_path").
|
||||
Join("library on library.id = folder.library_id")
|
||||
}
|
||||
|
||||
func (r folderRepository) Get(id string) (*model.Folder, error) {
|
||||
sq := r.selectFolder().Where(Eq{"folder.id": id})
|
||||
var res dbFolder
|
||||
err := r.queryOne(sq, &res)
|
||||
return res.Folder, err
|
||||
}
|
||||
|
||||
func (r folderRepository) GetByPath(lib model.Library, path string) (*model.Folder, error) {
|
||||
id := model.NewFolder(lib, path).ID
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, error) {
|
||||
sq := r.selectFolder(opt...)
|
||||
var res dbFolders
|
||||
err := r.queryAll(sq, &res)
|
||||
return res.toModels(), err
|
||||
}
|
||||
|
||||
func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
|
||||
sq := r.newSelect(opt...).Columns("count(*)")
|
||||
return r.count(sq)
|
||||
}
|
||||
|
||||
func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]time.Time, error) {
|
||||
sq := r.newSelect().Columns("id", "updated_at").Where(Eq{"library_id": lib.ID, "missing": false})
|
||||
var res []struct {
|
||||
ID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
err := r.queryAll(sq, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]time.Time, len(res))
|
||||
for _, f := range res {
|
||||
m[f.ID] = f.UpdatedAt
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r folderRepository) Put(f *model.Folder) error {
|
||||
dbf := dbFolder{Folder: f}
|
||||
_, err := r.put(dbf.ID, &dbf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r folderRepository) MarkMissing(missing bool, ids ...string) error {
|
||||
log.Debug(r.ctx, "Marking folders as missing", "ids", ids, "missing", missing)
|
||||
for chunk := range slices.Chunk(ids, 200) {
|
||||
sq := Update(r.tableName).
|
||||
Set("missing", missing).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": chunk})
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) {
|
||||
query := r.selectFolder().Where(And{
|
||||
Eq{"missing": false},
|
||||
Gt{"num_playlists": 0},
|
||||
ConcatExpr("folder.updated_at > library.last_scan_at"),
|
||||
})
|
||||
cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(model.Folder, error) bool) {
|
||||
for f, err := range cursor {
|
||||
if !yield(*f.Folder, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r folderRepository) purgeEmpty() error {
|
||||
sq := Delete(r.tableName).Where(And{
|
||||
Eq{"num_audio_files": 0},
|
||||
Eq{"num_playlists": 0},
|
||||
Eq{"image_files": "[]"},
|
||||
ConcatExpr("id not in (select parent_id from folder)"),
|
||||
ConcatExpr("id not in (select folder_id from media_file)"),
|
||||
})
|
||||
c, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purging empty folders: %w", err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purging empty folders", "totalDeleted", c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.FolderRepository = (*folderRepository)(nil)
|
|
@ -3,13 +3,10 @@ package persistence
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type genreRepository struct {
|
||||
|
@ -20,59 +17,46 @@ func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreReposito
|
|||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Genre{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
r.registerModel(&model.Tag{}, map[string]filterFunc{
|
||||
"name": containsFilter("tag_value"),
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "tag_name",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(opt...).
|
||||
Columns(
|
||||
"id",
|
||||
"tag_value as name",
|
||||
"album_count",
|
||||
"media_file_count as song_count",
|
||||
).
|
||||
Where(Eq{"tag.tag_name": model.TagGenre})
|
||||
}
|
||||
|
||||
func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) {
|
||||
sq := r.newSelect(opt...).Columns(
|
||||
"genre.id",
|
||||
"genre.name",
|
||||
"coalesce(a.album_count, 0) as album_count",
|
||||
"coalesce(m.song_count, 0) as song_count",
|
||||
).
|
||||
LeftJoin("(select ag.genre_id, count(ag.album_id) as album_count from album_genres ag group by ag.genre_id) a on a.genre_id = genre.id").
|
||||
LeftJoin("(select mg.genre_id, count(mg.media_file_id) as song_count from media_file_genres mg group by mg.genre_id) m on m.genre_id = genre.id")
|
||||
sq := r.selectGenre(opt...)
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sq, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Put is an Upsert operation, based on the name of the genre: If the name already exists, returns its ID, or else
|
||||
// insert the new genre in the DB and returns its new created ID.
|
||||
func (r *genreRepository) Put(m *model.Genre) error {
|
||||
if m.ID == "" {
|
||||
m.ID = uuid.NewString()
|
||||
}
|
||||
sql := Insert("genre").Columns("id", "name").Values(m.ID, m.Name).
|
||||
Suffix("on conflict (name) do update set name=excluded.name returning id")
|
||||
resp := model.Genre{}
|
||||
err := r.queryOne(sql, &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.ID = resp.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
return r.count(r.selectGenre(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
sel := r.selectGenre().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Genre
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Genres{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) EntityName() string {
|
||||
|
@ -83,24 +67,5 @@ func (r *genreRepository) NewInstance() interface{} {
|
|||
return &model.Genre{}
|
||||
}
|
||||
|
||||
func (r *genreRepository) purgeEmpty() error {
|
||||
del := Delete(r.tableName).Where(`id in (
|
||||
select genre.id from genre
|
||||
left join album_genres ag on genre.id = ag.genre_id
|
||||
left join artist_genres a on genre.id = a.genre_id
|
||||
left join media_file_genres mfg on genre.id = mfg.genre_id
|
||||
where ag.genre_id is null
|
||||
and a.genre_id is null
|
||||
and mfg.genre_id is null
|
||||
)`)
|
||||
c, err := r.executeSQL(del)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged unused genres", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.GenreRepository = (*genreRepository)(nil)
|
||||
var _ model.ResourceRepository = (*genreRepository)(nil)
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
package persistence_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GenreRepository", func() {
|
||||
var repo model.GenreRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), persistence.GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("GetAll()", func() {
|
||||
It("returns all records", func() {
|
||||
genres, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(genres).To(ConsistOf(
|
||||
model.Genre{ID: "gn-1", Name: "Electronic", AlbumCount: 1, SongCount: 2},
|
||||
model.Genre{ID: "gn-2", Name: "Rock", AlbumCount: 3, SongCount: 3},
|
||||
))
|
||||
})
|
||||
})
|
||||
Describe("Put()", Ordered, func() {
|
||||
It("does not insert existing genre names", func() {
|
||||
g := model.Genre{Name: "Rock"}
|
||||
err := repo.Put(&g)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(g.ID).To(Equal("gn-2"))
|
||||
|
||||
genres, _ := repo.GetAll()
|
||||
Expect(genres).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("insert non-existent genre names", func() {
|
||||
g := model.Genre{Name: "Reggae"}
|
||||
err := repo.Put(&g)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// ID is a uuid
|
||||
_, err = uuid.Parse(g.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
genres, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(genres).To(HaveLen(3))
|
||||
Expect(genres).To(ContainElement(model.Genre{ID: g.ID, Name: "Reggae", AlbumCount: 0, SongCount: 0}))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -19,11 +19,9 @@ func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
|
|||
m := structs.Map(rec)
|
||||
for k, v := range m {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
m[k] = t.Format(time.RFC3339Nano)
|
||||
case *time.Time:
|
||||
if t != nil {
|
||||
m[k] = t.Format(time.RFC3339Nano)
|
||||
m[k] = *t
|
||||
}
|
||||
case driver.Valuer:
|
||||
var err error
|
||||
|
@ -59,11 +57,19 @@ func toCamelCase(str string) string {
|
|||
})
|
||||
}
|
||||
|
||||
func exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
// rawSQL is a string that will be used as is in the SQL query executor
|
||||
// It does not support arguments
|
||||
type rawSQL string
|
||||
|
||||
func (r rawSQL) ToSql() (string, []interface{}, error) {
|
||||
return string(r), nil, nil
|
||||
}
|
||||
|
||||
func Exists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: false}
|
||||
}
|
||||
|
||||
func notExists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
func NotExists(subTable string, cond squirrel.Sqlizer) existsCond {
|
||||
return existsCond{subTable: subTable, cond: cond, not: true}
|
||||
}
|
||||
|
||||
|
@ -87,7 +93,8 @@ var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
|||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(order string) string {
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
order = strings.ToLower(order)
|
||||
return sortOrderRegex.ReplaceAllString(order, "(coalesce(nullif(sort_$1,''),order_$1) collate nocase)")
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||
}
|
||||
|
|
|
@ -57,16 +57,16 @@ var _ = Describe("Helpers", func() {
|
|||
HaveKeyWithValue("id", "123"),
|
||||
HaveKeyWithValue("album_id", "456"),
|
||||
HaveKeyWithValue("play_count", 2),
|
||||
HaveKeyWithValue("updated_at", now.Format(time.RFC3339Nano)),
|
||||
HaveKeyWithValue("created_at", now.Format(time.RFC3339Nano)),
|
||||
HaveKeyWithValue("updated_at", BeTemporally("~", now)),
|
||||
HaveKeyWithValue("created_at", BeTemporally("~", now)),
|
||||
Not(HaveKey("Embed")),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("exists", func() {
|
||||
Describe("Exists", func() {
|
||||
It("constructs the correct EXISTS query", func() {
|
||||
e := exists("album", squirrel.Eq{"id": 1})
|
||||
e := Exists("album", squirrel.Eq{"id": 1})
|
||||
sql, args, err := e.ToSql()
|
||||
Expect(sql).To(Equal("exists (select 1 from album where id = ?)"))
|
||||
Expect(args).To(ConsistOf(1))
|
||||
|
@ -74,9 +74,9 @@ var _ = Describe("Helpers", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("notExists", func() {
|
||||
Describe("NotExists", func() {
|
||||
It("constructs the correct NOT EXISTS query", func() {
|
||||
e := notExists("artist", squirrel.ConcatExpr("id = artist_id"))
|
||||
e := NotExists("artist", squirrel.ConcatExpr("id = artist_id"))
|
||||
sql, args, err := e.ToSql()
|
||||
Expect(sql).To(Equal("not exists (select 1 from artist where id = artist_id)"))
|
||||
Expect(args).To(BeEmpty())
|
||||
|
@ -87,19 +87,20 @@ var _ = Describe("Helpers", func() {
|
|||
Describe("mapSortOrder", func() {
|
||||
It("does not change the sort string if there are no order columns", func() {
|
||||
sort := "album_name asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(sort))
|
||||
})
|
||||
It("changes order columns to sort expression", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal("(coalesce(nullif(sort_album_name,''),order_album_name) collate nocase) asc"))
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||
` collate nocase) asc`))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(sort_title,''),order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase) desc, year desc`))
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -2,10 +2,12 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
@ -14,6 +16,11 @@ type libraryRepository struct {
|
|||
sqlRepository
|
||||
}
|
||||
|
||||
var (
|
||||
libCache = map[int]string{}
|
||||
libLock sync.RWMutex
|
||||
)
|
||||
|
||||
func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepository {
|
||||
r := &libraryRepository{}
|
||||
r.ctx = ctx
|
||||
|
@ -29,6 +36,36 @@ func (r *libraryRepository) Get(id int) (*model.Library, error) {
|
|||
return &res, err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) GetPath(id int) (string, error) {
|
||||
l := func() string {
|
||||
libLock.RLock()
|
||||
defer libLock.RUnlock()
|
||||
if l, ok := libCache[id]; ok {
|
||||
return l
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
if l != "" {
|
||||
return l, nil
|
||||
}
|
||||
|
||||
libLock.Lock()
|
||||
defer libLock.Unlock()
|
||||
libs, err := r.GetAll()
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading libraries from DB", err)
|
||||
return "", err
|
||||
}
|
||||
for _, l := range libs {
|
||||
libCache[l.ID] = l.Path
|
||||
}
|
||||
if l, ok := libCache[id]; ok {
|
||||
return l, nil
|
||||
} else {
|
||||
return "", model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Put(l *model.Library) error {
|
||||
cols := map[string]any{
|
||||
"name": l.Name,
|
||||
|
@ -44,16 +81,28 @@ func (r *libraryRepository) Put(l *model.Library) error {
|
|||
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
|
||||
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
libLock.Lock()
|
||||
defer libLock.Unlock()
|
||||
libCache[l.ID] = l.Path
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const hardCodedMusicFolderID = 1
|
||||
|
||||
// TODO Remove this method when we have a proper UI to add libraries
|
||||
// This is a temporary method to store the music folder path from the config in the DB
|
||||
func (r *libraryRepository) StoreMusicFolder() error {
|
||||
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).Set("updated_at", time.Now()).
|
||||
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": hardCodedMusicFolderID})
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
libLock.Lock()
|
||||
defer libLock.Unlock()
|
||||
libCache[hardCodedMusicFolderID] = conf.Server.MusicFolder
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -67,12 +116,36 @@ func (r *libraryRepository) AddArtist(id int, artistID string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepository) UpdateLastScan(id int, t time.Time) error {
|
||||
sq := Update(r.tableName).Set("last_scan_at", t).Where(Eq{"id": id})
|
||||
func (r *libraryRepository) ScanBegin(id int, fullScan bool) error {
|
||||
sq := Update(r.tableName).
|
||||
Set("last_scan_started_at", time.Now()).
|
||||
Set("full_scan_in_progress", fullScan).
|
||||
Where(Eq{"id": id})
|
||||
_, err := r.executeSQL(sq)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) ScanEnd(id int) error {
|
||||
sq := Update(r.tableName).
|
||||
Set("last_scan_at", time.Now()).
|
||||
Set("full_scan_in_progress", false).
|
||||
Set("last_scan_started_at", time.Time{}).
|
||||
Where(Eq{"id": id})
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
_, err = r.executeSQL(rawSQL("PRAGMA optimize=0x10012;"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) ScanInProgress() (bool, error) {
|
||||
query := r.newSelect().Where(NotEq{"last_scan_started_at": time.Time{}})
|
||||
count, err := r.count(query)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
|
||||
sq := r.newSelect(ops...).Columns("*")
|
||||
res := model.Libraries{}
|
||||
|
|
|
@ -3,15 +3,15 @@ package persistence
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -19,180 +19,290 @@ type mediaFileRepository struct {
|
|||
sqlRepository
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepository {
|
||||
type dbMediaFile struct {
|
||||
*model.MediaFile `structs:",flatten"`
|
||||
Participants string `structs:"-" json:"-"`
|
||||
Tags string `structs:"-" json:"-"`
|
||||
// These are necessary to map the correct names (rg_*) to the correct fields (RG*)
|
||||
// without using `db` struct tags in the model.MediaFile struct
|
||||
RgAlbumGain float64 `structs:"-" json:"-"`
|
||||
RgAlbumPeak float64 `structs:"-" json:"-"`
|
||||
RgTrackGain float64 `structs:"-" json:"-"`
|
||||
RgTrackPeak float64 `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
func (m *dbMediaFile) PostScan() error {
|
||||
m.RGTrackGain = m.RgTrackGain
|
||||
m.RGTrackPeak = m.RgTrackPeak
|
||||
m.RGAlbumGain = m.RgAlbumGain
|
||||
m.RGAlbumPeak = m.RgAlbumPeak
|
||||
var err error
|
||||
m.MediaFile.Participants, err = unmarshalParticipants(m.Participants)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing media_file from db: %w", err)
|
||||
}
|
||||
if m.Tags != "" {
|
||||
m.MediaFile.Tags, err = unmarshalTags(m.Tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing media_file from db: %w", err)
|
||||
}
|
||||
m.Genre, m.Genres = m.MediaFile.Tags.ToGenres()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
|
||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
|
||||
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
args["tags"] = marshalTags(m.MediaFile.Tags)
|
||||
args["participants"] = marshalParticipants(m.MediaFile.Participants)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dbMediaFiles []dbMediaFile
|
||||
|
||||
func (m dbMediaFiles) toModels() model.MediaFiles {
|
||||
return slice.Map(m, func(mf dbMediaFile) model.MediaFile { return *mf.MediaFile })
|
||||
}
|
||||
|
||||
func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFileRepository {
|
||||
r := &mediaFileRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "media_file"
|
||||
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"title": fullTextFilter,
|
||||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
r.registerModel(&model.MediaFile{}, mediaFileFilter())
|
||||
r.setSortMappings(map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file"),
|
||||
"starred": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
if _, exists := filters[string(tag)]; !exists {
|
||||
filters[string(tag)] = tagIDFilter
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := r.newSelectWithAnnotation("media_file.id")
|
||||
sql = r.withGenres(sql) // Required for filtering by genre
|
||||
return r.count(sql, options...)
|
||||
query := r.newSelect()
|
||||
query = r.withAnnotation(query, "media_file.id")
|
||||
// BFR WithParticipants (for filtering by name)?
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"media_file.id": id}))
|
||||
return r.exists(Eq{"media_file.id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist,
|
||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle)
|
||||
_, err := r.put(m.ID, m)
|
||||
m.CreatedAt = time.Now()
|
||||
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateGenres(m.ID, m.Genres)
|
||||
m.ID = id
|
||||
return r.updateParticipants(m.ID, m.Participants)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
|
||||
sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
|
||||
sql = r.withBookmark(sql, "media_file.id")
|
||||
if len(options) > 0 && options[0].Filters != nil {
|
||||
s, _, _ := options[0].Filters.ToSql()
|
||||
// If there's any reference of genre in the filter, joins with genre
|
||||
if strings.Contains(s, "genre") {
|
||||
sql = r.withGenres(sql)
|
||||
// If there's no filter on genre_id, group the results by media_file.id
|
||||
if !strings.Contains(s, "genre_id") {
|
||||
sql = sql.GroupBy("media_file.id")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sql
|
||||
sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path").
|
||||
LeftJoin("library on media_file.library_id = library.id")
|
||||
sql = r.withAnnotation(sql, "media_file.id")
|
||||
return r.withBookmark(sql, "media_file.id")
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
|
||||
sel := r.selectMediaFile().Where(Eq{"media_file.id": id})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
res, err := r.GetAll(model.QueryOptions{Filters: Eq{"media_file.id": id}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
err := loadAllGenres(r, res)
|
||||
return &res[0], err
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetWithParticipants(id string) (*model.MediaFile, error) {
|
||||
m, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Participants, err = r.getParticipants(m)
|
||||
return m, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) getParticipants(m *model.MediaFile) (model.Participants, error) {
|
||||
ar := NewArtistRepository(r.ctx, r.db)
|
||||
ids := m.Participants.AllIDs()
|
||||
artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting participants: %w", err)
|
||||
}
|
||||
artistMap := slice.ToMap(artists, func(a model.Artist) (string, model.Artist) {
|
||||
return a.ID, a
|
||||
})
|
||||
p := m.Participants
|
||||
for role, artistList := range p {
|
||||
for idx, artist := range artistList {
|
||||
if a, ok := artistMap[artist.ID]; ok {
|
||||
p[role][idx].Artist = a
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
r.resetSeededRandom(options)
|
||||
sq := r.selectMediaFile(options...)
|
||||
res := model.MediaFiles{}
|
||||
var res dbMediaFiles
|
||||
err := r.queryAll(sq, &res, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = loadAllGenres(r, res)
|
||||
return res, err
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(model.MediaFile, error) bool) {
|
||||
for m, err := range cursor {
|
||||
if m.MediaFile == nil {
|
||||
yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m))
|
||||
return
|
||||
}
|
||||
if !yield(*m.MediaFile, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
var res model.MediaFiles
|
||||
var res dbMediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
if !strings.HasSuffix(path, string(os.PathSeparator)) {
|
||||
path += string(os.PathSeparator)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathStartsWith(path string) Eq {
|
||||
substr := fmt.Sprintf("substr(path, 1, %d)", utf8.RuneCountInString(path))
|
||||
return Eq{substr: path}
|
||||
}
|
||||
|
||||
// FindAllByPath only return mediafiles that are direct children of requested path
|
||||
func (r *mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) {
|
||||
// Query by path based on https://stackoverflow.com/a/13911906/653632
|
||||
path = cleanPath(path)
|
||||
pathLen := utf8.RuneCountInString(path)
|
||||
sel0 := r.newSelect().Columns("media_file.*", fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
|
||||
Where(pathStartsWith(path))
|
||||
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
|
||||
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
|
||||
|
||||
res := model.MediaFiles{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// FindPathsRecursively returns a list of all subfolders of basePath, recursively
|
||||
func (r *mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) {
|
||||
path := cleanPath(basePath)
|
||||
// Query based on https://stackoverflow.com/a/38330814/653632
|
||||
sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))).
|
||||
Where(pathStartsWith(path))
|
||||
var res []string
|
||||
err := r.queryAllSlice(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) deleteNotInPath(basePath string) error {
|
||||
path := cleanPath(basePath)
|
||||
sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path)))
|
||||
c, err := r.executeSQL(sel)
|
||||
if err == nil {
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Deleted dangling tracks", "totalDeleted", c)
|
||||
}
|
||||
}
|
||||
return err
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
// DeleteByPath delete from the DB all mediafiles that are direct children of path
|
||||
func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
|
||||
path := cleanPath(basePath)
|
||||
pathLen := utf8.RuneCountInString(path)
|
||||
del := Delete(r.tableName).
|
||||
Where(And{pathStartsWith(path),
|
||||
Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", pathLen+2, string(os.PathSeparator)): 0}})
|
||||
log.Debug(r.ctx, "Deleting mediafiles by path", "path", path)
|
||||
return r.executeSQL(del)
|
||||
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return r.delete(
|
||||
And{
|
||||
Eq{"missing": true},
|
||||
Eq{"id": ids},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) removeNonAlbumArtistIds() error {
|
||||
upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id")))
|
||||
log.Debug(r.ctx, "Removing non-album artist_ids")
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
func (r *mediaFileRepository) MarkMissing(missing bool, mfs ...*model.MediaFile) error {
|
||||
ids := slice.SeqFunc(mfs, func(m *model.MediaFile) string { return m.ID })
|
||||
for chunk := range slice.CollectChunks(ids, 200) {
|
||||
upd := Update(r.tableName).
|
||||
Set("missing", missing).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": chunk})
|
||||
c, err := r.executeSQL(upd)
|
||||
if err != nil || c == 0 {
|
||||
log.Error(r.ctx, "Error setting mediafile missing flag", "ids", chunk, err)
|
||||
return err
|
||||
}
|
||||
log.Debug(r.ctx, "Marked missing mediafiles", "total", c, "ids", chunk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
|
||||
results := model.MediaFiles{}
|
||||
err := r.doSearch(q, offset, size, &results, "title")
|
||||
func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...string) error {
|
||||
for chunk := range slices.Chunk(folderIDs, 200) {
|
||||
upd := Update(r.tableName).
|
||||
Set("missing", missing).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(And{
|
||||
Eq{"folder_id": chunk},
|
||||
Eq{"missing": !missing},
|
||||
})
|
||||
c, err := r.executeSQL(upd)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err)
|
||||
return err
|
||||
}
|
||||
log.Debug(r.ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs)
|
||||
// that were added/updated after the last scan started. The result is ordered by PID.
|
||||
// It does not need to load bookmarks, annotations and participnts, as they are not used by the scanner.
|
||||
func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||
subQ := r.newSelect().Columns("pid").
|
||||
Where(And{
|
||||
Eq{"media_file.missing": true},
|
||||
Eq{"library_id": libId},
|
||||
})
|
||||
subQText, subQArgs, err := subQ.PlaceholderFormat(Question).ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = loadAllGenres(r, results)
|
||||
return results, err
|
||||
sel := r.newSelect().Columns("media_file.*", "library.path as library_path").
|
||||
LeftJoin("library on media_file.library_id = library.id").
|
||||
Where("pid in ("+subQText+")", subQArgs...).
|
||||
Where(Or{
|
||||
Eq{"missing": true},
|
||||
ConcatExpr("media_file.created_at > library.last_scan_started_at"),
|
||||
}).
|
||||
OrderBy("pid")
|
||||
cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(model.MediaFile, error) bool) {
|
||||
for m, err := range cursor {
|
||||
if !yield(*m.MediaFile, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) {
|
||||
results := dbMediaFiles{}
|
||||
err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &results, "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results.toModels(), err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
|
|
|
@ -5,9 +5,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
@ -23,7 +23,10 @@ var _ = Describe("MediaRepository", func() {
|
|||
})
|
||||
|
||||
It("gets mediafile from the DB", func() {
|
||||
Expect(mr.Get("1004")).To(Equal(&songAntenna))
|
||||
actual, err := mr.Get("1004")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
actual.CreatedAt = time.Time{}
|
||||
Expect(actual).To(Equal(&songAntenna))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound", func() {
|
||||
|
@ -40,99 +43,17 @@ var _ = Describe("MediaRepository", func() {
|
|||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("finds tracks by path when using wildcards chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Find:By'Path/_/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7001"))
|
||||
})
|
||||
|
||||
It("finds tracks by path when using UTF8 chars", func() {
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("finds tracks by path case sensitively", func() {
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
|
||||
|
||||
found, err := mr.FindAllByPath(P("/Casesensitive"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7003"))
|
||||
|
||||
found, err = mr.FindAllByPath(P("/casesensitive/"))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(found).To(HaveLen(1))
|
||||
Expect(found[0].ID).To(Equal("7004"))
|
||||
})
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
id := uuid.NewString()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
|
||||
Expect(mr.Delete(id)).To(BeNil())
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
|
||||
_, err := mr.Get(id)
|
||||
_, err := mr.Get(newID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delete tracks by path", func() {
|
||||
id1 := "6001"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6002"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
|
||||
id4 := "6004"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
|
||||
id5 := "6005"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1)))
|
||||
|
||||
Expect(mr.Get(id1)).ToNot(BeNil())
|
||||
Expect(mr.Get(id2)).ToNot(BeNil())
|
||||
Expect(mr.Get(id4)).ToNot(BeNil())
|
||||
Expect(mr.Get(id5)).ToNot(BeNil())
|
||||
_, err := mr.Get(id3)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("delete tracks by path containing UTF8 chars", func() {
|
||||
id1 := "6011"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6012"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6003"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3))
|
||||
Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3)))
|
||||
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("only deletes tracks that match exact path", func() {
|
||||
id1 := "6021"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
|
||||
id2 := "6022"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
|
||||
id3 := "6023"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
|
||||
|
||||
Expect(mr.FindAllByPath(P("/music/overlap/Ella Fitzgerald"))).To(HaveLen(2))
|
||||
Expect(mr.DeleteByPath(P("/music/overlap/Ella Fitzgerald"))).To(Equal(int64(2)))
|
||||
Expect(mr.FindAllByPath(P("/music/overlap"))).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("filters by genre", func() {
|
||||
XIt("filters by genre", func() {
|
||||
Expect(mr.GetAll(model.QueryOptions{
|
||||
Sort: "genre.name asc, title asc",
|
||||
Filters: squirrel.Eq{"genre.name": "Rock"},
|
||||
|
|
|
@ -4,10 +4,12 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/chain"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -35,10 +37,18 @@ func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository {
|
|||
return NewLibraryRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Folder(ctx context.Context) model.FolderRepository {
|
||||
return newFolderRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
|
||||
return NewGenreRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Tag(ctx context.Context) model.TagRepository {
|
||||
return NewTagRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
|
||||
return NewPlayQueueRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
@ -101,6 +111,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
|||
return s.Radio(ctx).(model.ResourceRepository)
|
||||
case model.Share:
|
||||
return s.Share(ctx).(model.ResourceRepository)
|
||||
case model.Tag:
|
||||
return s.Tag(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
|
@ -117,55 +129,29 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
|
||||
err := s.MediaFile(ctx).(*mediaFileRepository).deleteNotInPath(rootFolder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing dangling tracks", err)
|
||||
return err
|
||||
func (s *SQLStore) GC(ctx context.Context) error {
|
||||
trace := func(ctx context.Context, msg string, f func() error) func() error {
|
||||
return func() error {
|
||||
start := time.Now()
|
||||
err := f()
|
||||
log.Debug(ctx, "GC: "+msg, "elapsed", time.Since(start), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).removeNonAlbumArtistIds()
|
||||
|
||||
err := chain.RunSequentially(
|
||||
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }),
|
||||
trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }),
|
||||
trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing non-album artist_ids", err)
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing empty albums", err)
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).(*artistRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing empty artists", err)
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan mediafile annotations", err)
|
||||
return err
|
||||
}
|
||||
err = s.Album(ctx).(*albumRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan album annotations", err)
|
||||
return err
|
||||
}
|
||||
err = s.Artist(ctx).(*artistRepository).cleanAnnotations()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan artist annotations", err)
|
||||
return err
|
||||
}
|
||||
err = s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing orphan bookmarks", err)
|
||||
return err
|
||||
}
|
||||
err = s.Playlist(ctx).(*playlistRepository).removeOrphans()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error tidying up playlists", err)
|
||||
}
|
||||
err = s.Genre(ctx).(*genreRepository).purgeEmpty()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error removing unused genres", err)
|
||||
return err
|
||||
log.Error(ctx, "Error tidying up database", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -23,21 +23,38 @@ func TestPersistence(t *testing.T) {
|
|||
//os.Remove("./test-123.db")
|
||||
//conf.Server.DbPath = "./test-123.db"
|
||||
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
||||
defer db.Init()()
|
||||
defer db.Init(context.Background())()
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Persistence Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"}
|
||||
genreRock = model.Genre{ID: "gn-2", Name: "Rock"}
|
||||
testGenres = model.Genres{genreElectronic, genreRock}
|
||||
)
|
||||
// BFR Test tags
|
||||
//var (
|
||||
// genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"}
|
||||
// genreRock = model.Genre{ID: "gn-2", Name: "Rock"}
|
||||
// testGenres = model.Genres{genreElectronic, genreRock}
|
||||
//)
|
||||
|
||||
func mf(mf model.MediaFile) model.MediaFile {
|
||||
mf.Tags = model.Tags{}
|
||||
mf.LibraryID = 1
|
||||
mf.LibraryPath = "music" // Default folder
|
||||
mf.Participants = model.Participants{}
|
||||
return mf
|
||||
}
|
||||
|
||||
func al(al model.Album) model.Album {
|
||||
al.LibraryID = 1
|
||||
al.Discs = model.Discs{}
|
||||
al.Tags = model.Tags{}
|
||||
al.Participants = model.Participants{}
|
||||
return al
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles", AlbumCount: 2, FullText: " beatles the"}
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
artistBeatles,
|
||||
|
@ -45,9 +62,9 @@ var (
|
|||
)
|
||||
|
||||
var (
|
||||
albumSgtPeppers = model.Album{LibraryID: 1, ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the", Discs: model.Discs{}}
|
||||
albumAbbeyRoad = model.Album{LibraryID: 1, ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the", Discs: model.Discs{}}
|
||||
albumRadioactivity = model.Album{LibraryID: 1, ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity", Discs: model.Discs{}}
|
||||
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
||||
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
||||
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
|
||||
testAlbums = model.Albums{
|
||||
albumSgtPeppers,
|
||||
albumAbbeyRoad,
|
||||
|
@ -56,14 +73,14 @@ var (
|
|||
)
|
||||
|
||||
var (
|
||||
songDayInALife = model.MediaFile{LibraryID: 1, ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
|
||||
songComeTogether = model.MediaFile{LibraryID: 1, ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
|
||||
songRadioactivity = model.MediaFile{LibraryID: 1, ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
|
||||
songAntenna = model.MediaFile{LibraryID: 1, ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
||||
AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock},
|
||||
Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk",
|
||||
RgAlbumGain: 1.0, RgAlbumPeak: 2.0, RgTrackGain: 3.0, RgTrackPeak: 4.0,
|
||||
}
|
||||
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")})
|
||||
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")})
|
||||
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")})
|
||||
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
||||
AlbumID: "103",
|
||||
Path: p("/kraft/radio/antenna.mp3"),
|
||||
RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0,
|
||||
})
|
||||
testSongs = model.MediaFiles{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
|
@ -90,7 +107,7 @@ var (
|
|||
testUsers = model.Users{adminUser, regularUser}
|
||||
)
|
||||
|
||||
func P(path string) string {
|
||||
func p(path string) string {
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
|
@ -109,19 +126,18 @@ var _ = BeforeSuite(func() {
|
|||
}
|
||||
}
|
||||
|
||||
gr := NewGenreRepository(ctx, conn)
|
||||
for i := range testGenres {
|
||||
g := testGenres[i]
|
||||
err := gr.Put(&g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
//gr := NewGenreRepository(ctx, conn)
|
||||
//for i := range testGenres {
|
||||
// g := testGenres[i]
|
||||
// err := gr.Put(&g)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
//}
|
||||
|
||||
mr := NewMediaFileRepository(ctx, conn)
|
||||
for i := range testSongs {
|
||||
s := testSongs[i]
|
||||
err := mr.Put(&s)
|
||||
err := mr.Put(&testSongs[i])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
@ -187,7 +203,10 @@ var _ = BeforeSuite(func() {
|
|||
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
al, _ := alr.Get(albumRadioactivity.ID)
|
||||
al, err := alr.Get(albumRadioactivity.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
albumRadioactivity.Starred = true
|
||||
albumRadioactivity.StarredAt = al.StarredAt
|
||||
testAlbums[2] = albumRadioactivity
|
||||
|
@ -195,12 +214,15 @@ var _ = BeforeSuite(func() {
|
|||
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mf, _ := mr.Get(songComeTogether.ID)
|
||||
mf, err := mr.Get(songComeTogether.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
songComeTogether.Starred = true
|
||||
songComeTogether.StarredAt = mf.StarredAt
|
||||
testSongs[1] = songComeTogether
|
||||
})
|
||||
|
||||
func GetDBXBuilder() *dbx.DB {
|
||||
return dbx.NewFromDB(db.Db(), db.Driver)
|
||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, err
|
|||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(And{Eq{"id": id}, r.userFilter()}))
|
||||
return r.exists(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
|
@ -131,7 +131,8 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
|||
p.ID = id
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
r.refreshSmartPlaylist(p)
|
||||
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
||||
//r.refreshSmartPlaylist(p)
|
||||
return nil
|
||||
}
|
||||
// Only update tracks if they were specified
|
||||
|
@ -145,7 +146,7 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
|
|||
return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist bool) (*model.Playlist, error) {
|
||||
func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -153,7 +154,9 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist bool)
|
|||
if refreshSmartPlaylist {
|
||||
r.refreshSmartPlaylist(pls)
|
||||
}
|
||||
tracks, err := r.loadTracks(Select().From("playlist_tracks"), id)
|
||||
tracks, err := r.loadTracks(Select().From("playlist_tracks").
|
||||
Where(Eq{"missing": false}).
|
||||
OrderBy("playlist_tracks.id"), id)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return nil, err
|
||||
|
@ -241,9 +244,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
|||
From("media_file").LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file.id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" AND annotation.user_id = '" + userId(r.ctx) + "')").
|
||||
LeftJoin("media_file_genres ag on media_file.id = ag.media_file_id").
|
||||
LeftJoin("genre on ag.genre_id = genre.id").GroupBy("media_file.id")
|
||||
" AND annotation.user_id = '" + userId(r.ctx) + "')")
|
||||
sq = r.addCriteria(sq, rules)
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
_, err = r.executeSQL(insSql)
|
||||
|
@ -368,19 +369,21 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
|||
"coalesce(rating, 0) as rating",
|
||||
"f.*",
|
||||
"playlist_tracks.*",
|
||||
"library.path as library_path",
|
||||
).
|
||||
LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file_id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" AND annotation.user_id = '" + userId(r.ctx) + "')").
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": id}).OrderBy("playlist_tracks.id")
|
||||
tracks := model.PlaylistTracks{}
|
||||
Join("library on f.library_id = library.id").
|
||||
Where(Eq{"playlist_id": id})
|
||||
tracks := dbPlaylistTracks{}
|
||||
err := r.queryAll(tracksQuery, &tracks)
|
||||
for i, t := range tracks {
|
||||
tracks[i].MediaFile.ID = t.MediaFileID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tracks, err
|
||||
return tracks.toModels(), err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
|
@ -450,7 +453,7 @@ func (r *playlistRepository) removeOrphans() error {
|
|||
var pls []struct{ Id, Name string }
|
||||
err := r.queryAll(sel, &pls)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("fetching playlists with orphan tracks: %w", err)
|
||||
}
|
||||
|
||||
for _, pl := range pls {
|
||||
|
@ -461,13 +464,13 @@ func (r *playlistRepository) removeOrphans() error {
|
|||
})
|
||||
n, err := r.executeSQL(del)
|
||||
if n == 0 || err != nil {
|
||||
return err
|
||||
return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err)
|
||||
}
|
||||
log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n)
|
||||
|
||||
// Renumber the playlist if any track was removed
|
||||
if err := r.renumber(pl.Id); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
|
@ -57,7 +57,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
It("returns all tracks", func() {
|
||||
pls, err := repo.GetWithTracks(plsBest.ID, true)
|
||||
pls, err := repo.GetWithTracks(plsBest.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal(plsBest.Name))
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
|
@ -87,7 +87,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||
By("adds repeated songs to a playlist and keeps the order")
|
||||
newPls.AddTracks([]string{"1004"})
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
saved, _ := repo.GetWithTracks(newPls.ID, true)
|
||||
saved, _ := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
Expect(saved.Tracks[0].MediaFileID).To(Equal("1004"))
|
||||
Expect(saved.Tracks[1].MediaFileID).To(Equal("1003"))
|
||||
|
@ -145,7 +145,8 @@ var _ = Describe("PlaylistRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Context("child smart playlists", func() {
|
||||
// BFR Validate these tests
|
||||
XContext("child smart playlists", func() {
|
||||
When("refresh day has expired", func() {
|
||||
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
@ -163,7 +164,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true)
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
|
||||
|
@ -191,7 +192,7 @@ var _ = Describe("PlaylistRepository", func() {
|
|||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true)
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
|
||||
|
|
|
@ -17,6 +17,28 @@ type playlistTrackRepository struct {
|
|||
playlistRepo *playlistRepository
|
||||
}
|
||||
|
||||
type dbPlaylistTrack struct {
|
||||
dbMediaFile
|
||||
*model.PlaylistTrack `structs:",flatten"`
|
||||
}
|
||||
|
||||
func (t *dbPlaylistTrack) PostScan() error {
|
||||
if err := t.dbMediaFile.PostScan(); err != nil {
|
||||
return err
|
||||
}
|
||||
t.PlaylistTrack.MediaFile = *t.dbMediaFile.MediaFile
|
||||
t.PlaylistTrack.MediaFile.ID = t.MediaFileID
|
||||
return nil
|
||||
}
|
||||
|
||||
type dbPlaylistTracks []dbPlaylistTrack
|
||||
|
||||
func (t dbPlaylistTracks) toModels() model.PlaylistTracks {
|
||||
return slice.Map(t, func(trk dbPlaylistTrack) model.PlaylistTrack {
|
||||
return *trk.PlaylistTrack
|
||||
})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository {
|
||||
p := &playlistTrackRepository{}
|
||||
p.playlistRepo = r
|
||||
|
@ -24,14 +46,18 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||
p.ctx = r.ctx
|
||||
p.db = r.db
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.setSortMappings(map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{
|
||||
"missing": booleanFilter,
|
||||
})
|
||||
p.setSortMappings(
|
||||
map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
},
|
||||
"f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR.
|
||||
|
||||
pls, err := r.Get(playlistId)
|
||||
if err != nil {
|
||||
|
@ -46,7 +72,10 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||
}
|
||||
|
||||
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(r.ctx, options...))
|
||||
query := Select().
|
||||
LeftJoin("media_file f on f.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
return r.count(query, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
|
@ -66,15 +95,9 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
|||
).
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
|
||||
var trk model.PlaylistTrack
|
||||
var trk dbPlaylistTrack
|
||||
err := r.queryOne(sel, &trk)
|
||||
return &trk, err
|
||||
}
|
||||
|
||||
// This is a "hack" to allow loadAllGenres to work with playlist tracks. Will be removed once we have a new
|
||||
// one-to-many relationship solution
|
||||
func (r *playlistTrackRepository) getTableName() string {
|
||||
return "media_file"
|
||||
return trk.PlaylistTrack.MediaFile, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
|
||||
|
@ -82,24 +105,15 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mfs := tracks.MediaFiles()
|
||||
err = loadAllGenres(r, mfs)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading genres for playlist", "playlist", r.playlist.Name, "id", r.playlist.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
for i, mf := range mfs {
|
||||
tracks[i].MediaFile.Genres = mf.Genres
|
||||
}
|
||||
return tracks, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) {
|
||||
sql := r.newSelect(options...).Columns("distinct mf.album_id").
|
||||
query := r.newSelect(options...).Columns("distinct mf.album_id").
|
||||
Join("media_file mf on mf.id = media_file_id").
|
||||
Where(Eq{"playlist_id": r.playlistId})
|
||||
var ids []string
|
||||
err := r.queryAllSlice(sql, &ids)
|
||||
err := r.queryAllSlice(query, &ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
@ -56,6 +56,7 @@ var _ = Describe("PlayQueueRepository", func() {
|
|||
// Add a new song to the DB
|
||||
newSong := songRadioactivity
|
||||
newSong.ID = "temp-track"
|
||||
newSong.Path = "/new-path"
|
||||
mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
|
||||
Expect(mfRepo.Put(&newSong)).To(Succeed())
|
||||
|
@ -110,7 +111,7 @@ func aPlayQueue(userId, current string, position int64, items ...model.MediaFile
|
|||
createdAt := time.Now()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
return &model.PlayQueue{
|
||||
ID: uuid.NewString(),
|
||||
ID: id.NewRandom(),
|
||||
UserID: userId,
|
||||
Current: current,
|
||||
Position: position,
|
||||
|
|
|
@ -3,13 +3,12 @@ package persistence
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -70,7 +69,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
|
|||
|
||||
if radio.ID == "" {
|
||||
radio.CreatedAt = time.Now()
|
||||
radio.ID = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
radio.ID = id.NewRandom()
|
||||
values, _ = toSQLArgs(*radio)
|
||||
} else {
|
||||
values, _ = toSQLArgs(*radio)
|
||||
|
|
|
@ -6,8 +6,8 @@ import (
|
|||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -15,6 +15,20 @@ type scrobbleBufferRepository struct {
|
|||
sqlRepository
|
||||
}
|
||||
|
||||
type dbScrobbleBuffer struct {
|
||||
dbMediaFile
|
||||
*model.ScrobbleEntry `structs:",flatten"`
|
||||
}
|
||||
|
||||
func (t *dbScrobbleBuffer) PostScan() error {
|
||||
if err := t.dbMediaFile.PostScan(); err != nil {
|
||||
return err
|
||||
}
|
||||
t.ScrobbleEntry.MediaFile = *t.dbMediaFile.MediaFile
|
||||
t.ScrobbleEntry.MediaFile.ID = t.MediaFileID
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewScrobbleBufferRepository(ctx context.Context, db dbx.Builder) model.ScrobbleBufferRepository {
|
||||
r := &scrobbleBufferRepository{}
|
||||
r.ctx = ctx
|
||||
|
@ -38,7 +52,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) {
|
|||
|
||||
func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
|
||||
ins := Insert(r.tableName).SetMap(map[string]interface{}{
|
||||
"id": uuid.NewString(),
|
||||
"id": id.NewRandom(),
|
||||
"user_id": userId,
|
||||
"service": service,
|
||||
"media_file_id": mediaFileId,
|
||||
|
@ -60,16 +74,15 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S
|
|||
}).
|
||||
OrderBy("play_time", "s.rowid").Limit(1)
|
||||
|
||||
res := &model.ScrobbleEntry{}
|
||||
err := r.queryOne(sql, res)
|
||||
var res dbScrobbleBuffer
|
||||
err := r.queryOne(sql, &res)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.MediaFile.ID = res.MediaFileID
|
||||
return res, nil
|
||||
return res.ScrobbleEntry, nil
|
||||
}
|
||||
|
||||
func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error {
|
||||
|
|
|
@ -44,7 +44,7 @@ func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuild
|
|||
}
|
||||
|
||||
func (r *shareRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Select().Where(Eq{"id": id}))
|
||||
return r.exists(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
||||
|
@ -80,30 +80,33 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
|
|||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
noMissing := func(cond Sqlizer) Sqlizer {
|
||||
return And{cond, Eq{"missing": false}}
|
||||
}
|
||||
switch share.ResourceType {
|
||||
case "artist":
|
||||
albumRepo := NewAlbumRepository(r.ctx, r.db)
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: Eq{"album_artist_id": ids}, Sort: "artist"})
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: Eq{"album_artist_id": ids}, Sort: "artist"})
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"})
|
||||
return err
|
||||
case "album":
|
||||
albumRepo := NewAlbumRepository(r.ctx, r.db)
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
|
||||
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"id": ids})})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: Eq{"album_id": ids}, Sort: "album"})
|
||||
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"})
|
||||
return err
|
||||
case "playlist":
|
||||
// Create a context with a fake admin user, to be able to access all playlists
|
||||
ctx := request.WithUser(r.ctx, model.User{IsAdmin: true})
|
||||
plsRepo := NewPlaylistRepository(ctx, r.db)
|
||||
tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id"})
|
||||
tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -113,7 +116,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
|
|||
return nil
|
||||
case "media_file":
|
||||
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: Eq{"id": ids}})
|
||||
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})})
|
||||
share.Tracks = sortByIdPosition(tracks, ids)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -3,22 +3,26 @@ package persistence
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const annotationTable = "annotation"
|
||||
|
||||
func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder {
|
||||
query := r.newSelect(options...).
|
||||
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
||||
if userId(r.ctx) == invalidUserId {
|
||||
return query
|
||||
}
|
||||
query = query.
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = "+idField+
|
||||
" AND annotation.item_type = '"+r.tableName+"'"+
|
||||
// item_ids are unique across different item_types, so the clause below is not needed
|
||||
//" AND annotation.item_type = '"+r.tableName+"'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
|
@ -27,7 +31,9 @@ func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.
|
|||
"play_date",
|
||||
)
|
||||
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
|
||||
query = query.Columns("round(coalesce(round(cast(play_count as float) / coalesce(song_count, 1), 1), 0)) as play_count")
|
||||
query = query.Columns(
|
||||
fmt.Sprintf("round(coalesce(round(cast(play_count as float) / coalesce(%[1]s.song_count, 1), 1), 0)) as play_count", r.tableName),
|
||||
)
|
||||
} else {
|
||||
query = query.Columns("coalesce(play_count, 0) as play_count")
|
||||
}
|
||||
|
@ -95,11 +101,23 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error {
|
||||
if prevID == newID || prevID == "" || newID == "" {
|
||||
return nil
|
||||
}
|
||||
upd := Update(annotationTable).Where(And{
|
||||
Eq{annotationTable + ".item_type": r.tableName},
|
||||
Eq{annotationTable + ".item_id": prevID},
|
||||
}).Set("item_id", newID)
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) cleanAnnotations() error {
|
||||
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error cleaning up annotations: %w", err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
|
||||
|
|
|
@ -2,21 +2,24 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
id2 "github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -78,24 +81,27 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
|||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
func (r *sqlRepository) setSortMappings(mappings map[string]string) {
|
||||
func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) {
|
||||
tn := r.tableName
|
||||
if len(tableName) > 0 {
|
||||
tn = tableName[0]
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
for k, v := range mappings {
|
||||
v = mapSortOrder(v)
|
||||
v = mapSortOrder(tn, v)
|
||||
mappings[k] = v
|
||||
}
|
||||
}
|
||||
r.sortMappings = mappings
|
||||
}
|
||||
|
||||
func (r sqlRepository) getTableName() string {
|
||||
return r.tableName
|
||||
}
|
||||
|
||||
func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
sq := Select().From(r.tableName)
|
||||
sq = r.applyOptions(sq, options...)
|
||||
sq = r.applyFilters(sq, options...)
|
||||
if len(options) > 0 {
|
||||
r.resetSeededRandom(options)
|
||||
sq = r.applyOptions(sq, options...)
|
||||
sq = r.applyFilters(sq, options...)
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
|
@ -185,7 +191,10 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
|
|||
}
|
||||
|
||||
func (r sqlRepository) seedKey() string {
|
||||
return r.tableName + userId(r.ctx)
|
||||
// Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed
|
||||
// used in the query. Hashing the user ID and converting it to a hex string will do the trick
|
||||
userIDHash := md5.Sum([]byte(userId(r.ctx)))
|
||||
return fmt.Sprintf("%s|%x", r.tableName, userIDHash)
|
||||
}
|
||||
|
||||
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
|
||||
|
@ -219,7 +228,7 @@ func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
|
|||
return 0, err
|
||||
}
|
||||
}
|
||||
return res.RowsAffected()
|
||||
return c, err
|
||||
}
|
||||
|
||||
var placeholderRegex = regexp.MustCompile(`\?`)
|
||||
|
@ -256,6 +265,38 @@ func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
|
||||
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
|
||||
func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
|
||||
if len(options) > 0 && options[0].Offset > 0 {
|
||||
sq = r.optimizePagination(sq, options[0])
|
||||
}
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
rows, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Rows()
|
||||
r.logSQL(query, args, err, -1, start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(T, error) bool) {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var row T
|
||||
err := rows.ScanStruct(&row)
|
||||
if !yield(row, err) || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
var empty T
|
||||
yield(empty, err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error {
|
||||
if len(options) > 0 && options[0].Offset > 0 {
|
||||
sq = r.optimizePagination(sq, options[0])
|
||||
|
@ -295,16 +336,16 @@ func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) err
|
|||
func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder {
|
||||
if options.Offset > conf.Server.DevOffsetOptimize {
|
||||
sq = sq.RemoveOffset()
|
||||
oidSq := sq.RemoveColumns().Columns(r.tableName + ".oid")
|
||||
oidSq = oidSq.Limit(uint64(options.Offset))
|
||||
oidSql, args, _ := oidSq.ToSql()
|
||||
sq = sq.Where(r.tableName+".oid not in ("+oidSql+")", args...)
|
||||
rowidSq := sq.RemoveColumns().Columns(r.tableName + ".rowid")
|
||||
rowidSq = rowidSq.Limit(uint64(options.Offset))
|
||||
rowidSql, args, _ := rowidSq.ToSql()
|
||||
sq = sq.Where(r.tableName+".rowid not in ("+rowidSql+")", args...)
|
||||
}
|
||||
return sq
|
||||
}
|
||||
|
||||
func (r sqlRepository) exists(existsQuery SelectBuilder) (bool, error) {
|
||||
existsQuery = existsQuery.Columns("count(*) as exist").From(r.tableName)
|
||||
func (r sqlRepository) exists(cond Sqlizer) (bool, error) {
|
||||
existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond)
|
||||
var res struct{ Exist int64 }
|
||||
err := r.queryOne(existsQuery, &res)
|
||||
return res.Exist > 0, err
|
||||
|
@ -314,6 +355,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
|||
countQuery = countQuery.
|
||||
RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count").
|
||||
RemoveOffset().RemoveLimit().
|
||||
OrderBy(r.tableName + ".id"). // To remove any ORDER BY clause that could slow down the query
|
||||
From(r.tableName)
|
||||
countQuery = r.applyFilters(countQuery, options...)
|
||||
var res struct{ Count int64 }
|
||||
|
@ -321,6 +363,20 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
|||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) {
|
||||
if id != "" {
|
||||
return r.put(id, m, colsToUpdate...)
|
||||
}
|
||||
existsQuery := r.newSelect().Columns("id").From(r.tableName).Where(filter)
|
||||
|
||||
var res struct{ ID string }
|
||||
err := r.queryOne(existsQuery, &res)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return "", err
|
||||
}
|
||||
return r.put(res.ID, m, colsToUpdate...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
|
@ -331,17 +387,20 @@ func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (ne
|
|||
updateValues := map[string]interface{}{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := map[string]struct{}{}
|
||||
for _, c := range colsToUpdate {
|
||||
c2upd[toSnakeCase(c)] = struct{}{}
|
||||
}
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
return toSnakeCase(s), struct{}{}
|
||||
})
|
||||
for k, v := range values {
|
||||
if _, found := c2upd[k]; len(c2upd) == 0 || found {
|
||||
updateValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updateValues["id"] = id
|
||||
delete(updateValues, "created_at")
|
||||
// To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now
|
||||
// TODO move to mediafile_repository when each repo has its own upsert method
|
||||
delete(updateValues, "birth_time")
|
||||
update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
|
@ -353,7 +412,7 @@ func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (ne
|
|||
}
|
||||
// If it does not have an ID OR the ID was not found (when it is a new record with predefined id)
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
id = id2.NewRandom()
|
||||
values["id"] = id
|
||||
}
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
|
@ -372,20 +431,9 @@ func (r sqlRepository) delete(cond Sqlizer) error {
|
|||
|
||||
func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) {
|
||||
elapsed := time.Since(start)
|
||||
//var fmtArgs []string
|
||||
//for name, val := range args {
|
||||
// var f string
|
||||
// switch a := args[val].(type) {
|
||||
// case string:
|
||||
// f = `'` + a + `'`
|
||||
// default:
|
||||
// f = fmt.Sprintf("%v", a)
|
||||
// }
|
||||
// fmtArgs = append(fmtArgs, f)
|
||||
//}
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
|
||||
if err == nil || errors.Is(err, context.Canceled) {
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
|
||||
} else {
|
||||
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed)
|
||||
log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package persistence
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
|
@ -13,11 +14,15 @@ import (
|
|||
|
||||
const bookmarkTable = "bookmark"
|
||||
|
||||
func (r sqlRepository) withBookmark(sql SelectBuilder, idField string) SelectBuilder {
|
||||
return sql.
|
||||
func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder {
|
||||
if userId(r.ctx) == invalidUserId {
|
||||
return query
|
||||
}
|
||||
return query.
|
||||
LeftJoin("bookmark on (" +
|
||||
"bookmark.item_id = " + idField +
|
||||
" AND bookmark.item_type = '" + r.tableName + "'" +
|
||||
// item_ids are unique across different item_types, so the clause below is not needed
|
||||
//" AND bookmark.item_type = '" + r.tableName + "'" +
|
||||
" AND bookmark.user_id = '" + userId(r.ctx) + "')").
|
||||
Columns("coalesce(position, 0) as bookmark_position")
|
||||
}
|
||||
|
@ -96,19 +101,15 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
|
|||
user, _ := request.UserFrom(r.ctx)
|
||||
|
||||
idField := r.tableName + ".id"
|
||||
sq := r.newSelectWithAnnotation(idField).Columns(r.tableName + ".*")
|
||||
sq := r.newSelect().Columns(r.tableName + ".*")
|
||||
sq = r.withAnnotation(sq, idField)
|
||||
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
|
||||
var mfs model.MediaFiles
|
||||
var mfs dbMediaFiles // TODO Decouple from media_file
|
||||
err := r.queryAll(sq, &mfs)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err)
|
||||
return nil, err
|
||||
}
|
||||
err = loadAllGenres(r, mfs)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error loading genres for bookmarked songs", "user", user.UserName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(mfs))
|
||||
mfMap := make(map[string]int)
|
||||
|
@ -137,7 +138,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
|
|||
CreatedAt: bmk.CreatedAt,
|
||||
UpdatedAt: bmk.UpdatedAt,
|
||||
ChangedBy: bmk.ChangedBy,
|
||||
Item: mfs[itemIdx],
|
||||
Item: *mfs[itemIdx].MediaFile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -148,7 +149,7 @@ func (r sqlRepository) cleanBookmarks() error {
|
|||
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error cleaning up bookmarks: %w", err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func (r sqlRepository) withGenres(sql SelectBuilder) SelectBuilder {
|
||||
return sql.LeftJoin(r.tableName + "_genres ag on " + r.tableName + ".id = ag." + r.tableName + "_id").
|
||||
LeftJoin("genre on ag.genre_id = genre.id")
|
||||
}
|
||||
|
||||
func (r *sqlRepository) updateGenres(id string, genres model.Genres) error {
|
||||
tableName := r.getTableName()
|
||||
del := Delete(tableName + "_genres").Where(Eq{tableName + "_id": id})
|
||||
_, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(genres) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for chunk := range slices.Chunk(genres, 100) {
|
||||
ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id")
|
||||
for _, genre := range chunk {
|
||||
ins = ins.Values(genre.ID, id)
|
||||
}
|
||||
if _, err = r.executeSQL(ins); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type baseRepository interface {
|
||||
queryAll(SelectBuilder, any, ...model.QueryOptions) error
|
||||
getTableName() string
|
||||
}
|
||||
|
||||
type modelWithGenres interface {
|
||||
model.Album | model.Artist | model.MediaFile
|
||||
}
|
||||
|
||||
func getID[T modelWithGenres](item T) string {
|
||||
switch v := any(item).(type) {
|
||||
case model.Album:
|
||||
return v.ID
|
||||
case model.Artist:
|
||||
return v.ID
|
||||
case model.MediaFile:
|
||||
return v.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendGenre[T modelWithGenres](item *T, genre model.Genre) {
|
||||
switch v := any(item).(type) {
|
||||
case *model.Album:
|
||||
v.Genres = append(v.Genres, genre)
|
||||
case *model.Artist:
|
||||
v.Genres = append(v.Genres, genre)
|
||||
case *model.MediaFile:
|
||||
v.Genres = append(v.Genres, genre)
|
||||
}
|
||||
}
|
||||
|
||||
func loadGenres[T modelWithGenres](r baseRepository, ids []string, items map[string]*T) error {
|
||||
tableName := r.getTableName()
|
||||
|
||||
for chunk := range slices.Chunk(ids, 900) {
|
||||
sql := Select("genre.*", tableName+"_id as item_id").From("genre").
|
||||
Join(tableName+"_genres ig on genre.id = ig.genre_id").
|
||||
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": chunk})
|
||||
|
||||
var genres []struct {
|
||||
model.Genre
|
||||
ItemID string
|
||||
}
|
||||
if err := r.queryAll(sql, &genres); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range genres {
|
||||
appendGenre(items[g.ItemID], g.Genre)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAllGenres[T modelWithGenres](r baseRepository, items []T) error {
|
||||
// Map references to items by ID and collect all IDs
|
||||
m := map[string]*T{}
|
||||
var ids []string
|
||||
for i := range items {
|
||||
item := &(items)[i]
|
||||
id := getID(*item)
|
||||
ids = append(ids, id)
|
||||
m[id] = item
|
||||
}
|
||||
|
||||
return loadGenres(r, ids, m)
|
||||
}
|
66
persistence/sql_participations.go
Normal file
66
persistence/sql_participations.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type participant struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SubRole string `json:"subRole,omitempty"`
|
||||
}
|
||||
|
||||
func marshalParticipants(participants model.Participants) string {
|
||||
dbParticipants := make(map[model.Role][]participant)
|
||||
for role, artists := range participants {
|
||||
for _, artist := range artists {
|
||||
dbParticipants[role] = append(dbParticipants[role], participant{ID: artist.ID, SubRole: artist.SubRole, Name: artist.Name})
|
||||
}
|
||||
}
|
||||
res, _ := json.Marshal(dbParticipants)
|
||||
return string(res)
|
||||
}
|
||||
|
||||
func unmarshalParticipants(data string) (model.Participants, error) {
|
||||
var dbParticipants map[model.Role][]participant
|
||||
err := json.Unmarshal([]byte(data), &dbParticipants)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing participants: %w", err)
|
||||
}
|
||||
|
||||
participants := make(model.Participants, len(dbParticipants))
|
||||
for role, participantList := range dbParticipants {
|
||||
artists := slice.Map(participantList, func(p participant) model.Participant {
|
||||
return model.Participant{Artist: model.Artist{ID: p.ID, Name: p.Name}, SubRole: p.SubRole}
|
||||
})
|
||||
participants[role] = artists
|
||||
}
|
||||
return participants, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
|
||||
ids := participants.AllIDs()
|
||||
sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
|
||||
_, err := r.executeSQL(sqd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(participants) == 0 {
|
||||
return nil
|
||||
}
|
||||
sqi := Insert(r.tableName+"_artists").
|
||||
Columns(r.tableName+"_id", "artist_id", "role", "sub_role").
|
||||
Suffix(fmt.Sprintf("on conflict (artist_id, %s_id, role, sub_role) do nothing", r.tableName))
|
||||
for role, artists := range participants {
|
||||
for _, artist := range artists {
|
||||
sqi = sqi.Values(itemID, artist.ID, role.String(), artist.SubRole)
|
||||
}
|
||||
}
|
||||
_, err = r.executeSQL(sqi)
|
||||
return err
|
||||
}
|
|
@ -36,7 +36,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query
|
|||
}
|
||||
// Ignore invalid filters (not based on a field or filter function)
|
||||
if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) {
|
||||
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f)
|
||||
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f, "table", r.tableName)
|
||||
continue
|
||||
}
|
||||
// For fields ending in "id", use an exact match
|
||||
|
@ -72,7 +72,7 @@ func (r sqlRepository) sanitizeSort(sort, order string) (string, string) {
|
|||
sort = mapped
|
||||
} else {
|
||||
if !r.isFieldWhiteListed(sort) {
|
||||
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort)
|
||||
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName)
|
||||
sort = ""
|
||||
}
|
||||
}
|
||||
|
@ -102,15 +102,15 @@ func containsFilter(field string) func(string, any) Sqlizer {
|
|||
|
||||
func booleanFilter(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return Eq{field: strings.ToLower(v) == "true"}
|
||||
return Eq{field: v == "true"}
|
||||
}
|
||||
|
||||
func fullTextFilter(_ string, value any) Sqlizer {
|
||||
return fullTextExpr(value.(string))
|
||||
func fullTextFilter(tableName string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer { return fullTextExpr(tableName, value.(string)) }
|
||||
}
|
||||
|
||||
func substringFilter(field string, value any) Sqlizer {
|
||||
parts := strings.Split(value.(string), " ")
|
||||
parts := strings.Fields(value.(string))
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{field: "%" + part + "%"})
|
||||
|
@ -119,9 +119,7 @@ func substringFilter(field string, value any) Sqlizer {
|
|||
}
|
||||
|
||||
func idFilter(tableName string) func(string, any) Sqlizer {
|
||||
return func(field string, value any) Sqlizer {
|
||||
return Eq{tableName + ".id": value}
|
||||
}
|
||||
return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} }
|
||||
}
|
||||
|
||||
func invalidFilter(ctx context.Context) func(string, any) Sqlizer {
|
||||
|
|
|
@ -25,7 +25,7 @@ var _ = Describe("sqlRestful", func() {
|
|||
|
||||
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter,
|
||||
"name": fullTextFilter("table"),
|
||||
}
|
||||
options.Filters = map[string]interface{}{"name": "'"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
|
|
|
@ -9,34 +9,39 @@ import (
|
|||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func getFullText(text ...string) string {
|
||||
func formatFullText(text ...string) string {
|
||||
fullText := str.SanitizeStrings(text...)
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error {
|
||||
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, includeMissing bool, results any, orderBys ...string) error {
|
||||
q = strings.TrimSpace(q)
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
if len(q) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns(r.tableName + ".*")
|
||||
filter := fullTextExpr(q)
|
||||
//sq := r.newSelect().Columns(r.tableName + ".*")
|
||||
//sq = r.withAnnotation(sq, r.tableName+".id")
|
||||
//sq = r.withBookmark(sq, r.tableName+".id")
|
||||
filter := fullTextExpr(r.tableName, q)
|
||||
if filter != nil {
|
||||
sq = sq.Where(filter)
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
} else {
|
||||
// If the filter is empty, we sort by id.
|
||||
// If the filter is empty, we sort by rowid.
|
||||
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
|
||||
sq = sq.OrderBy("id")
|
||||
sq = sq.OrderBy(r.tableName + ".rowid")
|
||||
}
|
||||
if !includeMissing {
|
||||
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
||||
}
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||
}
|
||||
|
||||
func fullTextExpr(value string) Sqlizer {
|
||||
q := str.SanitizeStrings(value)
|
||||
func fullTextExpr(tableName string, s string) Sqlizer {
|
||||
q := str.SanitizeStrings(s)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
|
@ -47,7 +52,7 @@ func fullTextExpr(value string) Sqlizer {
|
|||
parts := strings.Split(q, " ")
|
||||
filters := And{}
|
||||
for _, part := range parts {
|
||||
filters = append(filters, Like{"full_text": "%" + sep + part + "%"})
|
||||
filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"})
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@ import (
|
|||
)
|
||||
|
||||
var _ = Describe("sqlRepository", func() {
|
||||
Describe("getFullText", func() {
|
||||
Describe("formatFullText", func() {
|
||||
It("prefixes with a space", func() {
|
||||
Expect(getFullText("legiao urbana")).To(Equal(" legiao urbana"))
|
||||
Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
57
persistence/sql_tags.go
Normal file
57
persistence/sql_tags.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Format of a tag in the DB
|
||||
type dbTag struct {
|
||||
ID string `json:"id"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type dbTags map[model.TagName][]dbTag
|
||||
|
||||
func unmarshalTags(data string) (model.Tags, error) {
|
||||
var dbTags dbTags
|
||||
err := json.Unmarshal([]byte(data), &dbTags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing tags: %w", err)
|
||||
}
|
||||
|
||||
res := make(model.Tags, len(dbTags))
|
||||
for name, tags := range dbTags {
|
||||
res[name] = make([]string, len(tags))
|
||||
for i, tag := range tags {
|
||||
res[name][i] = tag.Value
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func marshalTags(tags model.Tags) string {
|
||||
dbTags := dbTags{}
|
||||
for name, values := range tags {
|
||||
for _, value := range values {
|
||||
t := model.NewTag(name, value)
|
||||
dbTags[name] = append(dbTags[name], dbTag{ID: t.ID, Value: value})
|
||||
}
|
||||
}
|
||||
res, _ := json.Marshal(dbTags)
|
||||
return string(res)
|
||||
}
|
||||
|
||||
func tagIDFilter(name string, idValue any) Sqlizer {
|
||||
name = strings.TrimSuffix(name, "_id")
|
||||
return Exists(
|
||||
fmt.Sprintf(`json_tree(tags, "$.%s")`, name),
|
||||
And{
|
||||
NotEq{"json_tree.atom": nil},
|
||||
Eq{"value": idValue},
|
||||
},
|
||||
)
|
||||
}
|
116
persistence/tag_repository.go
Normal file
116
persistence/tag_repository.go
Normal file
|
@ -0,0 +1,116 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type tagRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository {
|
||||
r := &tagRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "tag"
|
||||
r.registerModel(&model.Tag{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *tagRepository) Add(tags ...model.Tag) error {
|
||||
for chunk := range slices.Chunk(tags, 200) {
|
||||
sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value").
|
||||
Suffix("on conflict (id) do nothing")
|
||||
for _, t := range chunk {
|
||||
sq = sq.Values(t.ID, t.TagName, t.TagValue)
|
||||
}
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCounts updates the album_count and media_file_count columns in the tag_counts table.
|
||||
// Only genres are being updated for now.
|
||||
func (r *tagRepository) UpdateCounts() error {
|
||||
template := `
|
||||
with updated_values as (
|
||||
select jt.value as id, count(distinct %[1]s.id) as %[1]s_count
|
||||
from %[1]s
|
||||
join json_tree(tags, '$.genre') as jt
|
||||
where atom is not null
|
||||
and key = 'id'
|
||||
group by jt.value
|
||||
)
|
||||
update tag
|
||||
set %[1]s_count = updated_values.%[1]s_count
|
||||
from updated_values
|
||||
where tag.id = updated_values.id;
|
||||
`
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := rawSQL(fmt.Sprintf(template, table))
|
||||
c, err := r.executeSQL(query)
|
||||
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating %s tag counts: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tagRepository) purgeUnused() error {
|
||||
del := Delete(r.tableName).Where(`
|
||||
id not in (select jt.value
|
||||
from album left join json_tree(album.tags, '$') as jt
|
||||
where atom is not null
|
||||
and key = 'id')
|
||||
`)
|
||||
c, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error purging unused tags: %w", err)
|
||||
}
|
||||
if c > 0 {
|
||||
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *tagRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newSelect(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *tagRepository) Read(id string) (interface{}, error) {
|
||||
query := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.Tag
|
||||
err := r.queryOne(query, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *tagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
var res model.TagList
|
||||
err := r.queryAll(query, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *tagRepository) EntityName() string {
|
||||
return "tag"
|
||||
}
|
||||
|
||||
func (r *tagRepository) NewInstance() interface{} {
|
||||
return model.Tag{}
|
||||
}
|
||||
|
||||
var _ model.ResourceRepository = &tagRepository{}
|
|
@ -11,11 +11,11 @@ import (
|
|||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
@ -62,13 +62,16 @@ func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, err
|
|||
|
||||
func (r *userRepository) Put(u *model.User) error {
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
u.ID = id.NewRandom()
|
||||
}
|
||||
u.UpdatedAt = time.Now()
|
||||
if u.NewPassword != "" {
|
||||
_ = r.encryptPassword(u)
|
||||
}
|
||||
values, _ := toSQLArgs(*u)
|
||||
values, err := toSQLArgs(*u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting user to SQL args: %w", err)
|
||||
}
|
||||
delete(values, "current_password")
|
||||
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
|
|
|
@ -5,10 +5,10 @@ import (
|
|||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
@ -86,7 +86,7 @@ var _ = Describe("UserRepository", func() {
|
|||
var user model.User
|
||||
BeforeEach(func() {
|
||||
loggedUser.IsAdmin = false
|
||||
loggedUser.Password = consts.PasswordAutogenPrefix + uuid.NewString()
|
||||
loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom()
|
||||
})
|
||||
It("does nothing if passwords are not specified", func() {
|
||||
user = *loggedUser
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue