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 https://discord.com/channels/671335427726114836/704303730660737113/1337574018143879248: 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:
Vendored
+6
@@ -9,6 +9,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const cacheSizeLimit = 100
|
||||
@@ -41,7 +43,10 @@ func NewHTTPClient(wrapped httpDoer, ttl time.Duration) *HTTPClient {
|
||||
|
||||
func (c *HTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
key := c.serializeReq(req)
|
||||
cached := true
|
||||
start := time.Now()
|
||||
respStr, err := c.cache.GetWithLoader(key, func(key string) (string, time.Duration, error) {
|
||||
cached = false
|
||||
req, err := c.deserializeReq(key)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
@@ -53,6 +58,7 @@ func (c *HTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
defer resp.Body.Close()
|
||||
return c.serializeResponse(resp), c.ttl, nil
|
||||
})
|
||||
log.Trace(req.Context(), "CachedHTTPClient.Do", "key", key, "cached", cached, "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package chain
|
||||
|
||||
import "golang.org/x/sync/errgroup"
|
||||
|
||||
// RunSequentially runs the given functions sequentially,
|
||||
// If any function returns an error, it stops the execution and returns that error.
|
||||
// If all functions return nil, it returns nil.
|
||||
func RunSequentially(fs ...func() error) error {
|
||||
for _, f := range fs {
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunParallel runs the given functions in parallel,
|
||||
// It waits for all functions to finish and returns the first error encountered.
|
||||
func RunParallel(fs ...func() error) func() error {
|
||||
return func() error {
|
||||
g := errgroup.Group{}
|
||||
for _, f := range fs {
|
||||
g.Go(func() error {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/chain"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestChain(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "chain Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("RunSequentially", func() {
|
||||
It("should return nil if no functions are provided", func() {
|
||||
err := chain.RunSequentially()
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return nil if all functions succeed", func() {
|
||||
err := chain.RunSequentially(
|
||||
func() error { return nil },
|
||||
func() error { return nil },
|
||||
)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return the error from the first failing function", func() {
|
||||
expectedErr := errors.New("error in function 2")
|
||||
err := chain.RunSequentially(
|
||||
func() error { return nil },
|
||||
func() error { return expectedErr },
|
||||
func() error { return errors.New("error in function 3") },
|
||||
)
|
||||
Expect(err).To(Equal(expectedErr))
|
||||
})
|
||||
|
||||
It("should not run functions after the first failing function", func() {
|
||||
expectedErr := errors.New("error in function 1")
|
||||
var runCount int
|
||||
err := chain.RunSequentially(
|
||||
func() error { runCount++; return expectedErr },
|
||||
func() error { runCount++; return nil },
|
||||
)
|
||||
Expect(err).To(Equal(expectedErr))
|
||||
Expect(runCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
package chrono
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
// Meter is a simple stopwatch
|
||||
type Meter struct {
|
||||
elapsed time.Duration
|
||||
mark *time.Time
|
||||
}
|
||||
|
||||
func (m *Meter) Start() {
|
||||
m.mark = P(time.Now())
|
||||
}
|
||||
|
||||
func (m *Meter) Stop() time.Duration {
|
||||
if m.mark == nil {
|
||||
return m.elapsed
|
||||
}
|
||||
m.elapsed += time.Since(*m.mark)
|
||||
m.mark = nil
|
||||
return m.elapsed
|
||||
}
|
||||
|
||||
func (m *Meter) Elapsed() time.Duration {
|
||||
elapsed := m.elapsed
|
||||
if m.mark != nil {
|
||||
elapsed += time.Since(*m.mark)
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chrono_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/navidrome/navidrome/utils/chrono"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestChrono(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Chrono Suite")
|
||||
}
|
||||
|
||||
// Note: These tests may be flaky due to the use of time.Sleep.
|
||||
var _ = Describe("Meter", func() {
|
||||
var meter *Meter
|
||||
|
||||
BeforeEach(func() {
|
||||
meter = &Meter{}
|
||||
})
|
||||
|
||||
Describe("Stop", func() {
|
||||
It("should return the elapsed time", func() {
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
elapsed := meter.Stop()
|
||||
Expect(elapsed).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
|
||||
})
|
||||
|
||||
It("should accumulate elapsed time on multiple starts and stops", func() {
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
meter.Stop()
|
||||
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
elapsed := meter.Stop()
|
||||
|
||||
Expect(elapsed).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Elapsed", func() {
|
||||
It("should return the total elapsed time", func() {
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
meter.Stop()
|
||||
|
||||
// Should not count the time the meter was stopped
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
meter.Stop()
|
||||
|
||||
Expect(meter.Elapsed()).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond))
|
||||
})
|
||||
|
||||
It("should include the current running time if started", func() {
|
||||
meter.Start()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
Expect(meter.Elapsed()).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,6 @@ func Decrypt(ctx context.Context, encKey []byte, encData string) (value string,
|
||||
// Recover from any panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error(ctx, "Panic during decryption", r)
|
||||
err = errors.New("decryption panicked")
|
||||
}
|
||||
}()
|
||||
|
||||
+9
-2
@@ -2,11 +2,18 @@ package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
func TempFileName(prefix, suffix string) string {
|
||||
return filepath.Join(os.TempDir(), prefix+uuid.NewString()+suffix)
|
||||
return filepath.Join(os.TempDir(), prefix+id.NewRandom()+suffix)
|
||||
}
|
||||
|
||||
func BaseName(filePath string) string {
|
||||
p := path.Base(filePath)
|
||||
return strings.TrimSuffix(p, path.Ext(p))
|
||||
}
|
||||
|
||||
@@ -14,3 +14,10 @@ func V[T any](p *T) T {
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func If[T any](cond bool, v1, v2 T) T {
|
||||
if cond {
|
||||
return v1
|
||||
}
|
||||
return v2
|
||||
}
|
||||
|
||||
@@ -39,4 +39,24 @@ var _ = Describe("GG", func() {
|
||||
Expect(gg.V(v)).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("If", func() {
|
||||
It("returns the first value if the condition is true", func() {
|
||||
Expect(gg.If(true, 1, 2)).To(Equal(1))
|
||||
})
|
||||
|
||||
It("returns the second value if the condition is false", func() {
|
||||
Expect(gg.If(false, 1, 2)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("works with string values", func() {
|
||||
Expect(gg.If(true, "a", "b")).To(Equal("a"))
|
||||
Expect(gg.If(false, "a", "b")).To(Equal("b"))
|
||||
})
|
||||
|
||||
It("works with different types", func() {
|
||||
Expect(gg.If(true, 1.1, 2.2)).To(Equal(1.1))
|
||||
Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package gravatar_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
|
||||
func TestGravatar(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Gravatar Test Suite")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Limiter is a rate limiter that allows a function to be executed at most once per ID and per interval.
|
||||
type Limiter struct {
|
||||
Interval time.Duration
|
||||
sm sync.Map
|
||||
}
|
||||
|
||||
// Do executes the provided function `f` if the rate limiter for the given `id` allows it.
|
||||
// It uses the interval specified in the Limiter struct or defaults to 1 minute if not set.
|
||||
func (m *Limiter) Do(id string, f func()) {
|
||||
interval := cmp.Or(
|
||||
m.Interval,
|
||||
time.Minute, // Default every 1 minute
|
||||
)
|
||||
limiter, _ := m.sm.LoadOrStore(id, &rate.Sometimes{Interval: interval})
|
||||
limiter.(*rate.Sometimes).Do(f)
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -22,7 +21,7 @@ var _ = Describe("GetInstance", func() {
|
||||
var numInstancesCreated int
|
||||
constructor := func() *T {
|
||||
numInstancesCreated++
|
||||
return &T{id: uuid.NewString()}
|
||||
return &T{id: id.NewRandom()}
|
||||
}
|
||||
|
||||
It("calls the constructor to create a new instance", func() {
|
||||
@@ -43,7 +42,7 @@ var _ = Describe("GetInstance", func() {
|
||||
instance := singleton.GetInstance(constructor)
|
||||
newInstance := singleton.GetInstance(func() T {
|
||||
numInstancesCreated++
|
||||
return T{id: uuid.NewString()}
|
||||
return T{id: id.NewRandom()}
|
||||
})
|
||||
|
||||
Expect(instance).To(BeAssignableToTypeOf(&T{}))
|
||||
|
||||
+49
-12
@@ -3,8 +3,12 @@ package slice
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"io"
|
||||
"iter"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
func Map[T any, R any](t []T, mapFunc func(T) R) []R {
|
||||
@@ -30,25 +34,46 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T {
|
||||
return m
|
||||
}
|
||||
|
||||
func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V {
|
||||
m := make(map[K]V, len(s))
|
||||
for _, item := range s {
|
||||
k, v := transformFunc(item)
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func CompactByFrequency[T comparable](list []T) []T {
|
||||
counters := make(map[T]int)
|
||||
for _, item := range list {
|
||||
counters[item]++
|
||||
}
|
||||
|
||||
sorted := maps.Keys(counters)
|
||||
slices.SortFunc(sorted, func(i, j T) int {
|
||||
return cmp.Compare(counters[j], counters[i])
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func MostFrequent[T comparable](list []T) T {
|
||||
var zero T
|
||||
if len(list) == 0 {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
counters := make(map[T]int)
|
||||
var topItem T
|
||||
var topCount int
|
||||
counters := map[T]int{}
|
||||
|
||||
if len(list) == 1 {
|
||||
topItem = list[0]
|
||||
} else {
|
||||
for _, id := range list {
|
||||
c := counters[id] + 1
|
||||
counters[id] = c
|
||||
if c > topCount {
|
||||
topItem = id
|
||||
topCount = c
|
||||
}
|
||||
for _, value := range list {
|
||||
if value == zero {
|
||||
continue
|
||||
}
|
||||
counters[value]++
|
||||
if counters[value] > topCount {
|
||||
topItem = value
|
||||
topCount = counters[value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +93,18 @@ func Move[T any](slice []T, srcIndex int, dstIndex int) []T {
|
||||
return Insert(Remove(slice, srcIndex), value, dstIndex)
|
||||
}
|
||||
|
||||
func Unique[T comparable](list []T) []T {
|
||||
seen := make(map[T]struct{})
|
||||
var result []T
|
||||
for _, item := range list {
|
||||
if _, ok := seen[item]; !ok {
|
||||
seen[item] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LinesFrom returns a Seq that reads lines from the given reader
|
||||
func LinesFrom(reader io.Reader) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
|
||||
@@ -63,6 +63,34 @@ var _ = Describe("Slice Utils", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToMap", func() {
|
||||
It("returns empty map for an empty input", func() {
|
||||
transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) }
|
||||
result := slice.ToMap([]int{}, transformFunc)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a map with the result of the transform function", func() {
|
||||
transformFunc := func(v int) (int, string) { return v * 2, strconv.Itoa(v * 2) }
|
||||
result := slice.ToMap([]int{1, 2, 3, 4}, transformFunc)
|
||||
Expect(result).To(HaveLen(4))
|
||||
Expect(result).To(HaveKeyWithValue(2, "2"))
|
||||
Expect(result).To(HaveKeyWithValue(4, "4"))
|
||||
Expect(result).To(HaveKeyWithValue(6, "6"))
|
||||
Expect(result).To(HaveKeyWithValue(8, "8"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CompactByFrequency", func() {
|
||||
It("returns empty slice for an empty input", func() {
|
||||
Expect(slice.CompactByFrequency([]int{})).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("groups by frequency", func() {
|
||||
Expect(slice.CompactByFrequency([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(2, 1, 3))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MostFrequent", func() {
|
||||
It("returns zero value if no arguments are passed", func() {
|
||||
Expect(slice.MostFrequent([]int{})).To(BeZero())
|
||||
@@ -74,6 +102,9 @@ var _ = Describe("Slice Utils", func() {
|
||||
It("returns the item that appeared more times", func() {
|
||||
Expect(slice.MostFrequent([]string{"1", "2", "1", "2", "3", "2"})).To(Equal("2"))
|
||||
})
|
||||
It("ignores zero values", func() {
|
||||
Expect(slice.MostFrequent([]int{0, 0, 0, 2, 2})).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Move", func() {
|
||||
@@ -88,6 +119,16 @@ var _ = Describe("Slice Utils", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unique", func() {
|
||||
It("returns empty slice for an empty input", func() {
|
||||
Expect(slice.Unique([]int{})).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns the unique elements", func() {
|
||||
Expect(slice.Unique([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(1, 2, 3))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("LinesFrom",
|
||||
func(path string, expected int) {
|
||||
count := 0
|
||||
|
||||
@@ -3,7 +3,7 @@ package str
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/sanitize"
|
||||
@@ -11,27 +11,28 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
)
|
||||
|
||||
var quotesRegex = regexp.MustCompile("[“”‘’'\"\\[({\\])}]")
|
||||
var ignoredCharsRegex = regexp.MustCompile("[“”‘’'\"\\[({\\])},]")
|
||||
var slashRemover = strings.NewReplacer("\\", " ", "/", " ")
|
||||
|
||||
func SanitizeStrings(text ...string) string {
|
||||
// Concatenate all strings, removing extra spaces
|
||||
sanitizedText := strings.Builder{}
|
||||
for _, txt := range text {
|
||||
sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ")
|
||||
sanitizedText.WriteString(strings.TrimSpace(txt))
|
||||
sanitizedText.WriteByte(' ')
|
||||
}
|
||||
words := make(map[string]struct{})
|
||||
for _, w := range strings.Fields(sanitizedText.String()) {
|
||||
words[w] = struct{}{}
|
||||
}
|
||||
var fullText []string
|
||||
for w := range words {
|
||||
w = quotesRegex.ReplaceAllString(w, "")
|
||||
w = slashRemover.Replace(w)
|
||||
if w != "" {
|
||||
fullText = append(fullText, w)
|
||||
}
|
||||
}
|
||||
sort.Strings(fullText)
|
||||
|
||||
// Remove special symbols, accents, extra spaces and slashes
|
||||
sanitizedStrings := slashRemover.Replace(Clear(sanitizedText.String()))
|
||||
sanitizedStrings = sanitize.Accents(strings.ToLower(sanitizedStrings))
|
||||
sanitizedStrings = ignoredCharsRegex.ReplaceAllString(sanitizedStrings, "")
|
||||
fullText := strings.Fields(sanitizedStrings)
|
||||
|
||||
// Remove duplicated words
|
||||
slices.Sort(fullText)
|
||||
fullText = slices.Compact(fullText)
|
||||
|
||||
// Returns the sanitized text as a single string
|
||||
return strings.Join(fullText, " ")
|
||||
}
|
||||
|
||||
@@ -44,12 +45,12 @@ func SanitizeText(text string) string {
|
||||
|
||||
func SanitizeFieldForSorting(originalValue string) string {
|
||||
v := strings.TrimSpace(sanitize.Accents(originalValue))
|
||||
return strings.ToLower(v)
|
||||
return Clear(strings.ToLower(v))
|
||||
}
|
||||
|
||||
func SanitizeFieldForSortingNoArticle(originalValue string) string {
|
||||
v := strings.TrimSpace(sanitize.Accents(originalValue))
|
||||
return strings.ToLower(RemoveArticle(v))
|
||||
return Clear(strings.ToLower(strings.TrimSpace(RemoveArticle(v))))
|
||||
}
|
||||
|
||||
func RemoveArticle(name string) string {
|
||||
|
||||
@@ -18,11 +18,11 @@ var _ = Describe("Sanitize Strings", func() {
|
||||
})
|
||||
|
||||
It("remove extra spaces", func() {
|
||||
Expect(str.SanitizeStrings(" some text ")).To(Equal("some text"))
|
||||
Expect(str.SanitizeStrings(" some text ", "text some")).To(Equal("some text"))
|
||||
})
|
||||
|
||||
It("remove duplicated words", func() {
|
||||
Expect(str.SanitizeStrings("legião urbana urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
Expect(str.SanitizeStrings("legião urbana", "urbana legiÃo")).To(Equal("legiao urbana"))
|
||||
})
|
||||
|
||||
It("remove symbols", func() {
|
||||
@@ -32,8 +32,20 @@ var _ = Describe("Sanitize Strings", func() {
|
||||
It("remove opening brackets", func() {
|
||||
Expect(str.SanitizeStrings("[Five Years]")).To(Equal("five years"))
|
||||
})
|
||||
|
||||
It("remove slashes", func() {
|
||||
Expect(str.SanitizeStrings("folder/file\\yyyy")).To(Equal("folder file yyyy"))
|
||||
Expect(str.SanitizeStrings("folder/file\\yyyy")).To(Equal("file folder yyyy"))
|
||||
})
|
||||
|
||||
It("normalizes utf chars", func() {
|
||||
// These uses different types of hyphens
|
||||
Expect(str.SanitizeStrings("k—os", "k−os")).To(Equal("k-os"))
|
||||
})
|
||||
|
||||
It("remove commas", func() {
|
||||
// This is specially useful for handling cases where the Sort field uses comma.
|
||||
// It reduces the size of the resulting string, thus reducing the size of the DB table and indexes.
|
||||
Expect(str.SanitizeStrings("Bob Marley", "Marley, Bob")).To(Equal("bob marley"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+15
-8
@@ -4,14 +4,21 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var utf8ToAscii = strings.NewReplacer(
|
||||
"–", "-",
|
||||
"‐", "-",
|
||||
"“", `"`,
|
||||
"”", `"`,
|
||||
"‘", `'`,
|
||||
"’", `'`,
|
||||
)
|
||||
var utf8ToAscii = func() *strings.Replacer {
|
||||
var utf8Map = map[string]string{
|
||||
"'": `‘’‛′`,
|
||||
`"`: `"〃ˮײ᳓″‶˶ʺ“”˝‟`,
|
||||
"-": `‐–—−―`,
|
||||
}
|
||||
|
||||
list := make([]string, 0, len(utf8Map)*2)
|
||||
for ascii, utf8 := range utf8Map {
|
||||
for _, r := range utf8 {
|
||||
list = append(list, string(r), ascii)
|
||||
}
|
||||
}
|
||||
return strings.NewReplacer(list...)
|
||||
}()
|
||||
|
||||
func Clear(name string) string {
|
||||
return utf8ToAscii.Replace(name)
|
||||
|
||||
@@ -23,6 +23,13 @@ var _ = Describe("String Utils", func() {
|
||||
It("finds the longest common prefix", func() {
|
||||
Expect(str.LongestCommonPrefix(testPaths)).To(Equal("/Music/iTunes 1/iTunes Media/Music/"))
|
||||
})
|
||||
It("does NOT handle partial prefixes", func() {
|
||||
albums := []string{
|
||||
"/artist/albumOne",
|
||||
"/artist/albumTwo",
|
||||
}
|
||||
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user