Compare commits

...

79 Commits
v0.1.0 ... main

Author SHA1 Message Date
5d510ff787 Integrate browse API into TUI MB daemon (#230)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m7s
Part 2 of #160

Reviewed-on: #230
2024-10-06 15:32:46 +02:00
4db09667fd Add support for MusicBrainz's Browse API (#228)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m58s
Cargo CI / Lint (push) Successful in 1m8s
Reviewed-on: #228
2024-09-29 21:33:42 +02:00
e5a367aa90 Upgrade the lookup example (#227)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m1s
Cargo CI / Lint (push) Successful in 1m8s
Reviewed-on: #227
2024-09-29 15:23:31 +02:00
0d7e6bb555 Allow fetching of a single album (#226)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
Closes #225

Reviewed-on: #226
2024-09-29 12:38:38 +02:00
e22068e461 Gracefully handle case of nothing being there to match (#222)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m7s
Closes #203

Reviewed-on: #222
2024-09-29 11:33:38 +02:00
0b0599318e Enable fetch to apply modifications to the database (#221)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m1s
Cargo CI / Lint (push) Successful in 1m8s
Closes #189

Reviewed-on: #221
2024-09-29 10:44:37 +02:00
dbaef0422f Implement cannot have MBID in core (#220)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m5s
Part 1 of #189

Reviewed-on: #220
2024-09-24 22:38:40 +02:00
90db5faae7 Add option for manual input during fetch (#219)
All checks were successful
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Build and Test (push) Successful in 1m57s
Closes #188

Reviewed-on: #219
2024-09-23 22:40:25 +02:00
d6f4b2b6b7 Daemonize the musicbrainz thread (#217)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m6s
Part 3 of #188

Reviewed-on: #217
2024-09-21 23:03:47 +02:00
38517caf4e Add manual input elements to the app an ui (#216)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m6s
Part 2 of #188

Reviewed-on: #216
2024-09-15 15:20:11 +02:00
8b008292cb Use more verbose type names for clarity (#214)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
PR 1 for #188

Reviewed-on: #214
2024-09-13 21:28:12 +02:00
9d1caffd9c Handle idle time between fetch results (#212)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m52s
Cargo CI / Lint (push) Successful in 1m7s
Closes #211

Reviewed-on: #212
2024-09-08 23:23:53 +02:00
8e48412282 Make fetch asynchronous (#210)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m51s
Cargo CI / Lint (push) Successful in 1m5s
Closes #187

Reviewed-on: #210
2024-09-01 17:47:39 +02:00
fd9d3677ec Separate metadata from collections (#209)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m6s
Part 1 of #187

Reviewed-on: #209
2024-08-31 22:55:25 +02:00
ebd63cc80b Add a "cannot-have-an-mbid" entry to possible matches (#208)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
Closes #190

Reviewed-on: #208
2024-08-31 16:29:36 +02:00
6333b7a131 Use a queue to communicate matches from browse to matches (#207)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m10s
Closes #202

Reviewed-on: #207
2024-08-31 14:42:46 +02:00
cda1487734 The tui feature is missing a dependence on musicbrainz (#206)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m4s
Cargo CI / Lint (push) Successful in 1m9s
Closes #205

Reviewed-on: #206
2024-08-31 13:19:28 +02:00
398963b9fd Make fetch also fetch artist MBID if it is missing (#201)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m4s
Cargo CI / Lint (push) Successful in 1m6s
Closes #191

Reviewed-on: #201
2024-08-30 17:58:44 +02:00
c38961c3c1 Split ui.rs into modules based on UI element (#200)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m5s
Closes #135

Reviewed-on: #200
2024-08-29 17:21:52 +02:00
0fefc52603 For the database serde implementation use Mbid rather than MbRef (#199)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m54s
Cargo CI / Lint (push) Successful in 1m7s
Closes #198

Reviewed-on: #199
2024-08-29 13:37:47 +02:00
f82a6376e0 Use the Deserialize trait for JSON just like for MusicBrainz (#197)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m53s
Cargo CI / Lint (push) Successful in 1m5s
Closes #195

Reviewed-on: #197
2024-08-28 23:02:14 +02:00
43961b3ea1 Decide carefully where external::musicbrainz belongs (#196)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m5s
Closes #193

Reviewed-on: #196
2024-08-28 18:21:13 +02:00
b70499d8de Replace MH: IMusicHoard generic with a trait object (#194)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m8s
Closes #192

Reviewed-on: #194
2024-08-27 18:45:03 +02:00
cf7e23c38c Provide a keyboard shortcut to sync all existing albums with MusicBrainz (#167)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Lint (pull_request) Successful in 1m4s
Cargo CI / Build and Test (pull_request) Successful in 1m58s
Closes #166

Reviewed-on: #167
2024-08-27 17:55:52 +02:00
d8fd952456 Add CLI option for setting binary (#184)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m54s
Cargo CI / Lint (pull_request) Successful in 1m3s
Closes #183

Reviewed-on: #184
2024-08-24 23:10:59 +02:00
871aeb8436 Update beets to 2.0.0 in CI (#182)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m53s
Cargo CI / Lint (pull_request) Successful in 1m6s
Closes #181

Reviewed-on: #182
2024-08-24 15:43:48 +02:00
8ff09e66ba Update rust toolchain to 1.80 (#180)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m55s
Cargo CI / Lint (push) Successful in 1m5s
Closes #179

Reviewed-on: #180
2024-08-24 15:10:54 +02:00
f395433343 Update rust toolchain to 1.79 (#176)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m49s
Cargo CI / Build and Test (pull_request) Successful in 1m51s
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Lint (pull_request) Successful in 1m6s
Closes #174

Reviewed-on: #176
2024-06-16 16:38:35 +02:00
d9d5945422 Display all the extra album info (#173)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m58s
Cargo CI / Lint (push) Successful in 1m3s
Closes #172

Reviewed-on: #173
2024-03-17 20:17:41 +01:00
a062817ae7 The MusicBrainz API search call should use the MBID if available (#171)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m59s
Cargo CI / Lint (pull_request) Successful in 1m4s
Closes #169

Reviewed-on: #171
2024-03-17 17:18:06 +01:00
3ed13ca0e9 Create examples of using the MusicBrainz API (#170)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m7s
Cargo CI / Build and Test (pull_request) Successful in 1m58s
Cargo CI / Lint (pull_request) Successful in 1m4s
Closes #168

Reviewed-on: #170
2024-03-17 14:19:30 +01:00
a75dd46a40 Add a MusicBrainz API (#163)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m48s
Cargo CI / Lint (push) Successful in 1m8s
Cargo CI / Build and Test (pull_request) Successful in 1m56s
Cargo CI / Lint (pull_request) Successful in 1m5s
Closes #158

Reviewed-on: #163
2024-03-16 16:57:50 +01:00
c53ba8f35f Break down the musichoard files (#165)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m45s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 1m46s
Cargo CI / Lint (pull_request) Successful in 1m16s
Closes #164

Reviewed-on: #165
2024-03-09 22:52:03 +01:00
8550f7d6da Move database and library implementations out of core (#162)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 1m40s
Cargo CI / Lint (pull_request) Successful in 1m17s
Closes #159

Reviewed-on: #162
2024-03-09 19:11:59 +01:00
bd7e9ceb4d Connect release groups to musicbrainz id (#157)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m42s
Cargo CI / Lint (push) Successful in 1m15s
Cargo CI / Build and Test (pull_request) Successful in 3m9s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #46

Reviewed-on: #157
2024-03-08 23:28:52 +01:00
b70711d886 Add a field that indicates album ownership (#156)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 3m2s
Cargo CI / Lint (pull_request) Successful in 1m14s
Closes #47

Reviewed-on: #156
2024-03-07 23:12:41 +01:00
c015f4c112 Sort albums by month if two releases of the same artist happen in the same year (#155)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 3m8s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #106

Reviewed-on: #155
2024-03-05 23:24:18 +01:00
4dc56f66c6 Make Selection fields private (#154)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m39s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 1m40s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #153

Reviewed-on: #154
2024-03-01 22:31:12 +01:00
42d1edb69c Extend incremental search to albums and tracks (#152)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m39s
Cargo CI / Lint (push) Successful in 1m14s
Closes #145

Reviewed-on: #152
2024-03-01 22:04:26 +01:00
fd19ea3eb3 Rescanning library does not update the database (#151)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m40s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 1m38s
Cargo CI / Lint (pull_request) Successful in 1m14s
Closes #150

Reviewed-on: #151
2024-03-01 15:34:20 +01:00
4d2ea77da9 Ensure consistency between in-memory and database state (#146)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m13s
Closes #120

Reviewed-on: #146
2024-03-01 09:00:52 +01:00
3bb8fb03ab Bump version (#140)
All checks were successful
Cargo CI / Lint (push) Successful in 1m15s
Cargo CI / Build and Test (push) Successful in 1m42s
Cargo CI / Build and Test (pull_request) Successful in 3m1s
Cargo CI / Lint (pull_request) Successful in 1m13s
Closes #104

Reviewed-on: #140
2024-02-19 21:02:30 +01:00
dcc33d62b1 Benchmark a custom string normalisation function (#139)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m40s
Cargo CI / Lint (push) Successful in 1m14s
Closes #138

Reviewed-on: #139
2024-02-19 20:56:03 +01:00
84a2cc83ca Provide search functionality through the TUI (#134)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m16s
Cargo CI / Build and Test (pull_request) Successful in 1m42s
Cargo CI / Lint (pull_request) Successful in 1m16s
Closes #24

Reviewed-on: #134
2024-02-18 22:12:41 +01:00
c4dc0d173b Bump dependencies (#133)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m40s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 3m0s
Cargo CI / Lint (pull_request) Successful in 1m12s
Closes #132

Reviewed-on: #133
2024-02-10 23:47:26 +01:00
6a18c5d9cc Add a minibuffer (#131)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m15s
Closes #125

Reviewed-on: #131
2024-02-10 23:25:59 +01:00
de564eb1a0 Remove serde feature from uuid and url dependencies (#130)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m17s
Cargo CI / Build and Test (pull_request) Successful in 1m41s
Cargo CI / Lint (pull_request) Successful in 1m16s
Closes #129

Reviewed-on: #130
2024-02-10 20:58:40 +01:00
fad49a48b8 Make serde dependency optional (#128)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m1s
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (pull_request) Successful in 1m1s
Cargo CI / Lint (pull_request) Successful in 45s
Closes #127

Reviewed-on: #128
2024-02-10 20:38:14 +01:00
36b4918a44 Limit the information stored in the database (#126)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m2s
Cargo CI / Build and Test (pull_request) Successful in 1m4s
Cargo CI / Lint (pull_request) Successful in 41s
Cargo CI / Lint (push) Successful in 42s
Closes #118
Closes #68

Reviewed-on: #126
2024-02-10 20:28:52 +01:00
87de8d2b4e Add critical error state (#124)
All checks were successful
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (push) Successful in 1m2s
Closes #123

Reviewed-on: #124
2024-02-09 20:07:48 +01:00
c2506657c3 Streamline adding new URL types (#122)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m0s
Cargo CI / Lint (push) Successful in 43s
Closes #117

Reviewed-on: #122
2024-02-09 18:41:20 +01:00
9e9c6a1a4b Add support for PgUp and PgDn scrolling (#121)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m4s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 2m41s
Cargo CI / Lint (pull_request) Successful in 44s
Closes #119

Reviewed-on: #121
2024-02-05 23:44:30 +01:00
e7413ed885 Add shortcut to reload database and/or library (#116)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m3s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 2m22s
Cargo CI / Lint (pull_request) Successful in 43s
Closes #105

Reviewed-on: #116
2024-02-03 14:32:13 +01:00
ba85505c9a Split lib.rs into smaller files (#115)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m2s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 2m23s
Cargo CI / Lint (pull_request) Successful in 44s
Closes #110

Reviewed-on: #115
2024-01-22 23:01:34 +01:00
6e9249e265 Separate the collection from beets output in tests (#114)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m4s
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (pull_request) Successful in 1m3s
Cargo CI / Lint (pull_request) Successful in 42s
Closes #113

Reviewed-on: #114
2024-01-21 15:29:37 +01:00
267f4a5461 Help message for musichoard-edit artist sort are not showing (#112)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m3s
Cargo CI / Lint (push) Successful in 43s
Cargo CI / Build and Test (pull_request) Successful in 1m5s
Cargo CI / Lint (pull_request) Successful in 43s
Closes #111

Reviewed-on: #112
2024-01-14 15:57:18 +01:00
d876b75d14 Artists with a _sort field show up twice (#109)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m5s
Cargo CI / Lint (push) Successful in 44s
Closes #108

Reviewed-on: #109
2024-01-14 15:46:33 +01:00
3109e576e3 Sort by <field>_sort from tags if it is available (#107)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m3s
Cargo CI / Lint (push) Successful in 44s
Closes #73

Reviewed-on: #107
2024-01-13 15:42:04 +01:00
83675c25e6 Missing docstrings (#102)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m1s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 2m26s
Cargo CI / Lint (pull_request) Successful in 45s
Closes #98

Reviewed-on: #102
2024-01-12 21:52:06 +01:00
95ee681229 IDatabase::load should return D not take a mutable reference of it (#99)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m1s
Cargo CI / Lint (push) Successful in 43s
Cargo CI / Build and Test (pull_request) Successful in 1m1s
Cargo CI / Lint (pull_request) Successful in 45s
Closes #96

Reviewed-on: #99
2024-01-12 21:34:01 +01:00
a315bf4229 Fix with_xxx function naming in main.rs (#101)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m0s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 1m0s
Cargo CI / Lint (pull_request) Successful in 42s
Closes #100

Reviewed-on: #101
2024-01-12 21:15:59 +01:00
845e9b09f4 Distinguish NoLibrary/NoDatabase from EmptyLibrary/EmptyDatabase (#97)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m1s
Cargo CI / Lint (push) Successful in 47s
Cargo CI / Build and Test (pull_request) Successful in 1m2s
Cargo CI / Lint (pull_request) Successful in 44s
Closes #95

Reviewed-on: #97
2024-01-12 20:42:37 +01:00
d528511249 Make it possible to launch main binary without database and/or library (#88)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m1s
Cargo CI / Lint (push) Successful in 43s
Cargo CI / Build and Test (pull_request) Successful in 2m23s
Cargo CI / Lint (pull_request) Successful in 44s
Closes #87

Reviewed-on: #88
2024-01-11 23:27:01 +01:00
0c48673032 Change artist new/delete to add/remove (#92)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m2s
Cargo CI / Lint (push) Successful in 44s
Cargo CI / Build and Test (pull_request) Successful in 1m0s
Cargo CI / Lint (pull_request) Successful in 43s
Closes #89

Reviewed-on: #92
2024-01-11 21:51:51 +01:00
395cc57b9c Have consistent naming for binaries (#91)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m2s
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (pull_request) Successful in 1m2s
Cargo CI / Lint (pull_request) Successful in 43s
Closes #90

Reviewed-on: #91
2024-01-11 21:29:03 +01:00
36b82049f2 Add method to manually add artist metadata (#85)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m2s
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (pull_request) Successful in 2m26s
Cargo CI / Lint (pull_request) Successful in 44s
Closes #55

Reviewed-on: #85
2024-01-10 22:33:57 +01:00
1bc612dc45 Make database and library optional (#86)
All checks were successful
Cargo CI / Lint (push) Successful in 55s
Cargo CI / Build and Test (push) Successful in 1m12s
Closes #25

Reviewed-on: #86
2024-01-07 11:07:35 +01:00
d7384476d4 Add a code coverage check to the CI pipeline (#84)
All checks were successful
Cargo CI / Build and Test (push) Successful in 58s
Cargo CI / Lint (push) Successful in 42s
Cargo CI / Build and Test (pull_request) Successful in 1m0s
Cargo CI / Lint (pull_request) Successful in 42s
Closes #83

Reviewed-on: #84
2024-01-06 19:49:41 +01:00
26f0ccd842 Add integration tests to CI (#82)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m1s
Cargo CI / Lint (push) Successful in 43s
Closes #81

Reviewed-on: #82
2024-01-06 16:14:07 +01:00
b1cf5d621d Add CI to repository (#80)
All checks were successful
Cargo CI / Pipeline (push) Successful in 1m3s
Cargo CI / Pipeline (pull_request) Successful in 1m7s
Closes #77

Reviewed-on: #80
2024-01-06 11:14:30 +01:00
74f7da20e6 Fix clippy lints for rust 1.75 (#79)
Closes #76

Reviewed-on: #79
2024-01-05 21:25:55 +01:00
62d6c43e3c Add a popup window for artist metadata (#70)
Closes #56

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/70
2023-05-21 22:48:48 +02:00
3cd0cfde18 Artist merge for non-null properties always erases database properties (#72)
Closes #71

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/72
2023-05-21 22:28:51 +02:00
fd775372cd Add artist metadata fields (#69)
Closes #54

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/69
2023-05-21 17:24:00 +02:00
bf5bf9d8ae Add database-library merge (#59)
Closes #48

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/59
2023-05-20 00:02:39 +02:00
d20a9a9dec Change Quality enum to a struct (#66)
Closes #65

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/66
2023-05-11 21:45:23 +02:00
d51f9c138b Replace TrackFormat with Quality (#63)
Closes #60

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/63
2023-05-10 23:44:02 +02:00
282e0e6f19 Clean up interfaces (#62)
Closes #61

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/62
2023-05-10 22:52:03 +02:00
c6ed827984 Features are not correct (#58)
Closes #57

Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/58
2023-05-06 11:38:51 +02:00
116 changed files with 19740 additions and 2993 deletions

18
.gitea/images/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM docker.io/library/rust:1.80
RUN rustup component add \
clippy \
llvm-tools-preview \
rustfmt
RUN cargo install \
grcov
RUN apt-get update && apt-get install -y \
nodejs \
pipx
# Once pipx>=1.5.0 is available use --global instead of env
RUN env PIPX_HOME=/usr/local/pipx \
PIPX_BIN_DIR=/usr/local/bin \
pipx install --include-deps --system-site-packages beets==2.0.0

View File

@ -0,0 +1,28 @@
import argparse
import json
import sys
def main(coverage_file, fail_under):
with open(coverage_file, encoding="utf-8") as f:
coverage_json = json.load(f)
coverage = float(coverage_json["message"][:-1])
print(f"Code coverage: {coverage:.2f}%; Threshold: {fail_under:.2f}%")
success = coverage >= fail_under
if coverage < fail_under:
print("Insufficient code coverage", file=sys.stderr)
return success
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Check coverage output by grcov")
parser.add_argument("--coverage-file", type=str, required=True,
help="Path to the coverage.json file output by grcov")
parser.add_argument("--fail-under", type=float, default=100.,
help="Threshold under which coverage is insufficient")
args = parser.parse_args()
if not main(args.coverage_file, args.fail_under):
exit(2)

View File

@ -0,0 +1,57 @@
name: Cargo CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
CARGO_TERM_COLOR: always
CARGO_TERM_VERBOSE: true
jobs:
build_and_test:
name: Build and Test
container: docker.io/drrobot/musichoard-ci:20240824-1
env:
BEETSDIR: ./
LLVM_PROFILE_FILE: target/debug/profraw/musichoard-%p-%m.profraw
RUSTFLAGS: -C instrument-coverage
steps:
- uses: actions/checkout@v3
- run: cargo build --no-default-features --all-targets
- run: cargo test --no-default-features --all-targets --no-fail-fast
- run: cargo build --all-targets
- run: cargo test --all-targets --no-fail-fast
- run: cargo build --all-features --all-targets
- run: cargo test --all-features --all-targets --no-fail-fast
- run: >-
grcov target/debug/profraw
--binary-path target/debug/
--output-types html
--source-dir .
--ignore-not-existing
--ignore "build.rs"
--ignore "examples/*"
--ignore "tests/*"
--ignore "src/main.rs"
--ignore "src/bin/musichoard-edit.rs"
--excl-line "^#\[derive|unimplemented\!\(\)"
--excl-start "GRCOV_EXCL_START|mod tests \{"
--excl-stop "GRCOV_EXCL_STOP"
--output-path ./target/debug/coverage/
- run: >-
python3 .gitea/scripts/coverage.py
--coverage-file ./target/debug/coverage/coverage.json
--fail-under 100.00
lint:
name: Lint
container: docker.io/drrobot/musichoard-ci:20240824-1
steps:
- uses: actions/checkout@v3
- run: cargo clippy --no-default-features --all-targets -- -D warnings
- run: cargo clippy --all-targets -- -D warnings
- run: cargo clippy --all-features --all-targets -- -D warnings
- run: cargo fmt -- --check

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
/codecov
database.json
library.db

1409
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,66 @@
[package]
name = "musichoard"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
crossterm = { version = "0.26.1", optional = true}
openssh = { version = "0.9.9", features = ["native-mux"], default-features = false, optional = true}
ratatui = { version = "0.20.1", optional = true}
serde = { version = "1.0.159", features = ["derive"] }
serde_json = { version = "1.0.95", optional = true}
aho-corasick = { version = "1.1.2", optional = true }
crossterm = { version = "0.28.1", optional = true}
once_cell = { version = "1.19.0", optional = true}
openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true}
paste = { version = "1.0.15", optional = true }
ratatui = { version = "0.28.1", optional = true}
reqwest = { version = "0.11.25", features = ["blocking", "json"], optional = true }
serde = { version = "1.0.196", features = ["derive"], optional = true }
serde_json = { version = "1.0.113", optional = true}
structopt = { version = "0.3.26", optional = true}
tokio = { version = "1.27.0", features = ["rt"], optional = true}
uuid = { version = "1.3.0", features = ["serde"] }
tokio = { version = "1.36.0", features = ["rt"], optional = true}
# ratatui/crossterm dependency version must match with musichhoard's ratatui/crossterm
tui-input = { version = "0.10.1", optional = true }
url = { version = "2.5.0" }
uuid = { version = "1.7.0" }
[build-dependencies]
version_check = "0.9.4"
[dev-dependencies]
mockall = "0.11.4"
once_cell = "1.17.1"
tempfile = "3.5.0"
mockall = "0.12.1"
once_cell = "1.19.0"
tempfile = "3.10.0"
[features]
default = ["database-json", "library-beets"]
bin = ["structopt"]
database-json = ["serde_json"]
library-ssh = ["openssh", "tokio"]
tui = ["crossterm", "ratatui"]
database-json = ["serde", "serde_json"]
library-beets = []
library-beets-ssh = ["openssh", "tokio"]
musicbrainz = ["paste", "reqwest", "serde", "serde_json"]
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui", "tui-input"]
[[bin]]
name = "musichoard"
required-features = ["bin", "database-json", "library-ssh", "tui"]
required-features = ["bin", "database-json", "library-beets", "library-beets-ssh", "musicbrainz", "tui"]
[[bin]]
name = "musichoard-edit"
required-features = ["bin", "database-json"]
[[example]]
name = "musicbrainz-api---browse"
path = "examples/musicbrainz_api/browse.rs"
required-features = ["bin", "musicbrainz"]
[[example]]
name = "musicbrainz-api---lookup"
path = "examples/musicbrainz_api/lookup.rs"
required-features = ["bin", "musicbrainz"]
[[example]]
name = "musicbrainz-api---search"
path = "examples/musicbrainz_api/search.rs"
required-features = ["bin", "musicbrainz"]
[package.metadata.docs.rs]
all-features = true

View File

@ -1,5 +1,24 @@
# Music Hoard
## Developing
### Pre-requisites
#### musicbrainz-api
This feature requires the `openssl` system library.
On Fedora:
``` sh
sudo dnf install openssl-devel
```
## Usage notes
### Text selection
To select and copy text use the terminal-specific modifier key (on Linux this is usually the Shift key).
## Code Coverage
### Pre-requisites
@ -13,19 +32,26 @@ cargo install grcov
```sh
env CARGO_TARGET_DIR=codecov \
cargo clean
rm -rf ./codecov/debug/{coverage,profraw}
env CARGO_TARGET_DIR=codecov \
cargo clean -p musichoard
env RUSTFLAGS="-C instrument-coverage" \
LLVM_PROFILE_FILE="codecov/debug/profraw/musichoard-%p-%m.profraw" \
CARGO_TARGET_DIR=codecov \
cargo test --all-features
BEETSDIR=./ \
cargo test --all-features --all-targets
grcov codecov/debug/profraw \
--binary-path ./codecov/debug/ \
--output-types html \
--source-dir . \
--ignore-not-existing \
--ignore "build.rs" \
--ignore "examples/*" \
--ignore "tests/*" \
--ignore "src/main.rs" \
--excl-start "mod tests \{|GRCOV_EXCL_START" \
--ignore "src/bin/musichoard-edit.rs" \
--excl-line "^#\[derive|unimplemented\!\(\)" \
--excl-start "GRCOV_EXCL_START|mod tests \{" \
--excl-stop "GRCOV_EXCL_STOP" \
--output-path ./codecov/debug/coverage/
xdg-open codecov/debug/coverage/index.html
@ -35,3 +61,17 @@ Note that some changes may not be visible until `codecov/debug/coverage` is remo
command is rerun.
For most cases `cargo clean` can be replaced with `rm -rf ./codecov/debug/{coverage,profraw}`.
## Benchmarks
### Pre-requisites
``` sh
rustup toolchain install nightly
```
### Running benchmarks
``` sh
env BEETSDIR=./ rustup run nightly cargo bench --all-features --all-targets
```

6
build.rs Normal file
View File

@ -0,0 +1,6 @@
fn main() {
println!("cargo::rustc-check-cfg=cfg(nightly)");
if let Some(true) = version_check::is_feature_flaggable() {
println!("cargo::rustc-cfg=nightly");
}
}

View File

@ -0,0 +1,94 @@
use std::{thread, time};
use musichoard::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient, PageSettings},
http::MusicBrainzHttp,
},
};
use structopt::StructOpt;
use uuid::Uuid;
const USER_AGENT: &str = concat!(
"MusicHoard---examples---musicbrainz-api---browse/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
entity: OptEntity,
}
#[derive(StructOpt)]
enum OptEntity {
#[structopt(about = "Browse release groups")]
ReleaseGroup(OptReleaseGroup),
}
#[derive(StructOpt)]
enum OptReleaseGroup {
#[structopt(about = "Browse release groups of an artist")]
Artist(OptMbid),
}
#[derive(StructOpt)]
struct OptMbid {
#[structopt(help = "MBID of the entity")]
mbid: Uuid,
}
fn main() {
let opt = Opt::from_args();
println!("USER_AGENT: {USER_AGENT}");
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
let mut client = MusicBrainzClient::new(http);
match opt.entity {
OptEntity::ReleaseGroup(opt_release_group) => match opt_release_group {
OptReleaseGroup::Artist(opt_mbid) => {
let mbid: Mbid = opt_mbid.mbid.into();
let request = BrowseReleaseGroupRequest::artist(&mbid);
let mut paging = PageSettings::with_max_limit();
let mut response_counts: Vec<usize> = Vec::new();
loop {
let response = client
.browse_release_group(&request, &paging)
.expect("failed to make API call");
for rg in response.release_groups.iter() {
println!("{rg:?}\n");
}
let count = response.release_groups.len();
response_counts.push(count);
println!("Release groups in this response: {count}");
paging = match response.page.next_page(paging, count) {
Some(paging) => paging,
None => break,
};
thread::sleep(time::Duration::from_secs(1));
}
println!(
"Total: {}={} release groups",
response_counts
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("+"),
response_counts.iter().sum::<usize>(),
);
}
},
}
}

View File

@ -0,0 +1,81 @@
use musichoard::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{
lookup::{LookupArtistRequest, LookupReleaseGroupRequest},
MusicBrainzClient,
},
http::MusicBrainzHttp,
},
};
use structopt::StructOpt;
use uuid::Uuid;
const USER_AGENT: &str = concat!(
"MusicHoard---examples---musicbrainz-api---lookup/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
entity: OptEntity,
}
#[derive(StructOpt)]
enum OptEntity {
#[structopt(about = "Lookup artist")]
Artist(OptArtist),
#[structopt(about = "Lookup release group")]
ReleaseGroup(OptReleaseGroup),
}
#[derive(StructOpt)]
struct OptArtist {
#[structopt(help = "Artist MBID to lookup")]
mbid: Uuid,
#[structopt(long, help = "Include release groups in lookup")]
release_groups: bool,
}
#[derive(StructOpt)]
struct OptReleaseGroup {
#[structopt(help = "Release group MBID to lookup")]
mbid: Uuid,
}
fn main() {
let opt = Opt::from_args();
println!("USER_AGENT: {USER_AGENT}");
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
let mut client = MusicBrainzClient::new(http);
match opt.entity {
OptEntity::Artist(opt_artist) => {
let mbid: Mbid = opt_artist.mbid.into();
let mut request = LookupArtistRequest::new(&mbid);
if opt_artist.release_groups {
request.include_release_groups();
}
let response = client
.lookup_artist(&request)
.expect("failed to make API call");
println!("{response:#?}");
}
OptEntity::ReleaseGroup(opt_release_group) => {
let mbid: Mbid = opt_release_group.mbid.into();
let request = LookupReleaseGroupRequest::new(&mbid);
let response = client
.lookup_release_group(&request)
.expect("failed to make API call");
println!("{response:#?}");
}
}
}

View File

@ -0,0 +1,150 @@
use std::{num::ParseIntError, str::FromStr};
use musichoard::{
collection::{album::AlbumDate, musicbrainz::Mbid},
external::musicbrainz::{
api::{
search::{SearchArtistRequest, SearchReleaseGroupRequest},
MusicBrainzClient, PageSettings,
},
http::MusicBrainzHttp,
},
};
use structopt::StructOpt;
use uuid::Uuid;
const USER_AGENT: &str = concat!(
"MusicHoard---examples---musicbrainz-api---search/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(subcommand)]
entity: OptEntity,
}
#[derive(StructOpt)]
enum OptEntity {
#[structopt(about = "Search artist")]
Artist(OptArtist),
#[structopt(about = "Search release group")]
ReleaseGroup(OptReleaseGroup),
}
#[derive(StructOpt)]
struct OptArtist {
#[structopt(help = "Artist search string")]
string: String,
}
#[derive(StructOpt)]
enum OptReleaseGroup {
#[structopt(about = "Search by artist MBID, title(, and date)")]
Title(OptReleaseGroupTitle),
#[structopt(about = "Search by release group MBID")]
Rgid(OptReleaseGroupRgid),
}
#[derive(StructOpt)]
struct OptReleaseGroupTitle {
#[structopt(help = "Release group's artist MBID")]
arid: Uuid,
#[structopt(help = "Release group title")]
title: String,
#[structopt(help = "Release group release date")]
date: Option<Date>,
}
#[derive(StructOpt)]
struct OptReleaseGroupRgid {
#[structopt(help = "Release group MBID")]
rgid: Uuid,
}
struct Date(AlbumDate);
impl FromStr for Date {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut elems = s.split('-');
let elem = elems.next();
let year = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let month = elem.map(|s| s.parse()).transpose()?;
let elem = elems.next();
let day = elem.map(|s| s.parse()).transpose()?;
Ok(Date(AlbumDate::new(year, month, day)))
}
}
impl From<Date> for AlbumDate {
fn from(value: Date) -> Self {
value.0
}
}
fn main() {
let opt = Opt::from_args();
println!("USER_AGENT: {USER_AGENT}");
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
let mut client = MusicBrainzClient::new(http);
match opt.entity {
OptEntity::Artist(opt_artist) => {
let query = SearchArtistRequest::new().string(&opt_artist.string);
println!("Query: {query}");
let paging = PageSettings::default();
let matches = client
.search_artist(&query, &paging)
.expect("failed to make API call");
println!("{matches:#?}");
}
OptEntity::ReleaseGroup(opt_release_group) => {
let arid: Mbid;
let date: AlbumDate;
let title: String;
let rgid: Mbid;
let query = match opt_release_group {
OptReleaseGroup::Title(opt_title) => {
arid = opt_title.arid.into();
date = opt_title.date.map(Into::into).unwrap_or_default();
title = opt_title.title;
SearchReleaseGroupRequest::new()
.arid(&arid)
.and()
.release_group(&title)
.and()
.first_release_date(&date)
}
OptReleaseGroup::Rgid(opt_rgid) => {
rgid = opt_rgid.rgid.into();
SearchReleaseGroupRequest::new().rgid(&rgid)
}
};
println!("Query: {query}");
let paging = PageSettings::default();
let matches = client
.search_release_group(&query, &paging)
.expect("failed to make API call");
println!("{matches:#?}");
}
}
}

257
src/bin/musichoard-edit.rs Normal file
View File

@ -0,0 +1,257 @@
use std::path::PathBuf;
use structopt::{clap::AppSettings, StructOpt};
use musichoard::{
collection::{album::AlbumId, artist::ArtistId},
external::database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
IMusicHoardDatabase, MusicHoard, MusicHoardBuilder, NoLibrary,
};
type MH = MusicHoard<JsonDatabase<JsonDatabaseFileBackend>, NoLibrary>;
#[derive(StructOpt, Debug)]
#[structopt(about = "musichoard-edit: edit the MusicHoard database",
global_settings=&[AppSettings::DeriveDisplayOrder])]
struct Opt {
#[structopt(
long = "database",
help = "Database file path",
default_value = "database.json"
)]
database_file_path: PathBuf,
#[structopt(subcommand)]
command: Command,
}
#[derive(StructOpt, Debug)]
enum Command {
#[structopt(about = "Modify artist information")]
Artist(ArtistOpt),
}
#[derive(StructOpt, Debug)]
struct ArtistOpt {
// For some reason, not specyfing the artist name with the `long` name makes StructOpt failed
// for inexplicable reason. For example, it won't recognise `Abadde` or `Abadden` as a name and
// will insteady try to process it as a command.
#[structopt(long, help = "The name of the artist")]
name: String,
#[structopt(subcommand)]
command: ArtistCommand,
}
#[derive(StructOpt, Debug)]
enum ArtistCommand {
#[structopt(about = "Add a new artist to the collection")]
Add,
#[structopt(about = "Remove an artist from the collection")]
Remove,
#[structopt(about = "Edit the artist's sort name")]
Sort(SortCommand),
#[structopt(about = "Edit a property of an artist")]
Property(PropertyCommand),
#[structopt(about = "Modify the artist's album information")]
Album(AlbumOpt),
}
#[derive(StructOpt, Debug)]
enum SortCommand {
#[structopt(about = "Set the provided name as the artist's sort name")]
Set(SortValue),
#[structopt(about = "Clear the artist's sort name")]
Clear,
}
#[derive(StructOpt, Debug)]
struct SortValue {
#[structopt(help = "The sort name")]
sort: String,
}
#[derive(StructOpt, Debug)]
enum PropertyCommand {
#[structopt(about = "Add values to the property without overwriting existing values")]
Add(PropertyValue),
#[structopt(about = "Remove values from the property")]
Remove(PropertyValue),
#[structopt(about = "Set the property's values overwriting any existing values")]
Set(PropertyValue),
#[structopt(about = "Clear all values of a property")]
Clear(PropertyName),
}
#[derive(StructOpt, Debug)]
struct PropertyValue {
#[structopt(help = "The name of the property")]
property: String,
#[structopt(help = "The list of values")]
values: Vec<String>,
}
#[derive(StructOpt, Debug)]
struct PropertyName {
#[structopt(help = "The name of the property")]
property: String,
}
#[derive(StructOpt, Debug)]
struct AlbumOpt {
// Using `long` for consistency with `ArtistOpt`.
#[structopt(long, help = "The title of the album")]
title: String,
#[structopt(subcommand)]
command: AlbumCommand,
}
#[derive(StructOpt, Debug)]
enum AlbumCommand {
#[structopt(about = "Edit the album's sequence value")]
Seq(AlbumSeqCommand),
}
#[derive(StructOpt, Debug)]
enum AlbumSeqCommand {
#[structopt(about = "Set the sequence value overwriting any existing value")]
Set(AlbumSeqValue),
#[structopt(about = "Clear the sequence value")]
Clear,
}
#[derive(StructOpt, Debug)]
struct AlbumSeqValue {
#[structopt(help = "The new sequence value")]
value: u8,
}
impl Command {
fn handle(self, music_hoard: &mut MH) {
match self {
Command::Artist(artist_opt) => artist_opt.handle(music_hoard),
}
}
}
impl ArtistOpt {
fn handle(self, music_hoard: &mut MH) {
self.command.handle(music_hoard, &self.name)
}
}
impl ArtistCommand {
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
match self {
ArtistCommand::Add => {
music_hoard
.add_artist(ArtistId::new(artist_name))
.expect("failed to add artist");
}
ArtistCommand::Remove => {
music_hoard
.remove_artist(ArtistId::new(artist_name))
.expect("failed to remove artist");
}
ArtistCommand::Sort(sort_command) => {
sort_command.handle(music_hoard, artist_name);
}
ArtistCommand::Property(property_command) => {
property_command.handle(music_hoard, artist_name);
}
ArtistCommand::Album(album_opt) => {
album_opt.handle(music_hoard, artist_name);
}
}
}
}
impl SortCommand {
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
match self {
SortCommand::Set(artist_sort_value) => music_hoard
.set_artist_sort(ArtistId::new(artist_name), artist_sort_value.sort)
.expect("faild to set artist sort name"),
SortCommand::Clear => music_hoard
.clear_artist_sort(ArtistId::new(artist_name))
.expect("failed to clear artist sort name"),
}
}
}
impl PropertyCommand {
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
match self {
PropertyCommand::Add(property_value) => music_hoard
.add_to_artist_property(
ArtistId::new(artist_name),
property_value.property,
property_value.values,
)
.expect("failed to add values to property"),
PropertyCommand::Remove(property_value) => music_hoard
.remove_from_artist_property(
ArtistId::new(artist_name),
property_value.property,
property_value.values,
)
.expect("failed to remove values from property"),
PropertyCommand::Set(property_value) => music_hoard
.set_artist_property(
ArtistId::new(artist_name),
property_value.property,
property_value.values,
)
.expect("failed to set property"),
PropertyCommand::Clear(property_name) => music_hoard
.clear_artist_property(ArtistId::new(artist_name), property_name.property)
.expect("failed to clear property"),
}
}
}
impl AlbumOpt {
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
self.command.handle(music_hoard, artist_name, &self.title)
}
}
impl AlbumCommand {
fn handle(self, music_hoard: &mut MH, artist_name: &str, album_name: &str) {
match self {
AlbumCommand::Seq(seq_command) => {
seq_command.handle(music_hoard, artist_name, album_name);
}
}
}
}
impl AlbumSeqCommand {
fn handle(self, music_hoard: &mut MH, artist_name: &str, album_name: &str) {
match self {
AlbumSeqCommand::Set(seq_value) => music_hoard
.set_album_seq(
ArtistId::new(artist_name),
AlbumId::new(album_name),
seq_value.value,
)
.expect("failed to set sequence value"),
AlbumSeqCommand::Clear => music_hoard
.clear_album_seq(ArtistId::new(artist_name), AlbumId::new(album_name))
.expect("failed to clear sequence value"),
}
}
}
fn main() {
let opt = Opt::from_args();
let db = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path));
let mut music_hoard = MusicHoardBuilder::default()
.set_database(db)
.build()
.expect("failed to initialise MusicHoard");
opt.command.handle(&mut music_hoard);
}

View File

@ -1,178 +0,0 @@
//! Module for managing the music collection, i.e. "The Music Hoard".
use std::fmt;
use crate::{
database::{self, Database},
library::{self, Library, Query},
Artist,
};
/// The collection type.
pub type Collection = Vec<Artist>;
/// Error type for collection manager.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The [`CollectionManager`] failed to read/write from/to the library.
LibraryError(String),
/// The [`CollectionManager`] failed to read/write from/to the database.
DatabaseError(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"),
Self::DatabaseError(ref s) => {
write!(f, "failed to read/write from/to the database: {s}")
}
}
}
}
impl From<library::Error> for Error {
fn from(err: library::Error) -> Error {
Error::LibraryError(err.to_string())
}
}
impl From<database::Error> for Error {
fn from(err: database::Error) -> Error {
Error::DatabaseError(err.to_string())
}
}
pub trait CollectionManager {
/// Rescan the library and integrate any updates into the collection.
fn rescan_library(&mut self) -> Result<(), Error>;
/// Save the collection state to the database.
fn save_to_database(&mut self) -> Result<(), Error>;
/// Get the current collection.
fn get_collection(&self) -> &Collection;
}
/// The collection manager. It is responsible for pulling information from both the library and the
/// database, ensuring its consistent and writing back any changes.
pub struct MhCollectionManager<LIB, DB> {
library: LIB,
database: DB,
collection: Collection,
}
impl<LIB: Library, DB: Database> MhCollectionManager<LIB, DB> {
/// Create a new [`CollectionManager`] with the provided [`Library`] and [`Database`].
pub fn new(library: LIB, database: DB) -> Self {
MhCollectionManager {
library,
database,
collection: vec![],
}
}
}
impl<LIB: Library, DB: Database> CollectionManager for MhCollectionManager<LIB, DB> {
fn rescan_library(&mut self) -> Result<(), Error> {
self.collection = self.library.list(&Query::new())?;
Ok(())
}
fn save_to_database(&mut self) -> Result<(), Error> {
self.database.write(&self.collection)?;
Ok(())
}
fn get_collection(&self) -> &Collection {
&self.collection
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::{
collection::Collection,
database::{self, MockDatabase},
library::{self, MockLibrary, Query},
tests::COLLECTION,
};
use super::{CollectionManager, Error, MhCollectionManager};
#[test]
fn read_get_write() {
let mut library = MockLibrary::new();
let mut database = MockDatabase::new();
let library_input = Query::new();
let library_result = Ok(COLLECTION.to_owned());
let database_input = COLLECTION.to_owned();
let database_result = Ok(());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
database
.expect_write()
.with(predicate::eq(database_input))
.times(1)
.return_once(|_: &Collection| database_result);
let mut collection_manager = MhCollectionManager::new(library, database);
collection_manager.rescan_library().unwrap();
assert_eq!(collection_manager.get_collection(), &*COLLECTION);
collection_manager.save_to_database().unwrap();
}
#[test]
fn library_error() {
let mut library = MockLibrary::new();
let database = MockDatabase::new();
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
library
.expect_list()
.times(1)
.return_once(|_| library_result);
let mut collection_manager = MhCollectionManager::new(library, database);
let actual_err = collection_manager.rescan_library().unwrap_err();
let expected_err =
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn database_error() {
let library = MockLibrary::new();
let mut database = MockDatabase::new();
let database_result = Err(database::Error::IoError(String::from("I/O error")));
database
.expect_write()
.times(1)
.return_once(|_: &Collection| database_result);
let mut collection_manager = MhCollectionManager::new(library, database);
let actual_err = collection_manager.save_to_database().unwrap_err();
let expected_err =
Error::DatabaseError(database::Error::IoError(String::from("I/O error")).to_string());
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}

View File

@ -0,0 +1,383 @@
use std::{
fmt::{self, Display},
mem,
};
use crate::core::collection::{
merge::{Merge, MergeSorted, WithId},
musicbrainz::{MbAlbumRef, MbRefOption},
track::{Track, TrackFormat},
};
/// An album is a collection of tracks that were released together.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Album {
pub meta: AlbumMeta,
pub tracks: Vec<Track>,
}
/// Album metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumMeta {
pub id: AlbumId,
pub date: AlbumDate,
pub seq: AlbumSeq,
pub info: AlbumInfo,
}
/// Album non-identifier metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumInfo {
pub musicbrainz: MbRefOption<MbAlbumRef>,
pub primary_type: Option<AlbumPrimaryType>,
pub secondary_types: Vec<AlbumSecondaryType>,
}
impl WithId for Album {
type Id = AlbumId;
fn id(&self) -> &Self::Id {
&self.meta.id
}
}
/// The album identifier.
#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)]
pub struct AlbumId {
pub title: String,
}
// There are crates for handling dates, but we don't need much complexity beyond year-month-day.
/// The album's release date.
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct AlbumDate {
pub year: Option<u32>,
pub month: Option<u8>,
pub day: Option<u8>,
}
impl AlbumDate {
pub fn new(year: Option<u32>, month: Option<u8>, day: Option<u8>) -> Self {
AlbumDate { year, month, day }
}
}
impl From<u32> for AlbumDate {
fn from(value: u32) -> Self {
AlbumDate::new(Some(value), None, None)
}
}
impl From<(u32, u8)> for AlbumDate {
fn from(value: (u32, u8)) -> Self {
AlbumDate::new(Some(value.0), Some(value.1), None)
}
}
impl From<(u32, u8, u8)> for AlbumDate {
fn from(value: (u32, u8, u8)) -> Self {
AlbumDate::new(Some(value.0), Some(value.1), Some(value.2))
}
}
/// The album's sequence to determine order when two or more albums have the same release date.
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd, Ord, Eq, Hash)]
pub struct AlbumSeq(pub u8);
/// Based on [MusicBrainz types](https://musicbrainz.org/doc/Release_Group/Type).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlbumPrimaryType {
/// Album
Album,
/// Single
Single,
/// EP
Ep,
/// Broadcast
Broadcast,
/// Other
Other,
}
/// Based on [MusicBrainz types](https://musicbrainz.org/doc/Release_Group/Type).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum AlbumSecondaryType {
/// Compilation
Compilation,
/// Soundtrack
Soundtrack,
/// Spokenword
Spokenword,
/// Interview
Interview,
/// Audiobook
Audiobook,
/// Audio drama
AudioDrama,
/// Live
Live,
/// Remix
Remix,
/// DJ-mix
DjMix,
/// Mixtape/Street
MixtapeStreet,
/// Demo
Demo,
/// Field recording
FieldRecording,
}
/// The album's ownership status.
pub enum AlbumStatus {
None,
Owned(TrackFormat),
}
impl AlbumStatus {
pub fn from_tracks(tracks: &[Track]) -> AlbumStatus {
match tracks.iter().map(|t| t.quality.format).min() {
Some(format) => AlbumStatus::Owned(format),
None => AlbumStatus::None,
}
}
}
impl Album {
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>(
id: Id,
date: Date,
primary_type: Option<AlbumPrimaryType>,
secondary_types: Vec<AlbumSecondaryType>,
) -> Self {
let info = AlbumInfo::new(MbRefOption::None, primary_type, secondary_types);
Album {
meta: AlbumMeta::new(id, date, info),
tracks: vec![],
}
}
pub fn get_status(&self) -> AlbumStatus {
AlbumStatus::from_tracks(&self.tracks)
}
}
impl PartialOrd for Album {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Album {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.meta.cmp(&other.meta)
}
}
impl Merge for Album {
fn merge_in_place(&mut self, other: Self) {
self.meta.merge_in_place(other.meta);
let tracks = mem::take(&mut self.tracks);
self.tracks = MergeSorted::new(tracks.into_iter(), other.tracks.into_iter()).collect();
}
}
impl AlbumMeta {
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>(
id: Id,
date: Date,
info: AlbumInfo,
) -> Self {
AlbumMeta {
id: id.into(),
date: date.into(),
seq: AlbumSeq::default(),
info,
}
}
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
(&self.date, &self.seq, &self.id)
}
pub fn set_seq(&mut self, seq: AlbumSeq) {
self.seq = seq;
}
pub fn clear_seq(&mut self) {
self.seq = AlbumSeq::default();
}
}
impl Default for AlbumInfo {
fn default() -> Self {
AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: None,
secondary_types: Vec::new(),
}
}
}
impl AlbumInfo {
pub fn new(
musicbrainz: MbRefOption<MbAlbumRef>,
primary_type: Option<AlbumPrimaryType>,
secondary_types: Vec<AlbumSecondaryType>,
) -> Self {
AlbumInfo {
musicbrainz,
primary_type,
secondary_types,
}
}
}
impl PartialOrd for AlbumMeta {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AlbumMeta {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.get_sort_key().cmp(&other.get_sort_key())
}
}
impl Merge for AlbumMeta {
fn merge_in_place(&mut self, other: Self) {
assert_eq!(self.id, other.id);
self.seq = std::cmp::max(self.seq, other.seq);
self.info.merge_in_place(other.info);
}
}
impl Merge for AlbumInfo {
fn merge_in_place(&mut self, other: Self) {
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
self.primary_type = self.primary_type.take().or(other.primary_type);
self.secondary_types.merge_in_place(other.secondary_types);
}
}
impl<S: Into<String>> From<S> for AlbumId {
fn from(value: S) -> Self {
AlbumId::new(value)
}
}
impl AsRef<AlbumId> for AlbumId {
fn as_ref(&self) -> &AlbumId {
self
}
}
impl AlbumId {
pub fn new<S: Into<String>>(name: S) -> AlbumId {
AlbumId { title: name.into() }
}
}
impl Display for AlbumId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.title)
}
}
#[cfg(test)]
mod tests {
use crate::core::testmod::FULL_COLLECTION;
use super::*;
#[test]
fn album_date_from() {
let date: AlbumDate = 1986.into();
assert_eq!(date, AlbumDate::new(Some(1986), None, None));
let date: AlbumDate = (1986, 5).into();
assert_eq!(date, AlbumDate::new(Some(1986), Some(5), None));
let date: AlbumDate = (1986, 6, 8).into();
assert_eq!(date, AlbumDate::new(Some(1986), Some(6), Some(8)));
}
#[test]
fn same_date_seq_cmp() {
let date: AlbumDate = (2024, 3, 2).into();
let album_id_1 = AlbumId {
title: String::from("album z"),
};
let mut album_1 = Album::new(album_id_1, date.clone(), None, vec![]);
album_1.meta.set_seq(AlbumSeq(1));
let album_id_2 = AlbumId {
title: String::from("album a"),
};
let mut album_2 = Album::new(album_id_2, date.clone(), None, vec![]);
album_2.meta.set_seq(AlbumSeq(2));
assert_ne!(album_1, album_2);
assert!(album_1 < album_2);
assert!(album_1.meta < album_2.meta);
}
#[test]
fn set_clear_seq() {
let mut album = Album::new("An album", AlbumDate::default(), None, vec![]);
assert_eq!(album.meta.seq, AlbumSeq(0));
// Setting a seq on an album.
album.meta.set_seq(AlbumSeq(6));
assert_eq!(album.meta.seq, AlbumSeq(6));
album.meta.set_seq(AlbumSeq(6));
assert_eq!(album.meta.seq, AlbumSeq(6));
album.meta.set_seq(AlbumSeq(8));
assert_eq!(album.meta.seq, AlbumSeq(8));
// Clearing seq.
album.meta.clear_seq();
assert_eq!(album.meta.seq, AlbumSeq(0));
}
#[test]
fn merge_album_no_overlap() {
let left = FULL_COLLECTION[0].albums[0].to_owned();
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
right.meta.id = left.meta.id.clone();
let mut expected = left.clone();
expected.tracks.append(&mut right.tracks.clone());
expected.tracks.sort_unstable();
let merged = left.clone().merge(right.clone());
assert_eq!(expected, merged);
// Non-overlapping merge should be commutative in the tracks.
let merged = right.clone().merge(left.clone());
assert_eq!(expected.tracks, merged.tracks);
}
#[test]
fn merge_album_overlap() {
let mut left = FULL_COLLECTION[0].albums[0].to_owned();
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
right.meta.id = left.meta.id.clone();
left.tracks.push(right.tracks[0].clone());
left.tracks.sort_unstable();
let mut expected = left.clone();
expected.tracks.append(&mut right.tracks.clone());
expected.tracks.sort_unstable();
expected.tracks.dedup();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
}

View File

@ -0,0 +1,459 @@
use std::{
collections::HashMap,
fmt::{self, Debug, Display},
mem,
};
use crate::core::collection::{
album::Album,
merge::{Merge, MergeCollections, WithId},
musicbrainz::{MbArtistRef, MbRefOption},
};
/// An artist.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Artist {
pub meta: ArtistMeta,
pub albums: Vec<Album>,
}
/// Artist metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtistMeta {
pub id: ArtistId,
pub sort: Option<String>,
pub info: ArtistInfo,
}
/// Artist non-identifier metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtistInfo {
pub musicbrainz: MbRefOption<MbArtistRef>,
pub properties: HashMap<String, Vec<String>>,
}
impl WithId for Artist {
type Id = ArtistId;
fn id(&self) -> &Self::Id {
&self.meta.id
}
}
/// The artist identifier.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ArtistId {
pub name: String,
}
impl Artist {
/// Create new [`Artist`] with the given [`ArtistId`].
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
Artist {
meta: ArtistMeta::new(id),
albums: vec![],
}
}
}
impl PartialOrd for Artist {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Artist {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.meta.cmp(&other.meta)
}
}
impl Merge for Artist {
fn merge_in_place(&mut self, other: Self) {
self.meta.merge_in_place(other.meta);
let albums = mem::take(&mut self.albums);
self.albums = MergeCollections::merge_iter(albums, other.albums);
}
}
impl ArtistMeta {
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
ArtistMeta {
id: id.into(),
sort: None,
info: ArtistInfo::default(),
}
}
pub fn get_sort_key(&self) -> (&str,) {
(self.sort.as_ref().unwrap_or(&self.id.name),)
}
pub fn set_sort_key<S: Into<String>>(&mut self, sort: S) {
self.sort = Some(sort.into());
}
pub fn clear_sort_key(&mut self) {
self.sort.take();
}
}
impl Default for ArtistInfo {
fn default() -> Self {
Self::new(MbRefOption::None)
}
}
impl ArtistInfo {
pub fn new(musicbrainz: MbRefOption<MbArtistRef>) -> Self {
ArtistInfo {
musicbrainz,
properties: HashMap::new(),
}
}
pub fn set_musicbrainz_ref(&mut self, mbref: MbRefOption<MbArtistRef>) {
self.musicbrainz = mbref
}
pub fn clear_musicbrainz_ref(&mut self) {
self.musicbrainz.take();
}
// In the functions below, it would be better to use `contains` instead of `iter().any`, but for
// type reasons that does not work:
// https://stackoverflow.com/questions/48985924/why-does-a-str-not-coerce-to-a-string-when-using-veccontains
pub fn add_to_property<S: AsRef<str> + Into<String>>(&mut self, property: S, values: Vec<S>) {
match self.properties.get_mut(property.as_ref()) {
Some(container) => {
container.append(
&mut values
.into_iter()
.filter(|val| !container.iter().any(|x| x == val.as_ref()))
.map(|val| val.into())
.collect(),
);
}
None => {
self.properties.insert(
property.into(),
values.into_iter().map(|s| s.into()).collect(),
);
}
}
}
pub fn remove_from_property<S: AsRef<str>>(&mut self, property: S, values: Vec<S>) {
if let Some(container) = self.properties.get_mut(property.as_ref()) {
container.retain(|val| !values.iter().any(|x| x.as_ref() == val));
if container.is_empty() {
self.properties.remove(property.as_ref());
}
}
}
pub fn set_property<S: AsRef<str> + Into<String>>(&mut self, property: S, values: Vec<S>) {
self.properties.insert(
property.into(),
values.into_iter().map(|s| s.into()).collect(),
);
}
pub fn clear_property<S: AsRef<str>>(&mut self, property: S) {
self.properties.remove(property.as_ref());
}
}
impl PartialOrd for ArtistMeta {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ArtistMeta {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.get_sort_key().cmp(&other.get_sort_key())
}
}
impl Merge for ArtistMeta {
fn merge_in_place(&mut self, other: Self) {
assert_eq!(self.id, other.id);
self.sort = self.sort.take().or(other.sort);
self.info.merge_in_place(other.info);
}
}
impl Merge for ArtistInfo {
fn merge_in_place(&mut self, other: Self) {
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
self.properties.merge_in_place(other.properties);
}
}
impl<S: Into<String>> From<S> for ArtistId {
fn from(value: S) -> Self {
ArtistId::new(value)
}
}
impl AsRef<ArtistId> for ArtistId {
fn as_ref(&self) -> &ArtistId {
self
}
}
impl ArtistId {
pub fn new<S: Into<String>>(name: S) -> ArtistId {
ArtistId { name: name.into() }
}
}
impl Display for ArtistId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
}
#[cfg(test)]
mod tests {
use crate::core::testmod::FULL_COLLECTION;
use super::*;
static MUSICBRAINZ: &str =
"https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8";
static MUSICBRAINZ_2: &str =
"https://musicbrainz.org/artist/823869a5-5ded-4f6b-9fb7-2a9344d83c6b";
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
#[test]
fn artist_sort_set_clear() {
let artist_id = ArtistId::new("an artist");
let sort_id_1 = String::from("sort id 1");
let sort_id_2 = String::from("sort id 2");
let mut artist = Artist::new(&artist_id.name);
assert_eq!(artist.meta.id, artist_id);
assert_eq!(artist.meta.sort, None);
assert_eq!(artist.meta.get_sort_key(), (artist_id.name.as_str(),));
assert!(artist.meta < ArtistMeta::new(sort_id_1.clone()));
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
assert!(artist < Artist::new(sort_id_1.clone()));
assert!(artist < Artist::new(sort_id_2.clone()));
artist.meta.set_sort_key(sort_id_1.clone());
assert_eq!(artist.meta.id, artist_id);
assert_eq!(artist.meta.sort.as_ref(), Some(&sort_id_1));
assert_eq!(artist.meta.get_sort_key(), (sort_id_1.as_str(),));
assert!(artist.meta > ArtistMeta::new(artist_id.clone()));
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
assert!(artist > Artist::new(artist_id.clone()));
assert!(artist < Artist::new(sort_id_2.clone()));
artist.meta.set_sort_key(sort_id_2.clone());
assert_eq!(artist.meta.id, artist_id);
assert_eq!(artist.meta.sort.as_ref(), Some(&sort_id_2));
assert_eq!(artist.meta.get_sort_key(), (sort_id_2.as_str(),));
assert!(artist.meta > ArtistMeta::new(artist_id.clone()));
assert!(artist.meta > ArtistMeta::new(sort_id_1.clone()));
assert!(artist > Artist::new(artist_id.clone()));
assert!(artist > Artist::new(sort_id_1.clone()));
artist.meta.clear_sort_key();
assert_eq!(artist.meta.id, artist_id);
assert_eq!(artist.meta.sort, None);
assert_eq!(artist.meta.get_sort_key(), (artist_id.name.as_str(),));
assert!(artist.meta < ArtistMeta::new(sort_id_1.clone()));
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
assert!(artist < Artist::new(sort_id_1.clone()));
assert!(artist < Artist::new(sort_id_2.clone()));
}
#[test]
fn set_clear_musicbrainz_url() {
let mut artist = Artist::new(ArtistId::new("an artist"));
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
assert_eq!(artist.meta.info.musicbrainz, expected);
// Setting a URL on an artist.
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
));
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
assert_eq!(artist.meta.info.musicbrainz, expected);
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
));
assert_eq!(artist.meta.info.musicbrainz, expected);
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap(),
));
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap());
assert_eq!(artist.meta.info.musicbrainz, expected);
// Clearing URLs.
artist.meta.info.clear_musicbrainz_ref();
expected.take();
assert_eq!(artist.meta.info.musicbrainz, expected);
}
#[test]
fn add_to_remove_from_property() {
let mut artist = Artist::new(ArtistId::new("an artist"));
let info = &mut artist.meta.info;
let mut expected: Vec<String> = vec![];
assert!(info.properties.is_empty());
// Adding a single URL.
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
expected.push(MUSICBUTLER.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Adding a URL that already exists is ok, but does not do anything.
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Adding another single URL.
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing a URL.
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
expected.retain(|url| url != MUSICBUTLER);
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing URls that do not exist is okay, they will be ignored.
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing a URL.
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
assert!(info.properties.is_empty());
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
assert!(info.properties.is_empty());
// Adding URLs if some exist is okay, they will be ignored.
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
expected.push(MUSICBUTLER.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing URLs if some do not exist is okay, they will be ignored.
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
expected.retain(|url| url.as_str() != MUSICBUTLER);
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
assert!(info.properties.is_empty());
// Adding mutliple URLs without clashes.
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing multiple URLs without clashes.
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
expected.clear();
assert!(info.properties.is_empty());
}
#[test]
fn set_clear_musicbutler_urls() {
let mut artist = Artist::new(ArtistId::new("an artist"));
let info = &mut artist.meta.info;
let mut expected: Vec<String> = vec![];
assert!(info.properties.is_empty());
// Set URLs.
info.set_property("MusicButler", vec![MUSICBUTLER]);
expected.push(MUSICBUTLER.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
info.set_property("MusicButler", vec![MUSICBUTLER_2]);
expected.clear();
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
info.set_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
expected.clear();
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Clear URLs.
info.clear_property("MusicButler");
expected.clear();
assert!(info.properties.is_empty());
}
#[test]
fn merge_artist_no_overlap() {
let left = FULL_COLLECTION[0].to_owned();
let mut right = FULL_COLLECTION[1].to_owned();
right.meta.id = left.meta.id.clone();
right.meta.info.musicbrainz = MbRefOption::None;
right.meta.info.properties = HashMap::new();
let mut expected = left.clone();
expected.meta.info.properties = expected
.meta
.info
.properties
.merge(right.clone().meta.info.properties);
expected.albums.append(&mut right.albums.clone());
expected.albums.sort_unstable();
let merged = left.clone().merge(right.clone());
assert_eq!(expected, merged);
// Non-overlapping merge should be commutative.
let merged = right.clone().merge(left.clone());
assert_eq!(expected, merged);
}
#[test]
fn merge_artist_overlap() {
let mut left = FULL_COLLECTION[0].to_owned();
let mut right = FULL_COLLECTION[1].to_owned();
right.meta.id = left.meta.id.clone();
left.albums.push(right.albums[0].clone());
left.albums.sort_unstable();
let mut expected = left.clone();
expected.meta.info.properties = expected
.meta
.info
.properties
.merge(right.clone().meta.info.properties);
expected.albums.append(&mut right.albums.clone());
expected.albums.sort_unstable();
expected.albums.dedup();
let merged = left.clone().merge(right);
assert_eq!(expected, merged);
}
}

View File

@ -0,0 +1,123 @@
use std::{cmp::Ordering, collections::HashMap, hash::Hash, iter::Peekable, marker::PhantomData};
/// A trait for merging two objects. The merge is asymmetric with the left argument considered to be
/// the primary whose properties are to be kept in case of collisions.
pub trait Merge {
fn merge_in_place(&mut self, other: Self);
fn merge(mut self, other: Self) -> Self
where
Self: Sized,
{
self.merge_in_place(other);
self
}
}
impl<T: Ord> Merge for Vec<T> {
fn merge_in_place(&mut self, mut other: Self) {
self.append(&mut other);
self.sort_unstable();
self.dedup();
}
}
impl<K: Hash + PartialEq + Eq, T: Ord> Merge for HashMap<K, Vec<T>> {
fn merge_in_place(&mut self, mut other: Self) {
for (other_key, other_value) in other.drain() {
if let Some(ref mut value) = self.get_mut(&other_key) {
value.merge_in_place(other_value)
} else {
self.insert(other_key, other_value);
}
}
}
}
pub struct MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
left: Peekable<L>,
right: Peekable<R>,
}
impl<L, R> MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
{
pub fn new(left: L, right: R) -> MergeSorted<L, R> {
MergeSorted {
left: left.peekable(),
right: right.peekable(),
}
}
}
impl<L, R> Iterator for MergeSorted<L, R>
where
L: Iterator<Item = R::Item>,
R: Iterator,
L::Item: Ord + Merge,
{
type Item = L::Item;
fn next(&mut self) -> Option<L::Item> {
let which = match (self.left.peek(), self.right.peek()) {
(Some(l), Some(r)) => l.cmp(r),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => return None,
};
match which {
Ordering::Less => self.left.next(),
Ordering::Equal => Some(self.left.next().unwrap().merge(self.right.next().unwrap())),
Ordering::Greater => self.right.next(),
}
}
}
pub trait WithId {
type Id;
fn id(&self) -> &Self::Id;
}
pub struct MergeCollections<ID, T, IT> {
_id: PhantomData<ID>,
_t: PhantomData<T>,
_it: PhantomData<IT>,
}
impl<ID, T, IT> MergeCollections<ID, T, IT>
where
ID: Eq + Hash + Clone,
T: WithId<Id = ID> + Merge + Ord,
IT: IntoIterator<Item = T>,
{
pub fn merge_iter(primary: IT, secondary: IT) -> Vec<T> {
let primary = primary
.into_iter()
.map(|item| (item.id().clone(), item))
.collect();
Self::merge(primary, secondary)
}
pub fn merge(mut primary: HashMap<ID, T>, secondary: IT) -> Vec<T> {
for secondary_item in secondary {
if let Some(ref mut primary_item) = primary.get_mut(secondary_item.id()) {
primary_item.merge_in_place(secondary_item);
} else {
primary.insert(secondary_item.id().clone(), secondary_item);
}
}
let mut collection: Vec<T> = primary.into_values().collect();
collection.sort_unstable();
collection
}
}

View File

@ -0,0 +1,42 @@
//! The collection module defines the core data types and their relations.
pub mod album;
pub mod artist;
pub mod merge;
pub mod musicbrainz;
pub mod track;
use std::fmt::{self, Display};
/// The [`Collection`] alias type for convenience.
pub type Collection = Vec<artist::Artist>;
/// Error type for the [`collection`] module.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// An error occurred when processing an MBID.
MbidError(String),
/// An error occurred when processing a URL.
UrlError(String),
}
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::MbidError(ref s) => write!(f, "an error occurred when processing an MBID: {s}"),
Self::UrlError(ref s) => write!(f, "an error occurred when processing a URL: {s}"),
}
}
}
impl From<url::ParseError> for Error {
fn from(err: url::ParseError) -> Error {
Error::UrlError(err.to_string())
}
}
impl From<uuid::Error> for Error {
fn from(err: uuid::Error) -> Error {
Error::MbidError(err.to_string())
}
}

View File

@ -0,0 +1,289 @@
use std::{fmt, mem};
use url::Url;
use uuid::Uuid;
use crate::core::collection::Error;
const MB_DOMAIN: &str = "musicbrainz.org";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mbid(Uuid);
impl Mbid {
pub fn uuid(&self) -> &Uuid {
&self.0
}
}
impl From<Uuid> for Mbid {
fn from(value: Uuid) -> Self {
Mbid(value)
}
}
macro_rules! try_from_impl_for_mbid {
($from:ty) => {
impl TryFrom<$from> for Mbid {
type Error = Error;
fn try_from(value: $from) -> Result<Self, Self::Error> {
Ok(Uuid::parse_str(value.as_ref())?.into())
}
}
};
}
try_from_impl_for_mbid!(&str);
try_from_impl_for_mbid!(&String);
try_from_impl_for_mbid!(String);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbRefOption<T> {
Some(T),
CannotHaveMbid,
None,
}
impl<T> MbRefOption<T> {
pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> {
match (&self, &optb) {
(MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self,
_ => optb,
}
}
pub fn replace(&mut self, value: T) -> MbRefOption<T> {
mem::replace(self, MbRefOption::Some(value))
}
pub fn take(&mut self) -> MbRefOption<T> {
mem::replace(self, MbRefOption::None)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct MusicBrainzRef {
mbid: Mbid,
url: Url,
}
pub trait IMusicBrainzRef {
fn mbid(&self) -> &Mbid;
fn url(&self) -> &Url;
fn entity() -> &'static str;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbArtistRef(MusicBrainzRef);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbAlbumRef(MusicBrainzRef);
macro_rules! impl_imusicbrainzref {
($mbref:ident, $entity:literal) => {
impl IMusicBrainzRef for $mbref {
fn mbid(&self) -> &Mbid {
&self.0.mbid
}
fn url(&self) -> &Url {
&self.0.url
}
fn entity() -> &'static str {
$entity
}
}
impl TryFrom<Url> for $mbref {
type Error = Error;
fn try_from(url: Url) -> Result<Self, Self::Error> {
Ok($mbref(MusicBrainzRef::from_url(url, $mbref::entity())?))
}
}
impl From<Uuid> for $mbref {
fn from(uuid: Uuid) -> Self {
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
}
}
impl From<Mbid> for $mbref {
fn from(mbid: Mbid) -> Self {
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
}
}
impl $mbref {
pub fn from_url_str<S: AsRef<str>>(url: S) -> Result<Self, Error> {
let url: Url = url.as_ref().try_into()?;
url.try_into()
}
}
impl $mbref {
pub fn from_uuid_str<S: AsRef<str>>(uuid: S) -> Result<Self, Error> {
let uuid: Uuid = uuid.as_ref().try_into()?;
Ok(uuid.into())
}
}
};
}
impl_imusicbrainzref!(MbArtistRef, "artist");
impl_imusicbrainzref!(MbAlbumRef, "release-group");
impl MusicBrainzRef {
fn from_url(url: Url, entity: &'static str) -> Result<Self, Error> {
if !url
.domain()
.map(|u| u.ends_with(MB_DOMAIN))
.unwrap_or(false)
{
return Err(Self::invalid_url_error(url, entity));
}
// path_segments only returns an empty iterator if the URL cannot-be-a-base. However, if the
// URL cannot-be-a-base then it will fail the check above already as it won't have a domain.
if url.path_segments().and_then(|mut ps| ps.nth(0)).unwrap() != entity {
return Err(Self::invalid_url_error(url, entity));
}
let mbid = match url.path_segments().and_then(|mut ps| ps.nth(1)) {
Some(segment) => Uuid::try_parse(segment)?.into(),
None => return Err(Self::invalid_url_error(url, entity)),
};
Ok(MusicBrainzRef { mbid, url })
}
fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
let mbid = id.into();
let uuid_str = mbid.uuid().to_string();
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
MusicBrainzRef { mbid, url }
}
fn invalid_url_error<U: fmt::Display>(url: U, entity: &'static str) -> Error {
Error::UrlError(format!("invalid {entity} MusicBrainz URL: {url}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artist() {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url_str = format!("https://musicbrainz.org/artist/{uuid}");
let mb = MbArtistRef::from_url_str(&url_str).unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let mb = MbArtistRef::from_uuid_str(uuid).unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
let mb: MbArtistRef = mbid.into();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let url: Url = url_str.as_str().try_into().unwrap();
let mb: MbArtistRef = url.try_into().unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
}
#[test]
fn album() {
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
let url_str = format!("https://musicbrainz.org/release-group/{uuid}");
let mb = MbAlbumRef::from_url_str(&url_str).unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let mb = MbAlbumRef::from_uuid_str(uuid).unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
let mb: MbAlbumRef = mbid.into();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
let url: Url = url_str.as_str().try_into().unwrap();
let mb: MbAlbumRef = url.try_into().unwrap();
assert_eq!(url_str, mb.url().as_ref());
assert_eq!(uuid, mb.mbid().uuid().to_string());
}
#[test]
fn not_a_url() {
let url = "not a url at all";
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn invalid_url() {
let url = "https://www.musicbutler.io/artist-page/483340948";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn artist_invalid_type() {
let url = "https://musicbrainz.org/release-group/i-am-not-a-uuid";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn album_invalid_type() {
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
let expected_error =
Error::UrlError(format!("invalid release-group MusicBrainz URL: {url}"));
let actual_error = MbAlbumRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn invalid_uuid() {
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into();
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn missing_type() {
let url = "https://musicbrainz.org";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}/"));
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
#[test]
fn missing_uuid() {
let url = "https://musicbrainz.org/artist";
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
assert_eq!(actual_error, expected_error);
assert_eq!(actual_error.to_string(), expected_error.to_string());
}
}

View File

@ -0,0 +1,96 @@
use crate::core::collection::merge::Merge;
/// A single track on an album.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Track {
pub id: TrackId,
pub number: TrackNum,
pub artist: Vec<String>,
pub quality: TrackQuality,
}
/// The track identifier.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TrackId {
pub title: String,
}
/// The track number.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TrackNum(pub u32);
/// The track quality. Combines format and bitrate information.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TrackQuality {
pub format: TrackFormat,
pub bitrate: u32,
}
impl Track {
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
(&self.number, &self.id)
}
}
/// The track file format.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TrackFormat {
Mp3,
Flac,
}
impl PartialOrd for Track {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Track {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.get_sort_key().cmp(&other.get_sort_key())
}
}
impl Merge for Track {
fn merge_in_place(&mut self, other: Self) {
assert_eq!(self.id, other.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn track_ord() {
assert!(TrackFormat::Mp3 < TrackFormat::Flac);
assert!(TrackFormat::Flac > TrackFormat::Mp3);
}
#[test]
fn merge_track() {
let left = Track {
id: TrackId {
title: String::from("a title"),
},
number: TrackNum(4),
artist: vec![String::from("left artist")],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1411,
},
};
let right = Track {
id: left.id.clone(),
number: left.number,
artist: vec![String::from("right artist")],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
};
let merged = left.clone().merge(right);
assert_eq!(left, merged);
}
}

View File

@ -0,0 +1,127 @@
//! Module for storing MusicHoard data in a database.
use std::fmt;
#[cfg(test)]
use mockall::automock;
use crate::core::collection::{self, Collection};
/// Trait for interacting with the database.
#[cfg_attr(test, automock)]
pub trait IDatabase {
/// Load collection from the database.
fn load(&self) -> Result<Collection, LoadError>;
/// Save collection to the database.
fn save(&mut self, collection: &Collection) -> Result<(), SaveError>;
}
/// Null database implementation of [`IDatabase`].
pub struct NullDatabase;
impl IDatabase for NullDatabase {
fn load(&self) -> Result<Collection, LoadError> {
Ok(vec![])
}
fn save(&mut self, _collection: &Collection) -> Result<(), SaveError> {
Ok(())
}
}
/// Error type for database calls.
#[derive(Debug)]
pub enum LoadError {
/// The database experienced an I/O read error.
IoError(String),
/// The database experienced a deserialisation error.
SerDeError(String),
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::IoError(ref s) => write!(f, "the database experienced an I/O read error: {s}"),
Self::SerDeError(ref s) => {
write!(f, "the database experienced a deserialisation error: {s}")
}
}
}
}
impl From<std::io::Error> for LoadError {
fn from(err: std::io::Error) -> LoadError {
LoadError::IoError(err.to_string())
}
}
impl From<collection::Error> for LoadError {
fn from(err: collection::Error) -> Self {
match err {
collection::Error::UrlError(e) | collection::Error::MbidError(e) => {
LoadError::SerDeError(e)
}
}
}
}
/// Error type for database calls.
#[derive(Debug)]
pub enum SaveError {
/// The database experienced an I/O write error.
IoError(String),
/// The database experienced a serialisation error.
SerDeError(String),
}
impl fmt::Display for SaveError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::IoError(ref s) => write!(f, "the database experienced an I/O write error: {s}"),
Self::SerDeError(ref s) => {
write!(f, "the database experienced a serialisation error: {s}")
}
}
}
}
impl From<std::io::Error> for SaveError {
fn from(err: std::io::Error) -> SaveError {
SaveError::IoError(err.to_string())
}
}
#[cfg(test)]
mod tests {
use std::io;
use super::*;
#[test]
fn null_database_load() {
let database = NullDatabase;
assert!(database.load().unwrap().is_empty());
}
#[test]
fn null_database_save() {
let mut database = NullDatabase;
assert!(database.save(&vec![]).is_ok());
}
#[test]
fn errors() {
let io_err: LoadError = io::Error::new(io::ErrorKind::Interrupted, "error").into();
assert!(!io_err.to_string().is_empty());
assert!(!format!("{:?}", io_err).is_empty());
let col_err: LoadError = collection::Error::UrlError(String::from("get rekt")).into();
assert!(!col_err.to_string().is_empty());
assert!(!format!("{:?}", col_err).is_empty());
let io_err: SaveError = io::Error::new(io::ErrorKind::Interrupted, "error").into();
assert!(!io_err.to_string().is_empty());
assert!(!format!("{:?}", io_err).is_empty());
}
}

View File

@ -5,43 +5,67 @@ use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
#[cfg(test)]
use mockall::automock;
use crate::Artist;
use crate::core::collection::track::TrackFormat;
pub mod beets;
/// Trait for interacting with the music library.
#[cfg_attr(test, automock)]
pub trait ILibrary {
/// List library items that match the a specific query.
fn list(&mut self, query: &Query) -> Result<Vec<Item>, Error>;
}
/// Null library implementation for [`ILibrary`].
pub struct NullLibrary;
impl ILibrary for NullLibrary {
fn list(&mut self, _query: &Query) -> Result<Vec<Item>, Error> {
Ok(vec![])
}
}
/// An item from the library. An item corresponds to an individual file (usually a single track).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Item {
pub album_artist: String,
pub album_artist_sort: Option<String>,
pub album_year: u32,
pub album_month: u8,
pub album_day: u8,
pub album_title: String,
pub track_number: u32,
pub track_title: String,
pub track_artist: Vec<String>,
pub track_format: TrackFormat,
pub track_bitrate: u32,
}
/// Individual fields that can be queried on.
#[derive(Debug, Hash, PartialEq, Eq)]
pub enum Field {
AlbumArtist(String),
AlbumArtistSort(String),
AlbumYear(u32),
AlbumMonth(u8),
AlbumDay(u8),
AlbumTitle(String),
TrackNumber(u32),
TrackTitle(String),
TrackArtist(Vec<String>),
TrackFormat(TrackFormat),
All(String),
}
/// A library query. Can include or exclude particular fields.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Query {
include: HashSet<Field>,
exclude: HashSet<Field>,
}
impl Default for Query {
/// Create an empty query.
fn default() -> Self {
Self::new()
}
pub include: HashSet<Field>,
pub exclude: HashSet<Field>,
}
impl Query {
/// Create an empty query.
pub fn new() -> Self {
Query {
include: HashSet::new(),
exclude: HashSet::new(),
}
Query::default()
}
/// Refine the query to include a particular search term.
@ -102,18 +126,20 @@ impl From<Utf8Error> for Error {
}
}
/// Trait for interacting with the music library.
#[cfg_attr(test, automock)]
pub trait Library {
/// List lirbary items that match the a specific query.
fn list(&mut self, query: &Query) -> Result<Vec<Artist>, Error>;
}
#[cfg(test)]
pub mod testmod;
#[cfg(test)]
mod tests {
use std::io;
use super::{Error, Field, Query};
use super::*;
#[test]
fn null_library_list() {
let mut library = NullLibrary;
assert!(library.list(&Query::default()).unwrap().is_empty());
}
#[test]
fn query() {
@ -137,6 +163,7 @@ mod tests {
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "Interrupted").into();
let inv_err = Error::Invalid(String::from("Invalid"));
let int_err: Error = "five".parse::<u32>().unwrap_err().into();
#[allow(invalid_from_utf8)]
let utf_err: Error = std::str::from_utf8(b"\xe2\x28\xa1").unwrap_err().into();
assert!(!exe_err.to_string().is_empty());

View File

@ -0,0 +1,321 @@
use once_cell::sync::Lazy;
use crate::core::{collection::track::TrackFormat, interface::library::Item};
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
vec![
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 1998,
album_month: 0,
album_day: 0,
album_title: String::from("album_title a.a"),
track_number: 1,
track_title: String::from("track a.a.1"),
track_artist: vec![String::from("artist a.a.1")],
track_format: TrackFormat::Flac,
track_bitrate: 992,
},
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 1998,
album_month: 0,
album_day: 0,
album_title: String::from("album_title a.a"),
track_number: 2,
track_title: String::from("track a.a.2"),
track_artist: vec![
String::from("artist a.a.2.1"),
String::from("artist a.a.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 320,
},
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 1998,
album_month: 0,
album_day: 0,
album_title: String::from("album_title a.a"),
track_number: 3,
track_title: String::from("track a.a.3"),
track_artist: vec![String::from("artist a.a.3")],
track_format: TrackFormat::Flac,
track_bitrate: 1061,
},
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 1998,
album_month: 0,
album_day: 0,
album_title: String::from("album_title a.a"),
track_number: 4,
track_title: String::from("track a.a.4"),
track_artist: vec![String::from("artist a.a.4")],
track_format: TrackFormat::Flac,
track_bitrate: 1042,
},
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 2015,
album_month: 4,
album_day: 0,
album_title: String::from("album_title a.b"),
track_number: 1,
track_title: String::from("track a.b.1"),
track_artist: vec![String::from("artist a.b.1")],
track_format: TrackFormat::Flac,
track_bitrate: 1004,
},
Item {
album_artist: String::from("Album_Artist A"),
album_artist_sort: None,
album_year: 2015,
album_month: 4,
album_day: 0,
album_title: String::from("album_title a.b"),
track_number: 2,
track_title: String::from("track a.b.2"),
track_artist: vec![String::from("artist a.b.2")],
track_format: TrackFormat::Flac,
track_bitrate: 1077,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2003,
album_month: 6,
album_day: 6,
album_title: String::from("album_title b.a"),
track_number: 1,
track_title: String::from("track b.a.1"),
track_artist: vec![String::from("artist b.a.1")],
track_format: TrackFormat::Mp3,
track_bitrate: 190,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2003,
album_month: 6,
album_day: 6,
album_title: String::from("album_title b.a"),
track_number: 2,
track_title: String::from("track b.a.2"),
track_artist: vec![
String::from("artist b.a.2.1"),
String::from("artist b.a.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2008,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.b"),
track_number: 1,
track_title: String::from("track b.b.1"),
track_artist: vec![String::from("artist b.b.1")],
track_format: TrackFormat::Flac,
track_bitrate: 1077,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2008,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.b"),
track_number: 2,
track_title: String::from("track b.b.2"),
track_artist: vec![
String::from("artist b.b.2.1"),
String::from("artist b.b.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 320,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2009,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.c"),
track_number: 1,
track_title: String::from("track b.c.1"),
track_artist: vec![String::from("artist b.c.1")],
track_format: TrackFormat::Mp3,
track_bitrate: 190,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2009,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.c"),
track_number: 2,
track_title: String::from("track b.c.2"),
track_artist: vec![
String::from("artist b.c.2.1"),
String::from("artist b.c.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2015,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.d"),
track_number: 1,
track_title: String::from("track b.d.1"),
track_artist: vec![String::from("artist b.d.1")],
track_format: TrackFormat::Mp3,
track_bitrate: 190,
},
Item {
album_artist: String::from("Album_Artist B"),
album_artist_sort: None,
album_year: 2015,
album_month: 0,
album_day: 0,
album_title: String::from("album_title b.d"),
track_number: 2,
track_title: String::from("track b.d.2"),
track_artist: vec![
String::from("artist b.d.2.1"),
String::from("artist b.d.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("The Album_Artist C"),
album_artist_sort: Some(String::from("Album_Artist C, The")),
album_year: 1985,
album_month: 0,
album_day: 0,
album_title: String::from("album_title c.a"),
track_number: 1,
track_title: String::from("track c.a.1"),
track_artist: vec![String::from("artist c.a.1")],
track_format: TrackFormat::Mp3,
track_bitrate: 320,
},
Item {
album_artist: String::from("The Album_Artist C"),
album_artist_sort: Some(String::from("Album_Artist C, The")),
album_year: 1985,
album_month: 0,
album_day: 0,
album_title: String::from("album_title c.a"),
track_number: 2,
track_title: String::from("track c.a.2"),
track_artist: vec![
String::from("artist c.a.2.1"),
String::from("artist c.a.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("The Album_Artist C"),
album_artist_sort: Some(String::from("Album_Artist C, The")),
album_year: 2018,
album_month: 0,
album_day: 0,
album_title: String::from("album_title c.b"),
track_number: 1,
track_title: String::from("track c.b.1"),
track_artist: vec![String::from("artist c.b.1")],
track_format: TrackFormat::Flac,
track_bitrate: 1041,
},
Item {
album_artist: String::from("The Album_Artist C"),
album_artist_sort: Some(String::from("Album_Artist C, The")),
album_year: 2018,
album_month: 0,
album_day: 0,
album_title: String::from("album_title c.b"),
track_number: 2,
track_title: String::from("track c.b.2"),
track_artist: vec![
String::from("artist c.b.2.1"),
String::from("artist c.b.2.2"),
],
track_format: TrackFormat::Flac,
track_bitrate: 756,
},
Item {
album_artist: String::from("Album_Artist D"),
album_artist_sort: None,
album_year: 1995,
album_month: 0,
album_day: 0,
album_title: String::from("album_title d.a"),
track_number: 1,
track_title: String::from("track d.a.1"),
track_artist: vec![String::from("artist d.a.1")],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("Album_Artist D"),
album_artist_sort: None,
album_year: 1995,
album_month: 0,
album_day: 0,
album_title: String::from("album_title d.a"),
track_number: 2,
track_title: String::from("track d.a.2"),
track_artist: vec![
String::from("artist d.a.2.1"),
String::from("artist d.a.2.2"),
],
track_format: TrackFormat::Mp3,
track_bitrate: 120,
},
Item {
album_artist: String::from("Album_Artist D"),
album_artist_sort: None,
album_year: 2028,
album_month: 0,
album_day: 0,
album_title: String::from("album_title d.b"),
track_number: 1,
track_title: String::from("track d.b.1"),
track_artist: vec![String::from("artist d.b.1")],
track_format: TrackFormat::Flac,
track_bitrate: 841,
},
Item {
album_artist: String::from("Album_Artist D"),
album_artist_sort: None,
album_year: 2028,
album_month: 0,
album_day: 0,
album_title: String::from("album_title d.b"),
track_number: 2,
track_title: String::from("track d.b.2"),
track_artist: vec![
String::from("artist d.b.2.1"),
String::from("artist d.b.2.2"),
],
track_format: TrackFormat::Flac,
track_bitrate: 756,
},
]
});

View File

@ -0,0 +1,2 @@
pub mod database;
pub mod library;

6
src/core/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod collection;
pub mod interface;
pub mod musichoard;
#[cfg(test)]
pub mod testmod;

231
src/core/musichoard/base.rs Normal file
View File

@ -0,0 +1,231 @@
use crate::core::{
collection::{
album::{Album, AlbumId},
artist::{Artist, ArtistId},
merge::MergeCollections,
Collection,
},
musichoard::{Error, MusicHoard},
};
pub trait IMusicHoardBase {
fn get_collection(&self) -> &Collection;
}
impl<Database, Library> IMusicHoardBase for MusicHoard<Database, Library> {
fn get_collection(&self) -> &Collection {
&self.collection
}
}
pub trait IMusicHoardBasePrivate {
fn sort_artists(collection: &mut [Artist]);
fn sort_albums_and_tracks<'a, C: Iterator<Item = &'a mut Artist>>(collection: C);
fn merge_collections(&self) -> Collection;
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist>;
fn get_artist_mut<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Option<&'a mut Artist>;
fn get_artist_mut_or_err<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Result<&'a mut Artist, Error>;
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>;
fn get_album_mut_or_err<'a>(
artist: &'a mut Artist,
album_id: &AlbumId,
) -> Result<&'a mut Album, Error>;
}
impl<Database, Library> IMusicHoardBasePrivate for MusicHoard<Database, Library> {
fn sort_artists(collection: &mut [Artist]) {
collection.sort_unstable();
}
fn sort_albums_and_tracks<'a, COL: Iterator<Item = &'a mut Artist>>(collection: COL) {
for artist in collection {
artist.albums.sort_unstable();
for album in artist.albums.iter_mut() {
album.tracks.sort_unstable();
}
}
}
fn merge_collections(&self) -> Collection {
MergeCollections::merge(self.library_cache.clone(), self.database_cache.clone())
}
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist> {
collection.iter().find(|a| &a.meta.id == artist_id)
}
fn get_artist_mut<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Option<&'a mut Artist> {
collection.iter_mut().find(|a| &a.meta.id == artist_id)
}
fn get_artist_mut_or_err<'a>(
collection: &'a mut Collection,
artist_id: &ArtistId,
) -> Result<&'a mut Artist, Error> {
Self::get_artist_mut(collection, artist_id).ok_or_else(|| {
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
})
}
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> {
artist.albums.iter_mut().find(|a| &a.meta.id == album_id)
}
fn get_album_mut_or_err<'a>(
artist: &'a mut Artist,
album_id: &AlbumId,
) -> Result<&'a mut Album, Error> {
Self::get_album_mut(artist, album_id).ok_or_else(|| {
Error::CollectionError(format!(
"album '{}' does not belong to the artist",
album_id
))
})
}
}
#[cfg(test)]
mod tests {
use crate::core::testmod::FULL_COLLECTION;
use super::*;
#[test]
fn merge_collection_no_overlap() {
let half: usize = FULL_COLLECTION.len() / 2;
let left = FULL_COLLECTION[..half].to_owned();
let right = FULL_COLLECTION[half..].to_owned();
let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable();
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge is completely non-overlapping so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
#[test]
fn merge_collection_overlap() {
let half: usize = FULL_COLLECTION.len() / 2;
let left = FULL_COLLECTION[..(half + 1)].to_owned();
let right = FULL_COLLECTION[half..].to_owned();
let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable();
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge does not overwrite any data so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
#[test]
fn merge_collection_incompatible_sorting() {
// It may be that the same artist in one collection has a "sort" field defined while the
// same artist in the other collection does not. This means that the two collections are not
// sorted consistently. If the merge assumes they are sorted consistently this will lead to
// the same artist appearing twice in the final list. This should not be the case.
// We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it
// a sorting name that would place it in the beginning.
let left = FULL_COLLECTION.to_owned();
let mut right: Vec<Artist> = vec![left.last().unwrap().clone()];
assert!(right.first().unwrap() > left.first().unwrap());
let artist_sort = Some(String::from("Album_Artist 0"));
right[0].meta.sort = artist_sort.clone();
assert!(right.first().unwrap() < left.first().unwrap());
// The result of the merge should be the same list of artists, but with the last artist now
// in first place.
let mut expected = left.to_owned();
expected.last_mut().as_mut().unwrap().meta.sort = artist_sort.clone();
expected.rotate_right(1);
let mut mh = MusicHoard {
library_cache: left
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: right.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
// The merge overwrites the sort data, but no data is erased so it should be commutative.
let mut mh = MusicHoard {
library_cache: right
.clone()
.into_iter()
.map(|a| (a.meta.id.clone(), a))
.collect(),
database_cache: left.clone(),
..Default::default()
};
mh.collection = mh.merge_collections();
assert_eq!(expected, mh.collection);
}
}

View File

@ -0,0 +1,191 @@
use std::collections::HashMap;
use crate::core::{
interface::{database::IDatabase, library::ILibrary},
musichoard::{database::IMusicHoardDatabase, Error, MusicHoard, NoDatabase, NoLibrary},
};
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
/// library/database or their absence.
pub struct MusicHoardBuilder<Database, Library> {
database: Database,
library: Library,
}
impl Default for MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Create a [`MusicHoardBuilder`].
fn default() -> Self {
Self::new()
}
}
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Create a [`MusicHoardBuilder`].
pub fn new() -> Self {
MusicHoardBuilder {
database: NoDatabase,
library: NoLibrary,
}
}
}
impl<Database, Library> MusicHoardBuilder<Database, Library> {
/// Set a library for [`MusicHoard`].
pub fn set_library<NewLibrary: ILibrary>(
self,
library: NewLibrary,
) -> MusicHoardBuilder<Database, NewLibrary> {
MusicHoardBuilder {
database: self.database,
library,
}
}
/// Set a database for [`MusicHoard`].
pub fn set_database<NewDatabase: IDatabase>(
self,
database: NewDatabase,
) -> MusicHoardBuilder<NewDatabase, Library> {
MusicHoardBuilder {
database,
library: self.library,
}
}
}
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<NoDatabase, NoLibrary> {
MusicHoard::empty()
}
}
impl MusicHoard<NoDatabase, NoLibrary> {
/// Create a new [`MusicHoard`] without any library or database.
pub fn empty() -> Self {
MusicHoard {
collection: vec![],
pre_commit: vec![],
database: NoDatabase,
database_cache: vec![],
library: NoLibrary,
library_cache: HashMap::new(),
}
}
}
impl<Library: ILibrary> MusicHoardBuilder<NoDatabase, Library> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> MusicHoard<NoDatabase, Library> {
MusicHoard::library(self.library)
}
}
impl<Library: ILibrary> MusicHoard<NoDatabase, Library> {
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and no database.
pub fn library(library: Library) -> Self {
MusicHoard {
collection: vec![],
pre_commit: vec![],
database: NoDatabase,
database_cache: vec![],
library,
library_cache: HashMap::new(),
}
}
}
impl<Database: IDatabase> MusicHoardBuilder<Database, NoLibrary> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<Database, NoLibrary>, Error> {
MusicHoard::database(self.database)
}
}
impl<Database: IDatabase> MusicHoard<Database, NoLibrary> {
/// Create a new [`MusicHoard`] with the provided [`IDatabase`] and no library.
pub fn database(database: Database) -> Result<Self, Error> {
let mut mh = MusicHoard {
collection: vec![],
pre_commit: vec![],
database,
database_cache: vec![],
library: NoLibrary,
library_cache: HashMap::new(),
};
mh.reload_database()?;
Ok(mh)
}
}
impl<Database: IDatabase, Library: ILibrary> MusicHoardBuilder<Database, Library> {
/// Build [`MusicHoard`] with the currently set library and database.
pub fn build(self) -> Result<MusicHoard<Database, Library>, Error> {
MusicHoard::new(self.database, self.library)
}
}
impl<Database: IDatabase, Library: ILibrary> MusicHoard<Database, Library> {
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
pub fn new(database: Database, library: Library) -> Result<Self, Error> {
let mut mh = MusicHoard {
collection: vec![],
pre_commit: vec![],
database,
database_cache: vec![],
library,
library_cache: HashMap::new(),
};
mh.reload_database()?;
Ok(mh)
}
}
#[cfg(test)]
mod tests {
use crate::core::{
interface::{database::NullDatabase, library::NullLibrary},
musichoard::library::IMusicHoardLibrary,
};
use super::*;
#[test]
fn no_library_no_database() {
MusicHoardBuilder::default().build();
}
#[test]
fn empty() {
let music_hoard = MusicHoard::empty();
assert!(!format!("{music_hoard:?}").is_empty());
}
#[test]
fn with_library_no_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.build();
assert!(mh.rescan_library().is_ok());
}
#[test]
fn no_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.reload_database().is_ok());
}
#[test]
fn with_library_with_database() {
let mut mh = MusicHoardBuilder::default()
.set_library(NullLibrary)
.set_database(NullDatabase)
.build()
.unwrap();
assert!(mh.rescan_library().is_ok());
assert!(mh.reload_database().is_ok());
}
}

View File

@ -0,0 +1,769 @@
use std::mem;
use crate::{
collection::{album::AlbumInfo, artist::ArtistInfo, merge::Merge},
core::{
collection::{
album::{Album, AlbumId, AlbumSeq},
artist::{Artist, ArtistId},
Collection,
},
interface::database::IDatabase,
musichoard::{base::IMusicHoardBasePrivate, Error, MusicHoard, NoDatabase},
},
};
pub trait IMusicHoardDatabase {
fn reload_database(&mut self) -> Result<(), Error>;
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error>;
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
&mut self,
artist_id: Id,
artist_sort: S,
) -> Result<(), Error>;
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
fn merge_artist_info<Id: AsRef<ArtistId>>(
&mut self,
artist_id: Id,
info: ArtistInfo,
) -> Result<(), Error>;
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error>;
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
) -> Result<(), Error>;
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
seq: u8,
) -> Result<(), Error>;
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error>;
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: Id,
album_id: AlbumIdRef,
info: AlbumInfo,
) -> Result<(), Error>;
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: Id,
album_id: AlbumIdRef,
) -> Result<(), Error>;
}
impl<Database: IDatabase, Library> IMusicHoardDatabase for MusicHoard<Database, Library> {
fn reload_database(&mut self) -> Result<(), Error> {
self.database_cache = self.database.load()?;
Self::sort_albums_and_tracks(self.database_cache.iter_mut());
self.collection = self.merge_collections();
self.pre_commit = self.collection.clone();
Ok(())
}
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error> {
let artist_id: ArtistId = artist_id.into();
self.update_collection(|collection| {
if Self::get_artist(collection, &artist_id).is_none() {
collection.push(Artist::new(artist_id));
Self::sort_artists(collection);
}
})
}
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
self.update_collection(|collection| {
let index_opt = collection
.iter()
.position(|a| &a.meta.id == artist_id.as_ref());
if let Some(index) = index_opt {
collection.remove(index);
}
})
}
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
&mut self,
artist_id: Id,
artist_sort: S,
) -> Result<(), Error> {
self.update_artist_and(
artist_id.as_ref(),
|artist| artist.meta.set_sort_key(artist_sort),
|collection| Self::sort_artists(collection),
)
}
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
self.update_artist_and(
artist_id.as_ref(),
|artist| artist.meta.clear_sort_key(),
|collection| Self::sort_artists(collection),
)
}
fn merge_artist_info<Id: AsRef<ArtistId>>(
&mut self,
artist_id: Id,
mut info: ArtistInfo,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
mem::swap(&mut artist.meta.info, &mut info);
artist.meta.info.merge_in_place(info);
})
}
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.meta.info = ArtistInfo::default()
})
}
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.meta.info.add_to_property(property, values)
})
}
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.meta.info.remove_from_property(property, values)
})
}
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
&mut self,
artist_id: Id,
property: S,
values: Vec<S>,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.meta.info.set_property(property, values)
})
}
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
&mut self,
artist_id: Id,
property: S,
) -> Result<(), Error> {
self.update_artist(artist_id.as_ref(), |artist| {
artist.meta.info.clear_property(property)
})
}
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
seq: u8,
) -> Result<(), Error> {
self.update_album_and(
artist_id.as_ref(),
album_id.as_ref(),
|album| album.meta.set_seq(AlbumSeq(seq)),
|artist| artist.albums.sort_unstable(),
)
}
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: ArtistIdRef,
album_id: AlbumIdRef,
) -> Result<(), Error> {
self.update_album_and(
artist_id.as_ref(),
album_id.as_ref(),
|album| album.meta.clear_seq(),
|artist| artist.albums.sort_unstable(),
)
}
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: Id,
album_id: AlbumIdRef,
mut info: AlbumInfo,
) -> Result<(), Error> {
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
mem::swap(&mut album.meta.info, &mut info);
album.meta.info.merge_in_place(info);
})
}
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
&mut self,
artist_id: Id,
album_id: AlbumIdRef,
) -> Result<(), Error> {
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
album.meta.info = AlbumInfo::default()
})
}
}
pub trait IMusicHoardDatabasePrivate {
fn commit(&mut self) -> Result<(), Error>;
}
impl<Library> IMusicHoardDatabasePrivate for MusicHoard<NoDatabase, Library> {
fn commit(&mut self) -> Result<(), Error> {
self.collection = self.pre_commit.clone();
Ok(())
}
}
impl<Database: IDatabase, Library> IMusicHoardDatabasePrivate for MusicHoard<Database, Library> {
fn commit(&mut self) -> Result<(), Error> {
if self.collection != self.pre_commit {
if let Err(err) = self.database.save(&self.pre_commit) {
self.pre_commit = self.collection.clone();
return Err(err.into());
}
self.collection = self.pre_commit.clone();
}
Ok(())
}
}
impl<Database: IDatabase, Library> MusicHoard<Database, Library> {
fn update_collection<FnColl>(&mut self, fn_coll: FnColl) -> Result<(), Error>
where
FnColl: FnOnce(&mut Collection),
{
fn_coll(&mut self.pre_commit);
self.commit()
}
fn update_artist_and<FnArtist, FnColl>(
&mut self,
artist_id: &ArtistId,
fn_artist: FnArtist,
fn_coll: FnColl,
) -> Result<(), Error>
where
FnArtist: FnOnce(&mut Artist),
FnColl: FnOnce(&mut Collection),
{
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
fn_artist(artist);
self.update_collection(fn_coll)
}
fn update_artist<FnArtist>(
&mut self,
artist_id: &ArtistId,
fn_artist: FnArtist,
) -> Result<(), Error>
where
FnArtist: FnOnce(&mut Artist),
{
self.update_artist_and(artist_id, fn_artist, |_| {})
}
fn update_album_and<FnAlbum, FnArtist>(
&mut self,
artist_id: &ArtistId,
album_id: &AlbumId,
fn_album: FnAlbum,
fn_artist: FnArtist,
) -> Result<(), Error>
where
FnAlbum: FnOnce(&mut Album),
FnArtist: FnOnce(&mut Artist),
{
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
let album = Self::get_album_mut_or_err(artist, album_id)?;
fn_album(album);
fn_artist(artist);
self.update_collection(|_| {})
}
fn update_album<FnAlbum>(
&mut self,
artist_id: &ArtistId,
album_id: &AlbumId,
fn_album: FnAlbum,
) -> Result<(), Error>
where
FnAlbum: FnOnce(&mut Album),
{
self.update_album_and(artist_id, album_id, fn_album, |_| {})
}
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use crate::{
collection::{
album::{AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::{MbArtistRef, MbRefOption},
},
core::{
collection::{album::AlbumDate, artist::ArtistId},
interface::database::{self, MockIDatabase},
musichoard::{base::IMusicHoardBase, NoLibrary},
testmod::FULL_COLLECTION,
},
};
use super::*;
static MBID: &str = "d368baa8-21ca-4759-9731-0b2753071ad8";
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
#[test]
fn artist_new_delete() {
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let collection = FULL_COLLECTION.to_owned();
let mut with_artist = collection.clone();
with_artist.push(Artist::new(artist_id.clone()));
let mut database = MockIDatabase::new();
let mut seq = Sequence::new();
database
.expect_load()
.times(1)
.times(1)
.in_sequence(&mut seq)
.returning(|| Ok(FULL_COLLECTION.to_owned()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(with_artist.clone()))
.returning(|_| Ok(()));
database
.expect_save()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(collection.clone()))
.returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.collection, collection);
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.remove_artist(&artist_id_2).is_ok());
assert_eq!(music_hoard.collection, with_artist);
assert!(music_hoard.remove_artist(&artist_id).is_ok());
assert_eq!(music_hoard.collection, collection);
}
#[test]
fn artist_sort_set_clear() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(4).returning(|_| Ok(()));
type MH = MusicHoard<MockIDatabase, NoLibrary>;
let mut music_hoard: MH = MusicHoard::database(database).unwrap();
let artist_1_id = ArtistId::new("the artist");
let artist_1_sort = String::from("artist, the");
// Must be after "artist, the", but before "the artist"
let artist_2_id = ArtistId::new("b-artist");
assert!(artist_1_sort < artist_2_id.name);
assert!(artist_2_id < artist_1_id);
assert!(music_hoard.add_artist(artist_1_id.clone()).is_ok());
assert!(music_hoard.add_artist(artist_2_id.clone()).is_ok());
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_2 < artist_1);
assert_eq!(artist_1, &music_hoard.collection[1]);
assert_eq!(artist_2, &music_hoard.collection[0]);
music_hoard
.set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone())
.unwrap();
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_1 < artist_2);
assert_eq!(artist_1, &music_hoard.collection[0]);
assert_eq!(artist_2, &music_hoard.collection[1]);
music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap();
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
assert!(artist_2 < artist_1);
assert_eq!(artist_1, &music_hoard.collection[1]);
assert_eq!(artist_2, &music_hoard.collection[0]);
}
#[test]
fn collection_error() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
let mut music_hoard = MusicHoard::database(database).unwrap();
let artist_id = ArtistId::new("an artist");
let actual_err = music_hoard
.merge_artist_info(&artist_id, ArtistInfo::default())
.unwrap_err();
let expected_err =
Error::CollectionError(format!("artist '{artist_id}' is not in the collection"));
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn set_clear_artist_info() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
let info = ArtistInfo::new(MbRefOption::Some(MbArtistRef::from_uuid_str(MBID).unwrap()));
// Setting a URL on an artist not in the collection is an error.
assert!(music_hoard
.merge_artist_info(&artist_id_2, info.clone())
.is_err());
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
// Setting a URL on an artist.
assert!(music_hoard
.merge_artist_info(&artist_id, info.clone())
.is_ok());
expected.replace(MbArtistRef::from_uuid_str(MBID).unwrap());
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
// Clearing URLs on an artist that does not exist is an error.
assert!(music_hoard.clear_artist_info(&artist_id_2).is_err());
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
// Clearing URLs.
assert!(music_hoard.clear_artist_info(&artist_id).is_ok());
expected.take();
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
}
#[test]
fn add_to_remove_from_property() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: Vec<String> = vec![];
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
// Adding URLs to an artist not in the collection is an error.
assert!(music_hoard
.add_to_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
// Adding mutliple URLs without clashes.
assert!(music_hoard
.add_to_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
.is_ok());
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
let info = &music_hoard.collection[0].meta.info;
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing URLs from an artist not in the collection is an error.
assert!(music_hoard
.remove_from_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
let info = &music_hoard.collection[0].meta.info;
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Removing multiple URLs without clashes.
assert!(music_hoard
.remove_from_artist_property(
&artist_id,
"MusicButler",
vec![MUSICBUTLER, MUSICBUTLER_2]
)
.is_ok());
expected.clear();
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
}
#[test]
fn set_clear_property() {
let mut database = MockIDatabase::new();
database.expect_load().times(1).returning(|| Ok(vec![]));
database.expect_save().times(3).returning(|_| Ok(()));
let artist_id = ArtistId::new("an artist");
let artist_id_2 = ArtistId::new("another artist");
let mut music_hoard = MusicHoard::database(database).unwrap();
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
let mut expected: Vec<String> = vec![];
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
// Seting URL on an artist not in the collection is an error.
assert!(music_hoard
.set_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
.is_err());
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
// Set URLs.
assert!(music_hoard
.set_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
.is_ok());
expected.clear();
expected.push(MUSICBUTLER.to_owned());
expected.push(MUSICBUTLER_2.to_owned());
let info = &music_hoard.collection[0].meta.info;
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
// Clearing URLs on an artist that does not exist is an error.
assert!(music_hoard
.clear_artist_property(&artist_id_2, "MusicButler")
.is_err());
// Clear URLs.
assert!(music_hoard
.clear_artist_property(&artist_id, "MusicButler")
.is_ok());
expected.clear();
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
}
#[test]
fn set_clear_album_seq() {
let mut database = MockIDatabase::new();
let artist_id = ArtistId::new("an artist");
let album_id = AlbumId::new("an album");
let album_id_2 = AlbumId::new("another album");
let mut database_result = vec![Artist::new(artist_id.clone())];
database_result[0].albums.push(Album::new(
album_id.clone(),
AlbumDate::default(),
None,
vec![],
));
database
.expect_load()
.times(1)
.return_once(|| Ok(database_result));
database.expect_save().times(2).returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
// Seting seq on an album not belonging to the artist is an error.
assert!(music_hoard
.set_album_seq(&artist_id, &album_id_2, 6)
.is_err());
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
// Set seq.
assert!(music_hoard.set_album_seq(&artist_id, &album_id, 6).is_ok());
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(6));
// Clearing seq on an album that does not exist is an error.
assert!(music_hoard
.clear_album_seq(&artist_id, &album_id_2)
.is_err());
// Clear seq.
assert!(music_hoard.clear_album_seq(&artist_id, &album_id).is_ok());
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
}
#[test]
fn set_clear_album_info() {
let mut database = MockIDatabase::new();
let artist_id = ArtistId::new("an artist");
let album_id = AlbumId::new("an album");
let album_id_2 = AlbumId::new("another album");
let mut database_result = vec![Artist::new(artist_id.clone())];
database_result[0].albums.push(Album::new(
album_id.clone(),
AlbumDate::default(),
None,
vec![],
));
database
.expect_load()
.times(1)
.return_once(|| Ok(database_result));
database.expect_save().times(2).returning(|_| Ok(()));
let mut music_hoard = MusicHoard::database(database).unwrap();
let meta = &music_hoard.collection[0].albums[0].meta;
assert_eq!(meta.info.musicbrainz, MbRefOption::None);
assert_eq!(meta.info.primary_type, None);
assert_eq!(meta.info.secondary_types, Vec::new());
let info = AlbumInfo::new(
MbRefOption::CannotHaveMbid,
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live],
);
// Seting info on an album not belonging to the artist is an error.
assert!(music_hoard
.merge_album_info(&artist_id, &album_id_2, info.clone())
.is_err());
let meta = &music_hoard.collection[0].albums[0].meta;
assert_eq!(meta.info, AlbumInfo::default());
// Set info.
assert!(music_hoard
.merge_album_info(&artist_id, &album_id, info.clone())
.is_ok());
let meta = &music_hoard.collection[0].albums[0].meta;
assert_eq!(meta.info, info);
// Clearing info on an album that does not exist is an error.
assert!(music_hoard
.clear_album_info(&artist_id, &album_id_2)
.is_err());
// Clear info.
assert!(music_hoard.clear_album_info(&artist_id, &album_id).is_ok());
let meta = &music_hoard.collection[0].albums[0].meta;
assert_eq!(meta.info, AlbumInfo::default());
}
#[test]
fn load_database() {
let mut database = MockIDatabase::new();
database
.expect_load()
.times(1)
.return_once(|| Ok(FULL_COLLECTION.to_owned()));
let music_hoard = MusicHoard::database(database).unwrap();
assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION);
}
#[test]
fn database_load_error() {
let mut database = MockIDatabase::new();
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
database
.expect_load()
.times(1)
.return_once(|| database_result);
let actual_err = MusicHoard::database(database).unwrap_err();
let expected_err = Error::DatabaseError(
database::LoadError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
#[test]
fn database_save_error() {
let mut database = MockIDatabase::new();
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
database.expect_load().return_once(|| Ok(vec![]));
database
.expect_save()
.times(1)
.return_once(|_: &Collection| database_result);
let mut music_hoard = MusicHoard::database(database).unwrap();
let actual_err = music_hoard
.add_artist(ArtistId::new("an artist"))
.unwrap_err();
let expected_err = Error::DatabaseError(
database::SaveError::IoError(String::from("I/O error")).to_string(),
);
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}

View File

@ -0,0 +1,317 @@
use std::collections::HashMap;
use crate::core::{
collection::{
album::{Album, AlbumDate, AlbumId},
artist::{Artist, ArtistId},
track::{Track, TrackId, TrackNum, TrackQuality},
Collection,
},
interface::{
database::IDatabase,
library::{ILibrary, Item, Query},
},
musichoard::{
base::IMusicHoardBasePrivate, database::IMusicHoardDatabasePrivate, Error, MusicHoard,
NoDatabase,
},
};
pub trait IMusicHoardLibrary {
fn rescan_library(&mut self) -> Result<(), Error>;
}
impl<Library: ILibrary> IMusicHoardLibrary for MusicHoard<NoDatabase, Library> {
fn rescan_library(&mut self) -> Result<(), Error> {
self.pre_commit = self.rescan_library_inner()?;
self.commit()
}
}
impl<Database: IDatabase, Library: ILibrary> IMusicHoardLibrary for MusicHoard<Database, Library> {
fn rescan_library(&mut self) -> Result<(), Error> {
self.pre_commit = self.rescan_library_inner()?;
self.commit()
}
}
impl<Database, Library: ILibrary> MusicHoard<Database, Library> {
fn rescan_library_inner(&mut self) -> Result<Collection, Error> {
let items = self.library.list(&Query::new())?;
self.library_cache = Self::items_to_artists(items)?;
Self::sort_albums_and_tracks(self.library_cache.values_mut());
Ok(self.merge_collections())
}
fn items_to_artists(items: Vec<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
let mut collection = HashMap::<ArtistId, Artist>::new();
for item in items.into_iter() {
let artist_id = ArtistId {
name: item.album_artist,
};
let artist_sort = item.album_artist_sort;
let album_id = AlbumId {
title: item.album_title,
};
let album_date = AlbumDate {
year: Some(item.album_year).filter(|y| y > &0),
month: Some(item.album_month).filter(|m| m > &0),
day: Some(item.album_day).filter(|d| d > &0),
};
let track = Track {
id: TrackId {
title: item.track_title,
},
number: TrackNum(item.track_number),
artist: item.track_artist,
quality: TrackQuality {
format: item.track_format,
bitrate: item.track_bitrate,
},
};
// There are usually many entries per artist. Therefore, we avoid simply calling
// .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is
// that insertions will thus do an additional lookup.
let artist = match collection.get_mut(&artist_id) {
Some(artist) => artist,
None => collection
.entry(artist_id.clone())
.or_insert_with(|| Artist::new(artist_id)),
};
if artist.meta.sort.is_some() {
if artist_sort.is_some() && (artist.meta.sort != artist_sort) {
return Err(Error::CollectionError(format!(
"multiple album_artist_sort found for artist '{}': '{}' != '{}'",
artist.meta.id,
artist.meta.sort.as_ref().unwrap(),
artist_sort.as_ref().unwrap()
)));
}
} else if artist_sort.is_some() {
artist.meta.sort = artist_sort;
}
// Do a linear search as few artists have more than a handful of albums. Search from the
// back as the original items vector is usually already sorted.
match artist
.albums
.iter_mut()
.rev()
.find(|album| album.meta.id == album_id)
{
Some(album) => album.tracks.push(track),
None => {
let mut album = Album::new(album_id, album_date, None, vec![]);
album.tracks.push(track);
artist.albums.push(album);
}
}
}
Ok(collection)
}
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use crate::core::{
interface::{
database::MockIDatabase,
library::{self, testmod::LIBRARY_ITEMS, MockILibrary},
},
musichoard::base::IMusicHoardBase,
testmod::LIBRARY_COLLECTION,
};
use super::*;
#[test]
fn rescan_library_ordered() {
let mut library = MockILibrary::new();
let mut database = MockIDatabase::new();
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS.to_owned());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
database.expect_load().times(1).returning(|| Ok(vec![]));
database
.expect_save()
.with(predicate::eq(&*LIBRARY_COLLECTION))
.times(1)
.return_once(|_| Ok(()));
let mut music_hoard = MusicHoard::new(database, library).unwrap();
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
}
#[test]
fn rescan_library_changed() {
let mut library = MockILibrary::new();
let mut seq = Sequence::new();
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS.to_owned());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.in_sequence(&mut seq)
.return_once(|_| library_result);
let library_input = Query::new();
let library_result = Ok(LIBRARY_ITEMS
.iter()
.filter(|item| item.album_title != "album_title a.a")
.cloned()
.collect());
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.in_sequence(&mut seq)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert!(music_hoard.get_collection()[0]
.albums
.iter()
.any(|album| album.meta.id.title == "album_title a.a"));
music_hoard.rescan_library().unwrap();
assert!(!music_hoard.get_collection()[0]
.albums
.iter()
.any(|album| album.meta.id.title == "album_title a.a"));
}
#[test]
fn rescan_library_unordered() {
let mut library = MockILibrary::new();
let library_input = Query::new();
let mut library_result = Ok(LIBRARY_ITEMS.to_owned());
// Swap the last item with the first.
let last = library_result.as_ref().unwrap().len() - 1;
library_result.as_mut().unwrap().swap(0, last);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
}
#[test]
fn rescan_library_album_id_clash() {
let mut library = MockILibrary::new();
let mut expected = LIBRARY_COLLECTION.to_owned();
let removed_album_id = expected[0].albums[0].meta.id.clone();
let clashed_album_id = &expected[1].albums[0].meta.id;
let mut items = LIBRARY_ITEMS.to_owned();
for item in items
.iter_mut()
.filter(|it| it.album_title == removed_album_id.title)
{
item.album_title = clashed_album_id.title.clone();
}
expected[0].albums[0].meta.id = clashed_album_id.clone();
let library_input = Query::new();
let library_result = Ok(items);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
music_hoard.rescan_library().unwrap();
assert_eq!(music_hoard.get_collection()[0], expected[0]);
assert_eq!(music_hoard.get_collection(), &expected);
}
#[test]
fn rescan_library_album_artist_sort_clash() {
let mut library = MockILibrary::new();
let library_input = Query::new();
let mut library_items = LIBRARY_ITEMS.to_owned();
assert_eq!(library_items[0].album_artist, library_items[1].album_artist);
library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone());
library_items[1].album_artist_sort = Some(
library_items[1]
.album_artist
.clone()
.chars()
.rev()
.collect(),
);
let library_result = Ok(library_items);
library
.expect_list()
.with(predicate::eq(library_input))
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
assert!(music_hoard.rescan_library().is_err());
}
#[test]
fn library_error() {
let mut library = MockILibrary::new();
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
library
.expect_list()
.times(1)
.return_once(|_| library_result);
let mut music_hoard = MusicHoard::library(library);
let actual_err = music_hoard.rescan_library().unwrap_err();
let expected_err =
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
assert_eq!(actual_err, expected_err);
assert_eq!(actual_err.to_string(), expected_err.to_string());
}
}

View File

@ -0,0 +1,95 @@
//! The core MusicHoard module. Serves as the main entry-point into the library.
mod base;
mod database;
mod library;
pub mod builder;
pub use base::IMusicHoardBase;
pub use database::IMusicHoardDatabase;
pub use library::IMusicHoardLibrary;
use std::{
collections::HashMap,
fmt::{self, Display},
};
use crate::core::collection::{
artist::{Artist, ArtistId},
Collection,
};
use crate::core::interface::{
database::{LoadError as DatabaseLoadError, SaveError as DatabaseSaveError},
library::Error as LibraryError,
};
/// The Music Hoard. It is responsible for pulling information from both the library and the
/// database, ensuring its consistent and writing back any changes.
// TODO: Split into inner and external/interfaces to facilitate building.
#[derive(Debug)]
pub struct MusicHoard<Database, Library> {
collection: Collection,
pre_commit: Collection,
database: Database,
database_cache: Collection,
library: Library,
library_cache: HashMap<ArtistId, Artist>,
}
/// Phantom type for when a library implementation is not needed.
#[derive(Debug)]
pub struct NoLibrary;
/// Phantom type for when a database implementation is not needed.
#[derive(Debug)]
pub struct NoDatabase;
impl Default for MusicHoard<NoDatabase, NoLibrary> {
/// Create a new [`MusicHoard`] without any library or database.
fn default() -> Self {
MusicHoard::empty()
}
}
/// Error type for `musichoard`.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The [`MusicHoard`] is not able to read/write its in-memory collection.
CollectionError(String),
/// The [`MusicHoard`] failed to read/write from/to the library.
LibraryError(String),
/// The [`MusicHoard`] failed to read/write from/to the database.
DatabaseError(String),
}
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::CollectionError(ref s) => write!(f, "failed to read/write the collection: {s}"),
Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"),
Self::DatabaseError(ref s) => {
write!(f, "failed to read/write from/to the database: {s}")
}
}
}
}
impl From<LibraryError> for Error {
fn from(err: LibraryError) -> Error {
Error::LibraryError(err.to_string())
}
}
impl From<DatabaseLoadError> for Error {
fn from(err: DatabaseLoadError) -> Error {
Error::DatabaseError(err.to_string())
}
}
impl From<DatabaseSaveError> for Error {
fn from(err: DatabaseSaveError) -> Error {
Error::DatabaseError(err.to_string())
}
}

13
src/core/testmod.rs Normal file
View File

@ -0,0 +1,13 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
use crate::core::collection::{
album::{Album, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSeq},
artist::{Artist, ArtistId, ArtistInfo, ArtistMeta},
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption},
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
};
use crate::testmod::*;
pub static LIBRARY_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| library::library_collection!());
pub static FULL_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());

View File

@ -1,199 +0,0 @@
//! Module for storing MusicHoard data in a JSON file database.
use serde::de::DeserializeOwned;
use serde::Serialize;
#[cfg(test)]
use mockall::automock;
use super::{Database, Error};
pub mod backend;
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Error {
Error::SerDeError(err.to_string())
}
}
/// Trait for the JSON database backend.
#[cfg_attr(test, automock)]
pub trait JsonDatabaseBackend {
/// Read the JSON string from the backend.
fn read(&self) -> Result<String, std::io::Error>;
/// Write the JSON string to the backend.
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
}
/// JSON database.
pub struct JsonDatabase<JDB> {
backend: JDB,
}
impl<JDB: JsonDatabaseBackend> JsonDatabase<JDB> {
/// Create a new JSON database with the provided backend, e.g.
/// [`backend::JsonDatabaseFileBackend`].
pub fn new(backend: JDB) -> Self {
JsonDatabase { backend }
}
}
impl<JDB: JsonDatabaseBackend> Database for JsonDatabase<JDB> {
fn read<D: DeserializeOwned>(&self, collection: &mut D) -> Result<(), Error> {
let serialized = self.backend.read()?;
*collection = serde_json::from_str(&serialized)?;
Ok(())
}
fn write<S: Serialize>(&mut self, collection: &S) -> Result<(), Error> {
let serialized = serde_json::to_string(&collection)?;
self.backend.write(&serialized)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use mockall::predicate;
use super::*;
use crate::{tests::COLLECTION, Artist, ArtistId, TrackFormat};
fn artist_to_json(artist: &Artist) -> String {
let album_artist = &artist.id.name;
let mut albums: Vec<String> = vec![];
for album in artist.albums.iter() {
let album_year = album.id.year;
let album_title = &album.id.title;
let mut tracks: Vec<String> = vec![];
for track in album.tracks.iter() {
let track_number = track.number;
let track_title = &track.title;
let mut track_artist: Vec<String> = vec![];
for artist in track.artist.iter() {
track_artist.push(format!("\"{artist}\""))
}
let track_artist = track_artist.join(",");
let track_format: &'static str = match track.format {
TrackFormat::Flac => stringify!(Flac),
TrackFormat::Mp3 => stringify!(Mp3),
};
tracks.push(format!(
"{{\
\"number\":{track_number},\
\"title\":\"{track_title}\",\
\"artist\":[{track_artist}],\
\"format\":\"{track_format}\"\
}}"
));
}
let tracks = tracks.join(",");
albums.push(format!(
"{{\
\"id\":{{\
\"year\":{album_year},\
\"title\":\"{album_title}\"\
}},\"tracks\":[{tracks}]\
}}"
));
}
let albums = albums.join(",");
format!(
"{{\
\"id\":{{\
\"name\":\"{album_artist}\"\
}},\"albums\":[{albums}]\
}}"
)
}
fn artists_to_json(artists: &[Artist]) -> String {
let mut artists_strings: Vec<String> = vec![];
for artist in artists.iter() {
artists_strings.push(artist_to_json(artist));
}
let artists_json = artists_strings.join(",");
format!("[{artists_json}]")
}
#[test]
fn write() {
let write_data = COLLECTION.to_owned();
let input = artists_to_json(&write_data);
let mut backend = MockJsonDatabaseBackend::new();
backend
.expect_write()
.with(predicate::eq(input))
.times(1)
.return_once(|_| Ok(()));
JsonDatabase::new(backend).write(&write_data).unwrap();
}
#[test]
fn read() {
let expected = COLLECTION.to_owned();
let result = Ok(artists_to_json(&expected));
let mut backend = MockJsonDatabaseBackend::new();
backend.expect_read().times(1).return_once(|| result);
let mut read_data: Vec<Artist> = vec![];
JsonDatabase::new(backend).read(&mut read_data).unwrap();
assert_eq!(read_data, expected);
}
#[test]
fn reverse() {
let expected = COLLECTION.to_owned();
let input = artists_to_json(&expected);
let result = Ok(input.clone());
let mut backend = MockJsonDatabaseBackend::new();
backend
.expect_write()
.with(predicate::eq(input))
.times(1)
.return_once(|_| Ok(()));
backend.expect_read().times(1).return_once(|| result);
let mut database = JsonDatabase::new(backend);
let write_data = COLLECTION.to_owned();
let mut read_data: Vec<Artist> = vec![];
database.write(&write_data).unwrap();
database.read(&mut read_data).unwrap();
assert_eq!(write_data, read_data);
}
#[test]
fn errors() {
let mut object = HashMap::<ArtistId, String>::new();
object.insert(
ArtistId {
name: String::from("artist"),
},
String::from("string"),
);
let serde_err = serde_json::to_string(&object);
assert!(serde_err.is_err());
let serde_err: Error = serde_err.unwrap_err().into();
assert!(!serde_err.to_string().is_empty());
assert!(!format!("{:?}", serde_err).is_empty());
}
}

View File

@ -1,61 +0,0 @@
//! Module for storing MusicHoard data in a database.
use std::fmt;
use serde::{de::DeserializeOwned, Serialize};
#[cfg(test)]
use mockall::automock;
#[cfg(feature = "database-json")]
pub mod json;
/// Error type for database calls.
#[derive(Debug)]
pub enum Error {
/// The database experienced an I/O error.
IoError(String),
/// The database experienced a (de)serialisation error.
SerDeError(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Self::IoError(ref s) => write!(f, "the database experienced an I/O error: {s}"),
Self::SerDeError(ref s) => {
write!(f, "the database experienced a (de)serialisation error: {s}")
}
}
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Error {
Error::IoError(err.to_string())
}
}
/// Trait for interacting with the database.
#[cfg_attr(test, automock)]
pub trait Database {
/// Read collection from the database.
fn read<D: DeserializeOwned + 'static>(&self, collection: &mut D) -> Result<(), Error>;
/// Write collection to the database.
fn write<S: Serialize + 'static>(&mut self, collection: &S) -> Result<(), Error>;
}
#[cfg(test)]
mod tests {
use std::io;
use super::Error;
#[test]
fn errors() {
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into();
assert!(!io_err.to_string().is_empty());
assert!(!format!("{:?}", io_err).is_empty());
}
}

View File

@ -3,7 +3,7 @@
use std::fs;
use std::path::PathBuf;
use super::JsonDatabaseBackend;
use crate::external::database::json::IJsonDatabaseBackend;
/// JSON database backend that uses a local file for persistent storage.
pub struct JsonDatabaseFileBackend {
@ -17,7 +17,7 @@ impl JsonDatabaseFileBackend {
}
}
impl JsonDatabaseBackend for JsonDatabaseFileBackend {
impl IJsonDatabaseBackend for JsonDatabaseFileBackend {
fn read(&self) -> Result<String, std::io::Error> {
// Read entire file to memory as for now this is faster than a buffered read from disk:
// https://github.com/serde-rs/json/issues/160

169
src/external/database/json/mod.rs vendored Normal file
View File

@ -0,0 +1,169 @@
//! Module for storing MusicHoard data in a JSON file database.
pub mod backend;
#[cfg(test)]
use mockall::automock;
use crate::{
core::{
collection::Collection,
interface::database::{IDatabase, LoadError, SaveError},
},
external::database::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase},
};
impl From<serde_json::Error> for LoadError {
fn from(err: serde_json::Error) -> LoadError {
LoadError::SerDeError(err.to_string())
}
}
impl From<serde_json::Error> for SaveError {
fn from(err: serde_json::Error) -> SaveError {
SaveError::SerDeError(err.to_string())
}
}
/// Trait for the JSON database backend.
#[cfg_attr(test, automock)]
pub trait IJsonDatabaseBackend {
/// Read the JSON string from the backend.
fn read(&self) -> Result<String, std::io::Error>;
/// Write the JSON string to the backend.
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
}
/// JSON database.
pub struct JsonDatabase<JDB> {
backend: JDB,
}
impl<JDB: IJsonDatabaseBackend> JsonDatabase<JDB> {
/// Create a new JSON database with the provided backend, e.g.
/// [`backend::JsonDatabaseFileBackend`].
pub fn new(backend: JDB) -> Self {
JsonDatabase { backend }
}
}
impl<JDB: IJsonDatabaseBackend> IDatabase for JsonDatabase<JDB> {
fn load(&self) -> Result<Collection, LoadError> {
let serialized = self.backend.read()?;
let database: DeserializeDatabase = serde_json::from_str(&serialized)?;
Ok(database.into())
}
fn save(&mut self, collection: &Collection) -> Result<(), SaveError> {
let database: SerializeDatabase = collection.into();
let serialized = serde_json::to_string(&database)?;
self.backend.write(&serialized)?;
Ok(())
}
}
#[cfg(test)]
pub mod testmod;
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use mockall::predicate;
use crate::core::{
collection::{album::AlbumDate, artist::Artist, Collection},
testmod::FULL_COLLECTION,
};
use super::*;
use testmod::DATABASE_JSON;
fn expected() -> Collection {
let mut expected = FULL_COLLECTION.to_owned();
for artist in expected.iter_mut() {
for album in artist.albums.iter_mut() {
album.meta.date = AlbumDate::default();
album.tracks.clear();
}
}
expected
}
#[test]
fn save() {
let write_data = FULL_COLLECTION.to_owned();
let input = DATABASE_JSON.to_owned();
let mut backend = MockIJsonDatabaseBackend::new();
backend
.expect_write()
.with(predicate::eq(input))
.times(1)
.return_once(|_| Ok(()));
JsonDatabase::new(backend).save(&write_data).unwrap();
}
#[test]
fn load() {
let expected = expected();
let result = Ok(DATABASE_JSON.to_owned());
eprintln!("{DATABASE_JSON}");
let mut backend = MockIJsonDatabaseBackend::new();
backend.expect_read().times(1).return_once(|| result);
let read_data: Vec<Artist> = JsonDatabase::new(backend).load().unwrap();
assert_eq!(read_data, expected);
}
#[test]
fn reverse() {
let input = DATABASE_JSON.to_owned();
let result = Ok(input.clone());
let mut backend = MockIJsonDatabaseBackend::new();
backend
.expect_write()
.with(predicate::eq(input))
.times(1)
.return_once(|_| Ok(()));
backend.expect_read().times(1).return_once(|| result);
let mut database = JsonDatabase::new(backend);
let write_data = FULL_COLLECTION.to_owned();
database.save(&write_data).unwrap();
let read_data: Vec<Artist> = database.load().unwrap();
// Album information is not saved to disk.
let expected = expected();
assert_eq!(read_data, expected);
}
#[test]
fn load_errors() {
let json = String::from("");
let serde_err = serde_json::from_str::<DeserializeDatabase>(&json);
assert!(serde_err.is_err());
let serde_err: LoadError = serde_err.unwrap_err().into();
assert!(!serde_err.to_string().is_empty());
assert!(!format!("{:?}", serde_err).is_empty());
}
#[test]
fn save_errors() {
// serde_json will raise an error as it has certain requirements on keys.
let mut object = HashMap::<Result<(), ()>, String>::new();
object.insert(Ok(()), String::from("string"));
let serde_err = serde_json::to_string(&object);
assert!(serde_err.is_err());
let serde_err: SaveError = serde_err.unwrap_err().into();
assert!(!serde_err.to_string().is_empty());
assert!(!format!("{:?}", serde_err).is_empty());
}
}

90
src/external/database/json/testmod.rs vendored Normal file
View File

@ -0,0 +1,90 @@
pub static DATABASE_JSON: &str = "{\
\"V20240924\":\
[\
{\
\"name\":\"Album_Artist A\",\
\"sort\":null,\
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
\"properties\":{\
\"MusicButler\":[\"https://www.musicbutler.io/artist-page/000000000\"],\
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums\"]\
},\
\"albums\":[\
{\
\"title\":\"album_title a.a\",\"seq\":1,\
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title a.b\",\"seq\":1,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
}\
]\
},\
{\
\"name\":\"Album_Artist B\",\
\"sort\":null,\
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
\"properties\":{\
\"Bandcamp\":[\"https://artist-b.bandcamp.com/\"],\
\"MusicButler\":[\
\"https://www.musicbutler.io/artist-page/111111111\",\
\"https://www.musicbutler.io/artist-page/111111112\"\
],\
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums\"]\
},\
\"albums\":[\
{\
\"title\":\"album_title b.a\",\"seq\":1,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title b.b\",\"seq\":3,\
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title b.c\",\"seq\":2,\
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111112\"},\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title b.d\",\"seq\":4,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
}\
]\
},\
{\
\"name\":\"The Album_Artist C\",\
\"sort\":\"Album_Artist C, The\",\
\"musicbrainz\":\"CannotHaveMbid\",\
\"properties\":{},\
\"albums\":[\
{\
\"title\":\"album_title c.a\",\"seq\":0,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title c.b\",\"seq\":0,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
}\
]\
},\
{\
\"name\":\"Album_Artist D\",\
\"sort\":null,\
\"musicbrainz\":\"None\",\
\"properties\":{},\
\"albums\":[\
{\
\"title\":\"album_title d.a\",\"seq\":0,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
},\
{\
\"title\":\"album_title d.b\",\"seq\":0,\"musicbrainz\":\"None\",\
\"primary_type\":\"Album\",\"secondary_types\":[]\
}\
]\
}\
]\
}";

4
src/external/database/mod.rs vendored Normal file
View File

@ -0,0 +1,4 @@
#[cfg(feature = "database-json")]
pub mod json;
#[cfg(feature = "database-json")]
mod serde;

71
src/external/database/serde/common.rs vendored Normal file
View File

@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use crate::{
collection::musicbrainz::MbRefOption,
core::collection::album::{AlbumPrimaryType, AlbumSecondaryType},
};
#[derive(Debug, Deserialize, Serialize)]
#[serde(remote = "MbRefOption")]
pub enum MbRefOptionDef<T> {
Some(T),
CannotHaveMbid,
None,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(remote = "AlbumPrimaryType")]
pub enum AlbumPrimaryTypeDef {
Album,
Single,
Ep,
Broadcast,
Other,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
fn from(value: SerdeAlbumPrimaryType) -> Self {
value.0
}
}
impl From<AlbumPrimaryType> for SerdeAlbumPrimaryType {
fn from(value: AlbumPrimaryType) -> Self {
SerdeAlbumPrimaryType(value)
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(remote = "AlbumSecondaryType")]
pub enum AlbumSecondaryTypeDef {
Compilation,
Soundtrack,
Spokenword,
Interview,
Audiobook,
AudioDrama,
Live,
Remix,
DjMix,
MixtapeStreet,
Demo,
FieldRecording,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
fn from(value: SerdeAlbumSecondaryType) -> Self {
value.0
}
}
impl From<AlbumSecondaryType> for SerdeAlbumSecondaryType {
fn from(value: AlbumSecondaryType) -> Self {
SerdeAlbumSecondaryType(value)
}
}

View File

@ -0,0 +1,167 @@
use std::{collections::HashMap, fmt};
use serde::{de::Visitor, Deserialize, Deserializer};
use crate::{
collection::{
album::{AlbumInfo, AlbumMeta},
artist::{ArtistInfo, ArtistMeta},
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption, Mbid},
},
core::collection::{
album::{Album, AlbumDate, AlbumId, AlbumSeq},
artist::{Artist, ArtistId},
Collection, Error as CollectionError,
},
external::database::serde::common::{SerdeAlbumPrimaryType, SerdeAlbumSecondaryType},
};
use super::common::MbRefOptionDef;
#[derive(Debug, Deserialize)]
pub enum DeserializeDatabase {
V20240924(Vec<DeserializeArtist>),
}
impl From<DeserializeDatabase> for Collection {
fn from(database: DeserializeDatabase) -> Self {
match database {
DeserializeDatabase::V20240924(collection) => {
collection.into_iter().map(Into::into).collect()
}
}
}
}
#[derive(Debug, Deserialize)]
pub struct DeserializeArtist {
name: String,
sort: Option<String>,
musicbrainz: DeserializeMbRefOption,
properties: HashMap<String, Vec<String>>,
albums: Vec<DeserializeAlbum>,
}
#[derive(Debug, Deserialize)]
pub struct DeserializeAlbum {
title: String,
seq: u8,
musicbrainz: DeserializeMbRefOption,
primary_type: Option<SerdeAlbumPrimaryType>,
secondary_types: Vec<SerdeAlbumSecondaryType>,
}
#[derive(Debug, Deserialize)]
pub struct DeserializeMbRefOption(#[serde(with = "MbRefOptionDef")] MbRefOption<DeserializeMbid>);
#[derive(Clone, Debug)]
pub struct DeserializeMbid(Mbid);
macro_rules! impl_from_for_mb_ref_option {
($ref:ty) => {
impl From<DeserializeMbRefOption> for MbRefOption<$ref> {
fn from(value: DeserializeMbRefOption) -> Self {
match value.0 {
MbRefOption::Some(val) => MbRefOption::Some(val.0.into()),
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
MbRefOption::None => MbRefOption::None,
}
}
}
};
}
impl_from_for_mb_ref_option!(MbArtistRef);
impl_from_for_mb_ref_option!(MbAlbumRef);
impl From<DeserializeMbid> for Mbid {
fn from(value: DeserializeMbid) -> Self {
value.0
}
}
struct DeserializeMbidVisitor;
impl<'de> Visitor<'de> for DeserializeMbidVisitor {
type Value = DeserializeMbid;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid MusicBrainz identifier")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(DeserializeMbid(
v.try_into()
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
))
}
}
impl<'de> Deserialize<'de> for DeserializeMbid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(DeserializeMbidVisitor)
}
}
impl From<DeserializeArtist> for Artist {
fn from(artist: DeserializeArtist) -> Self {
Artist {
meta: ArtistMeta {
id: ArtistId::new(artist.name),
sort: artist.sort,
info: ArtistInfo {
musicbrainz: artist.musicbrainz.into(),
properties: artist.properties,
},
},
albums: artist.albums.into_iter().map(Into::into).collect(),
}
}
}
impl From<DeserializeAlbum> for Album {
fn from(album: DeserializeAlbum) -> Self {
Album {
meta: AlbumMeta {
id: AlbumId { title: album.title },
date: AlbumDate::default(),
seq: AlbumSeq(album.seq),
info: AlbumInfo {
musicbrainz: album.musicbrainz.into(),
primary_type: album.primary_type.map(Into::into),
secondary_types: album.secondary_types.into_iter().map(Into::into).collect(),
},
},
tracks: vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_mbid() {
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
let mbid: DeserializeMbid = serde_json::from_str(mbid).unwrap();
let mbid: Mbid = mbid.into();
assert_eq!(
mbid,
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
);
let mbid = "null";
let result: Result<DeserializeMbid, _> = serde_json::from_str(mbid);
assert!(result
.unwrap_err()
.to_string()
.contains("a valid MusicBrainz identifier"));
}
}

5
src/external/database/serde/mod.rs vendored Normal file
View File

@ -0,0 +1,5 @@
//! Helper module for backends that can use serde for (de)serialisation.
mod common;
pub mod deserialize;
pub mod serialize;

106
src/external/database/serde/serialize.rs vendored Normal file
View File

@ -0,0 +1,106 @@
use std::collections::BTreeMap;
use serde::Serialize;
use crate::{
collection::musicbrainz::{MbRefOption, Mbid},
core::collection::{album::Album, artist::Artist, musicbrainz::IMusicBrainzRef, Collection},
external::database::serde::common::{
MbRefOptionDef, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType,
},
};
#[derive(Debug, Serialize)]
pub enum SerializeDatabase<'a> {
V20240924(Vec<SerializeArtist<'a>>),
}
impl<'a> From<&'a Collection> for SerializeDatabase<'a> {
fn from(collection: &'a Collection) -> Self {
SerializeDatabase::V20240924(collection.iter().map(Into::into).collect())
}
}
#[derive(Debug, Serialize)]
pub struct SerializeArtist<'a> {
name: &'a str,
sort: Option<&'a str>,
musicbrainz: SerializeMbRefOption<'a>,
properties: BTreeMap<&'a str, &'a Vec<String>>,
albums: Vec<SerializeAlbum<'a>>,
}
#[derive(Debug, Serialize)]
pub struct SerializeAlbum<'a> {
title: &'a str,
seq: u8,
musicbrainz: SerializeMbRefOption<'a>,
primary_type: Option<SerdeAlbumPrimaryType>,
secondary_types: Vec<SerdeAlbumSecondaryType>,
}
#[derive(Debug, Serialize)]
pub struct SerializeMbRefOption<'a>(
#[serde(with = "MbRefOptionDef")] MbRefOption<SerializeMbid<'a>>,
);
#[derive(Clone, Debug)]
pub struct SerializeMbid<'a>(&'a Mbid);
impl<'a, T: IMusicBrainzRef> From<&'a MbRefOption<T>> for SerializeMbRefOption<'a> {
fn from(value: &'a MbRefOption<T>) -> Self {
match value {
MbRefOption::Some(val) => {
SerializeMbRefOption(MbRefOption::Some(SerializeMbid(val.mbid())))
}
MbRefOption::CannotHaveMbid => SerializeMbRefOption(MbRefOption::CannotHaveMbid),
MbRefOption::None => SerializeMbRefOption(MbRefOption::None),
}
}
}
impl<'a> Serialize for SerializeMbid<'a> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0.uuid().as_hyphenated().to_string())
}
}
impl<'a> From<&'a Artist> for SerializeArtist<'a> {
fn from(artist: &'a Artist) -> Self {
SerializeArtist {
name: &artist.meta.id.name,
sort: artist.meta.sort.as_deref(),
musicbrainz: (&artist.meta.info.musicbrainz).into(),
properties: artist
.meta
.info
.properties
.iter()
.map(|(k, v)| (k.as_ref(), v))
.collect(),
albums: artist.albums.iter().map(Into::into).collect(),
}
}
}
impl<'a> From<&'a Album> for SerializeAlbum<'a> {
fn from(album: &'a Album) -> Self {
SerializeAlbum {
title: &album.meta.id.title,
seq: album.meta.seq.0,
musicbrainz: (&album.meta.info.musicbrainz).into(),
primary_type: album.meta.info.primary_type.map(Into::into),
secondary_types: album
.meta
.info
.secondary_types
.iter()
.copied()
.map(Into::into)
.collect(),
}
}
}

View File

@ -8,11 +8,11 @@ use std::{
str,
};
use super::{BeetsLibraryExecutor, Error};
use crate::{core::interface::library::Error, external::library::beets::IBeetsLibraryExecutor};
const BEET_DEFAULT: &str = "beet";
trait BeetsLibraryExecutorPrivate {
trait IBeetsLibraryExecutorPrivate {
fn output(output: Output) -> Result<Vec<String>, Error> {
if !output.status.success() {
return Err(Error::Executor(
@ -59,7 +59,7 @@ impl Default for BeetsLibraryProcessExecutor {
}
}
impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor {
impl IBeetsLibraryExecutor for BeetsLibraryProcessExecutor {
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error> {
let mut cmd = Command::new(&self.bin);
if let Some(ref path) = self.config {
@ -71,11 +71,13 @@ impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor {
}
}
impl BeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
impl IBeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
// GRCOV_EXCL_START
#[cfg(feature = "library-ssh")]
#[cfg(feature = "library-beets-ssh")]
pub mod ssh {
//! Module for interacting with the music library via
//! [beets](https://beets.readthedocs.io/en/stable/) over SSH.
use openssh::{KnownHosts, Session};
use tokio::runtime::{self, Runtime};
@ -128,7 +130,7 @@ pub mod ssh {
}
}
impl BeetsLibraryExecutor for BeetsLibrarySshExecutor {
impl IBeetsLibraryExecutor for BeetsLibrarySshExecutor {
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error> {
let mut cmd = self.session.command(&self.bin);
if let Some(ref path) = self.config {
@ -141,6 +143,6 @@ pub mod ssh {
}
}
impl BeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {}
impl IBeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {}
}
// GRCOV_EXCL_STOP

View File

@ -1,19 +1,15 @@
//! Module for interacting with the music library via
//! [beets](https://beets.readthedocs.io/en/stable/).
use std::{
collections::{HashMap, HashSet},
str,
};
pub mod executor;
#[cfg(test)]
use mockall::automock;
use crate::{Album, AlbumId, Artist, ArtistId, Track, TrackFormat};
use super::{Error, Field, Library, Query};
pub mod executor;
use crate::core::{
collection::track::TrackFormat,
interface::library::{Error, Field, ILibrary, Item, Query},
};
macro_rules! list_format_separator {
() => {
@ -27,8 +23,14 @@ const LIST_FORMAT_ARG: &str = concat!(
"--format=",
"$albumartist",
list_format_separator!(),
"$albumartist_sort",
list_format_separator!(),
"$year",
list_format_separator!(),
"$month",
list_format_separator!(),
"$day",
list_format_separator!(),
"$album",
list_format_separator!(),
"$track",
@ -37,11 +39,28 @@ const LIST_FORMAT_ARG: &str = concat!(
list_format_separator!(),
"$artist",
list_format_separator!(),
"$format"
"$format",
list_format_separator!(),
"$bitrate"
);
const TRACK_FORMAT_FLAC: &str = "FLAC";
const TRACK_FORMAT_MP3: &str = "MP3";
fn format_to_str(format: &TrackFormat) -> &'static str {
match format {
TrackFormat::Flac => TRACK_FORMAT_FLAC,
TrackFormat::Mp3 => TRACK_FORMAT_MP3,
}
}
fn str_to_format(format: &str) -> Option<TrackFormat> {
match format {
TRACK_FORMAT_FLAC => Some(TrackFormat::Flac),
TRACK_FORMAT_MP3 => Some(TrackFormat::Mp3),
_ => None,
}
}
trait ToBeetsArg {
fn to_arg(&self, include: bool) -> String;
}
@ -55,11 +74,15 @@ impl ToBeetsArg for Field {
let negate = if include { "" } else { "^" };
match self {
Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"),
Field::AlbumArtistSort(ref s) => format!("{negate}albumartist_sort:{s}"),
Field::AlbumYear(ref u) => format!("{negate}year:{u}"),
Field::AlbumMonth(ref e) => format!("{negate}month:{}", { *e }),
Field::AlbumDay(ref u) => format!("{negate}day:{u}"),
Field::AlbumTitle(ref s) => format!("{negate}album:{s}"),
Field::TrackNumber(ref u) => format!("{negate}track:{u}"),
Field::TrackTitle(ref s) => format!("{negate}title:{s}"),
Field::TrackArtist(ref v) => format!("{negate}artist:{}", v.join("; ")),
Field::TrackFormat(ref f) => format!("{negate}format:{}", format_to_str(f)),
Field::All(ref s) => format!("{negate}{s}"),
}
}
@ -78,7 +101,7 @@ impl ToBeetsArgs for Query {
/// Trait for invoking beets commands.
#[cfg_attr(test, automock)]
pub trait BeetsLibraryExecutor {
pub trait IBeetsLibraryExecutor {
/// Invoke beets with the provided arguments.
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error>;
}
@ -88,12 +111,7 @@ pub struct BeetsLibrary<BLE> {
executor: BLE,
}
trait LibraryPrivate {
fn list_cmd_and_args(query: &Query) -> Vec<String>;
fn list_to_artists<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Artist>, Error>;
}
impl<BLE: BeetsLibraryExecutor> BeetsLibrary<BLE> {
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
/// Create a new beets library with the provided executor, e.g.
/// [`executor::BeetsLibraryProcessExecutor`].
pub fn new(executor: BLE) -> Self {
@ -101,15 +119,15 @@ impl<BLE: BeetsLibraryExecutor> BeetsLibrary<BLE> {
}
}
impl<BLE: BeetsLibraryExecutor> Library for BeetsLibrary<BLE> {
fn list(&mut self, query: &Query) -> Result<Vec<Artist>, Error> {
impl<BLE: IBeetsLibraryExecutor> ILibrary for BeetsLibrary<BLE> {
fn list(&mut self, query: &Query) -> Result<Vec<Item>, Error> {
let cmd = Self::list_cmd_and_args(query);
let output = self.executor.exec(&cmd)?;
Self::list_to_artists(&output)
Self::list_to_items(&output)
}
}
impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
fn list_cmd_and_args(query: &Query) -> Vec<String> {
let mut cmd: Vec<String> = vec![String::from(CMD_LIST)];
cmd.push(LIST_FORMAT_ARG.to_string());
@ -117,9 +135,8 @@ impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
cmd
}
fn list_to_artists<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Artist>, Error> {
let mut artists: Vec<Artist> = vec![];
let mut album_ids = HashMap::<ArtistId, HashSet<AlbumId>>::new();
fn list_to_items<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Item>, Error> {
let mut items: Vec<Item> = vec![];
for line in list_output.iter().map(|s| s.as_ref()) {
if line.is_empty() {
@ -127,121 +144,58 @@ impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
}
let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect();
if split.len() != 7 {
if split.len() != 11 {
return Err(Error::Invalid(line.to_string()));
}
let album_artist = split[0].to_string();
let album_year = split[1].parse::<u32>()?;
let album_title = split[2].to_string();
let track_number = split[3].parse::<u32>()?;
let track_title = split[4].to_string();
let track_artist = split[5].to_string();
let track_format = split[6].to_string();
let artist_id = ArtistId { name: album_artist };
let album_id = AlbumId {
year: album_year,
title: album_title,
let album_artist_sort = match !split[1].is_empty() {
true => Some(split[1].to_string()),
false => None,
};
let track = Track {
number: track_number,
title: track_title,
artist: track_artist.split("; ").map(|s| s.to_owned()).collect(),
format: match track_format.as_ref() {
TRACK_FORMAT_FLAC => TrackFormat::Flac,
TRACK_FORMAT_MP3 => TrackFormat::Mp3,
_ => return Err(Error::Invalid(line.to_string())),
},
let album_year = split[2].parse::<u32>()?;
let album_month = split[3].parse::<u8>()?;
let album_day = split[4].parse::<u8>()?;
let album_title = split[5].to_string();
let track_number = split[6].parse::<u32>()?;
let track_title = split[7].to_string();
let track_artist = split[8].split("; ").map(|s| s.to_owned()).collect();
let track_format = match str_to_format(split[9].to_string().as_str()) {
Some(format) => format,
None => return Err(Error::Invalid(line.to_string())),
};
let track_bitrate = split[10].trim_end_matches("kbps").parse::<u32>()?;
let artist = if album_ids.contains_key(&artist_id) {
// Beets returns results in order so we look from the back.
artists
.iter_mut()
.rev()
.find(|a| a.id == artist_id)
.unwrap()
} else {
album_ids.insert(artist_id.clone(), HashSet::<AlbumId>::new());
artists.push(Artist {
id: artist_id.clone(),
albums: vec![],
});
artists.last_mut().unwrap()
};
if album_ids[&artist_id].contains(&album_id) {
// Beets returns results in order so we look from the back.
let album = artist
.albums
.iter_mut()
.rev()
.find(|a| a.id == album_id)
.unwrap();
album.tracks.push(track);
} else {
album_ids
.get_mut(&artist_id)
.unwrap()
.insert(album_id.clone());
artist.albums.push(Album {
id: album_id,
tracks: vec![track],
});
}
items.push(Item {
album_artist,
album_artist_sort,
album_year,
album_month,
album_day,
album_title,
track_number,
track_title,
track_artist,
track_format,
track_bitrate,
});
}
Ok(artists)
Ok(items)
}
}
#[cfg(test)]
mod testmod;
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::tests::COLLECTION;
use crate::core::interface::library::testmod::LIBRARY_ITEMS;
use super::*;
fn artist_to_beets_string(artist: &Artist) -> Vec<String> {
let mut strings = vec![];
let album_artist = &artist.id.name;
for album in artist.albums.iter() {
let album_year = &album.id.year;
let album_title = &album.id.title;
for track in album.tracks.iter() {
let track_number = &track.number;
let track_title = &track.title;
let track_artist = &track.artist.join("; ");
let track_format = match track.format {
TrackFormat::Flac => TRACK_FORMAT_FLAC,
TrackFormat::Mp3 => TRACK_FORMAT_MP3,
};
strings.push(format!(
"{album_artist}{0}{album_year}{0}{album_title}{0}\
{track_number}{0}{track_title}{0}{track_artist}{0}{track_format}",
LIST_FORMAT_SEPARATOR,
));
}
}
strings
}
fn artists_to_beets_string(artists: &[Artist]) -> Vec<String> {
let mut strings = vec![];
for artist in artists.iter() {
strings.append(&mut artist_to_beets_string(artist));
}
strings
}
use testmod::LIBRARY_BEETS;
#[test]
fn test_query() {
@ -252,6 +206,7 @@ mod tests {
String::from("some.artist.1"),
String::from("some.artist.2"),
]))
.exclude(Field::TrackFormat(TrackFormat::Mp3))
.exclude(Field::All(String::from("some.all")))
.to_args();
query.sort();
@ -260,6 +215,7 @@ mod tests {
query,
vec![
String::from("^album:some.album"),
String::from("^format:MP3"),
String::from("^some.all"),
String::from("artist:some.artist.1; some.artist.2"),
String::from("track:5"),
@ -268,8 +224,12 @@ mod tests {
let mut query = Query::default()
.exclude(Field::AlbumArtist(String::from("some.albumartist")))
.exclude(Field::AlbumArtistSort(String::from("some.albumartist")))
.include(Field::AlbumYear(3030))
.include(Field::AlbumMonth(4))
.include(Field::AlbumDay(6))
.include(Field::TrackTitle(String::from("some.track")))
.include(Field::TrackFormat(TrackFormat::Flac))
.exclude(Field::TrackArtist(vec![
String::from("some.artist.1"),
String::from("some.artist.2"),
@ -281,7 +241,11 @@ mod tests {
query,
vec![
String::from("^albumartist:some.albumartist"),
String::from("^albumartist_sort:some.albumartist"),
String::from("^artist:some.artist.1; some.artist.2"),
String::from("day:6"),
String::from("format:FLAC"),
String::from("month:4"),
String::from("title:some.track"),
String::from("year:3030"),
]
@ -293,7 +257,7 @@ mod tests {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let result = Ok(vec![]);
let mut executor = MockBeetsLibraryExecutor::new();
let mut executor = MockIBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))
@ -303,17 +267,17 @@ mod tests {
let mut beets = BeetsLibrary::new(executor);
let output = beets.list(&Query::new()).unwrap();
let expected: Vec<Artist> = vec![];
let expected: Vec<Item> = vec![];
assert_eq!(output, expected);
}
#[test]
fn test_list_ordered() {
fn test_list() {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let expected = COLLECTION.to_owned();
let result = Ok(artists_to_beets_string(&expected));
let expected: &Vec<Item> = LIBRARY_ITEMS.as_ref();
let result = Ok(LIBRARY_BEETS.to_owned());
let mut executor = MockBeetsLibraryExecutor::new();
let mut executor = MockIBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))
@ -323,64 +287,7 @@ mod tests {
let mut beets = BeetsLibrary::new(executor);
let output = beets.list(&Query::new()).unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_list_unordered() {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let mut expected = COLLECTION.to_owned();
let mut output = artists_to_beets_string(&expected);
let last = output.len() - 1;
output.swap(0, last);
let result = Ok(output);
// Putting the last track first will make the entire artist come first in the output.
expected.rotate_right(1);
// Same applies to that artists' albums.
expected[0].albums.rotate_right(1);
// Same applies to that album's tracks.
expected[0].albums[0].tracks.rotate_right(1);
// And the original first album's (now the first album of the second artist) tracks first
// track comes last.
expected[1].albums[0].tracks.rotate_left(1);
let mut executor = MockBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))
.times(1)
.return_once(|_| result);
let mut beets = BeetsLibrary::new(executor);
let output = beets.list(&Query::new()).unwrap();
assert_eq!(output, expected);
}
#[test]
fn test_list_album_title_year_clash() {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let mut expected = COLLECTION.to_owned();
expected[0].albums[0].id.year = expected[1].albums[0].id.year;
expected[0].albums[0].id.title = expected[1].albums[0].id.title.clone();
let output = artists_to_beets_string(&expected);
let result = Ok(output);
let mut executor = MockBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))
.times(1)
.return_once(|_| result);
let mut beets = BeetsLibrary::new(executor);
let output = beets.list(&Query::new()).unwrap();
assert_eq!(output, expected);
assert_eq!(&output, expected);
}
#[test]
@ -400,7 +307,7 @@ mod tests {
];
let result = Ok(vec![]);
let mut executor = MockBeetsLibraryExecutor::new();
let mut executor = MockIBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::function(move |x: &[String]| {
@ -414,15 +321,14 @@ mod tests {
let mut beets = BeetsLibrary::new(executor);
let output = beets.list(&query).unwrap();
let expected: Vec<Artist> = vec![];
let expected: Vec<Item> = vec![];
assert_eq!(output, expected);
}
#[test]
fn invalid_data_split() {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let expected = COLLECTION.to_owned();
let mut output = artists_to_beets_string(&expected);
let mut output: Vec<String> = LIBRARY_BEETS.to_owned();
let invalid_string = output[2]
.split(LIST_FORMAT_SEPARATOR)
.map(|s| s.to_owned())
@ -431,7 +337,7 @@ mod tests {
output[2] = invalid_string.clone();
let result = Ok(output);
let mut executor = MockBeetsLibraryExecutor::new();
let mut executor = MockIBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))
@ -447,22 +353,18 @@ mod tests {
#[test]
fn invalid_data_format() {
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
let expected = COLLECTION.to_owned();
let mut output = artists_to_beets_string(&expected);
let mut output: Vec<String> = LIBRARY_BEETS.to_owned();
let mut invalid_string = output[2]
.split(LIST_FORMAT_SEPARATOR)
.map(|s| s.to_owned())
.collect::<Vec<String>>();
invalid_string.last_mut().unwrap().clear();
invalid_string
.last_mut()
.unwrap()
.push_str("invalid format");
invalid_string[9].clear();
invalid_string[9].push_str("invalid format");
let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR);
output[2] = invalid_string.clone();
let result = Ok(output);
let mut executor = MockBeetsLibraryExecutor::new();
let mut executor = MockIBeetsLibraryExecutor::new();
executor
.expect_exec()
.with(predicate::eq(arguments))

116
src/external/library/beets/testmod.rs vendored Normal file
View File

@ -0,0 +1,116 @@
use once_cell::sync::Lazy;
pub static LIBRARY_BEETS: Lazy<Vec<String>> = Lazy::new(|| -> Vec<String> {
vec![
String::from(
"Album_Artist A -*^- -*^- \
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992",
),
String::from(
"Album_Artist A -*^- -*^- \
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320",
),
String::from(
"Album_Artist A -*^- -*^- \
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061",
),
String::from(
"Album_Artist A -*^- -*^- \
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042",
),
String::from(
"Album_Artist A -*^- -*^- \
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004",
),
String::from(
"Album_Artist A -*^- -*^- \
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077",
),
String::from(
"Album_Artist B -*^- -*^- \
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190",
),
String::from(
"Album_Artist B -*^- -*^- \
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120",
),
String::from(
"Album_Artist B -*^- -*^- \
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077",
),
String::from(
"Album_Artist B -*^- -*^- \
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320",
),
String::from(
"Album_Artist B -*^- -*^- \
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190",
),
String::from(
"Album_Artist B -*^- -*^- \
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120",
),
String::from(
"Album_Artist B -*^- -*^- \
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190",
),
String::from(
"Album_Artist B -*^- -*^- \
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120",
),
String::from(
"The Album_Artist C -*^- Album_Artist C, The -*^- \
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320",
),
String::from(
"The Album_Artist C -*^- Album_Artist C, The -*^- \
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120",
),
String::from(
"The Album_Artist C -*^- Album_Artist C, The -*^- \
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041",
),
String::from(
"The Album_Artist C -*^- Album_Artist C, The -*^- \
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756",
),
String::from(
"Album_Artist D -*^- -*^- \
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120",
),
String::from(
"Album_Artist D -*^- -*^- \
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120",
),
String::from(
"Album_Artist D -*^- -*^- \
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841",
),
String::from(
"Album_Artist D -*^- -*^- \
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756",
),
]
});

2
src/external/library/mod.rs vendored Normal file
View File

@ -0,0 +1,2 @@
#[cfg(feature = "library-beets")]
pub mod beets;

4
src/external/mod.rs vendored Normal file
View File

@ -0,0 +1,4 @@
pub mod database;
pub mod library;
#[cfg(feature = "musicbrainz")]
pub mod musicbrainz;

178
src/external/musicbrainz/api/browse.rs vendored Normal file
View File

@ -0,0 +1,178 @@
use std::fmt;
use serde::Deserialize;
use crate::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{
ApiDisplay, Error, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
SerdeMbReleaseGroupMeta, MB_BASE_URL,
},
IMusicBrainzHttp,
},
};
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct BrowseReleaseGroupPage {
release_group_offset: usize,
release_group_count: usize,
}
impl BrowseReleaseGroupPage {
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
settings.next_page(
self.release_group_offset,
self.release_group_count,
page_count,
)
}
}
pub type SerdeBrowseReleaseGroupPage = BrowseReleaseGroupPage;
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn browse_release_group(
&mut self,
request: &BrowseReleaseGroupRequest,
paging: &PageSettings,
) -> Result<BrowseReleaseGroupResponse, Error> {
let entity = &request.entity;
let mbid = request.mbid.uuid().as_hyphenated();
let page = ApiDisplay::format_page_settings(paging);
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{page}");
let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
Ok(response.into())
}
}
pub struct BrowseReleaseGroupRequest<'a> {
entity: BrowseReleaseGroupRequestEntity,
mbid: &'a Mbid,
}
enum BrowseReleaseGroupRequestEntity {
Artist,
}
impl fmt::Display for BrowseReleaseGroupRequestEntity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BrowseReleaseGroupRequestEntity::Artist => write!(f, "artist"),
}
}
}
impl<'a> BrowseReleaseGroupRequest<'a> {
pub fn artist(mbid: &'a Mbid) -> Self {
BrowseReleaseGroupRequest {
entity: BrowseReleaseGroupRequestEntity::Artist,
mbid,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct BrowseReleaseGroupResponse {
pub release_groups: Vec<MbReleaseGroupMeta>,
pub page: BrowseReleaseGroupPage,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeBrowseReleaseGroupResponse {
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
#[serde(flatten)]
page: SerdeBrowseReleaseGroupPage,
}
impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse {
fn from(value: DeserializeBrowseReleaseGroupResponse) -> Self {
BrowseReleaseGroupResponse {
page: value.page,
release_groups: value
.release_groups
.map(|rgs| rgs.into_iter().map(Into::into).collect())
.unwrap_or_default(),
}
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{
tests::next_page_test, SerdeAlbumDate, SerdeAlbumPrimaryType,
SerdeAlbumSecondaryType, SerdeMbid, MB_MAX_PAGE_LIMIT,
},
MockIMusicBrainzHttp,
},
};
use super::*;
#[test]
fn browse_release_group_next_page() {
let page = BrowseReleaseGroupPage {
release_group_offset: 5,
release_group_count: 45,
};
next_page_test(|val| page.next_page(PageSettings::default(), val));
}
#[test]
fn browse_release_group() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new();
let de_release_group_offset = 24;
let de_release_group_count = 302;
let de_meta = SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
secondary_types: Some(vec![SerdeAlbumSecondaryType(
AlbumSecondaryType::Compilation,
)]),
};
let de_response = DeserializeBrowseReleaseGroupResponse {
page: SerdeBrowseReleaseGroupPage {
release_group_offset: de_release_group_offset,
release_group_count: de_release_group_count,
},
release_groups: Some(vec![de_meta.clone()]),
};
let response = BrowseReleaseGroupResponse {
page: de_response.page,
release_groups: vec![de_meta.clone().into()],
};
let url = format!(
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_PAGE_LIMIT}",
);
let expect_response = de_response.clone();
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(expect_response));
let mut client = MusicBrainzClient::new(http);
let mbid: Mbid = mbid.try_into().unwrap();
let request = BrowseReleaseGroupRequest::artist(&mbid);
let paging = PageSettings::with_max_limit();
let result = client.browse_release_group(&request, &paging).unwrap();
assert_eq!(result, response);
}
}

221
src/external/musicbrainz/api/lookup.rs vendored Normal file
View File

@ -0,0 +1,221 @@
use serde::Deserialize;
use url::form_urlencoded;
use crate::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{Error, MusicBrainzClient, MB_BASE_URL},
IMusicBrainzHttp,
},
};
use super::{MbArtistMeta, MbReleaseGroupMeta, SerdeMbArtistMeta, SerdeMbReleaseGroupMeta};
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn lookup_artist(
&mut self,
request: &LookupArtistRequest,
) -> Result<LookupArtistResponse, Error> {
let mut include: Vec<String> = vec![];
if request.release_groups {
include.push(String::from("release-groups"));
}
let include: String =
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
let url = format!(
"{MB_BASE_URL}/artist/{mbid}?inc={include}",
mbid = request.mbid.uuid().as_hyphenated()
);
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
Ok(response.into())
}
pub fn lookup_release_group(
&mut self,
request: &LookupReleaseGroupRequest,
) -> Result<LookupReleaseGroupResponse, Error> {
let url = format!(
"{MB_BASE_URL}/release-group/{mbid}",
mbid = request.mbid.uuid().as_hyphenated()
);
let response: DeserializeLookupReleaseGroupResponse = self.http.get(&url)?;
Ok(response.into())
}
}
pub struct LookupArtistRequest<'a> {
mbid: &'a Mbid,
release_groups: bool,
}
impl<'a> LookupArtistRequest<'a> {
pub fn new(mbid: &'a Mbid) -> Self {
LookupArtistRequest {
mbid,
release_groups: false,
}
}
pub fn include_release_groups(&mut self) -> &mut Self {
self.release_groups = true;
self
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct LookupArtistResponse {
pub meta: MbArtistMeta,
pub release_groups: Vec<MbReleaseGroupMeta>,
}
#[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupArtistResponse {
#[serde(flatten)]
meta: SerdeMbArtistMeta,
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
}
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
fn from(value: DeserializeLookupArtistResponse) -> Self {
LookupArtistResponse {
meta: value.meta.into(),
release_groups: value
.release_groups
.map(|rgs| rgs.into_iter().map(Into::into).collect())
.unwrap_or_default(),
}
}
}
pub struct LookupReleaseGroupRequest<'a> {
mbid: &'a Mbid,
}
impl<'a> LookupReleaseGroupRequest<'a> {
pub fn new(mbid: &'a Mbid) -> Self {
LookupReleaseGroupRequest { mbid }
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct LookupReleaseGroupResponse {
pub meta: MbReleaseGroupMeta,
}
#[derive(Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeLookupReleaseGroupResponse {
#[serde(flatten)]
meta: SerdeMbReleaseGroupMeta,
}
impl From<DeserializeLookupReleaseGroupResponse> for LookupReleaseGroupResponse {
fn from(value: DeserializeLookupReleaseGroupResponse) -> Self {
LookupReleaseGroupResponse {
meta: value.meta.into(),
}
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid},
MockIMusicBrainzHttp,
},
};
use super::*;
#[test]
fn lookup_artist() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new();
let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",);
let de_meta = SerdeMbArtistMeta {
id: SerdeMbid(mbid.try_into().unwrap()),
name: String::from("the artist"),
sort_name: String::from("artist, the"),
disambiguation: Some(String::from("disambig")),
};
let de_release_group = SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
secondary_types: Some(vec![SerdeAlbumSecondaryType(
AlbumSecondaryType::Compilation,
)]),
};
let de_response = DeserializeLookupArtistResponse {
meta: de_meta.clone(),
release_groups: Some(vec![de_release_group.clone()]),
};
let response = LookupArtistResponse {
meta: de_meta.into(),
release_groups: vec![de_release_group.into()],
};
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
let mut request = LookupArtistRequest::new(&mbid);
request.include_release_groups();
let result = client.lookup_artist(&request).unwrap();
assert_eq!(result, response);
}
#[test]
fn lookup_release_group() {
let mbid = "00000000-0000-0000-0000-000000000000";
let mut http = MockIMusicBrainzHttp::new();
let url = format!("https://musicbrainz.org/ws/2/release-group/{mbid}",);
let de_meta = SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
secondary_types: Some(vec![SerdeAlbumSecondaryType(
AlbumSecondaryType::Compilation,
)]),
};
let de_response = DeserializeLookupReleaseGroupResponse {
meta: de_meta.clone(),
};
let response = LookupReleaseGroupResponse {
meta: de_meta.into(),
};
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
let request = LookupReleaseGroupRequest::new(&mbid);
let result = client.lookup_release_group(&request).unwrap();
assert_eq!(result, response);
}
}

461
src/external/musicbrainz/api/mod.rs vendored Normal file
View File

@ -0,0 +1,461 @@
use std::{fmt, num};
use serde::{de::Visitor, Deserialize, Deserializer};
use crate::{
collection::{
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
musicbrainz::Mbid,
Error as CollectionError,
},
external::musicbrainz::HttpError,
};
pub mod browse;
pub mod lookup;
pub mod search;
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
const MB_RATE_LIMIT_CODE: u16 = 503;
const MB_MAX_PAGE_LIMIT: usize = 100;
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The HTTP client failed.
Http(String),
/// The client reached the API rate limit.
RateLimit,
/// The API response could not be understood.
Unknown(u16),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Error::Http(s) => write!(f, "the HTTP client failed: {s}"),
Error::RateLimit => write!(f, "the API rate limit has been reached"),
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
}
}
}
impl From<HttpError> for Error {
fn from(err: HttpError) -> Self {
match err {
HttpError::Client(s) => Error::Http(s),
HttpError::Status(status) => match status {
MB_RATE_LIMIT_CODE => Error::RateLimit,
_ => Error::Unknown(status),
},
}
}
}
pub struct MusicBrainzClient<Http> {
http: Http,
}
impl<Http> MusicBrainzClient<Http> {
pub fn new(http: Http) -> Self {
MusicBrainzClient { http }
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct PageSettings {
limit: Option<usize>,
offset: Option<usize>,
}
impl PageSettings {
pub fn with_limit(limit: usize) -> Self {
PageSettings {
limit: Some(limit),
..Default::default()
}
}
pub fn with_max_limit() -> Self {
Self::with_limit(MB_MAX_PAGE_LIMIT)
}
pub fn with_offset(mut self, offset: usize) -> Self {
self.set_offset(offset);
self
}
pub fn set_offset(&mut self, offset: usize) {
self.offset = Some(offset);
}
pub fn next_page(self, offset: usize, total_count: usize, page_count: usize) -> Option<Self> {
let next_offset = offset + page_count;
if next_offset < total_count {
Some(self.with_offset(next_offset))
} else {
None
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbArtistMeta {
pub id: Mbid,
pub name: String,
pub sort_name: String,
pub disambiguation: Option<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct SerdeMbArtistMeta {
id: SerdeMbid,
name: String,
sort_name: String,
disambiguation: Option<String>,
}
impl From<SerdeMbArtistMeta> for MbArtistMeta {
fn from(value: SerdeMbArtistMeta) -> Self {
MbArtistMeta {
id: value.id.into(),
name: value.name,
sort_name: value.sort_name,
disambiguation: value.disambiguation,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbReleaseGroupMeta {
pub id: Mbid,
pub title: String,
pub first_release_date: AlbumDate,
pub primary_type: Option<AlbumPrimaryType>,
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct SerdeMbReleaseGroupMeta {
id: SerdeMbid,
title: String,
first_release_date: SerdeAlbumDate,
primary_type: Option<SerdeAlbumPrimaryType>,
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
}
impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
fn from(value: SerdeMbReleaseGroupMeta) -> Self {
MbReleaseGroupMeta {
id: value.id.into(),
title: value.title,
first_release_date: value.first_release_date.into(),
primary_type: value.primary_type.map(Into::into),
secondary_types: value
.secondary_types
.map(|v| v.into_iter().map(Into::into).collect()),
}
}
}
pub struct ApiDisplay;
impl ApiDisplay {
fn format_page_settings(paging: &PageSettings) -> String {
let limit = paging
.limit
.map(|l| format!("&limit={l}"))
.unwrap_or_default();
let offset = paging
.offset
.map(|o| format!("&offset={o}"))
.unwrap_or_default();
format!("{limit}{offset}")
}
fn format_album_date(date: &AlbumDate) -> String {
match date.year {
Some(year) => match date.month {
Some(month) => match date.day {
Some(day) => format!("{year}-{month:02}-{day:02}"),
None => format!("{year}-{month:02}"),
},
None => format!("{year}"),
},
None => String::from("*"),
}
}
}
#[derive(Clone, Debug)]
pub struct SerdeMbid(Mbid);
impl From<SerdeMbid> for Mbid {
fn from(value: SerdeMbid) -> Self {
value.0
}
}
struct SerdeMbidVisitor;
impl<'de> Visitor<'de> for SerdeMbidVisitor {
type Value = SerdeMbid;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid MusicBrainz identifier")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(SerdeMbid(
v.try_into()
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
))
}
}
impl<'de> Deserialize<'de> for SerdeMbid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SerdeMbidVisitor)
}
}
#[derive(Debug, Clone)]
pub struct SerdeAlbumDate(AlbumDate);
impl From<SerdeAlbumDate> for AlbumDate {
fn from(value: SerdeAlbumDate) -> Self {
value.0
}
}
struct SerdeAlbumDateVisitor;
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
type Value = SerdeAlbumDate;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let mut elems = v.split('-');
let elem = elems.next();
let year = elem
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
.transpose()
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
let elem = elems.next();
let month = elem
.map(|s| s.parse())
.transpose()
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
let elem = elems.next();
let day = elem
.map(|s| s.parse())
.transpose()
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
}
}
impl<'de> Deserialize<'de> for SerdeAlbumDate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(SerdeAlbumDateVisitor)
}
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumPrimaryType")]
pub enum AlbumPrimaryTypeDef {
Album,
Single,
#[serde(rename = "EP")]
Ep,
Broadcast,
Other,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
fn from(value: SerdeAlbumPrimaryType) -> Self {
value.0
}
}
#[derive(Debug, Deserialize)]
#[serde(remote = "AlbumSecondaryType")]
pub enum AlbumSecondaryTypeDef {
Compilation,
Soundtrack,
Spokenword,
Interview,
Audiobook,
#[serde(rename = "Audio drama")]
AudioDrama,
Live,
Remix,
#[serde(rename = "DJ-mix")]
DjMix,
#[serde(rename = "Mixtape/Street")]
MixtapeStreet,
Demo,
#[serde(rename = "Field recording")]
FieldRecording,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
fn from(value: SerdeAlbumSecondaryType) -> Self {
value.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors() {
let http_err = HttpError::Client(String::from("a http error"));
let http_err: Error = http_err.into();
assert!(matches!(http_err, Error::Http(_)));
assert!(!http_err.to_string().is_empty());
assert!(!format!("{http_err:?}").is_empty());
let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
let rate_err: Error = rate_err.into();
assert!(matches!(rate_err, Error::RateLimit));
assert!(!rate_err.to_string().is_empty());
assert!(!format!("{rate_err:?}").is_empty());
let unk_err = HttpError::Status(404);
let unk_err: Error = unk_err.into();
assert!(matches!(unk_err, Error::Unknown(_)));
assert!(!unk_err.to_string().is_empty());
assert!(!format!("{unk_err:?}").is_empty());
}
pub fn next_page_test<Fn>(mut f: Fn)
where
Fn: FnMut(usize) -> Option<PageSettings>,
{
let next = f(20);
assert_eq!(next.unwrap().offset, Some(25));
let next = f(40);
assert!(next.is_none());
let next = f(100);
assert!(next.is_none());
}
#[test]
fn next_page() {
next_page_test(|val| PageSettings::default().next_page(5, 45, val));
}
#[test]
fn format_page_settings() {
let paging = PageSettings::default();
assert_eq!(ApiDisplay::format_page_settings(&paging), "");
let paging = PageSettings::with_max_limit();
assert_eq!(ApiDisplay::format_page_settings(&paging), "&limit=100");
let mut paging = PageSettings::with_limit(45);
paging.set_offset(145);
assert_eq!(
ApiDisplay::format_page_settings(&paging),
"&limit=45&offset=145"
);
let mut paging = PageSettings::default();
paging.set_offset(26);
assert_eq!(ApiDisplay::format_page_settings(&paging), "&offset=26");
}
#[test]
fn format_album_date() {
assert_eq!(
ApiDisplay::format_album_date(&AlbumDate::new(None, None, None)),
"*"
);
assert_eq!(ApiDisplay::format_album_date(&(1986).into()), "1986");
assert_eq!(ApiDisplay::format_album_date(&(1986, 4).into()), "1986-04");
assert_eq!(
ApiDisplay::format_album_date(&(1986, 4, 21).into()),
"1986-04-21"
);
}
#[test]
fn serde() {
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
let mbid: SerdeMbid = serde_json::from_str(mbid).unwrap();
let mbid: Mbid = mbid.into();
assert_eq!(
mbid,
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
);
let mbid = "0";
let result: Result<SerdeMbid, _> = serde_json::from_str(mbid);
assert!(result
.unwrap_err()
.to_string()
.contains("a valid MusicBrainz identifier"));
let album_date = "\"1986-04-21\"";
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
let album_date: AlbumDate = album_date.into();
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), Some(21)));
let album_date = "\"1986-04\"";
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
let album_date: AlbumDate = album_date.into();
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), None));
let album_date = "\"1986\"";
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
let album_date: AlbumDate = album_date.into();
assert_eq!(album_date, AlbumDate::new(Some(1986), None, None));
let album_date = "0";
let result: Result<SerdeAlbumDate, _> = serde_json::from_str(album_date);
assert!(result
.unwrap_err()
.to_string()
.contains("a valid YYYY(-MM-(-DD)) date"));
let primary_type = "\"EP\"";
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
let primary_type: AlbumPrimaryType = primary_type.into();
assert_eq!(primary_type, AlbumPrimaryType::Ep);
let secondary_type = "\"Field recording\"";
let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
let secondary_type: AlbumSecondaryType = secondary_type.into();
assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
}
}

View File

@ -0,0 +1,149 @@
use std::fmt;
use serde::Deserialize;
use crate::external::musicbrainz::api::{
search::{
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
SearchPage, SerdeSearchPage,
},
MbArtistMeta, SerdeMbArtistMeta,
};
pub enum SearchArtist<'a> {
String(&'a str),
}
impl<'a> fmt::Display for SearchArtist<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
}
}
}
pub type SearchArtistRequest<'a> = Query<SearchArtist<'a>>;
impl_term!(string, SearchArtist<'a>, String, &'a str);
#[derive(Debug, PartialEq, Eq)]
pub struct SearchArtistResponse {
pub artists: Vec<SearchArtistResponseArtist>,
pub page: SearchPage,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct DeserializeSearchArtistResponse {
artists: Vec<DeserializeSearchArtistResponseArtist>,
#[serde(flatten)]
page: SerdeSearchPage,
}
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
fn from(value: DeserializeSearchArtistResponse) -> Self {
SearchArtistResponse {
artists: value.artists.into_iter().map(Into::into).collect(),
page: value.page,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtistResponseArtist {
pub score: u8,
pub meta: MbArtistMeta,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
struct DeserializeSearchArtistResponseArtist {
score: u8,
#[serde(flatten)]
meta: SerdeMbArtistMeta,
}
impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist {
fn from(value: DeserializeSearchArtistResponseArtist) -> Self {
SearchArtistResponseArtist {
score: value.score,
meta: value.meta.into(),
}
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::external::musicbrainz::{
api::{MusicBrainzClient, PageSettings, SerdeMbid},
MockIMusicBrainzHttp,
};
use super::*;
fn de_response() -> DeserializeSearchArtistResponse {
let de_offset = 24;
let de_count = 124;
let de_artist = DeserializeSearchArtistResponseArtist {
score: 67,
meta: SerdeMbArtistMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
name: String::from("an artist"),
sort_name: String::from("artist, an"),
disambiguation: None,
},
};
DeserializeSearchArtistResponse {
artists: vec![de_artist.clone()],
page: SerdeSearchPage {
offset: de_offset,
count: de_count,
},
}
}
fn response(de_response: DeserializeSearchArtistResponse) -> SearchArtistResponse {
SearchArtistResponse {
artists: de_response
.artists
.into_iter()
.map(|a| SearchArtistResponseArtist {
score: 67,
meta: a.meta.into(),
})
.collect(),
page: de_response.page,
}
}
#[test]
fn search_string() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/artist\
?query=%22{no_field}%22",
no_field = "an+artist",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let name = "an artist";
let query = SearchArtistRequest::new().string(name);
let paging = PageSettings::default();
let matches = client.search_artist(&query, &paging).unwrap();
assert_eq!(matches, response);
}
}

View File

@ -0,0 +1,81 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
mod artist;
mod query;
mod release_group;
pub use artist::{SearchArtistRequest, SearchArtistResponse, SearchArtistResponseArtist};
pub use release_group::{
SearchReleaseGroupRequest, SearchReleaseGroupResponse, SearchReleaseGroupResponseReleaseGroup,
};
use paste::paste;
use serde::Deserialize;
use url::form_urlencoded;
use crate::external::musicbrainz::{
api::{
search::{
artist::DeserializeSearchArtistResponse,
release_group::DeserializeSearchReleaseGroupResponse,
},
ApiDisplay, Error, MusicBrainzClient, PageSettings, MB_BASE_URL,
},
IMusicBrainzHttp,
};
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct SearchPage {
offset: usize,
count: usize,
}
impl SearchPage {
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
settings.next_page(self.offset, self.count, page_count)
}
}
pub type SerdeSearchPage = SearchPage;
macro_rules! impl_search_entity {
($name:ident, $entity:literal) => {
paste! {
pub fn [<search_ $name:snake>](
&mut self,
query: &[<Search $name Request>],
paging: &PageSettings,
) -> Result<[<Search $name Response>], Error> {
let query: String =
form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect();
let page = ApiDisplay::format_page_settings(paging);
let url = format!("{MB_BASE_URL}/{entity}?query={query}{page}", entity = $entity);
let response: [<DeserializeSearch $name Response>] = self.http.get(&url)?;
Ok(response.into())
}
}
};
}
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
impl_search_entity!(Artist, "artist");
impl_search_entity!(ReleaseGroup, "release-group");
}
#[cfg(test)]
mod tests {
use crate::external::musicbrainz::api::tests::next_page_test;
use super::*;
#[test]
fn search_next_page() {
let page = SearchPage {
offset: 5,
count: 45,
};
next_page_test(|val| page.next_page(PageSettings::default(), val));
}
}

View File

@ -0,0 +1,312 @@
use std::{fmt, marker::PhantomData};
pub enum Logical {
Unary(Unary),
Binary(Boolean),
}
impl fmt::Display for Logical {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Logical::Unary(u) => write!(f, "{u}"),
Logical::Binary(b) => write!(f, "{b}"),
}
}
}
pub enum Unary {
Require,
Prohibit,
}
impl fmt::Display for Unary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Unary::Require => write!(f, "+"),
Unary::Prohibit => write!(f, "-"),
}
}
}
pub enum Boolean {
And,
Or,
Not,
}
impl fmt::Display for Boolean {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Boolean::And => write!(f, "AND "),
Boolean::Or => write!(f, "OR "),
Boolean::Not => write!(f, "NOT "),
}
}
}
pub enum Expression<Entity> {
Term(Entity),
Expr(Query<Entity>),
}
impl<Entity> From<Entity> for Expression<Entity> {
fn from(value: Entity) -> Self {
Expression::Term(value)
}
}
impl<Entity> From<Query<Entity>> for Expression<Entity> {
fn from(value: Query<Entity>) -> Self {
Expression::Expr(value)
}
}
impl<Entity: fmt::Display> fmt::Display for Expression<Entity> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expression::Term(t) => write!(f, "{t}"),
Expression::Expr(q) => write!(f, "({q})"),
}
}
}
pub struct EmptyQuery<Entity> {
_marker: PhantomData<Entity>,
}
impl<Entity> Default for EmptyQuery<Entity> {
fn default() -> Self {
EmptyQuery {
_marker: PhantomData,
}
}
}
impl<Entity> EmptyQuery<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
Query {
left: (None, Box::new(expr.into())),
right: vec![],
}
}
pub fn require(self) -> EmptyQueryJoin<Entity> {
EmptyQueryJoin {
unary: Unary::Require,
_marker: PhantomData,
}
}
pub fn prohibit(self) -> EmptyQueryJoin<Entity> {
EmptyQueryJoin {
unary: Unary::Prohibit,
_marker: PhantomData,
}
}
}
pub struct EmptyQueryJoin<Entity> {
unary: Unary,
_marker: PhantomData<Entity>,
}
impl<Entity> EmptyQueryJoin<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
Query {
left: (Some(self.unary), Box::new(expr.into())),
right: vec![],
}
}
}
pub struct Query<Entity> {
left: (Option<Unary>, Box<Expression<Entity>>),
right: Vec<(Logical, Box<Expression<Entity>>)>,
}
impl<Entity: fmt::Display> fmt::Display for Query<Entity> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(u) = &self.left.0 {
write!(f, "{u}")?;
}
write!(f, "{}", self.left.1)?;
for (logical, expr) in self.right.iter() {
write!(f, " {logical}{expr}")?;
}
Ok(())
}
}
impl<Entity> Query<Entity> {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> EmptyQuery<Entity> {
EmptyQuery::default()
}
pub fn require(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Unary(Unary::Require),
query: self,
}
}
pub fn prohibit(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Unary(Unary::Prohibit),
query: self,
}
}
pub fn and(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::And),
query: self,
}
}
pub fn or(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::Or),
query: self,
}
}
pub fn not(self) -> QueryJoin<Entity> {
QueryJoin {
logical: Logical::Binary(Boolean::Not),
query: self,
}
}
}
pub struct QueryJoin<Entity> {
logical: Logical,
query: Query<Entity>,
}
impl<Entity> QueryJoin<Entity> {
pub fn expression<Expr: Into<Expression<Entity>>>(mut self, expr: Expr) -> Query<Entity> {
self.query.right.push((self.logical, Box::new(expr.into())));
self.query
}
}
macro_rules! impl_term {
($name:ident, $enum:ty, $variant:ident, $type:ty) => {
impl<'a> EmptyQuery<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
impl<'a> EmptyQueryJoin<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
impl<'a> QueryJoin<$enum> {
pub fn $name(self, $name: $type) -> Query<$enum> {
self.expression(<$enum>::$variant($name))
}
}
};
}
pub(crate) use impl_term;
#[cfg(test)]
mod tests {
use std::fmt;
use super::*;
pub enum TestEntity<'a> {
String(&'a str),
}
impl<'a> fmt::Display for TestEntity<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
}
}
}
type TestEntityRequest<'a> = Query<TestEntity<'a>>;
impl_term!(string, TestEntity<'a>, String, &'a str);
#[test]
fn lucene_logical() {
let query = TestEntityRequest::new()
.string("jakarta apache")
.or()
.string("jakarta");
assert_eq!(format!("{query}"), "\"jakarta apache\" OR \"jakarta\"");
let query = TestEntityRequest::new()
.string("jakarta apache")
.and()
.string("jakarta");
assert_eq!(format!("{query}"), "\"jakarta apache\" AND \"jakarta\"");
let query = TestEntityRequest::new()
.require()
.string("jakarta")
.or()
.string("lucene");
assert_eq!(format!("{query}"), "+\"jakarta\" OR \"lucene\"");
let query = TestEntityRequest::new()
.string("lucene")
.require()
.string("jakarta");
assert_eq!(format!("{query}"), "\"lucene\" +\"jakarta\"");
let query = TestEntityRequest::new()
.string("jakarta apache")
.not()
.string("Apache Lucene");
assert_eq!(
format!("{query}"),
"\"jakarta apache\" NOT \"Apache Lucene\""
);
let query = TestEntityRequest::new()
.prohibit()
.string("Apache Lucene")
.or()
.string("jakarta apache");
assert_eq!(
format!("{query}"),
"-\"Apache Lucene\" OR \"jakarta apache\""
);
let query = TestEntityRequest::new()
.string("jakarta apache")
.prohibit()
.string("Apache Lucene");
assert_eq!(format!("{query}"), "\"jakarta apache\" -\"Apache Lucene\"");
}
#[test]
fn lucene_grouping() {
let query = TestEntityRequest::new()
.expression(
TestEntityRequest::new()
.string("jakarta")
.or()
.string("apache"),
)
.and()
.string("website");
assert_eq!(
format!("{query}"),
"(\"jakarta\" OR \"apache\") AND \"website\""
);
}
}

View File

@ -0,0 +1,249 @@
use std::fmt;
use serde::Deserialize;
use crate::{
collection::{album::AlbumDate, musicbrainz::Mbid},
external::musicbrainz::api::{
search::{
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
SearchPage, SerdeSearchPage,
},
ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
},
};
pub enum SearchReleaseGroup<'a> {
String(&'a str),
Arid(&'a Mbid),
FirstReleaseDate(&'a AlbumDate),
ReleaseGroup(&'a str),
Rgid(&'a Mbid),
}
impl<'a> fmt::Display for SearchReleaseGroup<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::String(s) => write!(f, "\"{s}\""),
Self::Arid(arid) => write!(f, "arid:{}", arid.uuid().as_hyphenated()),
Self::FirstReleaseDate(date) => write!(
f,
"firstreleasedate:{}",
ApiDisplay::format_album_date(date)
),
Self::ReleaseGroup(release_group) => write!(f, "releasegroup:\"{release_group}\""),
Self::Rgid(rgid) => write!(f, "rgid:{}", rgid.uuid().as_hyphenated()),
}
}
}
pub type SearchReleaseGroupRequest<'a> = Query<SearchReleaseGroup<'a>>;
impl_term!(string, SearchReleaseGroup<'a>, String, &'a str);
impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid);
impl_term!(
first_release_date,
SearchReleaseGroup<'a>,
FirstReleaseDate,
&'a AlbumDate
);
impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str);
impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid);
#[derive(Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupResponse {
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
pub page: SearchPage,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct DeserializeSearchReleaseGroupResponse {
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
#[serde(flatten)]
page: SerdeSearchPage,
}
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
SearchReleaseGroupResponse {
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
page: value.page,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupResponseReleaseGroup {
pub score: u8,
pub meta: MbReleaseGroupMeta,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct DeserializeSearchReleaseGroupResponseReleaseGroup {
score: u8,
#[serde(flatten)]
meta: SerdeMbReleaseGroupMeta,
}
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
for SearchReleaseGroupResponseReleaseGroup
{
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
SearchReleaseGroupResponseReleaseGroup {
score: value.score,
meta: value.meta.into(),
}
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{
MusicBrainzClient, PageSettings, SerdeAlbumDate, SerdeAlbumPrimaryType,
SerdeAlbumSecondaryType, SerdeMbid,
},
MockIMusicBrainzHttp,
},
};
use super::*;
fn de_response() -> DeserializeSearchReleaseGroupResponse {
let de_offset = 26;
let de_count = 126;
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
score: 67,
meta: SerdeMbReleaseGroupMeta {
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
title: String::from("an album"),
first_release_date: SerdeAlbumDate((1986, 4).into()),
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
},
};
DeserializeSearchReleaseGroupResponse {
release_groups: vec![de_release_group.clone()],
page: SerdeSearchPage {
offset: de_offset,
count: de_count,
},
}
}
fn response(de_response: DeserializeSearchReleaseGroupResponse) -> SearchReleaseGroupResponse {
SearchReleaseGroupResponse {
release_groups: de_response
.release_groups
.into_iter()
.map(|rg| SearchReleaseGroupResponseReleaseGroup {
score: 67,
meta: rg.meta.into(),
})
.collect(),
page: de_response.page,
}
}
#[test]
fn search_string() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/release-group\
?query=%22{title}%22",
title = "an+album",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let title = "an album";
let query = SearchReleaseGroupRequest::new().string(title);
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
#[test]
fn search_arid_album_date_release_group() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/release-group\
?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22+AND+firstreleasedate%3A{date}",
arid = "00000000-0000-0000-0000-000000000000",
date = "1986-04",
title = "an+album",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
let title = "an album";
let date = (1986, 4).into();
let query = SearchReleaseGroupRequest::new()
.arid(&arid)
.and()
.release_group(title)
.and()
.first_release_date(&date);
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
#[test]
fn search_rgid() {
let mut http = MockIMusicBrainzHttp::new();
let url = format!(
"https://musicbrainz.org/ws/2\
/release-group\
?query=rgid%3A{rgid}",
rgid = "11111111-1111-1111-1111-111111111111",
);
let de_response = de_response();
let response = response(de_response.clone());
http.expect_get()
.times(1)
.with(predicate::eq(url))
.return_once(|_| Ok(de_response));
let mut client = MusicBrainzClient::new(http);
let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let query = SearchReleaseGroupRequest::new().rgid(&rgid);
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
}

46
src/external/musicbrainz/http.rs vendored Normal file
View File

@ -0,0 +1,46 @@
//! Module for interacting with the MusicBrainz API via an HTTP client.
use reqwest::{self, blocking::Client, header};
use serde::de::DeserializeOwned;
use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
// GRCOV_EXCL_START
pub struct MusicBrainzHttp(Client);
impl MusicBrainzHttp {
pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static(user_agent),
);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
Ok(MusicBrainzHttp(
Client::builder().default_headers(headers).build()?,
))
}
}
impl IMusicBrainzHttp for MusicBrainzHttp {
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
let response = self.0.get(url).send()?;
if response.status().is_success() {
Ok(response.json()?)
} else {
Err(HttpError::Status(response.status().as_u16()))
}
}
}
impl From<reqwest::Error> for HttpError {
fn from(err: reqwest::Error) -> Self {
HttpError::Client(err.to_string())
}
}
// GRCOV_EXCL_STOP

19
src/external/musicbrainz/mod.rs vendored Normal file
View File

@ -0,0 +1,19 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
pub mod api;
pub mod http;
#[cfg(test)]
use mockall::automock;
use serde::de::DeserializeOwned;
#[cfg_attr(test, automock)]
pub trait IMusicBrainzHttp {
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError>;
}
#[derive(Debug)]
pub enum HttpError {
Client(String),
Status(u16),
}

View File

@ -1,67 +1,16 @@
//! MusicHoard - a music collection manager.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
mod core;
pub mod external;
pub mod collection;
pub mod database;
pub mod library;
pub use core::collection;
pub use core::interface;
/// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
pub type Mbid = Uuid;
/// The track file format.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub enum TrackFormat {
Flac,
Mp3,
}
/// A single track on an album.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Track {
pub number: u32,
pub title: String,
pub artist: Vec<String>,
pub format: TrackFormat,
}
/// The album identifier.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub struct AlbumId {
pub year: u32,
pub title: String,
}
/// An album is a collection of tracks that were released together.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Album {
pub id: AlbumId,
pub tracks: Vec<Track>,
}
/// The artist identifier.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub struct ArtistId {
pub name: String,
}
/// An artist.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Artist {
pub id: ArtistId,
pub albums: Vec<Album>,
}
pub use core::musichoard::{
builder::MusicHoardBuilder, Error, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary,
MusicHoard, NoDatabase, NoLibrary,
};
#[cfg(test)]
#[macro_use]
mod testlib;
#[cfg(test)]
mod tests {
use once_cell::sync::Lazy;
use super::*;
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| collection!());
}
mod testmod;

View File

@ -1,105 +1,175 @@
use std::path::PathBuf;
use std::{ffi::OsString, io};
#![cfg_attr(nightly, feature(test))]
#[cfg(nightly)]
extern crate test;
mod tui;
use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf, thread};
use ratatui::{backend::CrosstermBackend, Terminal};
use structopt::StructOpt;
use musichoard::{
collection::MhCollectionManager,
database::{
json::{backend::JsonDatabaseFileBackend, JsonDatabase},
Database,
},
library::{
beets::{
external::{
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
library::beets::{
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
BeetsLibrary,
},
Library,
musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
},
interface::{
database::{IDatabase, NullDatabase},
library::{ILibrary, NullLibrary},
},
MusicHoardBuilder, NoDatabase, NoLibrary,
};
mod tui;
use tui::ui::MhUi;
use tui::{event::EventChannel, handler::TuiEventHandler, listener::TuiEventListener, Tui};
use tui::{
App, EventChannel, EventHandler, EventListener, JobChannel, MusicBrainz, MusicBrainzDaemon,
Tui, Ui,
};
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
"MusicHoard/",
env!("CARGO_PKG_VERSION"),
" ( musichoard@thenineworlds.net )"
);
#[derive(StructOpt)]
struct Opt {
#[structopt(long = "ssh", name = "beets SSH URI")]
#[structopt(flatten)]
lib_opt: LibOpt,
#[structopt(flatten)]
db_opt: DbOpt,
}
#[derive(StructOpt)]
struct LibOpt {
#[structopt(long = "ssh", help = "Beets SSH URI")]
beets_ssh_uri: Option<OsString>,
#[structopt(long = "beets", name = "beets config file path")]
#[structopt(long = "beets-bin", help = "Beets binary path")]
beets_bin_path: Option<OsString>,
#[structopt(long = "beets-config", help = "Beets config file path")]
beets_config_file_path: Option<OsString>,
#[structopt(long = "no-library", help = "Do not connect to the library")]
no_library: bool,
}
#[derive(StructOpt)]
struct DbOpt {
#[structopt(
long = "database",
name = "database file path",
help = "Database file path",
default_value = "database.json"
)]
database_file_path: PathBuf,
#[structopt(long = "no-database", help = "Do not read from/write to the database")]
no_database: bool,
}
fn with<LIB: Library, DB: Database>(lib: LIB, db: DB) {
let collection_manager = MhCollectionManager::new(lib, db);
fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
builder: MusicHoardBuilder<Database, Library>,
) {
let music_hoard = builder.build().expect("failed to initialise MusicHoard");
// Initialize the terminal user interface.
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend).expect("failed to initialise terminal");
let channel = EventChannel::new();
let listener = TuiEventListener::new(channel.sender());
let handler = TuiEventHandler::new(channel.receiver());
let http =
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
let client = MusicBrainzClient::new(http);
let musicbrainz = MusicBrainz::new(client);
let ui = MhUi::new(collection_manager).expect("failed to initialise ui");
let channel = EventChannel::new();
let listener_sender = channel.sender();
let app_sender = channel.sender();
let listener = EventListener::new(listener_sender);
let handler = EventHandler::new(channel.receiver());
let mb_job_channel = JobChannel::new();
let app = App::new(music_hoard, mb_job_channel.sender());
let ui = Ui;
// Run the TUI application.
Tui::run(terminal, ui, handler, listener).expect("failed to run tui");
thread::spawn(|| MusicBrainzDaemon::run(musicbrainz, mb_job_channel.receiver(), app_sender));
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
}
fn main() {
// Create the application.
let opt = Opt::from_args();
fn with_database<Library: ILibrary + 'static>(
db_opt: DbOpt,
builder: MusicHoardBuilder<NoDatabase, Library>,
) {
if db_opt.no_database {
with(builder.set_database(NullDatabase));
} else {
// Create an empty database file if it does not exist.
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&db_opt.database_file_path)
{
Ok(f) => {
drop(f);
JsonDatabase::new(JsonDatabaseFileBackend::new(&db_opt.database_file_path))
.save(&vec![])
.expect("failed to create empty database");
}
Err(e) => match e.kind() {
io::ErrorKind::AlreadyExists => {}
_ => panic!("failed to access database file"),
},
}
if let Some(uri) = opt.beets_ssh_uri {
let db_exec = JsonDatabaseFileBackend::new(&db_opt.database_file_path);
with(builder.set_database(JsonDatabase::new(db_exec)));
};
}
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoDatabase, NoLibrary>) {
if lib_opt.no_library {
with_database(db_opt, builder.set_library(NullLibrary));
} else if let Some(uri) = lib_opt.beets_ssh_uri {
let uri = uri.into_string().expect("invalid SSH URI");
let beets_config_file_path = opt
let beets_config_file_path = lib_opt
.beets_config_file_path
.map(|s| s.into_string())
.transpose()
.expect("failed to extract beets config file path");
let lib_exec = BeetsLibrarySshExecutor::new(uri)
.expect("failed to initialise beets")
.config(beets_config_file_path);
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
let lib_exec = match lib_opt.beets_bin_path {
Some(beets_bin) => {
let bin = beets_bin.into_string().expect("invalid beets binary path");
BeetsLibrarySshExecutor::bin(uri, bin)
}
None => BeetsLibrarySshExecutor::new(uri),
}
.expect("failed to initialise beets")
.config(beets_config_file_path);
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
} else {
let lib_exec = BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path);
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
let lib_exec = match lib_opt.beets_bin_path {
Some(beets_bin) => BeetsLibraryProcessExecutor::bin(beets_bin),
None => BeetsLibraryProcessExecutor::default(),
}
.config(lib_opt.beets_config_file_path);
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
}
}
fn main() {
let opt = Opt::from_args();
let builder = MusicHoardBuilder::default();
with_library(opt.lib_opt, opt.db_opt, builder);
}
#[cfg(test)]
#[macro_use]
mod testlib;
#[cfg(test)]
mod tests {
use mockall::mock;
use once_cell::sync::Lazy;
use musichoard::collection::{self, Collection, CollectionManager};
use musichoard::*;
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| collection!());
mock! {
pub CollectionManager {}
impl CollectionManager for CollectionManager {
fn rescan_library(&mut self) -> Result<(), collection::Error>;
fn save_to_database(&mut self) -> Result<(), collection::Error>;
fn get_collection(&self) -> &Collection;
}
}
}
mod testmod;

View File

@ -1,168 +0,0 @@
macro_rules! collection {
() => {
vec![
Artist {
id: ArtistId {
name: "album_artist a".to_string(),
},
albums: vec![
Album {
id: AlbumId {
year: 1998,
title: "album_title a.a".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track a.a.1".to_string(),
artist: vec!["artist a.a.1".to_string()],
format: TrackFormat::Flac,
},
Track {
number: 2,
title: "track a.a.2".to_string(),
artist: vec![
"artist a.a.2.1".to_string(),
"artist a.a.2.2".to_string(),
],
format: TrackFormat::Mp3,
},
Track {
number: 3,
title: "track a.a.3".to_string(),
artist: vec!["artist a.a.3".to_string()],
format: TrackFormat::Flac,
},
],
},
Album {
id: AlbumId {
year: 2015,
title: "album_title a.b".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track a.b.1".to_string(),
artist: vec!["artist a.b.1".to_string()],
format: TrackFormat::Flac,
},
Track {
number: 2,
title: "track a.b.2".to_string(),
artist: vec!["artist a.b.2".to_string()],
format: TrackFormat::Flac,
},
],
},
],
},
Artist {
id: ArtistId {
name: "album_artist b".to_string(),
},
albums: vec![
Album {
id: AlbumId {
year: 2003,
title: "album_title b.a".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track b.a.1".to_string(),
artist: vec!["artist b.a.1".to_string()],
format: TrackFormat::Mp3,
},
Track {
number: 2,
title: "track b.a.2".to_string(),
artist: vec![
"artist b.a.2.1".to_string(),
"artist b.a.2.2".to_string(),
],
format: TrackFormat::Mp3,
},
],
},
Album {
id: AlbumId {
year: 2008,
title: "album_title b.b".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track b.b.1".to_string(),
artist: vec!["artist b.b.1".to_string()],
format: TrackFormat::Flac,
},
Track {
number: 2,
title: "track b.b.2".to_string(),
artist: vec![
"artist b.b.2.1".to_string(),
"artist b.b.2.2".to_string(),
],
format: TrackFormat::Mp3,
},
],
},
],
},
Artist {
id: ArtistId {
name: "album_artist c".to_string(),
},
albums: vec![
Album {
id: AlbumId {
year: 1985,
title: "album_title c.a".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track c.a.1".to_string(),
artist: vec!["artist c.a.1".to_string()],
format: TrackFormat::Mp3,
},
Track {
number: 2,
title: "track c.a.2".to_string(),
artist: vec![
"artist c.a.2.1".to_string(),
"artist c.a.2.2".to_string(),
],
format: TrackFormat::Mp3,
},
],
},
Album {
id: AlbumId {
year: 2018,
title: "album_title c.b".to_string(),
},
tracks: vec![
Track {
number: 1,
title: "track c.b.1".to_string(),
artist: vec!["artist c.b.1".to_string()],
format: TrackFormat::Flac,
},
Track {
number: 2,
title: "track c.b.2".to_string(),
artist: vec![
"artist c.b.2.1".to_string(),
"artist c.b.2.2".to_string(),
],
format: TrackFormat::Flac,
},
],
},
],
},
]
};
}

525
src/testmod/full.rs Normal file
View File

@ -0,0 +1,525 @@
macro_rules! full_collection {
() => {
vec![
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist A".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000"
).unwrap()),
properties: HashMap::from([
(String::from("MusicButler"), vec![
String::from("https://www.musicbutler.io/artist-page/000000000"),
]),
(String::from("Qobuz"), vec![
String::from(
"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums",
)
]),
]),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title a.a".to_string(),
},
date: 1998.into(),
seq: AlbumSeq(1),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
"https://musicbrainz.org/release-group/00000000-0000-0000-0000-000000000000"
).unwrap()),
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track a.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist a.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 992,
},
},
Track {
id: TrackId {
title: "track a.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist a.a.2.1".to_string(),
"artist a.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
Track {
id: TrackId {
title: "track a.a.3".to_string(),
},
number: TrackNum(3),
artist: vec!["artist a.a.3".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1061,
},
},
Track {
id: TrackId {
title: "track a.a.4".to_string(),
},
number: TrackNum(4),
artist: vec!["artist a.a.4".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1042,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title a.b".to_string(),
},
date: (2015, 4).into(),
seq: AlbumSeq(1),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track a.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist a.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1004,
},
},
Track {
id: TrackId {
title: "track a.b.2".to_string(),
},
number: TrackNum(2),
artist: vec!["artist a.b.2".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1077,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist B".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111"
).unwrap()),
properties: HashMap::from([
(String::from("MusicButler"), vec![
String::from("https://www.musicbutler.io/artist-page/111111111"),
String::from("https://www.musicbutler.io/artist-page/111111112"),
]),
(String::from("Bandcamp"), vec![
String::from("https://artist-b.bandcamp.com/")
]),
(String::from("Qobuz"), vec![
String::from(
"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums",
)
]),
]),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.a".to_string(),
},
date: (2003, 6, 6).into(),
seq: AlbumSeq(1),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track b.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.a.2.1".to_string(),
"artist b.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.b".to_string(),
},
date: 2008.into(),
seq: AlbumSeq(3),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111111"
).unwrap()),
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track b.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1077,
},
},
Track {
id: TrackId {
title: "track b.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.b.2.1".to_string(),
"artist b.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.c".to_string(),
},
date: 2009.into(),
seq: AlbumSeq(2),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111112"
).unwrap()),
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track b.c.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.c.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.c.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.c.2.1".to_string(),
"artist b.c.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.d".to_string(),
},
date: 2015.into(),
seq: AlbumSeq(4),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track b.d.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.d.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.d.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.d.2.1".to_string(),
"artist b.d.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "The Album_Artist C".to_string(),
},
sort: Some("Album_Artist C, The".to_string()),
info: ArtistInfo {
musicbrainz: MbRefOption::CannotHaveMbid,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title c.a".to_string(),
},
date: 1985.into(),
seq: AlbumSeq(0),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track c.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist c.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
Track {
id: TrackId {
title: "track c.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist c.a.2.1".to_string(),
"artist c.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title c.b".to_string(),
},
date: 2018.into(),
seq: AlbumSeq(0),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track c.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist c.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1041,
},
},
Track {
id: TrackId {
title: "track c.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist c.b.2.1".to_string(),
"artist c.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 756,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist D".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title d.a".to_string(),
},
date: 1995.into(),
seq: AlbumSeq(0),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track d.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist d.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
Track {
id: TrackId {
title: "track d.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist d.a.2.1".to_string(),
"artist d.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title d.b".to_string(),
},
date: 2028.into(),
seq: AlbumSeq(0),
info: AlbumInfo {
musicbrainz: MbRefOption::None,
primary_type: Some(AlbumPrimaryType::Album),
secondary_types: vec![],
},
},
tracks: vec![
Track {
id: TrackId {
title: "track d.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist d.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 841,
},
},
Track {
id: TrackId {
title: "track d.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist d.b.2.1".to_string(),
"artist d.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 756,
},
},
],
},
],
},
]
};
}
pub(crate) use full_collection;

455
src/testmod/library.rs Normal file
View File

@ -0,0 +1,455 @@
#[allow(unused_macros)]
macro_rules! library_collection {
() => {
vec![
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist A".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title a.a".to_string(),
},
date: 1998.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track a.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist a.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 992,
},
},
Track {
id: TrackId {
title: "track a.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist a.a.2.1".to_string(),
"artist a.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
Track {
id: TrackId {
title: "track a.a.3".to_string(),
},
number: TrackNum(3),
artist: vec!["artist a.a.3".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1061,
},
},
Track {
id: TrackId {
title: "track a.a.4".to_string(),
},
number: TrackNum(4),
artist: vec!["artist a.a.4".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1042,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title a.b".to_string(),
},
date: (2015, 4).into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track a.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist a.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1004,
},
},
Track {
id: TrackId {
title: "track a.b.2".to_string(),
},
number: TrackNum(2),
artist: vec!["artist a.b.2".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1077,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist B".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.a".to_string(),
},
date: (2003, 6, 6).into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track b.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.a.2.1".to_string(),
"artist b.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.b".to_string(),
},
date: 2008.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track b.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1077,
},
},
Track {
id: TrackId {
title: "track b.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.b.2.1".to_string(),
"artist b.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.c".to_string(),
},
date: 2009.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track b.c.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.c.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.c.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.c.2.1".to_string(),
"artist b.c.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title b.d".to_string(),
},
date: 2015.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track b.d.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist b.d.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 190,
},
},
Track {
id: TrackId {
title: "track b.d.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist b.d.2.1".to_string(),
"artist b.d.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "The Album_Artist C".to_string(),
},
sort: Some("Album_Artist C, The".to_string()),
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title c.a".to_string(),
},
date: 1985.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track c.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist c.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 320,
},
},
Track {
id: TrackId {
title: "track c.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist c.a.2.1".to_string(),
"artist c.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title c.b".to_string(),
},
date: 2018.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track c.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist c.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 1041,
},
},
Track {
id: TrackId {
title: "track c.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist c.b.2.1".to_string(),
"artist c.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 756,
},
},
],
},
],
},
Artist {
meta: ArtistMeta {
id: ArtistId {
name: "Album_Artist D".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
},
albums: vec![
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title d.a".to_string(),
},
date: 1995.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track d.a.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist d.a.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
Track {
id: TrackId {
title: "track d.a.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist d.a.2.1".to_string(),
"artist d.a.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Mp3,
bitrate: 120,
},
},
],
},
Album {
meta: AlbumMeta {
id: AlbumId {
title: "album_title d.b".to_string(),
},
date: 2028.into(),
seq: AlbumSeq(0),
info: AlbumInfo::default(),
},
tracks: vec![
Track {
id: TrackId {
title: "track d.b.1".to_string(),
},
number: TrackNum(1),
artist: vec!["artist d.b.1".to_string()],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 841,
},
},
Track {
id: TrackId {
title: "track d.b.2".to_string(),
},
number: TrackNum(2),
artist: vec![
"artist d.b.2.1".to_string(),
"artist d.b.2.2".to_string(),
],
quality: TrackQuality {
format: TrackFormat::Flac,
bitrate: 756,
},
},
],
},
],
},
]
};
}
#[allow(unused_imports)]
pub(crate) use library_collection;

2
src/testmod/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod full;
pub mod library;

View File

@ -0,0 +1,144 @@
// Date: 2024-02-19
pub const ARTISTS: [&str; 141] = [
"Abadden",
"Acid Drinkers",
"Adema",
"Æther Realm",
"Alestorm",
"Alex Rivers",
"Alien Weaponry",
"Allegaeon",
"Alter Bridge",
"Amon Amarth",
"Amorphis",
"Apocalyptica",
"Arch Enemy",
"Аркона",
"Artas",
"As I Lay Dying",
"Avenged Sevenfold",
"Aversions Crown",
"Aviators",
"Azarath",
"Baaba Kulka",
"Battle Beast",
"Beast in Black",
"Behemoth",
"Black Sabbath",
"Blind Guardian",
"Blind Guardian Twilight Orchestra",
"Bloodbath",
"Bloodbound",
"Brothers of Metal",
"Carnation",
"Cellar Darling",
"Children of Bodom",
"Chimaira",
"Crystalic",
"Dark Tranquillity",
"Dethklok",
"DevilDriver",
"Dismember",
"Disturbed",
"The Dreadnoughts",
"Dynazty",
"Edguy",
"Eluveitie",
"Eminem",
"Enforcer",
"Ensiferum",
"Epica",
"Era",
"Evile",
"Ex Deo",
"Exit Eden",
"Faithful Darkness",
"Fear Factory",
"Fit for an Autopsy",
"Five Finger Death Punch",
"Fleshgod Apocalypse",
"Flotsam and Jetsam",
"Frontside",
"Furyon",
"Godsmack",
"Grand Magus",
"Grave Digger",
"Graveworm",
"Guns N Roses",
"Haggard",
"Hate",
"Havukruunu",
"Heaven Shall Burn",
"Heavens Basement",
"Heavy Load",
"Hermh",
"Immortal",
"In Flames",
"Insomnium",
"Iron Maiden",
"Kalmah",
"Kataklysm",
"Kontrust",
"Korn",
"Korpiklaani",
"The Last Hangmen",
"Level 70 Elite Tauren Chieftain",
"Linkin Park",
"Lost Dreams",
"Man Must Die",
"Me and That Man",
"Mercyful Fate",
"Metallica",
"Michael Jackson",
"Miracle of Sound",
"Misery Index",
"Mudvayne",
"Månegarm",
"Nickelback",
"Nightwish",
"Nile",
"Nine Treasures",
"Obscura",
"The Offspring",
"Oomph!",
"P.O.D.",
"Paddy and the Rats",
"Paul Stanley",
"Persefone",
"Peyton Parrish",
"Powerwolf",
"Primitai",
"Primordial",
"ProPain",
"Rammstein",
"Red Hot Chili Peppers",
"Revocation",
"Rob Zombie",
"Sabaton",
"Savatage",
"Scars on Broadway",
"Scorpions",
"Silent Descent",
"Slayer",
"Slipknot",
"Soilwork",
"Sonic Syndicate",
"Soulfallen",
"Spiritfall",
"Stratovarius",
"Sylosis",
"System of a Down",
"Tarot",
"Timecry",
"Trivium",
"Tuomas Holopainen",
"VNV Nation",
"Vader",
"Vicious Crusade",
"The Wages of Sin",
"Whitechapel",
"Within Temptation",
"Woe of Tyrants",
"Wovenwar",
"Xandria",
];

View File

@ -0,0 +1,188 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
selection::ListSelection,
AppPublicState, AppState, Delta, IAppInteractBrowse,
};
pub struct BrowseState;
impl AppMachine<BrowseState> {
pub fn browse_state(inner: AppInner) -> Self {
AppMachine::new(inner, BrowseState)
}
}
impl From<AppMachine<BrowseState>> for App {
fn from(machine: AppMachine<BrowseState>) -> Self {
AppState::Browse(machine)
}
}
impl<'a> From<&'a mut BrowseState> for AppPublicState<'a> {
fn from(_state: &'a mut BrowseState) -> Self {
AppState::Browse(())
}
}
impl IAppInteractBrowse for AppMachine<BrowseState> {
type APP = App;
fn quit(mut self) -> Self::APP {
self.inner.running = false;
self.into()
}
fn increment_category(mut self) -> Self::APP {
self.inner.selection.increment_category();
self.into()
}
fn decrement_category(mut self) -> Self::APP {
self.inner.selection.decrement_category();
self.into()
}
fn increment_selection(mut self, delta: Delta) -> Self::APP {
self.inner
.selection
.increment_selection(self.inner.music_hoard.get_collection(), delta);
self.into()
}
fn decrement_selection(mut self, delta: Delta) -> Self::APP {
self.inner
.selection
.decrement_selection(self.inner.music_hoard.get_collection(), delta);
self.into()
}
fn show_info_overlay(self) -> Self::APP {
AppMachine::info_state(self.inner).into()
}
fn show_reload_menu(self) -> Self::APP {
AppMachine::reload_state(self.inner).into()
}
fn begin_search(mut self) -> Self::APP {
let orig = ListSelection::get(&self.inner.selection);
self.inner
.selection
.reset(self.inner.music_hoard.get_collection());
AppMachine::search_state(self.inner, orig).into()
}
fn fetch_musicbrainz(self) -> Self::APP {
AppMachine::app_fetch_first(self.inner)
}
}
#[cfg(test)]
mod tests {
use crate::tui::{
app::{
machine::tests::{inner, inner_with_mb, music_hoard},
Category, IApp, IAppAccess,
},
lib::interface::musicbrainz::daemon::MockIMbJobSender,
testmod::COLLECTION,
};
use super::*;
#[test]
fn quit() {
let music_hoard = music_hoard(vec![]);
let browse = AppMachine::browse_state(inner(music_hoard));
let app = browse.quit();
assert!(!app.is_running());
app.unwrap_browse();
}
#[test]
fn increment_decrement() {
let mut browse = AppMachine::browse_state(inner(music_hoard(COLLECTION.to_owned())));
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Artist);
assert_eq!(sel.selected(), Some(0));
browse = browse.increment_selection(Delta::Line).unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Artist);
assert_eq!(sel.selected(), Some(1));
browse = browse.increment_category().unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Album);
assert_eq!(sel.selected(), Some(0));
browse = browse.increment_selection(Delta::Line).unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Album);
assert_eq!(sel.selected(), Some(1));
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Album);
assert_eq!(sel.selected(), Some(0));
browse = browse.decrement_category().unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Artist);
assert_eq!(sel.selected(), Some(1));
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
let sel = &browse.inner.selection;
assert_eq!(sel.category(), Category::Artist);
assert_eq!(sel.selected(), Some(0));
}
#[test]
fn show_info_overlay() {
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
let app = browse.show_info_overlay();
app.unwrap_info();
}
#[test]
fn show_reload_menu() {
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
let app = browse.show_reload_menu();
app.unwrap_reload();
}
#[test]
fn begin_search() {
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
let app = browse.begin_search();
app.unwrap_search();
}
#[test]
fn fetch_musicbrainz() {
let mut mb_job_sender = MockIMbJobSender::new();
mb_job_sender
.expect_submit_background_job()
.times(1)
.returning(|_, _| Ok(()));
let browse = AppMachine::browse_state(inner_with_mb(
music_hoard(COLLECTION.to_owned()),
mb_job_sender,
));
// Use the second artist for this test.
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let mut app = browse.fetch_musicbrainz();
let public = app.get();
// Because of fetch's threaded behaviour, this unit test cannot expect one or the other.
assert!(
matches!(public.state, AppState::Match(_))
|| matches!(public.state, AppState::Fetch(_))
);
}
}

View File

@ -0,0 +1,34 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
AppPublicState, AppState,
};
pub struct CriticalState {
string: String,
}
impl CriticalState {
fn new<S: Into<String>>(string: S) -> Self {
CriticalState {
string: string.into(),
}
}
}
impl AppMachine<CriticalState> {
pub fn critical_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
AppMachine::new(inner, CriticalState::new(string))
}
}
impl From<AppMachine<CriticalState>> for App {
fn from(machine: AppMachine<CriticalState>) -> Self {
AppState::Critical(machine)
}
}
impl<'a> From<&'a mut CriticalState> for AppPublicState<'a> {
fn from(state: &'a mut CriticalState) -> Self {
AppState::Critical(&state.string)
}
}

View File

@ -0,0 +1,56 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
AppPublicState, AppState, IAppInteractError,
};
pub struct ErrorState {
string: String,
}
impl ErrorState {
fn new<S: Into<String>>(string: S) -> Self {
ErrorState {
string: string.into(),
}
}
}
impl AppMachine<ErrorState> {
pub fn error_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
AppMachine::new(inner, ErrorState::new(string))
}
}
impl From<AppMachine<ErrorState>> for App {
fn from(machine: AppMachine<ErrorState>) -> Self {
AppState::Error(machine)
}
}
impl<'a> From<&'a mut ErrorState> for AppPublicState<'a> {
fn from(state: &'a mut ErrorState) -> Self {
AppState::Error(&state.string)
}
}
impl IAppInteractError for AppMachine<ErrorState> {
type APP = App;
fn dismiss_error(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
}
#[cfg(test)]
mod tests {
use crate::tui::app::machine::tests::{inner, music_hoard};
use super::*;
#[test]
fn dismiss_error() {
let error = AppMachine::error_state(inner(music_hoard(vec![])), "get rekt");
let app = error.dismiss_error();
app.unwrap_browse();
}
}

View File

@ -0,0 +1,705 @@
use std::{
collections::VecDeque,
slice,
sync::mpsc::{self, TryRecvError},
};
use musichoard::collection::{
album::{Album, AlbumId},
artist::{Artist, ArtistId, ArtistMeta},
musicbrainz::{IMusicBrainzRef, MbArtistRef, MbRefOption, Mbid},
};
use crate::tui::{
app::{
machine::{match_state::MatchState, App, AppInner, AppMachine},
AppPublicState, AppState, Category, IAppEventFetch, IAppInteractFetch,
},
lib::interface::musicbrainz::daemon::{
Error as DaemonError, IMbJobSender, MbApiResult, MbParams, MbReturn, ResultSender,
},
};
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
pub struct FetchState {
fetch_rx: FetchReceiver,
lookup_rx: Option<FetchReceiver>,
}
impl FetchState {
pub fn new(fetch_rx: FetchReceiver) -> Self {
FetchState {
fetch_rx,
lookup_rx: None,
}
}
fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> {
if let Some(lookup_rx) = &self.lookup_rx {
match lookup_rx.try_recv() {
x @ Ok(_) | x @ Err(TryRecvError::Empty) => return x,
Err(TryRecvError::Disconnected) => {
self.lookup_rx.take();
}
}
}
self.fetch_rx.try_recv()
}
}
enum FetchError {
NothingToFetch,
SubmitError(DaemonError),
}
impl From<DaemonError> for FetchError {
fn from(value: DaemonError) -> Self {
FetchError::SubmitError(value)
}
}
impl AppMachine<FetchState> {
fn fetch_state(inner: AppInner, state: FetchState) -> Self {
AppMachine::new(inner, state)
}
pub fn app_fetch_first(inner: AppInner) -> App {
Self::app_fetch_new(inner)
}
fn app_fetch_new(inner: AppInner) -> App {
let coll = inner.music_hoard.get_collection();
let artist = match inner.selection.state_artist(coll) {
Some(artist_state) => &coll[artist_state.index],
None => {
let err = "cannot fetch artist: no artist selected";
return AppMachine::error_state(inner, err).into();
}
};
let (fetch_tx, fetch_rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(fetch_rx);
let mb = &*inner.musicbrainz;
let result = match inner.selection.category() {
Category::Artist => Self::submit_search_artist_job(mb, fetch_tx, artist),
_ => {
let arid = match artist.meta.info.musicbrainz {
MbRefOption::Some(ref mbref) => mbref,
_ => {
let err = "cannot fetch album: artist has no MBID";
return AppMachine::error_state(inner, err).into();
}
};
let album = match inner.selection.state_album(coll) {
Some(album_state) => &artist.albums[album_state.index],
None => {
let err = "cannot fetch album: no album selected";
return AppMachine::error_state(inner, err).into();
}
};
let artist_id = &artist.meta.id;
Self::submit_search_release_group_job(mb, fetch_tx, artist_id, arid, album)
}
};
match result {
Ok(()) => AppMachine::fetch_state(inner, fetch).into(),
Err(FetchError::NothingToFetch) => AppMachine::browse_state(inner).into(),
Err(FetchError::SubmitError(daemon_err)) => {
AppMachine::error_state(inner, daemon_err.to_string()).into()
}
}
}
pub fn app_fetch_next(inner: AppInner, mut fetch: FetchState) -> App {
match fetch.try_recv() {
Ok(fetch_result) => match fetch_result {
Ok(retval) => Self::handle_mb_api_return(inner, fetch, retval),
Err(fetch_err) => {
AppMachine::error_state(inner, format!("fetch failed: {fetch_err}")).into()
}
},
Err(recv_err) => match recv_err {
TryRecvError::Empty => AppMachine::fetch_state(inner, fetch).into(),
TryRecvError::Disconnected => Self::app_fetch_new(inner),
},
}
}
fn handle_mb_api_return(inner: AppInner, fetch: FetchState, retval: MbReturn) -> App {
match retval {
MbReturn::Match(next_match) => {
AppMachine::match_state(inner, MatchState::new(next_match, fetch)).into()
}
_ => unimplemented!(),
}
}
pub fn app_lookup_artist(
inner: AppInner,
fetch: FetchState,
artist: &ArtistMeta,
mbid: Mbid,
) -> App {
let f = Self::submit_lookup_artist_job;
Self::app_lookup(f, inner, fetch, artist, mbid)
}
pub fn app_lookup_album(
inner: AppInner,
fetch: FetchState,
artist_id: &ArtistId,
album_id: &AlbumId,
mbid: Mbid,
) -> App {
let f = |mb: &dyn IMbJobSender, rs, album, mbid| {
Self::submit_lookup_release_group_job(mb, rs, artist_id, album, mbid)
};
Self::app_lookup(f, inner, fetch, album_id, mbid)
}
fn app_lookup<F, Meta>(
submit: F,
inner: AppInner,
mut fetch: FetchState,
meta: Meta,
mbid: Mbid,
) -> App
where
F: FnOnce(&dyn IMbJobSender, ResultSender, Meta, Mbid) -> Result<(), DaemonError>,
{
let (lookup_tx, lookup_rx) = mpsc::channel::<MbApiResult>();
if let Err(err) = submit(&*inner.musicbrainz, lookup_tx, meta, mbid) {
return AppMachine::error_state(inner, err.to_string()).into();
}
fetch.lookup_rx.replace(lookup_rx);
Self::app_fetch_next(inner, fetch)
}
fn submit_search_artist_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist: &Artist,
) -> Result<(), FetchError> {
let requests = match artist.meta.info.musicbrainz {
MbRefOption::Some(ref arid) => {
Self::search_albums_requests(&artist.meta.id, arid, &artist.albums)
}
MbRefOption::CannotHaveMbid => VecDeque::new(),
MbRefOption::None => Self::search_artist_request(&artist.meta),
};
if requests.is_empty() {
return Err(FetchError::NothingToFetch);
}
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
}
fn submit_search_release_group_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist_id: &ArtistId,
artist_mbid: &MbArtistRef,
album: &Album,
) -> Result<(), FetchError> {
if !matches!(album.meta.info.musicbrainz, MbRefOption::None) {
return Err(FetchError::NothingToFetch);
}
let requests = Self::search_albums_requests(artist_id, artist_mbid, slice::from_ref(album));
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
}
fn search_albums_requests(
artist: &ArtistId,
arid: &MbArtistRef,
albums: &[Album],
) -> VecDeque<MbParams> {
let arid = arid.mbid();
albums
.iter()
.filter(|album| matches!(album.meta.info.musicbrainz, MbRefOption::None))
.map(|album| {
MbParams::search_release_group(artist.clone(), arid.clone(), album.meta.clone())
})
.collect()
}
fn search_artist_request(meta: &ArtistMeta) -> VecDeque<MbParams> {
VecDeque::from([MbParams::search_artist(meta.clone())])
}
fn submit_lookup_artist_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist: &ArtistMeta,
mbid: Mbid,
) -> Result<(), DaemonError> {
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid)]);
musicbrainz.submit_foreground_job(result_sender, requests)
}
fn submit_lookup_release_group_job(
musicbrainz: &dyn IMbJobSender,
result_sender: ResultSender,
artist_id: &ArtistId,
album_id: &AlbumId,
mbid: Mbid,
) -> Result<(), DaemonError> {
let requests = VecDeque::from([MbParams::lookup_release_group(
artist_id.clone(),
album_id.clone(),
mbid,
)]);
musicbrainz.submit_foreground_job(result_sender, requests)
}
}
impl From<AppMachine<FetchState>> for App {
fn from(machine: AppMachine<FetchState>) -> Self {
AppState::Fetch(machine)
}
}
impl<'a> From<&'a mut FetchState> for AppPublicState<'a> {
fn from(_state: &'a mut FetchState) -> Self {
AppState::Fetch(())
}
}
impl IAppInteractFetch for AppMachine<FetchState> {
type APP = App;
fn abort(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
}
impl IAppEventFetch for AppMachine<FetchState> {
type APP = App;
fn fetch_result_ready(self) -> Self::APP {
Self::app_fetch_next(self.inner, self.state)
}
}
#[cfg(test)]
mod tests {
use mockall::predicate;
use musichoard::collection::{
album::AlbumMeta,
artist::{ArtistId, ArtistMeta},
musicbrainz::Mbid,
};
use crate::tui::{
app::{
machine::tests::{inner, music_hoard},
Delta, EntityMatches, IApp, IAppAccess, IAppInteractBrowse, MatchOption,
},
lib::interface::musicbrainz::{self, api::Entity, daemon::MockIMbJobSender},
testmod::COLLECTION,
};
use super::*;
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
#[test]
fn try_recv() {
let (fetch_tx, fetch_rx) = mpsc::channel();
let (lookup_tx, lookup_rx) = mpsc::channel();
let mut fetch = FetchState::new(fetch_rx);
fetch.lookup_rx.replace(lookup_rx);
let artist = COLLECTION[3].meta.clone();
let matches: Vec<Entity<ArtistMeta>> = vec![];
let fetch_result = MbReturn::Match(EntityMatches::artist_search(artist.clone(), matches));
fetch_tx.send(Ok(fetch_result.clone())).unwrap();
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
let lookup = Entity::new(artist.clone());
let lookup_result = MbReturn::Match(EntityMatches::artist_lookup(artist.clone(), lookup));
lookup_tx.send(Ok(lookup_result.clone())).unwrap();
assert_eq!(fetch.try_recv(), Ok(Ok(lookup_result)));
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
drop(lookup_tx);
assert_eq!(fetch.try_recv(), Ok(Ok(fetch_result)));
}
#[test]
fn fetch_no_artist() {
let app = AppMachine::app_fetch_first(inner(music_hoard(vec![])));
assert!(matches!(app.state(), AppState::Error(_)));
}
fn search_release_group_expectation(
job_sender: &mut MockIMbJobSender,
artist_id: &ArtistId,
artist_mbid: &Mbid,
albums: &[AlbumMeta],
) {
let mut requests = VecDeque::new();
for album in albums.iter() {
requests.push_back(MbParams::search_release_group(
artist_id.clone(),
artist_mbid.clone(),
album.clone(),
));
}
job_sender
.expect_submit_background_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn fetch_single_album() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist_id = COLLECTION[1].meta.id.clone();
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let album_meta = COLLECTION[1].albums[0].meta.clone();
search_release_group_expectation(
&mut mb_job_sender,
&artist_id,
&artist_mbid,
&[album_meta],
);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
// Use second artist and have album selected to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_category();
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Fetch(_)));
}
#[test]
fn fetch_single_album_nothing_to_fetch() {
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = inner(music_hoard);
// Use second artist, have second album selected (has MBID) to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_category().unwrap_browse();
let app = browse.increment_selection(Delta::Line);
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Browse(_)));
}
#[test]
fn fetch_single_album_no_artist_mbid() {
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = inner(music_hoard);
// Use third artist and have album selected to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_category();
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn fetch_single_album_no_album() {
let mut collection = COLLECTION.to_owned();
collection[1].albums.clear();
let music_hoard = music_hoard(collection);
let inner = inner(music_hoard);
// Use second artist and have album selected to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_category();
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn fetch_albums() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist_id = COLLECTION[1].meta.id.clone();
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
let album_1_meta = COLLECTION[1].albums[0].meta.clone();
let album_4_meta = COLLECTION[1].albums[3].meta.clone();
// Other albums have an MBID and so they will be skipped.
search_release_group_expectation(
&mut mb_job_sender,
&artist_id,
&artist_mbid,
&[album_1_meta, album_4_meta],
);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
// Use second artist to match the expectation.
let browse = AppMachine::browse_state(inner);
let app = browse.increment_selection(Delta::Line);
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Fetch(_)));
}
fn lookup_album_expectation(
job_sender: &mut MockIMbJobSender,
artist_id: &ArtistId,
album_id: &AlbumId,
) {
let requests = VecDeque::from([MbParams::lookup_release_group(
artist_id.clone(),
album_id.clone(),
mbid(),
)]);
job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn lookup_album() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist_id = COLLECTION[1].meta.id.clone();
let album_id = COLLECTION[1].albums[0].meta.id.clone();
lookup_album_expectation(&mut mb_job_sender, &artist_id, &album_id);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
AppMachine::app_lookup_album(inner, fetch, &artist_id, &album_id, mbid());
}
fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
let requests = VecDeque::from([MbParams::search_artist(artist.clone())]);
job_sender
.expect_submit_background_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn fetch_artist() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist = COLLECTION[3].meta.clone();
search_artist_expectation(&mut mb_job_sender, &artist);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
// Use fourth artist to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_selection(Delta::Line);
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Fetch(_)));
}
fn lookup_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.times(1)
.return_once(|_, _| Ok(()));
}
#[test]
fn lookup_artist() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist = COLLECTION[3].meta.clone();
lookup_artist_expectation(&mut mb_job_sender, &artist);
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
}
#[test]
fn fetch_artist_cannot_have_mbid() {
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = inner(music_hoard);
// Use third artist to match the expectation.
let browse = AppMachine::browse_state(inner);
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
let app = browse.increment_selection(Delta::Line);
let app = app.unwrap_browse().fetch_musicbrainz();
assert!(matches!(app, AppState::Browse(_)));
}
#[test]
fn fetch_artist_job_sender_err() {
let mut mb_job_sender = MockIMbJobSender::new();
mb_job_sender
.expect_submit_background_job()
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let browse = AppMachine::browse_state(inner);
let app = browse.fetch_musicbrainz();
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn lookup_artist_job_sender_err() {
let mut mb_job_sender = MockIMbJobSender::new();
mb_job_sender
.expect_submit_foreground_job()
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
let artist = COLLECTION[3].meta.clone();
let music_hoard = music_hoard(COLLECTION.to_owned());
let inner = AppInner::new(music_hoard, mb_job_sender);
let (_fetch_tx, fetch_rx) = mpsc::channel();
let fetch = FetchState::new(fetch_rx);
let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn recv_ok_fetch_ok() {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let artist = COLLECTION[3].meta.clone();
let artist_match = Entity::with_score(COLLECTION[2].meta.clone(), 80);
let artist_match_info =
EntityMatches::artist_search(artist.clone(), vec![artist_match.clone()]);
let fetch_result = Ok(MbReturn::Match(artist_match_info));
tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx);
let mut app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Match(_)));
let public = app.get();
let match_state = public.state.unwrap_match();
let match_options = vec![
artist_match.into(),
MatchOption::CannotHaveMbid,
MatchOption::ManualInputMbid,
];
let expected = EntityMatches::artist_search(artist, match_options);
assert_eq!(match_state.matches, &expected);
}
#[test]
fn recv_ok_fetch_err() {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let fetch_result = Err(musicbrainz::api::Error::RateLimit);
tx.send(fetch_result).unwrap();
let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx);
let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Error(_)));
}
#[test]
fn recv_err_empty() {
let (_tx, rx) = mpsc::channel::<MbApiResult>();
let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx);
let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Fetch(_)));
}
#[test]
fn recv_err_empty_first() {
let mut collection = COLLECTION.clone();
collection[0].albums.clear();
let app = AppMachine::app_fetch_first(inner(music_hoard(collection)));
assert!(matches!(app, AppState::Browse(_)));
}
#[test]
fn recv_err_empty_next() {
let mut collection = COLLECTION.clone();
collection[0].albums.clear();
let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx);
let app = AppMachine::app_fetch_next(inner(music_hoard(collection)), fetch);
assert!(matches!(app, AppState::Browse(_)));
}
#[test]
fn empty_first_then_ready() {
let (tx, rx) = mpsc::channel::<MbApiResult>();
let inner = inner(music_hoard(COLLECTION.clone()));
let fetch = FetchState::new(rx);
let app = AppMachine::app_fetch_next(inner, fetch);
assert!(matches!(app, AppState::Fetch(_)));
let artist = COLLECTION[3].meta.clone();
let match_info = EntityMatches::artist_search::<Entity<ArtistMeta>>(artist, vec![]);
let fetch_result = Ok(MbReturn::Match(match_info));
tx.send(fetch_result).unwrap();
let app = app.unwrap_fetch().fetch_result_ready();
assert!(matches!(app, AppState::Match(_)));
}
#[test]
fn abort() {
let (_, rx) = mpsc::channel::<MbApiResult>();
let fetch = FetchState::new(rx);
let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch);
let app = app.abort();
assert!(matches!(app, AppState::Browse(_)));
}
}

View File

@ -0,0 +1,46 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
AppPublicState, AppState, IAppInteractInfo,
};
pub struct InfoState;
impl AppMachine<InfoState> {
pub fn info_state(inner: AppInner) -> Self {
AppMachine::new(inner, InfoState)
}
}
impl From<AppMachine<InfoState>> for App {
fn from(machine: AppMachine<InfoState>) -> Self {
AppState::Info(machine)
}
}
impl<'a> From<&'a mut InfoState> for AppPublicState<'a> {
fn from(_state: &'a mut InfoState) -> Self {
AppState::Info(())
}
}
impl IAppInteractInfo for AppMachine<InfoState> {
type APP = App;
fn hide_info_overlay(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
}
#[cfg(test)]
mod tests {
use crate::tui::app::machine::tests::{inner, music_hoard};
use super::*;
#[test]
fn hide_info_overlay() {
let info = AppMachine::info_state(inner(music_hoard(vec![])));
let app = info.hide_info_overlay();
app.unwrap_browse();
}
}

View File

@ -0,0 +1,97 @@
use tui_input::backend::crossterm::EventHandler;
use crate::tui::app::{machine::App, AppMode, AppState, IAppInput, InputEvent, InputPublic};
#[derive(Default)]
pub struct Input(tui_input::Input);
impl<'app> From<&'app Input> for InputPublic<'app> {
fn from(value: &'app Input) -> Self {
&value.0
}
}
impl Input {
pub fn value(&self) -> &str {
self.0.value()
}
}
impl From<App> for AppMode<App, AppInputMode> {
fn from(mut app: App) -> Self {
if let Some(input) = app.input_mut().take() {
AppMode::Input(AppInputMode::new(input, app))
} else {
AppMode::State(app)
}
}
}
pub struct AppInputMode {
input: Input,
app: App,
}
impl AppInputMode {
pub fn new(input: Input, app: App) -> Self {
AppInputMode { input, app }
}
}
impl IAppInput for AppInputMode {
type APP = App;
fn input(mut self, input: InputEvent) -> Self::APP {
self.input
.0
.handle_event(&crossterm::event::Event::Key(input.into()));
self.app.input_mut().replace(self.input);
self.app
}
fn confirm(self) -> Self::APP {
if let AppState::Match(state) = self.app {
return state.submit_input(self.input);
}
self.app
}
fn cancel(self) -> Self::APP {
self.app
}
}
#[cfg(test)]
mod tests {
use crate::tui::app::{
machine::tests::{input_event, mb_job_sender, music_hoard_init},
IApp,
};
use super::*;
#[test]
fn handle_input() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
app.input_mut().replace(Input::default());
let input = app.mode().unwrap_input();
let app = input.input(input_event('H'));
let input = app.mode().unwrap_input();
let app = input.input(input_event('e'));
let input = app.mode().unwrap_input();
let app = input.input(input_event('l'));
let input = app.mode().unwrap_input();
let app = input.input(input_event('l'));
let input = app.mode().unwrap_input();
let app = input.input(input_event('o'));
assert_eq!(app.input_ref().as_ref().unwrap().0.value(), "Hello");
app.mode().unwrap_input().confirm().unwrap_browse();
}
}

View File

@ -0,0 +1,657 @@
use std::cmp;
use musichoard::collection::{
album::{AlbumInfo, AlbumMeta},
artist::{ArtistInfo, ArtistMeta},
musicbrainz::{MbRefOption, Mbid},
};
use crate::tui::app::{
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
AlbumMatches, AppPublicState, AppState, ArtistMatches, Delta, EntityMatches, IAppInteractMatch,
MatchOption, MatchStatePublic, WidgetState,
};
trait GetInfoMeta {
type InfoType;
}
impl GetInfoMeta for ArtistMeta {
type InfoType = ArtistInfo;
}
impl GetInfoMeta for AlbumMeta {
type InfoType = AlbumInfo;
}
trait GetInfo {
type InfoType;
fn get_info(&self) -> InfoOption<Self::InfoType>;
}
enum InfoOption<T> {
Info(T),
NeedInput,
}
impl GetInfo for MatchOption<ArtistMeta> {
type InfoType = ArtistInfo;
fn get_info(&self) -> InfoOption<Self::InfoType> {
let mut info = ArtistInfo::default();
match self {
MatchOption::Some(option) => info.musicbrainz = option.entity.info.musicbrainz.clone(),
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
}
InfoOption::Info(info)
}
}
impl GetInfo for MatchOption<AlbumMeta> {
type InfoType = AlbumInfo;
fn get_info(&self) -> InfoOption<Self::InfoType> {
let mut info = AlbumInfo::default();
match self {
MatchOption::Some(option) => info = option.entity.info.clone(),
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
}
InfoOption::Info(info)
}
}
trait ExtractInfo {
type InfoType;
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType>;
}
impl<T: GetInfoMeta> ExtractInfo for Vec<MatchOption<T>>
where
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
{
type InfoType = T::InfoType;
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType> {
self.get(index).unwrap().get_info()
}
}
impl ArtistMatches {
fn len(&self) -> usize {
self.list.len()
}
fn push_cannot_have_mbid(&mut self) {
self.list.push(MatchOption::CannotHaveMbid);
}
fn push_manual_input_mbid(&mut self) {
self.list.push(MatchOption::ManualInputMbid);
}
}
impl AlbumMatches {
fn len(&self) -> usize {
self.list.len()
}
fn push_cannot_have_mbid(&mut self) {
self.list.push(MatchOption::CannotHaveMbid);
}
fn push_manual_input_mbid(&mut self) {
self.list.push(MatchOption::ManualInputMbid);
}
}
impl EntityMatches {
fn len(&self) -> usize {
match self {
Self::Artist(a) => a.len(),
Self::Album(a) => a.len(),
}
}
pub fn push_cannot_have_mbid(&mut self) {
match self {
Self::Artist(a) => a.push_cannot_have_mbid(),
Self::Album(a) => a.push_cannot_have_mbid(),
}
}
pub fn push_manual_input_mbid(&mut self) {
match self {
Self::Artist(a) => a.push_manual_input_mbid(),
Self::Album(a) => a.push_manual_input_mbid(),
}
}
}
pub struct MatchState {
current: EntityMatches,
state: WidgetState,
fetch: FetchState,
}
impl MatchState {
pub fn new(mut current: EntityMatches, fetch: FetchState) -> Self {
current.push_cannot_have_mbid();
current.push_manual_input_mbid();
let state = WidgetState::default().with_selected(Some(0));
MatchState {
current,
state,
fetch,
}
}
}
impl AppMachine<MatchState> {
pub fn match_state(inner: AppInner, state: MatchState) -> Self {
AppMachine::new(inner, state)
}
pub fn submit_input(self, input: Input) -> App {
let mbid: Mbid = match input.value().try_into() {
Ok(mbid) => mbid,
Err(err) => return AppMachine::error_state(self.inner, err.to_string()).into(),
};
match self.state.current {
EntityMatches::Artist(artist_matches) => {
let matching = &artist_matches.matching;
AppMachine::app_lookup_artist(self.inner, self.state.fetch, matching, mbid)
}
EntityMatches::Album(album_matches) => {
let artist_id = &album_matches.artist;
let matching = &album_matches.matching;
AppMachine::app_lookup_album(
self.inner,
self.state.fetch,
artist_id,
matching,
mbid,
)
}
}
}
fn get_input(mut self) -> App {
self.input.replace(Input::default());
self.into()
}
}
impl From<AppMachine<MatchState>> for App {
fn from(machine: AppMachine<MatchState>) -> Self {
AppState::Match(machine)
}
}
impl<'a> From<&'a mut MatchState> for AppPublicState<'a> {
fn from(state: &'a mut MatchState) -> Self {
AppState::Match(MatchStatePublic {
matches: &state.current,
state: &mut state.state,
})
}
}
impl IAppInteractMatch for AppMachine<MatchState> {
type APP = App;
fn decrement_match(mut self, delta: Delta) -> Self::APP {
if let Some(index) = self.state.state.list.selected() {
let result = index.saturating_sub(delta.as_usize(&self.state.state));
self.state.state.list.select(Some(result));
}
self.into()
}
fn increment_match(mut self, delta: Delta) -> Self::APP {
let index = self.state.state.list.selected().unwrap();
let to = cmp::min(
index.saturating_add(delta.as_usize(&self.state.state)),
self.state.current.len().saturating_sub(1),
);
self.state.state.list.select(Some(to));
self.into()
}
fn select(mut self) -> Self::APP {
let index = self.state.state.list.selected().unwrap();
let mh = &mut self.inner.music_hoard;
let result = match self.state.current {
EntityMatches::Artist(ref mut matches) => match matches.list.extract_info(index) {
InfoOption::Info(info) => mh.merge_artist_info(&matches.matching.id, info),
InfoOption::NeedInput => return self.get_input(),
},
EntityMatches::Album(ref mut matches) => match matches.list.extract_info(index) {
InfoOption::Info(info) => {
mh.merge_album_info(&matches.artist, &matches.matching, info)
}
InfoOption::NeedInput => return self.get_input(),
},
};
if let Err(err) = result {
return AppMachine::error_state(self.inner, err.to_string()).into();
}
AppMachine::app_fetch_next(self.inner, self.state.fetch)
}
fn abort(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
}
#[cfg(test)]
mod tests {
use std::{collections::VecDeque, sync::mpsc};
use mockall::predicate::{self, eq};
use musichoard::collection::{
album::{AlbumDate, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
artist::{ArtistId, ArtistMeta},
};
use crate::tui::{
app::{
machine::tests::{inner, inner_with_mb, input_event, music_hoard},
IApp, IAppAccess, IAppInput,
},
lib::interface::musicbrainz::{
api::Entity,
daemon::{MbParams, MockIMbJobSender},
},
};
use super::*;
impl<T> Entity<T> {
pub fn with_score(entity: T, score: u8) -> Self {
Entity {
score: Some(score),
entity,
disambiguation: None,
}
}
}
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
fn artist_meta() -> ArtistMeta {
let mut meta = ArtistMeta::new(ArtistId::new("Artist"));
meta.info.musicbrainz = MbRefOption::Some(mbid().into());
meta
}
fn artist_match() -> EntityMatches {
let artist = artist_meta();
let artist_1 = artist.clone();
let artist_match_1 = Entity::with_score(artist_1, 100);
let artist_2 = artist.clone();
let mut artist_match_2 = Entity::with_score(artist_2, 100);
artist_match_2.disambiguation = Some(String::from("some disambiguation"));
let list = vec![artist_match_1.clone(), artist_match_2.clone()];
EntityMatches::artist_search(artist, list)
}
fn artist_lookup() -> EntityMatches {
let artist = artist_meta();
let lookup = Entity::new(artist.clone());
EntityMatches::artist_lookup(artist, lookup)
}
fn album_id() -> AlbumId {
AlbumId::new("Album")
}
fn album_meta(id: AlbumId) -> AlbumMeta {
AlbumMeta::new(
id,
AlbumDate::new(Some(1990), Some(5), None),
AlbumInfo::new(
MbRefOption::Some(mbid().into()),
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
),
)
}
fn album_match() -> EntityMatches {
let artist_id = ArtistId::new("Artist");
let album_id = album_id();
let album_meta = album_meta(album_id.clone());
let album_1 = album_meta.clone();
let album_match_1 = Entity::with_score(album_1, 100);
let mut album_2 = album_meta.clone();
album_2.id.title.push_str(" extra title part");
album_2.info.secondary_types.pop();
let album_match_2 = Entity::with_score(album_2, 100);
let list = vec![album_match_1.clone(), album_match_2.clone()];
EntityMatches::album_search(artist_id, album_id, list)
}
fn album_lookup() -> EntityMatches {
let artist_id = ArtistId::new("Artist");
let album_id = album_id();
let album_meta = album_meta(album_id.clone());
let lookup = Entity::new(album_meta.clone());
EntityMatches::album_lookup(artist_id, album_id, lookup)
}
fn fetch_state() -> FetchState {
let (_, rx) = mpsc::channel();
FetchState::new(rx)
}
fn match_state(match_state_info: EntityMatches) -> MatchState {
MatchState::new(match_state_info, fetch_state())
}
#[test]
fn create() {
let mut album_match = album_match();
let matches =
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
album_match.push_cannot_have_mbid();
album_match.push_manual_input_mbid();
let widget_state = WidgetState::default().with_selected(Some(0));
assert_eq!(matches.state.current, album_match);
assert_eq!(matches.state.state, widget_state);
let mut app: App = matches.into();
let public = app.get();
let public_matches = public.state.unwrap_match();
assert_eq!(public_matches.matches, &album_match);
assert_eq!(public_matches.state, &widget_state);
}
fn match_state_flow(mut matches_info: EntityMatches, len: usize) {
// tx must exist for rx to return Empty rather than Disconnected.
let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
let mut music_hoard = music_hoard(vec![]);
let artist_id = ArtistId::new("Artist");
match matches_info {
EntityMatches::Album(_) => {
let album_id = AlbumId::new("Album");
let info = AlbumInfo {
musicbrainz: MbRefOption::CannotHaveMbid,
..Default::default()
};
music_hoard
.expect_merge_album_info()
.with(eq(artist_id.clone()), eq(album_id.clone()), eq(info))
.times(1)
.return_once(|_, _, _| Ok(()));
}
EntityMatches::Artist(_) => {
let info = ArtistInfo {
musicbrainz: MbRefOption::CannotHaveMbid,
..Default::default()
};
music_hoard
.expect_merge_artist_info()
.with(eq(artist_id.clone()), eq(info))
.times(1)
.return_once(|_, _| Ok(()));
}
}
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
matches_info.push_cannot_have_mbid();
matches_info.push_manual_input_mbid();
let widget_state = WidgetState::default().with_selected(Some(0));
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state, widget_state);
let matches = matches.decrement_match(Delta::Line).unwrap_match();
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state.list.selected(), Some(0));
let mut matches = matches;
for ii in 1..len {
matches = matches.increment_match(Delta::Line).unwrap_match();
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state.list.selected(), Some(ii));
}
// Next is CannotHaveMBID
let matches = matches.increment_match(Delta::Line).unwrap_match();
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state.list.selected(), Some(len));
// Next is ManualInputMbid
let matches = matches.increment_match(Delta::Line).unwrap_match();
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
let matches = matches.increment_match(Delta::Line).unwrap_match();
assert_eq!(matches.state.current, matches_info);
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
// Go prev_match first as selecting on manual input does not go back to fetch.
let matches = matches.decrement_match(Delta::Line).unwrap_match();
matches.select().unwrap_fetch();
}
#[test]
fn artist_matches_flow() {
match_state_flow(artist_match(), 2);
}
#[test]
fn artist_lookup_flow() {
match_state_flow(artist_lookup(), 1);
}
#[test]
fn album_matches_flow() {
match_state_flow(album_match(), 2);
}
#[test]
fn album_lookup_flow() {
match_state_flow(album_lookup(), 1);
}
#[test]
fn set_artist_info() {
let matches_info = artist_match();
let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
let mut music_hoard = music_hoard(vec![]);
match matches_info {
EntityMatches::Album(_) => panic!(),
EntityMatches::Artist(_) => {
let meta = artist_meta();
music_hoard
.expect_merge_artist_info()
.with(eq(meta.id), eq(meta.info))
.times(1)
.return_once(|_, _| Ok(()));
}
}
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
matches.select().unwrap_fetch();
}
#[test]
fn set_album_info() {
let matches_info = album_match();
let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
let mut music_hoard = music_hoard(vec![]);
match matches_info {
EntityMatches::Artist(_) => panic!(),
EntityMatches::Album(matches) => {
let meta = album_meta(album_id());
music_hoard
.expect_merge_album_info()
.with(eq(matches.artist), eq(meta.id), eq(meta.info))
.times(1)
.return_once(|_, _, _| Ok(()));
}
}
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
matches.select().unwrap_fetch();
}
#[test]
fn set_info_error() {
let matches_info = artist_match();
let (_tx, rx) = mpsc::channel();
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
let mut music_hoard = music_hoard(vec![]);
match matches_info {
EntityMatches::Album(_) => panic!(),
EntityMatches::Artist(_) => {
music_hoard.expect_merge_artist_info().return_once(|_, _| {
Err(musichoard::Error::DatabaseError(String::from("error")))
});
}
}
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
matches.select().unwrap_error();
}
#[test]
fn abort() {
let mut album_match = album_match();
let matches =
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
album_match.push_cannot_have_mbid();
album_match.push_manual_input_mbid();
let widget_state = WidgetState::default().with_selected(Some(0));
assert_eq!(matches.state.current, album_match);
assert_eq!(matches.state.state, widget_state);
matches.abort().unwrap_browse();
}
#[test]
fn select_manual_input_empty() {
let matches =
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match()));
// album_match has two matches which means that the fourth option should be manual input.
let matches = matches.increment_match(Delta::Line).unwrap_match();
let matches = matches.increment_match(Delta::Line).unwrap_match();
let matches = matches.increment_match(Delta::Line).unwrap_match();
let matches = matches.increment_match(Delta::Line).unwrap_match();
let app = matches.select();
let input = app.mode().unwrap_input();
input.confirm().unwrap_error();
}
fn input_mbid(mut app: App) -> App {
let mbid = mbid().uuid().to_string();
for c in mbid.chars() {
let input = app.mode().unwrap_input();
app = input.input(input_event(c));
}
app
}
#[test]
fn select_manual_input_artist() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist = ArtistMeta::new(ArtistId::new("Artist"));
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
mb_job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.return_once(|_, _| Ok(()));
let matches_vec: Vec<Entity<ArtistMeta>> = vec![];
let artist_match = EntityMatches::artist_search(artist.clone(), matches_vec);
let matches = AppMachine::match_state(
inner_with_mb(music_hoard(vec![]), mb_job_sender),
match_state(artist_match),
);
// There are no matches which means that the second option should be manual input.
let matches = matches.increment_match(Delta::Line).unwrap_match();
let matches = matches.increment_match(Delta::Line).unwrap_match();
let mut app = matches.select();
app = input_mbid(app);
let input = app.mode().unwrap_input();
input.confirm();
}
#[test]
fn select_manual_input_album() {
let mut mb_job_sender = MockIMbJobSender::new();
let artist_id = ArtistId::new("Artist");
let album = AlbumMeta::new("Album", 1990, AlbumInfo::default());
let requests = VecDeque::from([MbParams::lookup_release_group(
artist_id.clone(),
album.id.clone(),
mbid(),
)]);
mb_job_sender
.expect_submit_foreground_job()
.with(predicate::always(), predicate::eq(requests))
.return_once(|_, _| Ok(()));
let matches_vec: Vec<Entity<AlbumMeta>> = vec![];
let album_match =
EntityMatches::album_search(artist_id.clone(), album.id.clone(), matches_vec);
let matches = AppMachine::match_state(
inner_with_mb(music_hoard(vec![]), mb_job_sender),
match_state(album_match),
);
// There are no matches which means that the second option should be manual input.
let matches = matches.increment_match(Delta::Line).unwrap_match();
let matches = matches.increment_match(Delta::Line).unwrap_match();
let mut app = matches.select();
app = input_mbid(app);
let input = app.mode().unwrap_input();
input.confirm();
}
}

607
src/tui/app/machine/mod.rs Normal file
View File

@ -0,0 +1,607 @@
mod browse_state;
mod critical_state;
mod error_state;
mod fetch_state;
mod info_state;
mod input;
mod match_state;
mod reload_state;
mod search_state;
use crate::tui::{
app::{
selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp,
IAppAccess, IAppBase, IAppState,
},
lib::{interface::musicbrainz::daemon::IMbJobSender, IMusicHoard},
};
use browse_state::BrowseState;
use critical_state::CriticalState;
use error_state::ErrorState;
use fetch_state::FetchState;
use info_state::InfoState;
use input::{AppInputMode, Input};
use match_state::MatchState;
use reload_state::ReloadState;
use search_state::SearchState;
pub type App = AppState<
AppMachine<BrowseState>,
AppMachine<InfoState>,
AppMachine<ReloadState>,
AppMachine<SearchState>,
AppMachine<FetchState>,
AppMachine<MatchState>,
AppMachine<ErrorState>,
AppMachine<CriticalState>,
>;
pub struct AppMachine<STATE> {
inner: AppInner,
state: STATE,
input: Option<Input>,
}
pub struct AppInner {
running: bool,
music_hoard: Box<dyn IMusicHoard>,
musicbrainz: Box<dyn IMbJobSender>,
selection: Selection,
}
macro_rules! app_field_ref {
($app:ident, $field:ident) => {
match $app {
AppState::Browse(state) => &state.$field,
AppState::Info(state) => &state.$field,
AppState::Reload(state) => &state.$field,
AppState::Search(state) => &state.$field,
AppState::Fetch(state) => &state.$field,
AppState::Match(state) => &state.$field,
AppState::Error(state) => &state.$field,
AppState::Critical(state) => &state.$field,
}
};
}
macro_rules! app_field_mut {
($app:ident, $field:ident) => {
match $app {
AppState::Browse(state) => &mut state.$field,
AppState::Info(state) => &mut state.$field,
AppState::Reload(state) => &mut state.$field,
AppState::Search(state) => &mut state.$field,
AppState::Fetch(state) => &mut state.$field,
AppState::Match(state) => &mut state.$field,
AppState::Error(state) => &mut state.$field,
AppState::Critical(state) => &mut state.$field,
}
};
}
impl App {
pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
mut music_hoard: MH,
musicbrainz: MB,
) -> Self {
let init_result = Self::init(&mut music_hoard);
let inner = AppInner::new(music_hoard, musicbrainz);
match init_result {
Ok(()) => AppMachine::browse_state(inner).into(),
Err(err) => AppMachine::critical_state(inner, err.to_string()).into(),
}
}
fn init<MH: IMusicHoard>(music_hoard: &mut MH) -> Result<(), musichoard::Error> {
music_hoard.rescan_library()?;
Ok(())
}
fn inner_ref(&self) -> &AppInner {
app_field_ref!(self, inner)
}
fn inner_mut(&mut self) -> &mut AppInner {
app_field_mut!(self, inner)
}
#[cfg(test)]
fn input_ref(&self) -> &Option<Input> {
app_field_ref!(self, input)
}
fn input_mut(&mut self) -> &mut Option<Input> {
app_field_mut!(self, input)
}
}
impl IApp for App {
type BrowseState = AppMachine<BrowseState>;
type InfoState = AppMachine<InfoState>;
type ReloadState = AppMachine<ReloadState>;
type SearchState = AppMachine<SearchState>;
type FetchState = AppMachine<FetchState>;
type MatchState = AppMachine<MatchState>;
type ErrorState = AppMachine<ErrorState>;
type CriticalState = AppMachine<CriticalState>;
type InputMode = AppInputMode;
fn is_running(&self) -> bool {
self.inner_ref().running
}
fn force_quit(mut self) -> Self {
self.inner_mut().running = false;
self
}
fn state(self) -> IAppState!() {
self
}
fn mode(self) -> AppMode<IAppState!(), Self::InputMode> {
self.into()
}
}
impl<T: Into<App>> IAppBase for T {
type APP = App;
fn no_op(self) -> Self::APP {
self.into()
}
}
impl IAppAccess for App {
fn get(&mut self) -> AppPublic {
match self {
AppState::Browse(state) => state.into(),
AppState::Info(state) => state.into(),
AppState::Reload(state) => state.into(),
AppState::Search(state) => state.into(),
AppState::Fetch(state) => state.into(),
AppState::Match(state) => state.into(),
AppState::Error(state) => state.into(),
AppState::Critical(state) => state.into(),
}
}
}
impl AppInner {
pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
music_hoard: MH,
musicbrainz: MB,
) -> Self {
let selection = Selection::new(music_hoard.get_collection());
AppInner {
running: true,
music_hoard: Box::new(music_hoard),
musicbrainz: Box::new(musicbrainz),
selection,
}
}
}
impl<'a> From<&'a mut AppInner> for AppPublicInner<'a> {
fn from(inner: &'a mut AppInner) -> Self {
AppPublicInner {
collection: inner.music_hoard.get_collection(),
selection: &mut inner.selection,
}
}
}
impl<State> AppMachine<State> {
pub fn new(inner: AppInner, state: State) -> Self {
AppMachine {
inner,
state,
input: None,
}
}
}
impl<'a, State> From<&'a mut AppMachine<State>> for AppPublic<'a>
where
&'a mut State: Into<AppPublicState<'a>>,
{
fn from(machine: &'a mut AppMachine<State>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: (&mut machine.state).into(),
input: machine.input.as_ref().map(Into::into),
}
}
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use musichoard::collection::{
artist::{ArtistId, ArtistMeta},
Collection,
};
use crate::tui::{
app::{AppState, EntityMatches, IApp, IAppInput, IAppInteractBrowse, InputEvent},
lib::{
interface::musicbrainz::{api::Entity, daemon::MockIMbJobSender},
MockIMusicHoard,
},
};
use super::*;
impl<StateMode, InputMode> AppMode<StateMode, InputMode> {
fn unwrap_state(self) -> StateMode {
match self {
AppMode::State(state) => state,
_ => panic!(),
}
}
pub fn unwrap_input(self) -> InputMode {
match self {
AppMode::Input(input) => input,
_ => panic!(),
}
}
}
impl<
BrowseState,
InfoState,
ReloadState,
SearchState,
FetchState,
MatchState,
ErrorState,
CriticalState,
>
AppState<
BrowseState,
InfoState,
ReloadState,
SearchState,
FetchState,
MatchState,
ErrorState,
CriticalState,
>
{
pub fn unwrap_browse(self) -> BrowseState {
match self {
AppState::Browse(browse) => browse,
_ => panic!(),
}
}
pub fn unwrap_info(self) -> InfoState {
match self {
AppState::Info(info) => info,
_ => panic!(),
}
}
pub fn unwrap_reload(self) -> ReloadState {
match self {
AppState::Reload(reload) => reload,
_ => panic!(),
}
}
pub fn unwrap_search(self) -> SearchState {
match self {
AppState::Search(search) => search,
_ => panic!(),
}
}
pub fn unwrap_fetch(self) -> FetchState {
match self {
AppState::Fetch(fetch) => fetch,
_ => panic!(),
}
}
pub fn unwrap_match(self) -> MatchState {
match self {
AppState::Match(matches) => matches,
_ => panic!(),
}
}
pub fn unwrap_error(self) -> ErrorState {
match self {
AppState::Error(error) => error,
_ => panic!(),
}
}
pub fn unwrap_critical(self) -> CriticalState {
match self {
AppState::Critical(critical) => critical,
_ => panic!(),
}
}
}
pub fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new();
music_hoard.expect_get_collection().return_const(collection);
music_hoard
}
pub fn music_hoard_init(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = music_hoard(collection);
music_hoard
.expect_rescan_library()
.times(1)
.return_once(|| Ok(()));
music_hoard
}
pub fn mb_job_sender() -> MockIMbJobSender {
MockIMbJobSender::new()
}
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner {
AppInner::new(music_hoard, mb_job_sender())
}
pub fn inner_with_mb(
music_hoard: MockIMusicHoard,
mb_job_sender: MockIMbJobSender,
) -> AppInner {
AppInner::new(music_hoard, mb_job_sender)
}
pub fn input_event(c: char) -> InputEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::empty(),
)
.into()
}
#[test]
fn input_mode() {
let app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
let mode = app.mode();
assert!(matches!(mode, AppMode::State(_)));
let state = mode.unwrap_state();
assert!(matches!(state, AppState::Browse(_)));
let mut app = state;
app.input_mut().replace(Input::default());
let public = app.get();
assert!(public.input.is_some());
let mode = app.mode();
assert!(matches!(mode, AppMode::Input(_)));
let mut app = mode.unwrap_input().cancel();
assert!(matches!(app, AppState::Browse(_)));
let public = app.get();
assert!(public.input.is_none());
}
#[test]
fn state_browse() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
let state = app.state();
assert!(matches!(state, AppState::Browse(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Browse(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Browse(_)));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_info() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
app = app.unwrap_browse().show_info_overlay();
let state = app.state();
assert!(matches!(state, AppState::Info(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Info(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Info(_)));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_reload() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
app = app.unwrap_browse().show_reload_menu();
let state = app.state();
assert!(matches!(state, AppState::Reload(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Reload(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Reload(_)));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_search() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
app = app.unwrap_browse().begin_search();
let state = app.state();
assert!(matches!(state, AppState::Search(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Search(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Search("")));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_fetch() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
let (_, rx) = mpsc::channel();
let inner = app.unwrap_browse().inner;
let state = FetchState::new(rx);
app = AppMachine::new(inner, state).into();
let state = app.state();
assert!(matches!(state, AppState::Fetch(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Fetch(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Fetch(_)));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_match() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
let (_, rx) = mpsc::channel();
let fetch = FetchState::new(rx);
let artist = ArtistMeta::new(ArtistId::new("Artist"));
let info = EntityMatches::artist_lookup(artist.clone(), Entity::new(artist.clone()));
app =
AppMachine::match_state(app.unwrap_browse().inner, MatchState::new(info, fetch)).into();
let state = app.state();
assert!(matches!(state, AppState::Match(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Match(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Match(_)));
let app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_error() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
app = AppMachine::error_state(app.unwrap_browse().inner, "get rekt").into();
let state = app.state();
assert!(matches!(state, AppState::Error(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Error(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Error("get rekt")));
app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn state_critical() {
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
assert!(app.is_running());
app = AppMachine::critical_state(app.unwrap_browse().inner, "get rekt").into();
let state = app.state();
assert!(matches!(state, AppState::Critical(_)));
app = state;
app = app.no_op();
let state = app.state();
assert!(matches!(state, AppState::Critical(_)));
app = state;
let public = app.get();
assert!(matches!(public.state, AppState::Critical("get rekt")));
app = app.force_quit();
assert!(!app.is_running());
}
#[test]
fn init_error() {
let mut music_hoard = MockIMusicHoard::new();
music_hoard
.expect_rescan_library()
.times(1)
.return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt"))));
music_hoard.expect_get_collection().return_const(vec![]);
let app = App::new(music_hoard, mb_job_sender());
assert!(app.is_running());
app.unwrap_critical();
}
}
#[cfg(nightly)]
#[cfg(test)]
mod benchmod;

View File

@ -0,0 +1,125 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
selection::KeySelection,
AppPublicState, AppState, IAppInteractReload,
};
pub struct ReloadState;
impl AppMachine<ReloadState> {
pub fn reload_state(inner: AppInner) -> Self {
AppMachine::new(inner, ReloadState)
}
}
impl From<AppMachine<ReloadState>> for App {
fn from(machine: AppMachine<ReloadState>) -> Self {
AppState::Reload(machine)
}
}
impl<'a> From<&'a mut ReloadState> for AppPublicState<'a> {
fn from(_state: &'a mut ReloadState) -> Self {
AppState::Reload(())
}
}
impl IAppInteractReload for AppMachine<ReloadState> {
type APP = App;
fn reload_library(mut self) -> Self::APP {
let previous = KeySelection::get(
self.inner.music_hoard.get_collection(),
&self.inner.selection,
);
let result = self.inner.music_hoard.rescan_library();
self.refresh(previous, result)
}
fn reload_database(mut self) -> Self::APP {
let previous = KeySelection::get(
self.inner.music_hoard.get_collection(),
&self.inner.selection,
);
let result = self.inner.music_hoard.reload_database();
self.refresh(previous, result)
}
fn hide_reload_menu(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
}
trait IAppInteractReloadPrivate {
fn refresh(self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App;
}
impl IAppInteractReloadPrivate for AppMachine<ReloadState> {
fn refresh(mut self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App {
match result {
Ok(()) => {
self.inner
.selection
.select_by_id(self.inner.music_hoard.get_collection(), previous);
AppMachine::browse_state(self.inner).into()
}
Err(err) => AppMachine::error_state(self.inner, err.to_string()).into(),
}
}
}
#[cfg(test)]
mod tests {
use crate::tui::app::machine::tests::{inner, music_hoard};
use super::*;
#[test]
fn hide_reload_menu() {
let reload = AppMachine::reload_state(inner(music_hoard(vec![])));
let app = reload.hide_reload_menu();
app.unwrap_browse();
}
#[test]
fn reload_database() {
let mut music_hoard = music_hoard(vec![]);
music_hoard
.expect_reload_database()
.times(1)
.return_once(|| Ok(()));
let reload = AppMachine::reload_state(inner(music_hoard));
let app = reload.reload_database();
app.unwrap_browse();
}
#[test]
fn reload_library() {
let mut music_hoard = music_hoard(vec![]);
music_hoard
.expect_rescan_library()
.times(1)
.return_once(|| Ok(()));
let reload = AppMachine::reload_state(inner(music_hoard));
let app = reload.reload_library();
app.unwrap_browse();
}
#[test]
fn reload_error() {
let mut music_hoard = music_hoard(vec![]);
music_hoard
.expect_reload_database()
.times(1)
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
let reload = AppMachine::reload_state(inner(music_hoard));
let app = reload.reload_database();
app.unwrap_error();
}
}

View File

@ -0,0 +1,571 @@
use aho_corasick::AhoCorasick;
use once_cell::sync::Lazy;
use musichoard::collection::{album::Album, artist::Artist, track::Track};
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
selection::{ListSelection, SelectionState},
AppPublicState, AppState, Category, IAppInteractSearch,
};
// Unlikely that this covers all possible strings, but it should at least cover strings
// relevant for music (at least in English). The list of characters handled is based on
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
//
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash, U+2015 horizontal bar, U+2018,
// U+2019, U+201C, U+201D, U+2026, U+2212 minus sign
const SPECIAL: [char; 11] = ['', '', '', '—', '―', '', '', '“', '”', '…', ''];
const REPLACE: [&str; 11] = ["-", "-", "-", "-", "-", "'", "'", "\"", "\"", "...", "-"];
static AC: Lazy<AhoCorasick> =
Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap());
pub struct SearchState {
string: String,
orig: ListSelection,
memo: Vec<SearchStateMemo>,
}
struct SearchStateMemo {
index: Option<usize>,
char: bool,
}
impl SearchState {
fn new(orig: ListSelection) -> Self {
SearchState {
string: String::new(),
orig,
memo: vec![],
}
}
}
impl AppMachine<SearchState> {
pub fn search_state(inner: AppInner, orig: ListSelection) -> Self {
AppMachine::new(inner, SearchState::new(orig))
}
}
impl From<AppMachine<SearchState>> for App {
fn from(machine: AppMachine<SearchState>) -> Self {
AppState::Search(machine)
}
}
impl<'a> From<&'a mut SearchState> for AppPublicState<'a> {
fn from(state: &'a mut SearchState) -> Self {
AppState::Search(&state.string)
}
}
impl IAppInteractSearch for AppMachine<SearchState> {
type APP = App;
fn append_character(mut self, ch: char) -> Self::APP {
self.state.string.push(ch);
let index = self.inner.selection.selected();
self.state.memo.push(SearchStateMemo { index, char: true });
self.incremental_search(false);
self.into()
}
fn search_next(mut self) -> Self::APP {
if !self.state.string.is_empty() {
let index = self.inner.selection.selected();
self.state.memo.push(SearchStateMemo { index, char: false });
self.incremental_search(true);
}
self.into()
}
fn step_back(mut self) -> Self::APP {
let collection = self.inner.music_hoard.get_collection();
if let Some(memo) = self.state.memo.pop() {
if memo.char {
self.state.string.pop();
}
self.inner.selection.select(collection, memo.index);
}
self.into()
}
fn finish_search(self) -> Self::APP {
AppMachine::browse_state(self.inner).into()
}
fn cancel_search(mut self) -> Self::APP {
self.inner.selection.select_by_list(self.state.orig);
AppMachine::browse_state(self.inner).into()
}
}
trait IAppInteractSearchPrivate {
fn incremental_search(&mut self, next: bool);
fn next<P, T>(pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
where
P: FnMut(bool, bool, &str, &T) -> bool;
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize>;
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize>;
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize>;
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool;
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool;
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool;
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool;
fn is_case_sensitive(artist_name: &str) -> bool;
fn is_char_sensitive(artist_name: &str) -> bool;
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String;
}
impl IAppInteractSearchPrivate for AppMachine<SearchState> {
fn incremental_search(&mut self, next: bool) {
let collection = self.inner.music_hoard.get_collection();
let search = &self.state.string;
let sel = &self.inner.selection;
let result = match sel.category() {
Category::Artist => sel
.state_artist(collection)
.and_then(|state| Self::search_artists(search, next, state)),
Category::Album => sel
.state_album(collection)
.and_then(|state| Self::search_albums(search, next, state)),
Category::Track => sel
.state_track(collection)
.and_then(|state| Self::search_tracks(search, next, state)),
};
if result.is_some() {
let collection = self.inner.music_hoard.get_collection();
self.inner.selection.select(collection, result);
}
}
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize> {
Self::next(Self::predicate_artists, name, next, st)
}
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize> {
Self::next(Self::predicate_albums, name, next, st)
}
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize> {
Self::next(Self::predicate_tracks, name, next, st)
}
fn next<P, T>(mut pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
where
P: FnMut(bool, bool, &str, &T) -> bool,
{
let case_sens = Self::is_case_sensitive(name);
let char_sens = Self::is_char_sensitive(name);
let search = Self::normalize_search(name, !case_sens, !char_sens);
let mut index = st.index;
if next && ((index + 1) < st.list.len()) {
index += 1;
}
let slice = &st.list[index..];
slice
.iter()
.position(|probe| pred(case_sens, char_sens, &search, probe))
.map(|slice_index| index + slice_index)
}
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool {
let name = Self::normalize_search(&probe.meta.id.name, !case_sens, !char_sens);
let mut result = name.starts_with(search);
if let Some(ref probe_sort) = probe.meta.sort {
if !result {
let name = Self::normalize_search(probe_sort, !case_sens, !char_sens);
result = name.starts_with(search);
}
}
result
}
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool {
Self::predicate_title(case_sens, char_sens, search, &probe.meta.id.title)
}
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool {
Self::predicate_title(case_sens, char_sens, search, &probe.id.title)
}
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool {
Self::normalize_search(title, !case_sens, !char_sens).starts_with(search)
}
fn is_case_sensitive(artist_name: &str) -> bool {
artist_name
.chars()
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
}
fn is_char_sensitive(artist_name: &str) -> bool {
// Benchmarking reveals that using AhoCorasick is slower. At a guess, this is likely due to
// a high constant cost of AhoCorasick and the otherwise simple nature of the task.
artist_name.chars().any(|ch| SPECIAL.contains(&ch))
}
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
if asciify {
if lowercase {
AC.replace_all(&search.to_lowercase(), &REPLACE)
} else {
AC.replace_all(search, &REPLACE)
}
} else if lowercase {
search.to_lowercase()
} else {
search.to_owned()
}
}
}
#[cfg(test)]
mod tests {
use ratatui::widgets::ListState;
use crate::tui::{
app::machine::tests::{inner, music_hoard},
testmod::COLLECTION,
};
use super::*;
fn orig(index: Option<usize>) -> ListSelection {
let mut artist = ListState::default();
artist.select(index);
ListSelection {
artist,
album: ListState::default(),
track: ListState::default(),
}
}
#[test]
fn artist_incremental_search() {
// Empty collection.
let mut search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
assert_eq!(search.inner.selection.selected(), None);
search.state.string = String::from("album_artist 'a'");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), None);
// Basic test, first element.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist 'a'");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
// Basic test, non-first element.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist 'c'");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
// Non-lowercase.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("Album_Artist ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("Album_Artist 'C'");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
// Non-ascii.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist c");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
// Non-lowercase, non-ascii.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("Album_Artist ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("Album_Artist C");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
// Stop at name, not sort name.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("the ");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
search.state.string = String::from("the album_artist 'c'");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(2));
// Search next with common prefix.
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
assert_eq!(search.inner.selection.selected(), Some(0));
search.state.string = String::from("album_artist");
search.incremental_search(false);
assert_eq!(search.inner.selection.selected(), Some(0));
search.incremental_search(true);
assert_eq!(search.inner.selection.selected(), Some(1));
search.incremental_search(true);
assert_eq!(search.inner.selection.selected(), Some(2));
search.incremental_search(true);
assert_eq!(search.inner.selection.selected(), Some(3));
search.incremental_search(true);
assert_eq!(search.inner.selection.selected(), Some(3));
}
#[test]
fn album_incremental_search() {
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
search.inner.selection.increment_category();
assert_eq!(search.inner.selection.category(), Category::Album);
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(0));
search.state.string = String::from("album_title ");
search.incremental_search(false);
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(0));
let search = search.append_character('a').unwrap_search();
let search = search.append_character('.').unwrap_search();
let search = search.append_character('b').unwrap_search();
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(1));
}
#[test]
fn track_incremental_search() {
let mut search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
search.inner.selection.increment_category();
search.inner.selection.increment_category();
assert_eq!(search.inner.selection.category(), Category::Track);
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(0));
search.state.string = String::from("track ");
search.incremental_search(false);
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(0));
let search = search.append_character('a').unwrap_search();
let search = search.append_character('.').unwrap_search();
let search = search.append_character('a').unwrap_search();
let search = search.append_character('.').unwrap_search();
let search = search.append_character('2').unwrap_search();
let sel = &search.inner.selection;
assert_eq!(sel.selected(), Some(1));
}
#[test]
fn search() {
let search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.append_character('a').unwrap_search();
let search = search.append_character('l').unwrap_search();
let search = search.append_character('b').unwrap_search();
let search = search.append_character('u').unwrap_search();
let search = search.append_character('m').unwrap_search();
let search = search.append_character('_').unwrap_search();
let search = search.append_character('a').unwrap_search();
let search = search.append_character('r').unwrap_search();
let search = search.append_character('t').unwrap_search();
let search = search.append_character('i').unwrap_search();
let search = search.append_character('s').unwrap_search();
let search = search.append_character('t').unwrap_search();
let search = search.append_character(' ').unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.append_character('\'').unwrap_search();
let search = search.append_character('c').unwrap_search();
let search = search.append_character('\'').unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(2));
let search = search.step_back().unwrap_search();
let search = search.step_back().unwrap_search();
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.append_character('\'').unwrap_search();
let search = search.append_character('b').unwrap_search();
let search = search.append_character('\'').unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(1));
let app = search.finish_search();
let browse = app.unwrap_browse();
assert_eq!(browse.inner.selection.selected(), Some(1));
}
#[test]
fn search_next_step_back() {
let search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.append_character('a').unwrap_search();
let search = search.search_next().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(1));
let search = search.search_next().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(2));
let search = search.search_next().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(3));
let search = search.search_next().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(3));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(3));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(2));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(1));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(0));
}
#[test]
fn cancel_search() {
let search =
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
assert_eq!(search.inner.selection.selected(), Some(0));
let search = search.append_character('a').unwrap_search();
let search = search.append_character('l').unwrap_search();
let search = search.append_character('b').unwrap_search();
let search = search.append_character('u').unwrap_search();
let search = search.append_character('m').unwrap_search();
let search = search.append_character('_').unwrap_search();
let search = search.append_character('a').unwrap_search();
let search = search.append_character('r').unwrap_search();
let search = search.append_character('t').unwrap_search();
let search = search.append_character('i').unwrap_search();
let search = search.append_character('s').unwrap_search();
let search = search.append_character('t').unwrap_search();
let search = search.append_character(' ').unwrap_search();
let search = search.append_character('\'').unwrap_search();
let search = search.append_character('b').unwrap_search();
let search = search.append_character('\'').unwrap_search();
assert_eq!(search.inner.selection.selected(), Some(1));
let browse = search.cancel_search().unwrap_browse();
assert_eq!(browse.inner.selection.selected(), Some(2));
}
#[test]
fn empty_search() {
let search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
assert_eq!(search.inner.selection.selected(), None);
let search = search.append_character('a').unwrap_search();
assert_eq!(search.inner.selection.selected(), None);
let search = search.search_next().unwrap_search();
assert_eq!(search.inner.selection.selected(), None);
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), None);
let search = search.step_back().unwrap_search();
assert_eq!(search.inner.selection.selected(), None);
let browse = search.cancel_search().unwrap_browse();
assert_eq!(browse.inner.selection.selected(), None);
}
}
#[cfg(nightly)]
#[cfg(test)]
mod benches {
// The purpose of these benches was to evaluate the benefit of AhoCorasick over std solutions.
use test::Bencher;
use crate::tui::{app::machine::benchmod::ARTISTS, lib::MockIMusicHoard};
use super::*;
type Search = AppMachine<MockIMusicHoard, SearchState>;
#[bench]
fn is_char_sensitive(b: &mut Bencher) {
let mut iter = ARTISTS.iter().cycle();
b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap())))
}
#[bench]
fn normalize_search(b: &mut Bencher) {
let mut iter = ARTISTS.iter().cycle();
b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), true, true)))
}
}

312
src/tui/app/mod.rs Normal file
View File

@ -0,0 +1,312 @@
mod machine;
mod selection;
pub use machine::App;
use ratatui::widgets::ListState;
pub use selection::{Category, Selection};
use musichoard::collection::{
album::{AlbumId, AlbumMeta},
artist::{ArtistId, ArtistMeta},
Collection,
};
use crate::tui::lib::interface::musicbrainz::api::Entity;
pub enum AppState<B, I, R, S, F, M, E, C> {
Browse(B),
Info(I),
Reload(R),
Search(S),
Fetch(F),
Match(M),
Error(E),
Critical(C),
}
pub enum AppMode<StateMode, InputMode> {
State(StateMode),
Input(InputMode),
}
macro_rules! IAppState {
() => {
AppState<Self::BrowseState, Self::InfoState, Self::ReloadState, Self::SearchState,
Self::FetchState, Self::MatchState, Self::ErrorState, Self::CriticalState>
};
}
use IAppState;
pub trait IApp {
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>;
type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>;
type ReloadState: IAppBase<APP = Self> + IAppInteractReload<APP = Self>;
type SearchState: IAppBase<APP = Self> + IAppInteractSearch<APP = Self>;
type FetchState: IAppBase<APP = Self>
+ IAppInteractFetch<APP = Self>
+ IAppEventFetch<APP = Self>;
type MatchState: IAppBase<APP = Self> + IAppInteractMatch<APP = Self>;
type ErrorState: IAppBase<APP = Self> + IAppInteractError<APP = Self>;
type CriticalState: IAppBase<APP = Self>;
type InputMode: IAppInput<APP = Self>;
fn is_running(&self) -> bool;
fn force_quit(self) -> Self;
fn state(self) -> IAppState!();
#[allow(clippy::type_complexity)]
fn mode(self) -> AppMode<IAppState!(), Self::InputMode>;
}
pub trait IAppBase {
type APP: IApp;
fn no_op(self) -> Self::APP;
}
pub trait IAppInteractBrowse {
type APP: IApp;
fn quit(self) -> Self::APP;
fn increment_category(self) -> Self::APP;
fn decrement_category(self) -> Self::APP;
fn increment_selection(self, delta: Delta) -> Self::APP;
fn decrement_selection(self, delta: Delta) -> Self::APP;
fn show_info_overlay(self) -> Self::APP;
fn show_reload_menu(self) -> Self::APP;
fn begin_search(self) -> Self::APP;
fn fetch_musicbrainz(self) -> Self::APP;
}
pub trait IAppInteractInfo {
type APP: IApp;
fn hide_info_overlay(self) -> Self::APP;
}
pub trait IAppInteractReload {
type APP: IApp;
fn reload_library(self) -> Self::APP;
fn reload_database(self) -> Self::APP;
fn hide_reload_menu(self) -> Self::APP;
}
pub trait IAppInteractSearch {
type APP: IApp;
fn append_character(self, ch: char) -> Self::APP;
fn search_next(self) -> Self::APP;
fn step_back(self) -> Self::APP;
fn finish_search(self) -> Self::APP;
fn cancel_search(self) -> Self::APP;
}
pub trait IAppInteractFetch {
type APP: IApp;
fn abort(self) -> Self::APP;
}
pub trait IAppEventFetch {
type APP: IApp;
fn fetch_result_ready(self) -> Self::APP;
}
pub trait IAppInteractMatch {
type APP: IApp;
fn decrement_match(self, delta: Delta) -> Self::APP;
fn increment_match(self, delta: Delta) -> Self::APP;
fn select(self) -> Self::APP;
fn abort(self) -> Self::APP;
}
pub struct InputEvent(crossterm::event::KeyEvent);
impl From<crossterm::event::KeyEvent> for InputEvent {
fn from(value: crossterm::event::KeyEvent) -> Self {
InputEvent(value)
}
}
impl From<InputEvent> for crossterm::event::KeyEvent {
fn from(value: InputEvent) -> Self {
value.0
}
}
pub trait IAppInput {
type APP: IApp;
fn input(self, input: InputEvent) -> Self::APP;
fn confirm(self) -> Self::APP;
fn cancel(self) -> Self::APP;
}
pub trait IAppInteractError {
type APP: IApp;
fn dismiss_error(self) -> Self::APP;
}
#[derive(Clone, Debug, Default)]
pub struct WidgetState {
pub list: ListState,
pub height: usize,
}
impl PartialEq for WidgetState {
fn eq(&self, other: &Self) -> bool {
self.list.selected().eq(&other.list.selected()) && self.height.eq(&other.height)
}
}
impl WidgetState {
#[must_use]
pub const fn with_selected(mut self, selected: Option<usize>) -> Self {
self.list = self.list.with_selected(selected);
self
}
}
pub enum Delta {
Line,
Page,
}
impl Delta {
fn as_usize(&self, state: &WidgetState) -> usize {
match self {
Delta::Line => 1,
Delta::Page => state.height.saturating_sub(1),
}
}
}
// It would be preferable to have a getter for each field separately. However, the selection field
// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait.
// This in turn complicates simultaneous field access since only a single mutable borrow is allowed.
// Therefore, all fields are grouped into a single struct and returned as a batch.
pub trait IAppAccess {
fn get(&mut self) -> AppPublic;
}
pub struct AppPublic<'app> {
pub inner: AppPublicInner<'app>,
pub state: AppPublicState<'app>,
pub input: Option<InputPublic<'app>>,
}
pub struct AppPublicInner<'app> {
pub collection: &'app Collection,
pub selection: &'app mut Selection,
}
pub type InputPublic<'app> = &'app tui_input::Input;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MatchOption<T> {
Some(Entity<T>),
CannotHaveMbid,
ManualInputMbid,
}
impl<T> From<Entity<T>> for MatchOption<T> {
fn from(value: Entity<T>) -> Self {
MatchOption::Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtistMatches {
pub matching: ArtistMeta,
pub list: Vec<MatchOption<ArtistMeta>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumMatches {
pub artist: ArtistId,
pub matching: AlbumId,
pub list: Vec<MatchOption<AlbumMeta>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntityMatches {
Artist(ArtistMatches),
Album(AlbumMatches),
}
impl EntityMatches {
pub fn artist_search<M: Into<MatchOption<ArtistMeta>>>(
matching: ArtistMeta,
list: Vec<M>,
) -> Self {
let list = list.into_iter().map(Into::into).collect();
EntityMatches::Artist(ArtistMatches { matching, list })
}
pub fn album_search<M: Into<MatchOption<AlbumMeta>>>(
artist: ArtistId,
matching: AlbumId,
list: Vec<M>,
) -> Self {
let list = list.into_iter().map(Into::into).collect();
EntityMatches::Album(AlbumMatches {
artist,
matching,
list,
})
}
pub fn artist_lookup<M: Into<MatchOption<ArtistMeta>>>(matching: ArtistMeta, item: M) -> Self {
let list = vec![item.into()];
EntityMatches::Artist(ArtistMatches { matching, list })
}
pub fn album_lookup<M: Into<MatchOption<AlbumMeta>>>(
artist: ArtistId,
matching: AlbumId,
item: M,
) -> Self {
let list = vec![item.into()];
EntityMatches::Album(AlbumMatches {
artist,
matching,
list,
})
}
}
pub struct MatchStatePublic<'app> {
pub matches: &'app EntityMatches,
pub state: &'app mut WidgetState,
}
pub type AppPublicState<'app> =
AppState<(), (), (), &'app str, (), MatchStatePublic<'app>, &'app str, &'app str>;
impl<B, I, R, S, F, M, E, C> AppState<B, I, R, S, F, M, E, C> {
pub fn is_search(&self) -> bool {
matches!(self, AppState::Search(_))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_is_state() {
let state = AppPublicState::Search("get rekt");
assert!(state.is_search());
}
}

View File

@ -0,0 +1,359 @@
use std::cmp;
use musichoard::collection::{
album::{Album, AlbumDate, AlbumId, AlbumSeq},
track::Track,
};
use crate::tui::app::{
selection::{
track::{KeySelectTrack, TrackSelection},
SelectionState,
},
Delta, WidgetState,
};
#[derive(Clone, Debug, PartialEq)]
pub struct AlbumSelection {
pub state: WidgetState,
pub track: TrackSelection,
}
impl AlbumSelection {
pub fn initialise(albums: &[Album]) -> Self {
let mut selection = AlbumSelection {
state: WidgetState::default(),
track: TrackSelection::initialise(&[]),
};
selection.reinitialise(albums, None);
selection
}
pub fn reinitialise(&mut self, albums: &[Album], album: Option<KeySelectAlbum>) {
if let Some(album) = album {
let result =
albums.binary_search_by(|a| a.meta.get_sort_key().cmp(&album.get_sort_key()));
match result {
Ok(index) => self.reinitialise_with_index(albums, index, album.track),
Err(index) => self.reinitialise_with_index(albums, index, None),
}
} else {
self.reinitialise_with_index(albums, 0, None)
}
}
fn reinitialise_with_index(
&mut self,
albums: &[Album],
index: usize,
active_track: Option<KeySelectTrack>,
) {
if albums.is_empty() {
self.state.list.select(None);
self.track = TrackSelection::initialise(&[]);
} else if index >= albums.len() {
let end = albums.len() - 1;
self.state.list.select(Some(end));
self.track = TrackSelection::initialise(&albums[end].tracks);
} else {
self.state.list.select(Some(index));
self.track.reinitialise(&albums[index].tracks, active_track);
}
}
pub fn selected(&self) -> Option<usize> {
self.state.list.selected()
}
pub fn selected_track(&self) -> Option<usize> {
self.track.selected()
}
pub fn select(&mut self, albums: &[Album], to: Option<usize>) {
match to {
Some(to) => self.select_to(albums, to),
None => self.state.list.select(None),
}
}
pub fn select_track(&mut self, albums: &[Album], to: Option<usize>) {
if let Some(index) = self.state.list.selected() {
self.track.select(&albums[index].tracks, to);
}
}
fn select_to(&mut self, albums: &[Album], mut to: usize) {
to = cmp::min(to, albums.len() - 1);
if self.state.list.selected() != Some(to) {
self.state.list.select(Some(to));
self.track = TrackSelection::initialise(&albums[to].tracks);
}
}
pub fn selection_state<'a>(&self, list: &'a [Album]) -> Option<SelectionState<'a, Album>> {
let selected = self.state.list.selected();
selected.map(|index| SelectionState { list, index })
}
pub fn state_tracks<'a>(&self, albums: &'a [Album]) -> Option<SelectionState<'a, Track>> {
let selected = self.state.list.selected();
selected.and_then(|index| self.track.selection_state(&albums[index].tracks))
}
pub fn widget_state(&mut self) -> &mut WidgetState {
&mut self.state
}
pub fn widget_state_track(&mut self) -> &mut WidgetState {
self.track.widget_state()
}
pub fn reset(&mut self, albums: &[Album]) {
if self.state.list.selected() != Some(0) {
self.reinitialise(albums, None);
}
}
pub fn reset_track(&mut self, albums: &[Album]) {
if let Some(index) = self.state.list.selected() {
self.track.reset(&albums[index].tracks);
}
}
pub fn increment(&mut self, albums: &[Album], delta: Delta) {
self.increment_by(albums, delta.as_usize(&self.state));
}
pub fn increment_track(&mut self, albums: &[Album], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.track.increment(&albums[index].tracks, delta);
}
}
fn increment_by(&mut self, albums: &[Album], by: usize) {
if let Some(index) = self.state.list.selected() {
let mut result = index.saturating_add(by);
if result >= albums.len() {
result = albums.len() - 1;
}
if self.state.list.selected() != Some(result) {
self.state.list.select(Some(result));
self.track = TrackSelection::initialise(&albums[result].tracks);
}
}
}
pub fn decrement(&mut self, albums: &[Album], delta: Delta) {
self.decrement_by(albums, delta.as_usize(&self.state));
}
pub fn decrement_track(&mut self, albums: &[Album], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.track.decrement(&albums[index].tracks, delta);
}
}
fn decrement_by(&mut self, albums: &[Album], by: usize) {
if let Some(index) = self.state.list.selected() {
let result = index.saturating_sub(by);
if self.state.list.selected() != Some(result) {
self.state.list.select(Some(result));
self.track = TrackSelection::initialise(&albums[result].tracks);
}
}
}
}
pub struct KeySelectAlbum {
key: (AlbumDate, AlbumSeq, AlbumId),
track: Option<KeySelectTrack>,
}
impl KeySelectAlbum {
pub fn get(albums: &[Album], selection: &AlbumSelection) -> Option<Self> {
selection.state.list.selected().map(|index| {
let album = &albums[index];
let key = album.meta.get_sort_key();
KeySelectAlbum {
key: (key.0.to_owned(), key.1.to_owned(), key.2.to_owned()),
track: KeySelectTrack::get(&album.tracks, &selection.track),
}
})
}
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
(&self.key.0, &self.key.1, &self.key.2)
}
}
#[cfg(test)]
mod tests {
use crate::tui::testmod::COLLECTION;
use super::*;
#[test]
fn album_select() {
let albums = &COLLECTION[0].albums;
assert!(albums.len() > 1);
let mut sel = AlbumSelection::initialise(albums);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select(albums, None);
assert_eq!(sel.selected(), None);
assert_eq!(sel.selected_track(), Some(0));
sel.select(albums, Some(albums.len()));
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(0));
sel.select_track(albums, None);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), None);
sel.reset_track(albums);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(0));
sel.select_track(albums, Some(1));
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(1));
sel.reset(albums);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
}
#[test]
fn album_delta_line() {
let albums = &COLLECTION[0].albums;
assert!(albums.len() > 1);
let mut empty = AlbumSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
assert_eq!(empty.selected_track(), None);
empty.increment(albums, Delta::Line);
assert_eq!(empty.selected(), None);
assert_eq!(empty.selected_track(), None);
empty.decrement(albums, Delta::Line);
assert_eq!(empty.selected(), None);
assert_eq!(empty.selected_track(), None);
let mut sel = AlbumSelection::initialise(albums);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.increment_track(albums, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(1));
// Verify that decrement that doesn't change index does not reset track.
sel.decrement(albums, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(1));
sel.increment(albums, Delta::Line);
assert_eq!(sel.selected(), Some(1));
assert_eq!(sel.selected_track(), Some(0));
sel.decrement(albums, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
for _ in 0..(albums.len() + 5) {
sel.increment(albums, Delta::Line);
}
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(0));
sel.increment_track(albums, Delta::Line);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(1));
// Verify that increment that doesn't change index does not reset track.
sel.increment(albums, Delta::Line);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(1));
}
#[test]
fn album_delta_page() {
let albums = &COLLECTION[1].albums;
assert!(albums.len() > 1);
let empty = AlbumSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
let mut sel = AlbumSelection::initialise(albums);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
assert!(albums.len() >= 4);
sel.state.height = 3;
sel.increment_track(albums, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(1));
// Verify that decrement that doesn't change index does not reset track.
sel.decrement(albums, Delta::Page);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(1));
sel.increment(albums, Delta::Page);
assert_eq!(sel.selected(), Some(2));
assert_eq!(sel.selected_track(), Some(0));
sel.decrement(albums, Delta::Page);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
for _ in 0..(albums.len() + 5) {
sel.increment(albums, Delta::Page);
}
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(0));
sel.increment_track(albums, Delta::Line);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(1));
// Verify that increment that doesn't change index does not reset track.
sel.increment(albums, Delta::Page);
assert_eq!(sel.selected(), Some(albums.len() - 1));
assert_eq!(sel.selected_track(), Some(1));
}
#[test]
fn album_reinitialise() {
let albums = &COLLECTION[0].albums;
assert!(albums.len() > 1);
let mut sel = AlbumSelection::initialise(albums);
sel.state.list.select(Some(albums.len() - 1));
sel.track.state.list.select(Some(1));
// Re-initialise.
let expected = sel.clone();
let active_album = KeySelectAlbum::get(albums, &sel);
sel.reinitialise(albums, active_album);
assert_eq!(sel, expected);
// Re-initialise out-of-bounds.
let mut expected = sel.clone();
expected.decrement(albums, Delta::Line);
let active_album = KeySelectAlbum::get(albums, &sel);
sel.reinitialise(&albums[..(albums.len() - 1)], active_album);
assert_eq!(sel, expected);
// Re-initialise empty.
let expected = AlbumSelection::initialise(&[]);
let active_album = KeySelectAlbum::get(albums, &sel);
sel.reinitialise(&[], active_album);
assert_eq!(sel, expected);
}
}

View File

@ -0,0 +1,414 @@
use std::cmp;
use musichoard::collection::{
album::Album,
artist::{Artist, ArtistId},
track::Track,
};
use crate::tui::app::{
selection::{
album::{AlbumSelection, KeySelectAlbum},
SelectionState,
},
Delta, WidgetState,
};
#[derive(Clone, Debug, PartialEq)]
pub struct ArtistSelection {
pub state: WidgetState,
pub album: AlbumSelection,
}
impl ArtistSelection {
pub fn initialise(artists: &[Artist]) -> Self {
let mut selection = ArtistSelection {
state: WidgetState::default(),
album: AlbumSelection::initialise(&[]),
};
selection.reinitialise(artists, None);
selection
}
pub fn reinitialise(&mut self, artists: &[Artist], active: Option<KeySelectArtist>) {
if let Some(active) = active {
let result =
artists.binary_search_by(|a| a.meta.get_sort_key().cmp(&active.get_sort_key()));
match result {
Ok(index) => self.reinitialise_with_index(artists, index, active.album),
Err(index) => self.reinitialise_with_index(artists, index, None),
}
} else {
self.reinitialise_with_index(artists, 0, None)
}
}
fn reinitialise_with_index(
&mut self,
artists: &[Artist],
index: usize,
active_album: Option<KeySelectAlbum>,
) {
if artists.is_empty() {
self.state.list.select(None);
self.album = AlbumSelection::initialise(&[]);
} else if index >= artists.len() {
let end = artists.len() - 1;
self.state.list.select(Some(end));
self.album = AlbumSelection::initialise(&artists[end].albums);
} else {
self.state.list.select(Some(index));
self.album
.reinitialise(&artists[index].albums, active_album);
}
}
pub fn selected(&self) -> Option<usize> {
self.state.list.selected()
}
pub fn selected_album(&self) -> Option<usize> {
self.album.selected()
}
pub fn selected_track(&self) -> Option<usize> {
self.album.selected_track()
}
pub fn select(&mut self, artists: &[Artist], to: Option<usize>) {
match to {
Some(to) => self.select_to(artists, to),
None => self.state.list.select(None),
}
}
pub fn select_album(&mut self, artists: &[Artist], to: Option<usize>) {
if let Some(index) = self.state.list.selected() {
self.album.select(&artists[index].albums, to);
}
}
pub fn select_track(&mut self, artists: &[Artist], to: Option<usize>) {
if let Some(index) = self.state.list.selected() {
self.album.select_track(&artists[index].albums, to);
}
}
fn select_to(&mut self, artists: &[Artist], mut to: usize) {
to = cmp::min(to, artists.len() - 1);
if self.state.list.selected() != Some(to) {
self.state.list.select(Some(to));
self.album = AlbumSelection::initialise(&artists[to].albums);
}
}
pub fn selection_state<'a>(&self, list: &'a [Artist]) -> Option<SelectionState<'a, Artist>> {
let selected = self.state.list.selected();
selected.map(|index| SelectionState { list, index })
}
pub fn state_album<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Album>> {
let selected = self.state.list.selected();
selected.and_then(|index| self.album.selection_state(&artists[index].albums))
}
pub fn state_track<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Track>> {
let selected = self.state.list.selected();
selected.and_then(|index| self.album.state_tracks(&artists[index].albums))
}
pub fn widget_state(&mut self) -> &mut WidgetState {
&mut self.state
}
pub fn widget_state_album(&mut self) -> &mut WidgetState {
self.album.widget_state()
}
pub fn widget_state_track(&mut self) -> &mut WidgetState {
self.album.widget_state_track()
}
pub fn reset(&mut self, artists: &[Artist]) {
if self.state.list.selected() != Some(0) {
self.reinitialise(artists, None);
}
}
pub fn reset_album(&mut self, artists: &[Artist]) {
if let Some(index) = self.state.list.selected() {
self.album.reset(&artists[index].albums);
}
}
pub fn reset_track(&mut self, artists: &[Artist]) {
if let Some(index) = self.state.list.selected() {
self.album.reset_track(&artists[index].albums);
}
}
pub fn increment(&mut self, artists: &[Artist], delta: Delta) {
self.increment_by(artists, delta.as_usize(&self.state));
}
pub fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.album.increment(&artists[index].albums, delta);
}
}
pub fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.album.increment_track(&artists[index].albums, delta);
}
}
fn increment_by(&mut self, artists: &[Artist], by: usize) {
if let Some(index) = self.state.list.selected() {
let result = index.saturating_add(by);
self.select_to(artists, result);
}
}
pub fn decrement(&mut self, artists: &[Artist], delta: Delta) {
self.decrement_by(artists, delta.as_usize(&self.state));
}
pub fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.album.decrement(&artists[index].albums, delta);
}
}
pub fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
if let Some(index) = self.state.list.selected() {
self.album.decrement_track(&artists[index].albums, delta);
}
}
fn decrement_by(&mut self, artists: &[Artist], by: usize) {
if let Some(index) = self.state.list.selected() {
let result = index.saturating_sub(by);
if self.state.list.selected() != Some(result) {
self.state.list.select(Some(result));
self.album = AlbumSelection::initialise(&artists[result].albums);
}
}
}
}
pub struct KeySelectArtist {
key: (ArtistId,),
album: Option<KeySelectAlbum>,
}
impl KeySelectArtist {
pub fn get(artists: &[Artist], selection: &ArtistSelection) -> Option<Self> {
selection.state.list.selected().map(|index| {
let artist = &artists[index];
let key = artist.meta.get_sort_key();
KeySelectArtist {
key: (key.0.into(),),
album: KeySelectAlbum::get(&artist.albums, &selection.album),
}
})
}
pub fn get_sort_key(&self) -> (&str,) {
(&self.key.0.name,)
}
}
#[cfg(test)]
mod tests {
use crate::tui::testmod::COLLECTION;
use super::*;
#[test]
fn artist_select() {
let artists = &COLLECTION;
assert!(artists.len() > 1);
let mut sel = ArtistSelection::initialise(artists);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select(artists, None);
assert_eq!(sel.selected(), None);
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select(artists, Some(artists.len()));
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select_track(artists, None);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), None);
sel.select_album(artists, None);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), None);
assert_eq!(sel.selected_track(), None);
sel.reset_album(artists);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select_track(artists, None);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), None);
sel.reset_track(artists);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
sel.select_album(artists, Some(1));
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.selected_album(), Some(1));
assert_eq!(sel.selected_track(), Some(0));
sel.reset(artists);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.selected_album(), Some(0));
assert_eq!(sel.selected_track(), Some(0));
}
#[test]
fn artist_delta_line() {
let artists = &COLLECTION;
assert!(artists.len() > 1);
let mut empty = ArtistSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
assert_eq!(empty.album.selected(), None);
empty.increment(artists, Delta::Line);
assert_eq!(empty.selected(), None);
assert_eq!(empty.album.selected(), None);
empty.decrement(artists, Delta::Line);
assert_eq!(empty.selected(), None);
assert_eq!(empty.album.selected(), None);
let mut sel = ArtistSelection::initialise(artists);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(0));
sel.increment_album(artists, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(1));
// Verify that decrement that doesn't change index does not reset album.
sel.decrement(artists, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(1));
sel.increment(artists, Delta::Line);
assert_eq!(sel.selected(), Some(1));
assert_eq!(sel.album.selected(), Some(0));
sel.decrement(artists, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(0));
for _ in 0..(artists.len() + 5) {
sel.increment(artists, Delta::Line);
}
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(0));
sel.increment_album(artists, Delta::Line);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(1));
// Verify that increment that doesn't change index does not reset album.
sel.increment(artists, Delta::Line);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(1));
}
#[test]
fn artist_delta_page() {
let artists = &COLLECTION;
assert!(artists.len() > 1);
let empty = ArtistSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
let mut sel = ArtistSelection::initialise(artists);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(0));
assert!(artists.len() >= 4);
sel.state.height = 3;
sel.increment_album(artists, Delta::Line);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(1));
// Verify that decrement that doesn't change index does not reset album.
sel.decrement(artists, Delta::Page);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(1));
sel.increment(artists, Delta::Page);
assert_eq!(sel.selected(), Some(2));
assert_eq!(sel.album.selected(), Some(0));
sel.decrement(artists, Delta::Page);
assert_eq!(sel.selected(), Some(0));
assert_eq!(sel.album.selected(), Some(0));
for _ in 0..(artists.len() + 5) {
sel.increment(artists, Delta::Page);
}
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(0));
sel.increment_album(artists, Delta::Line);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(1));
// Verify that increment that doesn't change index does not reset album.
sel.increment(artists, Delta::Page);
assert_eq!(sel.selected(), Some(artists.len() - 1));
assert_eq!(sel.album.selected(), Some(1));
}
#[test]
fn artist_reinitialise() {
let artists = &COLLECTION;
assert!(artists.len() > 1);
let mut sel = ArtistSelection::initialise(artists);
sel.state.list.select(Some(artists.len() - 1));
sel.album.state.list.select(Some(1));
// Re-initialise.
let expected = sel.clone();
let active_artist = KeySelectArtist::get(artists, &sel);
sel.reinitialise(artists, active_artist);
assert_eq!(sel, expected);
// Re-initialise out-of-bounds.
let mut expected = sel.clone();
expected.decrement(artists, Delta::Line);
let active_artist = KeySelectArtist::get(artists, &sel);
sel.reinitialise(&artists[..(artists.len() - 1)], active_artist);
assert_eq!(sel, expected);
// Re-initialise empty.
let expected = ArtistSelection::initialise(&[]);
let active_artist = KeySelectArtist::get(artists, &sel);
sel.reinitialise(&[], active_artist);
assert_eq!(sel, expected);
}
}

View File

@ -0,0 +1,382 @@
mod album;
mod artist;
mod track;
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
use ratatui::widgets::ListState;
use crate::tui::app::{
selection::artist::{ArtistSelection, KeySelectArtist},
Delta, WidgetState,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Category {
Artist,
Album,
Track,
}
pub struct Selection {
active: Category,
artist: ArtistSelection,
}
pub struct SelectionState<'a, T> {
pub list: &'a [T],
pub index: usize,
}
impl Selection {
pub fn new(artists: &[Artist]) -> Self {
Selection {
active: Category::Artist,
artist: ArtistSelection::initialise(artists),
}
}
pub fn select_by_list(&mut self, selected: ListSelection) {
self.artist.state.list = selected.artist;
self.artist.album.state.list = selected.album;
self.artist.album.track.state.list = selected.track;
}
pub fn select_by_id(&mut self, artists: &[Artist], selected: KeySelection) {
self.artist.reinitialise(artists, selected.artist);
}
pub fn increment_category(&mut self) {
self.active = match self.active {
Category::Artist => Category::Album,
Category::Album => Category::Track,
Category::Track => Category::Track,
};
}
pub fn decrement_category(&mut self) {
self.active = match self.active {
Category::Artist => Category::Artist,
Category::Album => Category::Artist,
Category::Track => Category::Album,
};
}
pub fn select(&mut self, collection: &Collection, index: Option<usize>) {
match self.active {
Category::Artist => self.select_artist(collection, index),
Category::Album => self.select_album(collection, index),
Category::Track => self.select_track(collection, index),
}
}
fn select_artist(&mut self, artists: &[Artist], index: Option<usize>) {
self.artist.select(artists, index);
}
fn select_album(&mut self, artists: &[Artist], index: Option<usize>) {
self.artist.select_album(artists, index);
}
fn select_track(&mut self, artists: &[Artist], index: Option<usize>) {
self.artist.select_track(artists, index);
}
pub fn category(&self) -> Category {
self.active
}
pub fn selected(&self) -> Option<usize> {
match self.active {
Category::Artist => self.selected_artist(),
Category::Album => self.selected_album(),
Category::Track => self.selected_track(),
}
}
fn selected_artist(&self) -> Option<usize> {
self.artist.selected()
}
fn selected_album(&self) -> Option<usize> {
self.artist.selected_album()
}
fn selected_track(&self) -> Option<usize> {
self.artist.selected_track()
}
pub fn state_artist<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Artist>> {
self.artist.selection_state(coll)
}
pub fn state_album<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Album>> {
self.artist.state_album(coll)
}
pub fn state_track<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Track>> {
self.artist.state_track(coll)
}
pub fn widget_state_artist(&mut self) -> &mut WidgetState {
self.artist.widget_state()
}
pub fn widget_state_album(&mut self) -> &mut WidgetState {
self.artist.widget_state_album()
}
pub fn widget_state_track(&mut self) -> &mut WidgetState {
self.artist.widget_state_track()
}
pub fn reset(&mut self, collection: &Collection) {
match self.active {
Category::Artist => self.reset_artist(collection),
Category::Album => self.reset_album(collection),
Category::Track => self.reset_track(collection),
}
}
fn reset_artist(&mut self, artists: &[Artist]) {
self.artist.reset(artists);
}
fn reset_album(&mut self, artists: &[Artist]) {
self.artist.reset_album(artists);
}
fn reset_track(&mut self, artists: &[Artist]) {
self.artist.reset_track(artists);
}
pub fn increment_selection(&mut self, collection: &Collection, delta: Delta) {
match self.active {
Category::Artist => self.increment_artist(collection, delta),
Category::Album => self.increment_album(collection, delta),
Category::Track => self.increment_track(collection, delta),
}
}
fn increment_artist(&mut self, artists: &[Artist], delta: Delta) {
self.artist.increment(artists, delta);
}
fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
self.artist.increment_album(artists, delta);
}
fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
self.artist.increment_track(artists, delta);
}
pub fn decrement_selection(&mut self, collection: &Collection, delta: Delta) {
match self.active {
Category::Artist => self.decrement_artist(collection, delta),
Category::Album => self.decrement_album(collection, delta),
Category::Track => self.decrement_track(collection, delta),
}
}
fn decrement_artist(&mut self, artists: &[Artist], delta: Delta) {
self.artist.decrement(artists, delta);
}
fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
self.artist.decrement_album(artists, delta);
}
fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
self.artist.decrement_track(artists, delta);
}
}
pub struct ListSelection {
pub artist: ListState,
pub album: ListState,
pub track: ListState,
}
impl ListSelection {
pub fn get(selection: &Selection) -> Self {
ListSelection {
artist: selection.artist.state.list.clone(),
album: selection.artist.album.state.list.clone(),
track: selection.artist.album.track.state.list.clone(),
}
}
}
pub struct KeySelection {
artist: Option<KeySelectArtist>,
}
impl KeySelection {
pub fn get(collection: &Collection, selection: &Selection) -> Self {
KeySelection {
artist: KeySelectArtist::get(collection, &selection.artist),
}
}
}
#[cfg(test)]
mod tests {
use crate::tui::testmod::COLLECTION;
use super::*;
#[test]
fn selection_select() {
let mut selection = Selection::new(&COLLECTION);
selection.select(&COLLECTION, Some(1));
selection.increment_category();
selection.select(&COLLECTION, Some(1));
selection.increment_category();
selection.select(&COLLECTION, Some(1));
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(1));
selection.reset(&COLLECTION);
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.select(&COLLECTION, Some(1));
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(1));
selection.decrement_category();
selection.reset(&COLLECTION);
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.select(&COLLECTION, Some(1));
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.decrement_category();
selection.reset(&COLLECTION);
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.artist.selected(), Some(0));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
}
#[test]
fn selection_delta() {
let mut selection = Selection::new(&COLLECTION);
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(0));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_category();
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_category();
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(1));
selection.increment_category();
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(1));
selection.decrement_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Track);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_selection(&COLLECTION, Delta::Line);
selection.decrement_category();
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(1));
selection.decrement_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Album);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_selection(&COLLECTION, Delta::Line);
selection.decrement_category();
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.selected(), Some(1));
assert_eq!(selection.artist.selected(), Some(1));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.decrement_selection(&COLLECTION, Delta::Line);
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(0));
assert_eq!(selection.artist.album.selected(), Some(0));
assert_eq!(selection.artist.album.track.selected(), Some(0));
selection.increment_category();
selection.increment_selection(&COLLECTION, Delta::Line);
selection.decrement_category();
selection.decrement_selection(&COLLECTION, Delta::Line);
selection.decrement_category();
assert_eq!(selection.active, Category::Artist);
assert_eq!(selection.selected(), Some(0));
assert_eq!(selection.artist.selected(), Some(0));
assert_eq!(selection.artist.album.selected(), Some(1));
assert_eq!(selection.artist.album.track.selected(), Some(0));
}
}

View File

@ -0,0 +1,235 @@
use std::cmp;
use musichoard::collection::track::{Track, TrackId, TrackNum};
use crate::tui::app::{selection::SelectionState, Delta, WidgetState};
#[derive(Clone, Debug, PartialEq)]
pub struct TrackSelection {
pub state: WidgetState,
}
impl TrackSelection {
pub fn initialise(tracks: &[Track]) -> Self {
let mut selection = TrackSelection {
state: WidgetState::default(),
};
selection.reinitialise(tracks, None);
selection
}
pub fn reinitialise(&mut self, tracks: &[Track], track: Option<KeySelectTrack>) {
if let Some(track) = track {
let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.get_sort_key()));
match result {
Ok(index) | Err(index) => self.reinitialise_with_index(tracks, index),
}
} else {
self.reinitialise_with_index(tracks, 0)
}
}
fn reinitialise_with_index(&mut self, tracks: &[Track], index: usize) {
if tracks.is_empty() {
self.state.list.select(None);
} else if index >= tracks.len() {
self.state.list.select(Some(tracks.len() - 1));
} else {
self.state.list.select(Some(index));
}
}
pub fn selected(&self) -> Option<usize> {
self.state.list.selected()
}
pub fn select(&mut self, tracks: &[Track], to: Option<usize>) {
match to {
Some(to) => self.select_to(tracks, to),
None => self.state.list.select(None),
}
}
fn select_to(&mut self, tracks: &[Track], mut to: usize) {
to = cmp::min(to, tracks.len() - 1);
self.state.list.select(Some(to));
}
pub fn selection_state<'a>(&self, list: &'a [Track]) -> Option<SelectionState<'a, Track>> {
let selected = self.state.list.selected();
selected.map(|index| SelectionState { list, index })
}
pub fn widget_state(&mut self) -> &mut WidgetState {
&mut self.state
}
pub fn reset(&mut self, tracks: &[Track]) {
if self.state.list.selected() != Some(0) {
self.reinitialise(tracks, None);
}
}
pub fn increment(&mut self, tracks: &[Track], delta: Delta) {
self.increment_by(tracks, delta.as_usize(&self.state));
}
fn increment_by(&mut self, tracks: &[Track], by: usize) {
if let Some(index) = self.state.list.selected() {
let mut result = index.saturating_add(by);
if result >= tracks.len() {
result = tracks.len() - 1;
}
if self.state.list.selected() != Some(result) {
self.state.list.select(Some(result));
}
}
}
pub fn decrement(&mut self, tracks: &[Track], delta: Delta) {
self.decrement_by(tracks, delta.as_usize(&self.state));
}
fn decrement_by(&mut self, _tracks: &[Track], by: usize) {
if let Some(index) = self.state.list.selected() {
let result = index.saturating_sub(by);
if self.state.list.selected() != Some(result) {
self.state.list.select(Some(result));
}
}
}
}
pub struct KeySelectTrack {
key: (TrackNum, TrackId),
}
impl KeySelectTrack {
pub fn get(tracks: &[Track], selection: &TrackSelection) -> Option<Self> {
selection.state.list.selected().map(|index| {
let track = &tracks[index];
let key = track.get_sort_key();
KeySelectTrack {
key: (key.0.to_owned(), key.1.to_owned()),
}
})
}
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
(&self.key.0, &self.key.1)
}
}
#[cfg(test)]
mod tests {
use crate::tui::testmod::COLLECTION;
use super::*;
#[test]
fn track_select() {
let tracks = &COLLECTION[0].albums[0].tracks;
assert!(tracks.len() > 1);
let mut sel = TrackSelection::initialise(tracks);
assert_eq!(sel.selected(), Some(0));
sel.select(tracks, None);
assert_eq!(sel.selected(), None);
sel.select(tracks, Some(tracks.len()));
assert_eq!(sel.selected(), Some(tracks.len() - 1));
sel.reset(tracks);
assert_eq!(sel.selected(), Some(0));
}
#[test]
fn track_delta_line() {
let tracks = &COLLECTION[0].albums[0].tracks;
assert!(tracks.len() > 1);
let mut empty = TrackSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
empty.increment(tracks, Delta::Line);
assert_eq!(empty.selected(), None);
empty.decrement(tracks, Delta::Line);
assert_eq!(empty.selected(), None);
let mut sel = TrackSelection::initialise(tracks);
assert_eq!(sel.selected(), Some(0));
sel.decrement(tracks, Delta::Line);
assert_eq!(sel.selected(), Some(0));
sel.increment(tracks, Delta::Line);
assert_eq!(sel.selected(), Some(1));
sel.decrement(tracks, Delta::Line);
assert_eq!(sel.selected(), Some(0));
for _ in 0..(tracks.len() + 5) {
sel.increment(tracks, Delta::Line);
}
assert_eq!(sel.selected(), Some(tracks.len() - 1));
}
#[test]
fn track_delta_page() {
let tracks = &COLLECTION[0].albums[0].tracks;
assert!(tracks.len() > 1);
let empty = TrackSelection::initialise(&[]);
assert_eq!(empty.selected(), None);
let mut sel = TrackSelection::initialise(tracks);
assert_eq!(sel.selected(), Some(0));
assert!(tracks.len() >= 4);
sel.state.height = 3;
sel.decrement(tracks, Delta::Page);
assert_eq!(sel.selected(), Some(0));
sel.increment(tracks, Delta::Page);
assert_eq!(sel.selected(), Some(2));
sel.decrement(tracks, Delta::Page);
assert_eq!(sel.selected(), Some(0));
for _ in 0..(tracks.len() + 5) {
sel.increment(tracks, Delta::Page);
}
assert_eq!(sel.selected(), Some(tracks.len() - 1));
}
#[test]
fn track_reinitialise() {
let tracks = &COLLECTION[0].albums[0].tracks;
assert!(tracks.len() > 1);
let mut sel = TrackSelection::initialise(tracks);
sel.state.list.select(Some(tracks.len() - 1));
// Re-initialise.
let expected = sel.clone();
let active_track = KeySelectTrack::get(tracks, &sel);
sel.reinitialise(tracks, active_track);
assert_eq!(sel, expected);
// Re-initialise out-of-bounds.
let mut expected = sel.clone();
expected.decrement(tracks, Delta::Line);
let active_track = KeySelectTrack::get(tracks, &sel);
sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track);
assert_eq!(sel, expected);
// Re-initialise empty.
let expected = TrackSelection::initialise(&[]);
let active_track = KeySelectTrack::get(tracks, &sel);
sel.reinitialise(&[], active_track);
assert_eq!(sel, expected);
}
}

View File

@ -1,7 +1,10 @@
use crossterm::event::{KeyEvent, MouseEvent};
use crossterm::event::KeyEvent;
use std::fmt;
use std::sync::mpsc;
#[cfg(test)]
use mockall::automock;
#[derive(Debug)]
pub enum EventError {
Send(Event),
@ -33,11 +36,16 @@ impl From<mpsc::RecvError> for EventError {
}
}
#[derive(Clone, Copy, Debug)]
impl From<mpsc::TryRecvError> for EventError {
fn from(_: mpsc::TryRecvError) -> EventError {
EventError::Recv
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
FetchComplete,
}
pub struct EventChannel {
@ -45,6 +53,16 @@ pub struct EventChannel {
receiver: mpsc::Receiver<Event>,
}
pub trait IKeyEventSender {
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError>;
}
#[cfg_attr(test, automock)]
pub trait IFetchCompleteEventSender {
fn send_fetch_complete(&self) -> Result<(), EventError>;
}
#[derive(Clone)]
pub struct EventSender {
sender: mpsc::Sender<Event>,
}
@ -72,9 +90,15 @@ impl EventChannel {
}
}
impl EventSender {
pub fn send(&self, event: Event) -> Result<(), EventError> {
Ok(self.sender.send(event)?)
impl IKeyEventSender for EventSender {
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError> {
Ok(self.sender.send(Event::Key(key_event))?)
}
}
impl IFetchCompleteEventSender for EventSender {
fn send_fetch_complete(&self) -> Result<(), EventError> {
Ok(self.sender.send(Event::FetchComplete)?)
}
}
@ -82,6 +106,11 @@ impl EventReceiver {
pub fn recv(&self) -> Result<Event, EventError> {
Ok(self.receiver.recv()?)
}
#[cfg(test)]
pub fn try_recv(&self) -> Result<Event, EventError> {
Ok(self.receiver.try_recv()?)
}
}
#[cfg(test)]
@ -90,20 +119,20 @@ mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use super::{Event, EventChannel, EventError};
use super::*;
#[test]
fn event_sender() {
let channel = EventChannel::new();
let sender = channel.sender();
let receiver = channel.receiver();
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty()));
let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
let result = sender.send(event);
let result = sender.send_key(key_event);
assert!(result.is_ok());
drop(receiver);
let result = sender.send(event);
let result = sender.send_key(key_event);
assert!(result.is_err());
}
@ -112,9 +141,9 @@ mod tests {
let channel = EventChannel::new();
let sender = channel.sender();
let receiver = channel.receiver();
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty()));
let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
sender.send(event).unwrap();
sender.send_key(key_event).unwrap();
let result = receiver.recv();
assert!(result.is_ok());
@ -123,6 +152,24 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn event_receiver_try() {
let channel = EventChannel::new();
let sender = channel.sender();
let receiver = channel.receiver();
let result = receiver.try_recv();
assert!(result.is_err());
sender.send_fetch_complete().unwrap();
let result = receiver.try_recv();
assert!(result.is_ok());
drop(sender);
let result = receiver.try_recv();
assert!(result.is_err());
}
#[test]
fn errors() {
let send_err = EventError::Send(Event::Key(KeyEvent {

View File

@ -3,71 +3,249 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[cfg(test)]
use mockall::automock;
use super::{
use crate::tui::{
app::{
AppMode, AppState, Delta, IApp, IAppBase, IAppEventFetch, IAppInput, IAppInteractBrowse,
IAppInteractError, IAppInteractFetch, IAppInteractInfo, IAppInteractMatch,
IAppInteractReload, IAppInteractSearch,
},
event::{Event, EventError, EventReceiver},
ui::Ui,
};
#[cfg_attr(test, automock)]
pub trait EventHandler<UI> {
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError>;
pub trait IEventHandler<APP: IApp> {
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
}
trait EventHandlerPrivate<UI> {
fn handle_key_event(ui: &mut UI, key_event: KeyEvent);
trait IEventHandlerPrivate<APP: IApp> {
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP;
fn handle_browse_key_event(app: <APP as IApp>::BrowseState, key_event: KeyEvent) -> APP;
fn handle_info_key_event(app: <APP as IApp>::InfoState, key_event: KeyEvent) -> APP;
fn handle_reload_key_event(app: <APP as IApp>::ReloadState, key_event: KeyEvent) -> APP;
fn handle_search_key_event(app: <APP as IApp>::SearchState, key_event: KeyEvent) -> APP;
fn handle_fetch_key_event(app: <APP as IApp>::FetchState, key_event: KeyEvent) -> APP;
fn handle_match_key_event(app: <APP as IApp>::MatchState, key_event: KeyEvent) -> APP;
fn handle_error_key_event(app: <APP as IApp>::ErrorState, key_event: KeyEvent) -> APP;
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, key_event: KeyEvent) -> APP;
fn handle_fetch_complete_event(app: APP) -> APP;
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP;
}
pub struct TuiEventHandler {
pub struct EventHandler {
events: EventReceiver,
}
// GRCOV_EXCL_START
impl TuiEventHandler {
impl EventHandler {
pub fn new(events: EventReceiver) -> Self {
TuiEventHandler { events }
EventHandler { events }
}
}
impl<UI: Ui> EventHandler<UI> for TuiEventHandler {
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> {
match self.events.recv()? {
Event::Key(key_event) => Self::handle_key_event(ui, key_event),
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
};
Ok(())
impl<APP: IApp> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
Ok(match self.events.recv()? {
Event::Key(key_event) => Self::handle_key_event(app, key_event),
Event::FetchComplete => Self::handle_fetch_complete_event(app),
})
}
}
impl<UI: Ui> EventHandlerPrivate<UI> for TuiEventHandler {
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) {
impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP {
if key_event.modifiers == KeyModifiers::CONTROL {
match key_event.code {
// Exit application on `Ctrl-C`.
KeyCode::Char('c') | KeyCode::Char('C') => return app.force_quit(),
_ => {}
};
}
match app.mode() {
AppMode::Input(input_mode) => Self::handle_input_key_event(input_mode, key_event),
AppMode::State(state_mode) => match state_mode {
AppState::Browse(browse_state) => {
Self::handle_browse_key_event(browse_state, key_event)
}
AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event),
AppState::Reload(reload_state) => {
Self::handle_reload_key_event(reload_state, key_event)
}
AppState::Search(search_state) => {
Self::handle_search_key_event(search_state, key_event)
}
AppState::Fetch(fetch_state) => {
Self::handle_fetch_key_event(fetch_state, key_event)
}
AppState::Match(match_state) => {
Self::handle_match_key_event(match_state, key_event)
}
AppState::Error(error_state) => {
Self::handle_error_key_event(error_state, key_event)
}
AppState::Critical(critical_state) => {
Self::handle_critical_key_event(critical_state, key_event)
}
},
}
}
fn handle_fetch_complete_event(app: APP) -> APP {
match app.state() {
AppState::Browse(state) => state.no_op(),
AppState::Info(state) => state.no_op(),
AppState::Reload(state) => state.no_op(),
AppState::Search(state) => state.no_op(),
AppState::Fetch(fetch_state) => fetch_state.fetch_result_ready(),
AppState::Match(state) => state.no_op(),
AppState::Error(state) => state.no_op(),
AppState::Critical(state) => state.no_op(),
}
}
fn handle_browse_key_event(app: <APP as IApp>::BrowseState, key_event: KeyEvent) -> APP {
match key_event.code {
// Exit application on `ESC` or `q`.
KeyCode::Esc | KeyCode::Char('q') => {
ui.quit();
}
// Exit application on `Ctrl-C`.
KeyCode::Char('c') | KeyCode::Char('C') => {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(),
// Category change.
KeyCode::Left => app.decrement_category(),
KeyCode::Right => app.increment_category(),
// Selection change.
KeyCode::Up => app.decrement_selection(Delta::Line),
KeyCode::Down => app.increment_selection(Delta::Line),
KeyCode::PageUp => app.decrement_selection(Delta::Page),
KeyCode::PageDown => app.increment_selection(Delta::Page),
// Toggle info overlay.
KeyCode::Char('m') | KeyCode::Char('M') => app.show_info_overlay(),
// Toggle reload menu.
KeyCode::Char('g') | KeyCode::Char('G') => app.show_reload_menu(),
// Toggle search.
KeyCode::Char('s') | KeyCode::Char('S') => {
if key_event.modifiers == KeyModifiers::CONTROL {
ui.quit();
app.begin_search()
} else {
app.no_op()
}
}
// Category change.
KeyCode::Left => {
ui.decrement_category();
}
KeyCode::Right => {
ui.increment_category();
}
// Selection change.
KeyCode::Up => {
ui.decrement_selection();
}
KeyCode::Down => {
ui.increment_selection();
}
// Other keys.
_ => {}
KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_info_key_event(app: <APP as IApp>::InfoState, key_event: KeyEvent) -> APP {
match key_event.code {
// Toggle overlay.
KeyCode::Esc
| KeyCode::Char('q')
| KeyCode::Char('Q')
| KeyCode::Char('m')
| KeyCode::Char('M') => app.hide_info_overlay(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_reload_key_event(app: <APP as IApp>::ReloadState, key_event: KeyEvent) -> APP {
match key_event.code {
// Reload keys.
KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(),
KeyCode::Char('d') | KeyCode::Char('D') => app.reload_database(),
// Return.
KeyCode::Esc
| KeyCode::Char('q')
| KeyCode::Char('Q')
| KeyCode::Char('g')
| KeyCode::Char('G') => app.hide_reload_menu(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_search_key_event(app: <APP as IApp>::SearchState, key_event: KeyEvent) -> APP {
if key_event.modifiers == KeyModifiers::CONTROL {
return match key_event.code {
KeyCode::Char('s') | KeyCode::Char('S') => app.search_next(),
KeyCode::Char('g') | KeyCode::Char('G') => app.cancel_search(),
_ => app.no_op(),
};
}
match key_event.code {
// Add/remove character to search.
KeyCode::Char(ch) => app.append_character(ch),
KeyCode::Backspace => app.step_back(),
// Return.
KeyCode::Esc | KeyCode::Enter => app.finish_search(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_fetch_key_event(app: <APP as IApp>::FetchState, key_event: KeyEvent) -> APP {
if key_event.modifiers == KeyModifiers::CONTROL {
return match key_event.code {
KeyCode::Char('g') | KeyCode::Char('G') => app.abort(),
_ => app.no_op(),
};
}
match key_event.code {
// Abort.
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_match_key_event(app: <APP as IApp>::MatchState, key_event: KeyEvent) -> APP {
if key_event.modifiers == KeyModifiers::CONTROL {
return match key_event.code {
KeyCode::Char('g') | KeyCode::Char('G') => app.abort(),
_ => app.no_op(),
};
}
match key_event.code {
// Abort.
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
// Select.
KeyCode::Up => app.decrement_match(Delta::Line),
KeyCode::Down => app.increment_match(Delta::Line),
KeyCode::PageUp => app.decrement_match(Delta::Page),
KeyCode::PageDown => app.increment_match(Delta::Page),
KeyCode::Enter => app.select(),
// Othey keys.
_ => app.no_op(),
}
}
fn handle_error_key_event(app: <APP as IApp>::ErrorState, _key_event: KeyEvent) -> APP {
// Any key dismisses the error.
app.dismiss_error()
}
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, _key_event: KeyEvent) -> APP {
// No action is allowed.
app.no_op()
}
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP {
if key_event.modifiers == KeyModifiers::CONTROL {
match key_event.code {
KeyCode::Char('g') | KeyCode::Char('G') => return app.cancel(),
_ => {}
};
}
match key_event.code {
// Return.
KeyCode::Esc => app.cancel(),
KeyCode::Enter => app.confirm(),
// Othey keys.
_ => app.input(key_event.into()),
}
}
}

1
src/tui/lib/external/mod.rs vendored Normal file
View File

@ -0,0 +1 @@
pub mod musicbrainz;

View File

@ -0,0 +1,185 @@
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
use std::collections::HashMap;
use musichoard::{
collection::{
album::{AlbumDate, AlbumInfo, AlbumMeta, AlbumSeq},
artist::{ArtistInfo, ArtistMeta},
musicbrainz::{MbRefOption, Mbid},
},
external::musicbrainz::{
api::{
browse::{BrowseReleaseGroupRequest, BrowseReleaseGroupResponse},
lookup::{
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
LookupReleaseGroupResponse,
},
search::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup,
},
MbArtistMeta, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
},
IMusicBrainzHttp,
},
};
use crate::tui::lib::interface::musicbrainz::api::{Entity, Error, IMusicBrainz};
// GRCOV_EXCL_START
pub struct MusicBrainz<Http> {
client: MusicBrainzClient<Http>,
}
impl<Http> MusicBrainz<Http> {
pub fn new(client: MusicBrainzClient<Http>) -> Self {
MusicBrainz { client }
}
}
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error> {
let request = LookupArtistRequest::new(mbid);
let mb_response = self.client.lookup_artist(&request)?;
Ok(from_lookup_artist_response(mb_response))
}
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error> {
let request = LookupReleaseGroupRequest::new(mbid);
let mb_response = self.client.lookup_release_group(&request)?;
Ok(from_lookup_release_group_response(mb_response))
}
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name);
let paging = PageSettings::default();
let mb_response = self.client.search_artist(&query, &paging)?;
Ok(mb_response
.artists
.into_iter()
.map(from_search_artist_response_artist)
.collect())
}
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
// Some release groups may have a promotional early release messing up the search. Searching
// with just the year should be enough anyway.
let date = AlbumDate::new(album.date.year, None, None);
let query = SearchReleaseGroupRequest::new()
.arid(arid)
.and()
.first_release_date(&date)
.and()
.release_group(&album.id.title);
let paging = PageSettings::default();
let mb_response = self.client.search_release_group(&query, &paging)?;
Ok(mb_response
.release_groups
.into_iter()
.map(from_search_release_group_response_release_group)
.collect())
}
fn browse_release_group(
&mut self,
artist: &Mbid,
paging: &mut Option<PageSettings>,
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
let request = BrowseReleaseGroupRequest::artist(artist);
let page = paging.take().unwrap_or_default();
let mb_response = self.client.browse_release_group(&request, &page)?;
let page_count = mb_response.release_groups.len();
*paging = mb_response.page.next_page(page, page_count);
Ok(from_browse_release_group_response(mb_response))
}
}
fn from_mb_artist_meta(meta: MbArtistMeta) -> (ArtistMeta, Option<String>) {
let sort = Some(meta.sort_name).filter(|s| s != &meta.name);
(
ArtistMeta {
id: meta.name.into(),
sort,
info: ArtistInfo {
musicbrainz: MbRefOption::Some(meta.id.into()),
properties: HashMap::new(),
},
},
meta.disambiguation,
)
}
fn from_mb_release_group_meta(meta: MbReleaseGroupMeta) -> AlbumMeta {
AlbumMeta {
id: meta.title.into(),
date: meta.first_release_date,
seq: AlbumSeq::default(),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(meta.id.into()),
primary_type: meta.primary_type,
secondary_types: meta.secondary_types.unwrap_or_default(),
},
}
}
fn from_lookup_artist_response(response: LookupArtistResponse) -> Entity<ArtistMeta> {
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
Entity {
score: None,
entity,
disambiguation,
}
}
fn from_lookup_release_group_response(response: LookupReleaseGroupResponse) -> Entity<AlbumMeta> {
Entity {
score: None,
entity: from_mb_release_group_meta(response.meta),
disambiguation: None,
}
}
fn from_search_artist_response_artist(response: SearchArtistResponseArtist) -> Entity<ArtistMeta> {
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
Entity {
score: Some(response.score),
entity,
disambiguation,
}
}
fn from_search_release_group_response_release_group(
response: SearchReleaseGroupResponseReleaseGroup,
) -> Entity<AlbumMeta> {
Entity {
score: Some(response.score),
entity: from_mb_release_group_meta(response.meta),
disambiguation: None,
}
}
fn from_browse_release_group_response(
entity: BrowseReleaseGroupResponse,
) -> Vec<Entity<AlbumMeta>> {
let rgs = entity.release_groups.into_iter();
let metas = rgs.map(from_mb_release_group_meta);
metas.map(Entity::new).collect()
}
// GRCOV_EXCL_STOP

View File

@ -0,0 +1,963 @@
use std::{collections::VecDeque, sync::mpsc, thread, time};
use musichoard::external::musicbrainz::api::PageSettings;
use crate::tui::{
app::EntityMatches,
event::IFetchCompleteEventSender,
lib::interface::musicbrainz::{
api::{Error as ApiError, IMusicBrainz},
daemon::{
BrowseParams, EntityList, Error, IMbJobSender, LookupParams, MbParams, MbReturn,
ResultSender, SearchParams,
},
},
};
pub struct MusicBrainzDaemon {
musicbrainz: Box<dyn IMusicBrainz>,
job_receiver: mpsc::Receiver<Job>,
job_queue: JobQueue,
event_sender: Box<dyn IFetchCompleteEventSender>,
}
struct JobQueue {
foreground_queue: VecDeque<JobInstance>,
background_queue: VecDeque<JobInstance>,
}
#[derive(Debug)]
struct Job {
priority: JobPriority,
instance: JobInstance,
}
impl Job {
pub fn new(priority: JobPriority, instance: JobInstance) -> Self {
Job { priority, instance }
}
}
#[derive(Debug)]
enum JobPriority {
Foreground,
Background,
}
#[derive(Debug)]
struct JobInstance {
result_sender: ResultSender,
requests: VecDeque<MbParams>,
paging: Option<PageSettings>,
}
#[derive(Debug, PartialEq, Eq)]
enum JobInstanceStatus {
Continue,
Complete,
}
#[derive(Debug, PartialEq, Eq)]
enum JobInstanceError {
ReturnChannelDisconnected,
EventChannelDisconnected,
}
impl JobInstance {
fn new(result_sender: ResultSender, requests: VecDeque<MbParams>) -> Self {
JobInstance {
result_sender,
requests,
paging: None,
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum JobError {
JobQueueEmpty,
EventChannelDisconnected,
}
pub struct JobChannel {
sender: mpsc::Sender<Job>,
receiver: mpsc::Receiver<Job>,
}
pub struct JobSender {
sender: mpsc::Sender<Job>,
}
pub struct JobReceiver {
receiver: mpsc::Receiver<Job>,
}
impl JobChannel {
pub fn new() -> Self {
let (sender, receiver) = mpsc::channel();
JobChannel { receiver, sender }
}
pub fn sender(&self) -> JobSender {
JobSender {
sender: self.sender.clone(),
}
}
pub fn receiver(self) -> JobReceiver {
JobReceiver {
receiver: self.receiver,
}
}
}
impl IMbJobSender for JobSender {
fn submit_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_job(JobPriority::Foreground, result_sender, requests)
}
fn submit_background_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
self.send_job(JobPriority::Background, result_sender, requests)
}
}
impl JobSender {
fn send_job(
&self,
priority: JobPriority,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error> {
let instance = JobInstance::new(result_sender, requests);
let job = Job::new(priority, instance);
self.sender.send(job).or(Err(Error::JobChannelDisconnected))
}
}
impl MusicBrainzDaemon {
// GRCOV_EXCL_START
pub fn run<MB: IMusicBrainz + 'static, ES: IFetchCompleteEventSender + 'static>(
musicbrainz: MB,
job_receiver: JobReceiver,
event_sender: ES,
) -> Result<(), Error> {
let daemon = MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender),
};
daemon.main()
}
fn main(mut self) -> Result<(), Error> {
loop {
self.enqueue_all_pending_jobs()?;
match self.execute_next_job() {
Ok(()) => {
// Sleep for one second. Required by MB API rate limiting. Assume all other
// processing takes negligible time such that regardless of how much other
// processing there is to be done, this one second sleep is necessary.
thread::sleep(time::Duration::from_secs(1));
}
Err(JobError::JobQueueEmpty) => {
self.wait_for_jobs()?;
}
Err(JobError::EventChannelDisconnected) => {
return Err(Error::EventChannelDisconnected);
}
}
}
}
// GRCOV_EXCL_STOP
fn enqueue_all_pending_jobs(&mut self) -> Result<(), Error> {
loop {
match self.job_receiver.try_recv() {
Ok(job) => self.job_queue.push_back(job),
Err(mpsc::TryRecvError::Empty) => return Ok(()),
Err(mpsc::TryRecvError::Disconnected) => {
return Err(Error::JobChannelDisconnected);
}
}
}
}
fn execute_next_job(&mut self) -> Result<(), JobError> {
if let Some(instance) = self.job_queue.front_mut() {
let result = instance.execute_next(&mut *self.musicbrainz, &mut *self.event_sender);
match result {
Ok(JobInstanceStatus::Continue) => {}
Ok(JobInstanceStatus::Complete)
| Err(JobInstanceError::ReturnChannelDisconnected) => {
self.job_queue.pop_front();
}
Err(JobInstanceError::EventChannelDisconnected) => {
return Err(JobError::EventChannelDisconnected)
}
}
return Ok(());
}
Err(JobError::JobQueueEmpty)
}
fn wait_for_jobs(&mut self) -> Result<(), Error> {
assert!(self.job_queue.is_empty());
match self.job_receiver.recv() {
Ok(job) => self.job_queue.push_back(job),
Err(mpsc::RecvError) => return Err(Error::JobChannelDisconnected),
}
Ok(())
}
}
impl JobInstance {
fn execute_next(
&mut self,
musicbrainz: &mut dyn IMusicBrainz,
event_sender: &mut dyn IFetchCompleteEventSender,
) -> Result<JobInstanceStatus, JobInstanceError> {
// self.requests can be empty if the caller submits an empty job.
if let Some(params) = self.requests.front() {
let result_sender = &mut self.result_sender;
let paging = &mut self.paging;
Self::execute(musicbrainz, result_sender, event_sender, params, paging)?;
};
if self.paging.is_none() {
self.requests.pop_front();
}
if self.requests.is_empty() {
Ok(JobInstanceStatus::Complete)
} else {
Ok(JobInstanceStatus::Continue)
}
}
fn execute(
musicbrainz: &mut dyn IMusicBrainz,
result_sender: &mut ResultSender,
event_sender: &mut dyn IFetchCompleteEventSender,
api_params: &MbParams,
paging: &mut Option<PageSettings>,
) -> Result<(), JobInstanceError> {
let result = match api_params {
MbParams::Lookup(lookup) => match lookup {
LookupParams::Artist(p) => musicbrainz
.lookup_artist(&p.mbid)
.map(|rv| EntityMatches::artist_lookup(p.artist.clone(), rv)),
LookupParams::ReleaseGroup(p) => {
musicbrainz.lookup_release_group(&p.mbid).map(|rv| {
EntityMatches::album_lookup(p.artist_id.clone(), p.album_id.clone(), rv)
})
}
}
.map(MbReturn::Match),
MbParams::Search(search) => match search {
SearchParams::Artist(p) => musicbrainz
.search_artist(&p.artist)
.map(|rv| EntityMatches::artist_search(p.artist.clone(), rv)),
SearchParams::ReleaseGroup(p) => musicbrainz
.search_release_group(&p.artist_mbid, &p.album)
.map(|rv| {
EntityMatches::album_search(p.artist_id.clone(), p.album.id.clone(), rv)
}),
}
.map(MbReturn::Match),
MbParams::Browse(browse) => match browse {
BrowseParams::ReleaseGroup(params) => {
Self::init_paging_if_none(paging);
musicbrainz
.browse_release_group(&params.artist, paging)
.map(|rv| EntityList::Album(rv.into_iter().map(|rg| rg.entity).collect()))
}
}
.map(MbReturn::Fetch),
};
Self::return_result(result_sender, event_sender, result)
}
fn init_paging_if_none(paging: &mut Option<PageSettings>) {
if paging.is_none() {
*paging = Some(PageSettings::with_max_limit());
}
}
fn return_result(
result_sender: &mut ResultSender,
event_sender: &mut dyn IFetchCompleteEventSender,
result: Result<MbReturn, ApiError>,
) -> Result<(), JobInstanceError> {
result_sender
.send(result)
.map_err(|_| JobInstanceError::ReturnChannelDisconnected)?;
// If this send fails the event listener is dead. Don't panic as this function runs in a
// detached thread so this might be happening during normal shut down.
event_sender
.send_fetch_complete()
.map_err(|_| JobInstanceError::EventChannelDisconnected)?;
Ok(())
}
}
impl JobQueue {
fn new() -> Self {
JobQueue {
foreground_queue: VecDeque::new(),
background_queue: VecDeque::new(),
}
}
fn is_empty(&self) -> bool {
self.foreground_queue.is_empty() && self.background_queue.is_empty()
}
fn front_mut(&mut self) -> Option<&mut JobInstance> {
self.foreground_queue
.front_mut()
.or_else(|| self.background_queue.front_mut())
}
fn pop_front(&mut self) -> Option<JobInstance> {
self.foreground_queue
.pop_front()
.or_else(|| self.background_queue.pop_front())
}
fn push_back(&mut self, job: Job) {
match job.priority {
JobPriority::Foreground => self.foreground_queue.push_back(job.instance),
JobPriority::Background => self.background_queue.push_back(job.instance),
}
}
}
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use musichoard::collection::{
album::AlbumMeta,
artist::{ArtistId, ArtistMeta},
musicbrainz::{IMusicBrainzRef, MbRefOption, Mbid},
};
use crate::tui::{
event::{Event, EventError, MockIFetchCompleteEventSender},
lib::interface::musicbrainz::api::{Entity, MockIMusicBrainz},
testmod::COLLECTION,
};
use super::*;
fn mb_ref_opt_unwrap<T>(opt: MbRefOption<T>) -> T {
match opt {
MbRefOption::Some(val) => val,
_ => panic!(),
}
}
fn mb_ref_opt_as_ref<T>(opt: &MbRefOption<T>) -> MbRefOption<&T> {
match *opt {
MbRefOption::Some(ref x) => MbRefOption::Some(x),
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
MbRefOption::None => MbRefOption::None,
}
}
fn musicbrainz() -> MockIMusicBrainz {
MockIMusicBrainz::new()
}
fn job_channel() -> (JobSender, JobReceiver) {
let channel = JobChannel::new();
let sender = channel.sender();
let receiver = channel.receiver();
(sender, receiver)
}
fn event_sender() -> MockIFetchCompleteEventSender {
MockIFetchCompleteEventSender::new()
}
fn fetch_complete_expectation(event_sender: &mut MockIFetchCompleteEventSender, times: usize) {
event_sender
.expect_send_fetch_complete()
.times(times)
.returning(|| Ok(()));
}
fn daemon(job_receiver: JobReceiver) -> MusicBrainzDaemon {
MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz()),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender()),
}
}
fn daemon_with(
musicbrainz: MockIMusicBrainz,
job_receiver: JobReceiver,
event_sender: MockIFetchCompleteEventSender,
) -> MusicBrainzDaemon {
MusicBrainzDaemon {
musicbrainz: Box::new(musicbrainz),
job_receiver: job_receiver.receiver,
job_queue: JobQueue::new(),
event_sender: Box::new(event_sender),
}
}
fn mbid() -> Mbid {
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
}
fn lookup_artist_requests() -> VecDeque<MbParams> {
let artist = COLLECTION[3].meta.clone();
let mbid = mbid();
VecDeque::from([MbParams::lookup_artist(artist, mbid)])
}
fn lookup_release_group_requests() -> VecDeque<MbParams> {
let artist_id = COLLECTION[1].meta.id.clone();
let album_id = COLLECTION[1].albums[0].meta.id.clone();
let mbid = mbid();
VecDeque::from([MbParams::lookup_release_group(artist_id, album_id, mbid)])
}
fn search_artist_requests() -> VecDeque<MbParams> {
let artist = COLLECTION[3].meta.clone();
VecDeque::from([MbParams::search_artist(artist)])
}
fn search_artist_expectations() -> (ArtistMeta, Vec<Entity<ArtistMeta>>) {
let artist = COLLECTION[3].meta.clone();
let artist_match_1 = Entity::with_score(artist.clone(), 100);
let artist_match_2 = Entity::with_score(artist.clone(), 50);
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
(artist, matches)
}
fn search_albums_requests() -> VecDeque<MbParams> {
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
let artist_id = COLLECTION[1].meta.id.clone();
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
VecDeque::from([
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_1),
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_4),
])
}
fn browse_albums_requests() -> VecDeque<MbParams> {
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
VecDeque::from([MbParams::browse_release_group(arid)])
}
fn album_artist_id() -> ArtistId {
COLLECTION[1].meta.id.clone()
}
fn album_arid_expectation() -> Mbid {
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
mb_ref_opt_unwrap(mbref).mbid().clone()
}
fn search_album_expectations_1() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_1_1 = Entity::with_score(album_1.clone(), 100);
let album_match_1_2 = Entity::with_score(album_4.clone(), 50);
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
(album_1, matches_1)
}
fn search_album_expectations_4() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_4_1 = Entity::with_score(album_4.clone(), 100);
let album_match_4_2 = Entity::with_score(album_1.clone(), 30);
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
(album_4, matches_4)
}
#[test]
fn enqueue_job_channel_disconnected() {
let (_, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Err(Error::JobChannelDisconnected));
}
#[test]
fn wait_for_job_channel_disconnected() {
let (_, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.wait_for_jobs();
assert_eq!(result, Err(Error::JobChannelDisconnected));
}
#[test]
fn enqueue_job_queue_empty() {
let (_job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
}
#[test]
fn enqueue_job() {
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let requests = search_artist_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests.clone());
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
}
#[test]
fn wait_for_jobs_job() {
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let requests = search_artist_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests.clone());
assert_eq!(result, Ok(()));
let result = daemon.wait_for_jobs();
assert_eq!(result, Ok(()));
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
}
#[test]
fn execute_empty() {
let (_job_sender, job_receiver) = job_channel();
let mut daemon = daemon(job_receiver);
let (result_sender, _) = mpsc::channel();
let mut instance = JobInstance::new(result_sender, VecDeque::new());
let result = instance.execute_next(&mut musicbrainz(), &mut event_sender());
assert_eq!(result, Ok(JobInstanceStatus::Complete));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
}
fn lookup_artist_expectation(
musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid,
lookup: &Entity<ArtistMeta>,
) {
let result = Ok(lookup.clone());
musicbrainz
.expect_lookup_artist()
.with(predicate::eq(mbid.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_lookup_artist() {
let mut musicbrainz = musicbrainz();
let mbid = mbid();
let artist = COLLECTION[3].meta.clone();
let lookup = Entity::new(artist.clone());
lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = lookup_artist_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_foreground_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
let result = result_receiver.try_recv().unwrap();
assert_eq!(
result,
Ok(MbReturn::Match(EntityMatches::artist_lookup(
artist, lookup
)))
);
}
fn lookup_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid,
lookup: &Entity<AlbumMeta>,
) {
let result = Ok(lookup.clone());
musicbrainz
.expect_lookup_release_group()
.with(predicate::eq(mbid.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_lookup_release_group() {
let mut musicbrainz = musicbrainz();
let mbid = mbid();
let album_id = COLLECTION[1].albums[0].meta.id.clone();
let album_meta = COLLECTION[1].albums[0].meta.clone();
let lookup = Entity::new(album_meta.clone());
lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = lookup_release_group_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_foreground_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
let result = result_receiver.try_recv().unwrap();
let artist_id = album_artist_id();
assert_eq!(
result,
Ok(MbReturn::Match(EntityMatches::album_lookup(
artist_id, album_id, lookup
)))
);
}
fn search_artist_expectation(
musicbrainz: &mut MockIMusicBrainz,
artist: &ArtistMeta,
matches: &[Entity<ArtistMeta>],
) {
let result = Ok(matches.to_owned());
musicbrainz
.expect_search_artist()
.with(predicate::eq(artist.clone()))
.times(1)
.return_once(|_| result);
}
#[test]
fn execute_search_artist() {
let mut musicbrainz = musicbrainz();
let (artist, matches) = search_artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 1);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_artist_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
let result = result_receiver.try_recv().unwrap();
assert_eq!(
result,
Ok(MbReturn::Match(EntityMatches::artist_search(
artist, matches
)))
);
}
fn search_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz,
seq: &mut Sequence,
arid: &Mbid,
album: &AlbumMeta,
matches: &[Entity<AlbumMeta>],
) {
let result = Ok(matches.to_owned());
musicbrainz
.expect_search_release_group()
.with(predicate::eq(arid.clone()), predicate::eq(album.clone()))
.times(1)
.in_sequence(seq)
.return_once(|_, _| result);
}
#[test]
fn execute_search_release_groups() {
let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation();
let (album_1, matches_1) = search_album_expectations_1();
let (album_4, matches_4) = search_album_expectations_4();
let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_4, &matches_4);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 2);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_albums_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
let artist_id = album_artist_id();
let result = result_receiver.try_recv().unwrap();
let matches = EntityMatches::album_search(artist_id.clone(), album_1.id, matches_1);
assert_eq!(result, Ok(MbReturn::Match(matches)));
let result = result_receiver.try_recv().unwrap();
let matches = EntityMatches::album_search(artist_id.clone(), album_4.id, matches_4);
assert_eq!(result, Ok(MbReturn::Match(matches)));
}
#[test]
fn execute_search_release_groups_result_disconnect() {
let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation();
let (album_1, matches_1) = search_album_expectations_1();
let mut seq = Sequence::new();
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 0);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_albums_requests();
let (result_sender, _) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
// Two albums were submitted, but as one job. If the first one fails due to the
// result_channel disconnecting, all remaining requests in that job are dropped.
assert!(daemon.job_queue.pop_front().is_none());
}
#[test]
fn execute_search_artist_event_disconnect() {
let mut musicbrainz = musicbrainz();
let (artist, matches) = search_artist_expectations();
search_artist_expectation(&mut musicbrainz, &artist, &matches);
let mut event_sender = event_sender();
event_sender
.expect_send_fetch_complete()
.times(1)
.return_once(|| Err(EventError::Send(Event::FetchComplete)));
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = search_artist_requests();
let (result_sender, _result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::EventChannelDisconnected));
}
fn browse_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz,
seq: &mut Sequence,
mbid: &Mbid,
page: Option<PageSettings>,
matches: &[Entity<AlbumMeta>],
next_page: Option<PageSettings>,
) {
let result = Ok(matches.to_owned());
musicbrainz
.expect_browse_release_group()
.with(predicate::eq(mbid.clone()), predicate::eq(page))
.times(1)
.in_sequence(seq)
.return_once(move |_, paging| {
*paging = next_page;
result
});
}
#[test]
fn execute_browse_release_groups() {
let mut musicbrainz = musicbrainz();
let arid = album_arid_expectation();
let (_, matches_1) = search_album_expectations_1();
let (_, matches_4) = search_album_expectations_4();
let mut seq = Sequence::new();
let page = Some(PageSettings::with_max_limit());
let next = Some(PageSettings::with_max_limit().with_offset(10));
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_1, next);
let page = next;
let next = None;
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_4, next);
let mut event_sender = event_sender();
fetch_complete_expectation(&mut event_sender, 2);
let (job_sender, job_receiver) = job_channel();
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
let requests = browse_albums_requests();
let (result_sender, result_receiver) = mpsc::channel();
let result = job_sender.submit_background_job(result_sender, requests);
assert_eq!(result, Ok(()));
let result = daemon.enqueue_all_pending_jobs();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Ok(()));
let result = daemon.execute_next_job();
assert_eq!(result, Err(JobError::JobQueueEmpty));
let result = result_receiver.try_recv().unwrap();
let fetch = EntityList::Album(matches_1.into_iter().map(|m| m.entity).collect());
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
let result = result_receiver.try_recv().unwrap();
let fetch = EntityList::Album(matches_4.into_iter().map(|m| m.entity).collect());
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
}
#[test]
fn job_queue() {
let mut queue = JobQueue::new();
assert!(queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 0);
let (result_sender, _) = mpsc::channel();
let bg = Job::new(
JobPriority::Background,
JobInstance::new(result_sender, VecDeque::new()),
);
queue.push_back(bg);
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 1);
let (result_sender, _) = mpsc::channel();
let fg = Job::new(
JobPriority::Foreground,
JobInstance::new(result_sender, VecDeque::new()),
);
queue.push_back(fg);
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 1);
assert_eq!(queue.background_queue.len(), 1);
let instance = queue.pop_front();
assert!(instance.is_some());
assert!(!queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 1);
let instance = queue.pop_front();
assert!(instance.is_some());
assert!(queue.is_empty());
assert_eq!(queue.foreground_queue.len(), 0);
assert_eq!(queue.background_queue.len(), 0);
let instance = queue.pop_front();
assert!(instance.is_none());
assert!(queue.is_empty());
}
}

View File

@ -0,0 +1,2 @@
pub mod api;
pub mod daemon;

View File

@ -0,0 +1 @@
pub mod musicbrainz;

View File

@ -0,0 +1,45 @@
//! Module for accessing MusicBrainz metadata.
#[cfg(test)]
use mockall::automock;
use musichoard::{
collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid},
external::musicbrainz::api::PageSettings,
};
#[cfg_attr(test, automock)]
pub trait IMusicBrainz {
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error>;
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error>;
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error>;
fn search_release_group(
&mut self,
arid: &Mbid,
album: &AlbumMeta,
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
fn browse_release_group(
&mut self,
artist: &Mbid,
paging: &mut Option<PageSettings>,
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entity<T> {
pub score: Option<u8>,
pub entity: T,
pub disambiguation: Option<String>,
}
impl<T> Entity<T> {
pub fn new(entity: T) -> Self {
Entity {
score: None,
entity,
disambiguation: None,
}
}
}
pub type Error = musichoard::external::musicbrainz::api::Error;

View File

@ -0,0 +1,156 @@
use std::{collections::VecDeque, fmt, sync::mpsc};
use musichoard::collection::{
album::{AlbumId, AlbumMeta},
artist::{ArtistId, ArtistMeta},
musicbrainz::Mbid,
};
use crate::tui::{app::EntityMatches, lib::interface::musicbrainz::api::Error as MbApiError};
#[cfg(test)]
use mockall::automock;
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
EventChannelDisconnected,
JobChannelDisconnected,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Error::EventChannelDisconnected => write!(f, "the event channel is disconnected"),
Error::JobChannelDisconnected => write!(f, "the job channel is disconnected"),
}
}
}
pub type MbApiResult = Result<MbReturn, MbApiError>;
pub type ResultSender = mpsc::Sender<MbApiResult>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbReturn {
Match(EntityMatches),
Fetch(EntityList),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntityList {
Album(Vec<AlbumMeta>),
}
#[cfg_attr(test, automock)]
pub trait IMbJobSender {
fn submit_foreground_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error>;
fn submit_background_job(
&self,
result_sender: ResultSender,
requests: VecDeque<MbParams>,
) -> Result<(), Error>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MbParams {
Lookup(LookupParams),
Search(SearchParams),
#[allow(dead_code)] // TODO: remove with completion of #160
Browse(BrowseParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LookupParams {
Artist(LookupArtistParams),
ReleaseGroup(LookupReleaseGroupParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LookupArtistParams {
pub artist: ArtistMeta,
pub mbid: Mbid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LookupReleaseGroupParams {
pub artist_id: ArtistId,
pub album_id: AlbumId,
pub mbid: Mbid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchParams {
Artist(SearchArtistParams),
ReleaseGroup(SearchReleaseGroupParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchArtistParams {
pub artist: ArtistMeta,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchReleaseGroupParams {
pub artist_id: ArtistId,
pub artist_mbid: Mbid,
pub album: AlbumMeta,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrowseParams {
#[allow(dead_code)] // TODO: remove with completion of #160
ReleaseGroup(BrowseReleaseGroupParams),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowseReleaseGroupParams {
pub artist: Mbid,
}
impl MbParams {
pub fn lookup_artist(artist: ArtistMeta, mbid: Mbid) -> Self {
MbParams::Lookup(LookupParams::Artist(LookupArtistParams { artist, mbid }))
}
pub fn lookup_release_group(artist_id: ArtistId, album_id: AlbumId, mbid: Mbid) -> Self {
MbParams::Lookup(LookupParams::ReleaseGroup(LookupReleaseGroupParams {
artist_id,
album_id,
mbid,
}))
}
pub fn search_artist(artist: ArtistMeta) -> Self {
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
}
pub fn search_release_group(artist_id: ArtistId, artist_mbid: Mbid, album: AlbumMeta) -> Self {
MbParams::Search(SearchParams::ReleaseGroup(SearchReleaseGroupParams {
artist_id,
artist_mbid,
album,
}))
}
#[allow(dead_code)] // TODO: to be removed by completion of #160
pub fn browse_release_group(artist: Mbid) -> Self {
MbParams::Browse(BrowseParams::ReleaseGroup(BrowseReleaseGroupParams {
artist,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors() {
assert!(!format!("{}", Error::EventChannelDisconnected).is_empty());
assert!(!format!("{}", Error::JobChannelDisconnected).is_empty());
}
}

View File

@ -0,0 +1,2 @@
pub mod api;
pub mod daemon;

67
src/tui/lib/mod.rs Normal file
View File

@ -0,0 +1,67 @@
pub mod external;
pub mod interface;
use musichoard::{
collection::{
album::{AlbumId, AlbumInfo},
artist::{ArtistId, ArtistInfo},
Collection,
},
interface::{database::IDatabase, library::ILibrary},
IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard,
};
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait IMusicHoard {
fn rescan_library(&mut self) -> Result<(), musichoard::Error>;
fn reload_database(&mut self) -> Result<(), musichoard::Error>;
fn get_collection(&self) -> &Collection;
fn merge_artist_info(
&mut self,
id: &ArtistId,
info: ArtistInfo,
) -> Result<(), musichoard::Error>;
fn merge_album_info(
&mut self,
artist_id: &ArtistId,
album_id: &AlbumId,
info: AlbumInfo,
) -> Result<(), musichoard::Error>;
}
// GRCOV_EXCL_START
impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardLibrary>::rescan_library(self)
}
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::reload_database(self)
}
fn get_collection(&self) -> &Collection {
<Self as IMusicHoardBase>::get_collection(self)
}
fn merge_artist_info(
&mut self,
id: &ArtistId,
info: ArtistInfo,
) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::merge_artist_info(self, id, info)
}
fn merge_album_info(
&mut self,
artist_id: &ArtistId,
album_id: &AlbumId,
info: AlbumInfo,
) -> Result<(), musichoard::Error> {
<Self as IMusicHoardDatabase>::merge_album_info(self, artist_id, album_id, info)
}
}
// GRCOV_EXCL_STOP

View File

@ -4,25 +4,27 @@ use std::thread;
#[cfg(test)]
use mockall::automock;
use super::event::{Event, EventError, EventSender};
use crate::tui::event::{EventError, IKeyEventSender};
#[cfg_attr(test, automock)]
pub trait EventListener {
pub trait IEventListener {
fn spawn(self) -> thread::JoinHandle<EventError>;
}
pub struct TuiEventListener {
events: EventSender,
pub struct EventListener {
event_sender: Box<dyn IKeyEventSender + Send>,
}
// GRCOV_EXCL_START
impl TuiEventListener {
pub fn new(events: EventSender) -> Self {
TuiEventListener { events }
impl EventListener {
pub fn new<ES: IKeyEventSender + Send + 'static>(event_sender: ES) -> Self {
EventListener {
event_sender: Box::new(event_sender),
}
}
}
impl EventListener for TuiEventListener {
impl IEventListener for EventListener {
fn spawn(self) -> thread::JoinHandle<EventError> {
thread::spawn(move || {
loop {
@ -32,10 +34,8 @@ impl EventListener for TuiEventListener {
match event::read() {
Ok(event) => {
if let Err(err) = match event {
CrosstermEvent::Key(e) => self.events.send(Event::Key(e)),
CrosstermEvent::Mouse(e) => self.events.send(Event::Mouse(e)),
CrosstermEvent::Resize(w, h) => self.events.send(Event::Resize(w, h)),
_ => unimplemented!(),
CrosstermEvent::Key(e) => self.event_sender.send_key(e),
_ => Ok(()),
} {
return err;
}

View File

@ -1,35 +1,42 @@
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use musichoard::collection;
use ratatui::backend::Backend;
use ratatui::Terminal;
use std::io;
use std::marker::PhantomData;
mod app;
mod event;
mod handler;
mod lib;
mod listener;
mod ui;
pub mod event;
pub mod handler;
pub mod listener;
pub mod ui;
pub use app::App;
pub use event::EventChannel;
pub use handler::EventHandler;
pub use lib::external::musicbrainz::{
api::MusicBrainz,
daemon::{JobChannel, MusicBrainzDaemon},
};
pub use listener::EventListener;
pub use ui::Ui;
use self::event::EventError;
use self::handler::EventHandler;
use self::listener::EventListener;
use self::ui::Ui;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::Backend, Terminal};
use std::{io, marker::PhantomData};
#[derive(Debug, PartialEq, Eq)]
use crate::tui::{
app::{IApp, IAppAccess},
event::EventError,
handler::IEventHandler,
listener::IEventListener,
ui::IUi,
};
#[derive(Debug, Eq, PartialEq)]
pub enum Error {
Collection(String),
Io(String),
Event(String),
ListenerPanic,
}
impl From<collection::Error> for Error {
fn from(err: collection::Error) -> Error {
Error::Collection(err.to_string())
}
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Error {
Error::Io(err.to_string())
@ -42,12 +49,12 @@ impl From<EventError> for Error {
}
}
pub struct Tui<B: Backend, UI> {
pub struct Tui<B: Backend, UI: IUi, APP: IApp + IAppAccess> {
terminal: Terminal<B>,
_phantom: PhantomData<UI>,
_phantom: PhantomData<(UI, APP)>,
}
impl<B: Backend, UI: Ui> Tui<B, UI> {
impl<B: Backend, UI: IUi, APP: IApp + IAppAccess> Tui<B, UI, APP> {
fn init(&mut self) -> Result<(), Error> {
self.terminal.hide_cursor()?;
self.terminal.clear()?;
@ -64,10 +71,15 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
self.exit();
}
fn main_loop(&mut self, mut ui: UI, handler: impl EventHandler<UI>) -> Result<(), Error> {
while ui.is_running() {
self.terminal.draw(|frame| ui.render(frame))?;
handler.handle_next_event(&mut ui)?;
fn main_loop(
&mut self,
mut app: APP,
_ui: UI,
handler: impl IEventHandler<APP>,
) -> Result<(), Error> {
while app.is_running() {
self.terminal.draw(|frame| UI::render(&mut app, frame))?;
app = handler.handle_next_event(app)?;
}
Ok(())
@ -75,9 +87,10 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
fn main(
term: Terminal<B>,
app: APP,
ui: UI,
handler: impl EventHandler<UI>,
listener: impl EventListener,
handler: impl IEventHandler<APP>,
listener: impl IEventListener,
) -> Result<(), Error> {
let mut tui = Tui {
terminal: term,
@ -87,7 +100,7 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
tui.init()?;
let listener_handle = listener.spawn();
let result = tui.main_loop(ui, handler);
let result = tui.main_loop(app, ui, handler);
match result {
Ok(_) => {
@ -103,8 +116,8 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
match listener_handle.join() {
Ok(err) => return Err(err.into()),
// Calling std::panic::resume_unwind(err) as recommended by the Rust docs
// will not produce an error message. The panic error message is printed at
// the location of the panic which at the time is hidden by the TUI.
// will not produce an error message. This may be due to the panic simply
// causing the process to abort in which case there is nothing to unwind.
Err(_) => return Err(Error::ListenerPanic),
}
}
@ -134,12 +147,13 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
pub fn run(
term: Terminal<B>,
app: APP,
ui: UI,
handler: impl EventHandler<UI>,
listener: impl EventListener,
handler: impl IEventHandler<APP>,
listener: impl IEventListener,
) -> Result<(), Error> {
Self::enable()?;
let result = Self::main(term, ui, handler, listener);
let result = Self::main(term, app, ui, handler, listener);
match result {
Ok(_) => {
Self::disable()?;
@ -156,113 +170,116 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
// GRCOV_EXCL_STOP
}
#[cfg(test)]
mod testmod;
#[cfg(test)]
mod tests {
use std::{io, thread};
use musichoard::collection::{self, Collection};
use lib::interface::musicbrainz::daemon::MockIMbJobSender;
use ratatui::{backend::TestBackend, Terminal};
use crate::tests::{MockCollectionManager, COLLECTION};
use musichoard::collection::Collection;
use super::{
event::EventError,
handler::MockEventHandler,
listener::MockEventListener,
ui::{MhUi, Ui},
Error, Tui,
use crate::tui::{
app::App, handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener,
ui::Ui,
};
use super::*;
use testmod::COLLECTION;
pub fn terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(150, 30);
Terminal::new(backend).unwrap()
}
pub fn ui(collection: Collection) -> MhUi<MockCollectionManager> {
let mut collection_manager = MockCollectionManager::new();
fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new();
collection_manager
.expect_rescan_library()
.returning(|| Ok(()));
collection_manager
.expect_get_collection()
.return_const(collection);
music_hoard.expect_reload_database().returning(|| Ok(()));
music_hoard.expect_rescan_library().returning(|| Ok(()));
music_hoard.expect_get_collection().return_const(collection);
MhUi::new(collection_manager).unwrap()
music_hoard
}
fn listener() -> MockEventListener {
let mut listener = MockEventListener::new();
fn app(collection: Collection) -> App {
App::new(music_hoard(collection), MockIMbJobSender::new())
}
fn listener() -> MockIEventListener {
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| {
thread::spawn(|| {
thread::park();
return EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked"));
EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked"))
})
});
listener
}
fn handler() -> MockEventHandler<MhUi<MockCollectionManager>> {
let mut handler = MockEventHandler::new();
fn handler() -> MockIEventHandler<App> {
let mut handler = MockIEventHandler::new();
handler
.expect_handle_next_event()
.return_once(|ui: &mut MhUi<MockCollectionManager>| {
ui.quit();
Ok(())
});
.return_once(|app: App| Ok(app.force_quit()));
handler
}
#[test]
fn run() {
let terminal = terminal();
let ui = ui(COLLECTION.to_owned());
let app = app(COLLECTION.to_owned());
let ui = Ui;
let listener = listener();
let handler = handler();
let result = Tui::main(terminal, ui, handler, listener);
let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_ok());
}
#[test]
fn event_error() {
let terminal = terminal();
let ui = ui(COLLECTION.to_owned());
let app = app(COLLECTION.to_owned());
let ui = Ui;
let listener = listener();
let mut handler = MockEventHandler::new();
let mut handler = MockIEventHandler::new();
handler
.expect_handle_next_event()
.return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, handler, listener);
let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
Error::Event(EventError::Recv.to_string())
);
let error = EventError::Recv;
assert_eq!(result.unwrap_err(), Error::Event(error.to_string()));
}
#[test]
fn listener_error() {
let terminal = terminal();
let ui = ui(COLLECTION.to_owned());
let app = app(COLLECTION.to_owned());
let ui = Ui;
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error);
while !listener_handle.is_finished() {}
let mut listener = MockEventListener::new();
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| listener_handle);
let mut handler = MockEventHandler::new();
let mut handler = MockIEventHandler::new();
handler
.expect_handle_next_event()
.return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, handler, listener);
let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err());
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
@ -272,32 +289,31 @@ mod tests {
#[test]
fn listener_panic() {
let terminal = terminal();
let ui = ui(COLLECTION.to_owned());
let app = app(COLLECTION.to_owned());
let ui = Ui;
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!());
while !listener_handle.is_finished() {}
let mut listener = MockEventListener::new();
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| listener_handle);
let mut handler = MockEventHandler::new();
let mut handler = MockIEventHandler::new();
handler
.expect_handle_next_event()
.return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, handler, listener);
let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), Error::ListenerPanic);
}
#[test]
fn errors() {
let collection_err: Error = collection::Error::DatabaseError(String::from("")).into();
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into();
let event_err: Error = EventError::Recv.into();
let listener_err = Error::ListenerPanic;
assert!(!format!("{:?}", collection_err).is_empty());
assert!(!format!("{:?}", io_err).is_empty());
assert!(!format!("{:?}", event_err).is_empty());
assert!(!format!("{:?}", listener_err).is_empty());

13
src/tui/testmod.rs Normal file
View File

@ -0,0 +1,13 @@
use std::collections::HashMap;
use musichoard::collection::{
album::{Album, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSeq},
artist::{Artist, ArtistId, ArtistInfo, ArtistMeta},
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption},
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
};
use once_cell::sync::Lazy;
use crate::testmod::*;
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());

File diff suppressed because it is too large Load Diff

243
src/tui/ui/browse_state.rs Normal file
View File

@ -0,0 +1,243 @@
use musichoard::collection::{
album::{Album, AlbumStatus},
artist::Artist,
track::{Track, TrackFormat},
};
use ratatui::{
layout::Rect,
text::Line,
widgets::{List, ListItem, Paragraph},
};
use crate::tui::{
app::WidgetState,
ui::{display::UiDisplay, style::UiColor},
};
pub struct ArtistArea {
pub list: Rect,
}
pub struct AlbumArea {
pub list: Rect,
pub info: Rect,
}
pub struct TrackArea {
pub list: Rect,
pub info: Rect,
}
pub struct BrowseArea {
pub artist: ArtistArea,
pub album: AlbumArea,
pub track: TrackArea,
}
pub struct FrameArea {
pub browse: BrowseArea,
pub minibuffer: Rect,
}
impl FrameArea {
pub fn new(frame: Rect) -> Self {
let minibuffer_height = 3;
let buffer_height = frame.height.saturating_sub(minibuffer_height);
let width_one_third = frame.width / 3;
let height_one_third = buffer_height / 3;
let panel_width = width_one_third;
let panel_width_last = frame.width.saturating_sub(2 * panel_width);
let panel_height_top = buffer_height.saturating_sub(height_one_third);
let panel_height_bottom = height_one_third;
let artist_list = Rect {
x: frame.x,
y: frame.y,
width: panel_width,
height: buffer_height,
};
let album_list = Rect {
x: artist_list.x + artist_list.width,
y: frame.y,
width: panel_width,
height: panel_height_top,
};
let album_info = Rect {
x: album_list.x,
y: album_list.y + album_list.height,
width: album_list.width,
height: panel_height_bottom,
};
let track_list = Rect {
x: album_list.x + album_list.width,
y: frame.y,
width: panel_width_last,
height: panel_height_top,
};
let track_info = Rect {
x: track_list.x,
y: track_list.y + track_list.height,
width: track_list.width,
height: panel_height_bottom,
};
let minibuffer = Rect {
x: frame.x,
y: frame.y + buffer_height,
width: frame.width,
height: minibuffer_height,
};
FrameArea {
browse: BrowseArea {
artist: ArtistArea { list: artist_list },
album: AlbumArea {
list: album_list,
info: album_info,
},
track: TrackArea {
list: track_list,
info: track_info,
},
},
minibuffer,
}
}
}
pub struct ArtistState<'a, 'b> {
pub active: bool,
pub list: List<'a>,
pub state: &'b mut WidgetState,
}
impl<'a, 'b> ArtistState<'a, 'b> {
pub fn new(
active: bool,
artists: &'a [Artist],
state: &'b mut WidgetState,
) -> ArtistState<'a, 'b> {
let list = List::new(
artists
.iter()
.map(|a| ListItem::new(a.meta.id.name.as_str()))
.collect::<Vec<ListItem>>(),
);
ArtistState {
active,
list,
state,
}
}
}
pub struct AlbumState<'a, 'b> {
pub active: bool,
pub list: List<'a>,
pub state: &'b mut WidgetState,
pub info: Paragraph<'a>,
}
impl<'a, 'b> AlbumState<'a, 'b> {
pub fn new(
active: bool,
albums: &'a [Album],
state: &'b mut WidgetState,
) -> AlbumState<'a, 'b> {
let list = List::new(
albums
.iter()
.map(Self::to_list_item)
.collect::<Vec<ListItem>>(),
);
let album = state.list.selected().map(|i| &albums[i]);
let info = Paragraph::new(format!(
"Title: {}\n\
Date: {}\n\
Type: {}\n\
Status: {}",
album.map(|a| a.meta.id.title.as_str()).unwrap_or(""),
album
.map(|a| UiDisplay::display_date(&a.meta.date, &a.meta.seq))
.unwrap_or_default(),
album
.map(|a| UiDisplay::display_type(
&a.meta.info.primary_type,
&a.meta.info.secondary_types
))
.unwrap_or_default(),
album
.map(|a| UiDisplay::display_album_status(&a.get_status()))
.unwrap_or("")
));
AlbumState {
active,
list,
state,
info,
}
}
fn to_list_item(album: &Album) -> ListItem {
let line = match album.get_status() {
AlbumStatus::None => Line::raw(album.meta.id.title.as_str()),
AlbumStatus::Owned(format) => match format {
TrackFormat::Mp3 => Line::styled(album.meta.id.title.as_str(), UiColor::FG_WARN),
TrackFormat::Flac => Line::styled(album.meta.id.title.as_str(), UiColor::FG_GOOD),
},
};
ListItem::new(line)
}
}
pub struct TrackState<'a, 'b> {
pub active: bool,
pub list: List<'a>,
pub state: &'b mut WidgetState,
pub info: Paragraph<'a>,
}
impl<'a, 'b> TrackState<'a, 'b> {
pub fn new(
active: bool,
tracks: &'a [Track],
state: &'b mut WidgetState,
) -> TrackState<'a, 'b> {
let list = List::new(
tracks
.iter()
.map(|tr| ListItem::new(tr.id.title.as_str()))
.collect::<Vec<ListItem>>(),
);
let track = state.list.selected().map(|i| &tracks[i]);
let info = Paragraph::new(format!(
"Track: {}\n\
Title: {}\n\
Artist: {}\n\
Quality: {}",
track.map(|t| t.number.0.to_string()).unwrap_or_default(),
track.map(|t| t.id.title.as_str()).unwrap_or(""),
track.map(|t| t.artist.join("; ")).unwrap_or_default(),
track
.map(|t| UiDisplay::display_track_quality(&t.quality))
.unwrap_or_default(),
));
TrackState {
active,
list,
state,
info,
}
}
}

296
src/tui/ui/display.rs Normal file
View File

@ -0,0 +1,296 @@
use musichoard::collection::{
album::{
AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus,
},
artist::ArtistMeta,
musicbrainz::{IMusicBrainzRef, MbRefOption},
track::{TrackFormat, TrackQuality},
};
use crate::tui::app::{EntityMatches, MatchOption};
pub struct UiDisplay;
impl UiDisplay {
pub fn display_date(date: &AlbumDate, seq: &AlbumSeq) -> String {
if seq.0 > 0 {
format!("{} ({})", Self::display_album_date(date), seq.0)
} else {
Self::display_album_date(date)
}
}
pub fn display_album_date(date: &AlbumDate) -> String {
match date.year {
Some(year) => match date.month {
Some(month) => match date.day {
Some(day) => format!("{year}{month:02}{day:02}"),
None => format!("{year}{month:02}"),
},
None => format!("{year}"),
},
None => String::from(""),
}
}
pub fn display_mb_ref_option_as_url<T: IMusicBrainzRef>(option: &MbRefOption<T>) -> &str {
match option {
MbRefOption::Some(val) => val.url().as_str(),
MbRefOption::CannotHaveMbid => "cannot have a MusicBrainz identifier",
MbRefOption::None => "",
}
}
pub fn display_type(
primary: &Option<AlbumPrimaryType>,
secondary: &Vec<AlbumSecondaryType>,
) -> String {
match primary {
Some(ref primary) => {
if secondary.is_empty() {
Self::display_primary_type(primary).to_string()
} else {
format!(
"{} ({})",
Self::display_primary_type(primary),
Self::display_secondary_types(secondary)
)
}
}
None => String::default(),
}
}
pub fn display_primary_type(value: &AlbumPrimaryType) -> &'static str {
match value {
AlbumPrimaryType::Album => "Album",
AlbumPrimaryType::Single => "Single",
AlbumPrimaryType::Ep => "EP",
AlbumPrimaryType::Broadcast => "Broadcast",
AlbumPrimaryType::Other => "Other",
}
}
pub fn display_secondary_types(values: &Vec<AlbumSecondaryType>) -> String {
let mut types: Vec<&'static str> = vec![];
for value in values {
match value {
AlbumSecondaryType::Compilation => types.push("Compilation"),
AlbumSecondaryType::Soundtrack => types.push("Soundtrack"),
AlbumSecondaryType::Spokenword => types.push("Spokenword"),
AlbumSecondaryType::Interview => types.push("Interview"),
AlbumSecondaryType::Audiobook => types.push("Audiobook"),
AlbumSecondaryType::AudioDrama => types.push("Audio drama"),
AlbumSecondaryType::Live => types.push("Live"),
AlbumSecondaryType::Remix => types.push("Remix"),
AlbumSecondaryType::DjMix => types.push("DJ-mix"),
AlbumSecondaryType::MixtapeStreet => types.push("Mixtape/Street"),
AlbumSecondaryType::Demo => types.push("Demo"),
AlbumSecondaryType::FieldRecording => types.push("Field recording"),
}
}
types.join(", ")
}
pub fn display_album_status(status: &AlbumStatus) -> &'static str {
match status {
AlbumStatus::None => "None",
AlbumStatus::Owned(format) => match format {
TrackFormat::Mp3 => "MP3",
TrackFormat::Flac => "FLAC",
},
}
}
pub fn display_track_quality(quality: &TrackQuality) -> String {
match quality.format {
TrackFormat::Flac => "FLAC".to_string(),
TrackFormat::Mp3 => format!("MP3 {}kbps", quality.bitrate),
}
}
pub fn display_artist_matching(artist: &ArtistMeta) -> String {
format!("Matching artist: {}", &artist.id.name)
}
pub fn display_album_matching(album: &AlbumId) -> String {
format!("Matching album: {}", &album.title)
}
pub fn display_matching_info(info: &EntityMatches) -> String {
match info {
EntityMatches::Artist(m) => UiDisplay::display_artist_matching(&m.matching),
EntityMatches::Album(m) => UiDisplay::display_album_matching(&m.matching),
}
}
pub fn display_match_option_artist(match_option: &MatchOption<ArtistMeta>) -> String {
Self::display_match_option(Self::display_option_artist, match_option)
}
pub fn display_match_option_album(match_option: &MatchOption<AlbumMeta>) -> String {
Self::display_match_option(Self::display_option_album, match_option)
}
fn display_match_option<Fn, T>(display_fn: Fn, match_option: &MatchOption<T>) -> String
where
Fn: FnOnce(&T, &Option<String>) -> String,
{
match match_option {
MatchOption::Some(match_artist) => format!(
"{}{}",
display_fn(&match_artist.entity, &match_artist.disambiguation),
Self::display_option_score(match_artist.score),
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
}
}
fn display_option_artist(artist: &ArtistMeta, disambiguation: &Option<String>) -> String {
format!(
"{}{}",
artist.id.name,
disambiguation
.as_ref()
.filter(|s| !s.is_empty())
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
)
}
fn display_option_album(album: &AlbumMeta, _disambiguation: &Option<String>) -> String {
format!(
"{:010} | {} [{}]",
UiDisplay::display_album_date(&album.date),
album.id.title,
UiDisplay::display_type(&album.info.primary_type, &album.info.secondary_types),
)
}
fn display_option_score(score: Option<u8>) -> String {
score.map(|s| format!(" ({s}%)")).unwrap_or_default()
}
fn display_cannot_have_mbid() -> &'static str {
"-- Cannot have a MusicBrainz Identifier --"
}
fn display_manual_input_mbid() -> &'static str {
"-- Manually enter a MusicBrainz Identifier --"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_album_date() {
assert_eq!(UiDisplay::display_album_date(&AlbumDate::default()), "");
assert_eq!(UiDisplay::display_album_date(&1990.into()), "1990");
assert_eq!(UiDisplay::display_album_date(&(1990, 5).into()), "199005");
assert_eq!(
UiDisplay::display_album_date(&(1990, 5, 6).into()),
"19900506"
);
}
#[test]
fn display_date() {
let date: AlbumDate = 1990.into();
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq::default()), "1990");
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(0)), "1990");
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(5)), "1990 (5)");
}
#[test]
fn display_primary_type() {
assert_eq!(
UiDisplay::display_primary_type(&AlbumPrimaryType::Album),
"Album"
);
assert_eq!(
UiDisplay::display_primary_type(&AlbumPrimaryType::Single),
"Single"
);
assert_eq!(UiDisplay::display_primary_type(&AlbumPrimaryType::Ep), "EP");
assert_eq!(
UiDisplay::display_primary_type(&AlbumPrimaryType::Broadcast),
"Broadcast"
);
assert_eq!(
UiDisplay::display_primary_type(&AlbumPrimaryType::Other),
"Other"
);
}
#[test]
fn display_secondary_types() {
assert_eq!(
UiDisplay::display_secondary_types(&vec![
AlbumSecondaryType::Compilation,
AlbumSecondaryType::Soundtrack,
AlbumSecondaryType::Spokenword,
AlbumSecondaryType::Interview,
AlbumSecondaryType::Audiobook,
AlbumSecondaryType::AudioDrama,
AlbumSecondaryType::Live,
AlbumSecondaryType::Remix,
AlbumSecondaryType::DjMix,
AlbumSecondaryType::MixtapeStreet,
AlbumSecondaryType::Demo,
AlbumSecondaryType::FieldRecording,
]),
"Compilation, Soundtrack, Spokenword, Interview, Audiobook, Audio drama, Live, Remix, \
DJ-mix, Mixtape/Street, Demo, Field recording"
);
}
#[test]
fn display_type() {
assert_eq!(UiDisplay::display_type(&None, &vec![]), "");
assert_eq!(
UiDisplay::display_type(&Some(AlbumPrimaryType::Album), &vec![]),
"Album"
);
assert_eq!(
UiDisplay::display_type(
&Some(AlbumPrimaryType::Album),
&vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation]
),
"Album (Live, Compilation)"
);
}
#[test]
fn display_album_status() {
assert_eq!(UiDisplay::display_album_status(&AlbumStatus::None), "None");
assert_eq!(
UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Mp3)),
"MP3"
);
assert_eq!(
UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Flac)),
"FLAC"
);
}
#[test]
fn display_track_quality() {
assert_eq!(
UiDisplay::display_track_quality(&TrackQuality {
format: TrackFormat::Flac,
bitrate: 1411
}),
"FLAC"
);
assert_eq!(
UiDisplay::display_track_quality(&TrackQuality {
format: TrackFormat::Mp3,
bitrate: 218
}),
"MP3 218kbps"
);
}
}

14
src/tui/ui/error_state.rs Normal file
View File

@ -0,0 +1,14 @@
use ratatui::{
layout::Alignment,
widgets::{Paragraph, Wrap},
};
pub struct ErrorOverlay;
impl ErrorOverlay {
pub fn paragraph(msg: &str) -> Paragraph {
Paragraph::new(msg)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
}
}

View File

@ -0,0 +1,9 @@
use ratatui::widgets::Paragraph;
pub struct FetchOverlay;
impl FetchOverlay {
pub fn paragraph<'a>() -> Paragraph<'a> {
Paragraph::new(" -- fetching --")
}
}

113
src/tui/ui/info_state.rs Normal file
View File

@ -0,0 +1,113 @@
use std::collections::HashMap;
use musichoard::collection::{album::Album, artist::Artist};
use ratatui::widgets::{ListState, Paragraph};
use super::display::UiDisplay;
struct InfoOverlay;
impl InfoOverlay {
const ITEM_INDENT: &'static str = " ";
const LIST_INDENT: &'static str = " - ";
}
pub struct ArtistOverlay<'a> {
pub properties: Paragraph<'a>,
}
impl<'a> ArtistOverlay<'a> {
fn opt_hashmap_to_string<K: Ord + AsRef<str>, T: AsRef<str>>(
opt_map: Option<&HashMap<K, Vec<T>>>,
item_indent: &str,
list_indent: &str,
) -> String {
opt_map
.map(|map| Self::hashmap_to_string(map, item_indent, list_indent))
.unwrap_or_else(|| String::from(""))
}
fn hashmap_to_string<K: AsRef<str>, T: AsRef<str>>(
map: &HashMap<K, Vec<T>>,
item_indent: &str,
list_indent: &str,
) -> String {
let mut vec: Vec<(&str, &Vec<T>)> = map.iter().map(|(k, v)| (k.as_ref(), v)).collect();
vec.sort_by(|x, y| x.0.cmp(y.0));
let indent = format!("\n{item_indent}");
let list = vec
.iter()
.map(|(k, v)| format!("{k}: {}", Self::slice_to_string(v, list_indent)))
.collect::<Vec<String>>()
.join(&indent);
format!("{indent}{list}")
}
fn slice_to_string<S: AsRef<str>>(vec: &[S], indent: &str) -> String {
if vec.len() < 2 {
vec.first()
.map(|item| item.as_ref())
.unwrap_or("")
.to_string()
} else {
let indent = format!("\n{indent}");
let list = vec
.iter()
.map(|item| item.as_ref())
.collect::<Vec<&str>>()
.join(&indent);
format!("{indent}{list}")
}
}
pub fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> {
let artist = state.selected().map(|i| &artists[i]);
let item_indent = InfoOverlay::ITEM_INDENT;
let list_indent = InfoOverlay::LIST_INDENT;
let double_item_indent = format!("{item_indent}{item_indent}");
let double_list_indent = format!("{item_indent}{list_indent}");
let properties = Paragraph::new(format!(
"Artist: {}\n\n{item_indent}\
MusicBrainz: {}\n{item_indent}\
Properties: {}",
artist.map(|a| a.meta.id.name.as_str()).unwrap_or(""),
artist
.map(|a| UiDisplay::display_mb_ref_option_as_url(&a.meta.info.musicbrainz))
.unwrap_or_default(),
Self::opt_hashmap_to_string(
artist.map(|a| &a.meta.info.properties),
&double_item_indent,
&double_list_indent
),
));
ArtistOverlay { properties }
}
}
pub struct AlbumOverlay<'a> {
pub properties: Paragraph<'a>,
}
impl<'a> AlbumOverlay<'a> {
pub fn new(albums: &'a [Album], state: &ListState) -> AlbumOverlay<'a> {
let album = state.selected().map(|i| &albums[i]);
let item_indent = InfoOverlay::ITEM_INDENT;
let properties = Paragraph::new(format!(
"Album: {}\n\n{item_indent}\
MusicBrainz: {}",
album.map(|a| a.meta.id.title.as_str()).unwrap_or(""),
album
.map(|a| UiDisplay::display_mb_ref_option_as_url(&a.meta.info.musicbrainz))
.unwrap_or_default(),
));
AlbumOverlay { properties }
}
}

20
src/tui/ui/input.rs Normal file
View File

@ -0,0 +1,20 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
use crate::tui::app::InputPublic;
pub struct InputOverlay;
impl InputOverlay {
pub fn paragraph<'a>(text: &str) -> Paragraph<'a> {
Paragraph::new(format!(" {text}"))
}
pub fn place_cursor(input: InputPublic, area: Rect, frame: &mut Frame) {
let width = area.width.max(4) - 4; // keep 2 for borders, 1 for left-pad, and 1 for cursor
let scroll = input.visual_scroll(width as usize);
frame.set_cursor_position((
area.x + ((input.visual_cursor()).max(scroll) - scroll) as u16 + 2,
area.y + 1,
))
}
}

70
src/tui/ui/match_state.rs Normal file
View File

@ -0,0 +1,70 @@
use musichoard::collection::{
album::{AlbumId, AlbumMeta},
artist::ArtistMeta,
};
use ratatui::widgets::{List, ListItem};
use crate::tui::{
app::{EntityMatches, MatchOption, WidgetState},
ui::display::UiDisplay,
};
pub struct MatchOverlay<'a, 'b> {
pub matching: String,
pub list: List<'a>,
pub state: &'b mut WidgetState,
}
impl<'a, 'b> MatchOverlay<'a, 'b> {
pub fn new(info: &'a EntityMatches, state: &'b mut WidgetState) -> Self {
match info {
EntityMatches::Artist(m) => Self::artists(&m.matching, &m.list, state),
EntityMatches::Album(m) => Self::albums(&m.matching, &m.list, state),
}
}
fn artists(
matching: &ArtistMeta,
matches: &'a [MatchOption<ArtistMeta>],
state: &'b mut WidgetState,
) -> Self {
let matching = UiDisplay::display_artist_matching(matching);
let list = Self::display_list(UiDisplay::display_match_option_artist, matches);
MatchOverlay {
matching,
list,
state,
}
}
fn albums(
matching: &AlbumId,
matches: &'a [MatchOption<AlbumMeta>],
state: &'b mut WidgetState,
) -> Self {
let matching = UiDisplay::display_album_matching(matching);
let list = Self::display_list(UiDisplay::display_match_option_album, matches);
MatchOverlay {
matching,
list,
state,
}
}
fn display_list<F, T>(display: F, options: &[T]) -> List
where
F: FnMut(&T) -> String,
{
List::new(
options
.iter()
.map(display)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
)
}
}

95
src/tui/ui/minibuffer.rs Normal file
View File

@ -0,0 +1,95 @@
use ratatui::{
layout::{Alignment, Rect},
widgets::Paragraph,
};
use crate::tui::{
app::{AppPublicState, AppState},
ui::UiDisplay,
};
pub struct Minibuffer<'a> {
pub paragraphs: Vec<Paragraph<'a>>,
pub columns: u16,
}
impl Minibuffer<'_> {
pub fn area(ar: Rect) -> Rect {
let space = 3;
Rect {
x: ar.x + 1 + space,
y: ar.y + 1,
width: ar.width.saturating_sub(2 + 2 * space),
height: 1,
}
}
pub fn new(state: &AppPublicState) -> Self {
let columns = 3;
let mut mb = match state {
AppState::Browse(()) => Minibuffer {
paragraphs: vec![
Paragraph::new("m: show info overlay"),
Paragraph::new("g: show reload menu"),
Paragraph::new("ctrl+s: search artist"),
Paragraph::new("f: fetch musicbrainz"),
],
columns,
},
AppState::Info(()) => Minibuffer {
paragraphs: vec![Paragraph::new("m: hide info overlay")],
columns,
},
AppState::Reload(()) => Minibuffer {
paragraphs: vec![
Paragraph::new("g: hide reload menu"),
Paragraph::new("d: reload database"),
Paragraph::new("l: reload library"),
],
columns,
},
AppState::Search(ref s) => Minibuffer {
paragraphs: vec![
Paragraph::new(format!("I-search: {s}")),
Paragraph::new("ctrl+s: search next").alignment(Alignment::Center),
Paragraph::new("ctrl+g: cancel search").alignment(Alignment::Center),
],
columns,
},
AppState::Fetch(()) => Minibuffer {
paragraphs: vec![
Paragraph::new("fetching..."),
Paragraph::new("ctrl+g: abort"),
],
columns: 2,
},
AppState::Match(public) => Minibuffer {
paragraphs: vec![
Paragraph::new(UiDisplay::display_matching_info(public.matches)),
Paragraph::new("ctrl+g: abort"),
],
columns: 2,
},
AppState::Error(_) => Minibuffer {
paragraphs: vec![Paragraph::new(
"Press any key to dismiss the error message...",
)],
columns: 0,
},
AppState::Critical(_) => Minibuffer {
paragraphs: vec![Paragraph::new("Press ctrl+c to terminate the program...")],
columns: 0,
},
};
if !state.is_search() {
mb.paragraphs = mb
.paragraphs
.into_iter()
.map(|p| p.alignment(Alignment::Center))
.collect();
}
mb
}
}

432
src/tui/ui/mod.rs Normal file
View File

@ -0,0 +1,432 @@
mod browse_state;
mod display;
mod error_state;
mod fetch_state;
mod info_state;
mod input;
mod match_state;
mod minibuffer;
mod overlay;
mod reload_state;
mod style;
mod widgets;
use browse_state::BrowseArea;
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
use musichoard::collection::{album::Album, Collection};
use crate::tui::{
app::{
AppPublicState, AppState, Category, EntityMatches, IAppAccess, InputPublic, Selection,
WidgetState,
},
ui::{
browse_state::{
AlbumArea, AlbumState, ArtistArea, ArtistState, FrameArea, TrackArea, TrackState,
},
display::UiDisplay,
error_state::ErrorOverlay,
fetch_state::FetchOverlay,
info_state::{AlbumOverlay, ArtistOverlay},
input::InputOverlay,
match_state::MatchOverlay,
minibuffer::Minibuffer,
overlay::{OverlayBuilder, OverlaySize},
reload_state::ReloadOverlay,
widgets::UiWidget,
},
};
pub trait IUi {
fn render<APP: IAppAccess>(app: &mut APP, frame: &mut Frame);
}
pub struct Ui;
impl Ui {
fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame) {
UiWidget::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr);
}
fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame) {
UiWidget::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr);
UiWidget::render_info_widget("Album info", st.info, st.active, ar.info, fr);
}
fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame) {
UiWidget::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr);
UiWidget::render_info_widget("Track info", st.info, st.active, ar.info, fr);
}
fn render_minibuffer(state: &AppPublicState, ar: Rect, fr: &mut Frame) {
let mb = Minibuffer::new(state);
let area = Minibuffer::area(ar);
UiWidget::render_info_widget("Minibuffer", Paragraph::new(""), false, ar, fr);
UiWidget::render_columns(mb.paragraphs, mb.columns, false, area, fr);
}
fn render_browse_frame(
artists: &Collection,
selection: &mut Selection,
areas: BrowseArea,
frame: &mut Frame,
) {
let active = selection.category();
let artist_state = ArtistState::new(
active == Category::Artist,
artists,
selection.widget_state_artist(),
);
Self::render_artist_column(artist_state, areas.artist, frame);
let albums = selection
.state_album(artists)
.map(|st| st.list)
.unwrap_or_default();
let album_state = AlbumState::new(
active == Category::Album,
albums,
selection.widget_state_album(),
);
Self::render_album_column(album_state, areas.album, frame);
let tracks = selection
.state_track(artists)
.map(|st| st.list)
.unwrap_or_default();
let track_state = TrackState::new(
active == Category::Track,
tracks,
selection.widget_state_track(),
);
Self::render_track_column(track_state, areas.track, frame);
}
fn render_info_overlay(artists: &Collection, selection: &mut Selection, frame: &mut Frame) {
let area = OverlayBuilder::default().build(frame.area());
if selection.category() == Category::Artist {
let overlay = ArtistOverlay::new(artists, &selection.widget_state_artist().list);
UiWidget::render_overlay_widget("Artist", overlay.properties, area, false, frame);
} else {
let no_albums: Vec<Album> = vec![];
let albums = selection
.state_album(artists)
.map(|st| st.list)
.unwrap_or_else(|| &no_albums);
let overlay = AlbumOverlay::new(albums, &selection.widget_state_album().list);
UiWidget::render_overlay_widget("Album", overlay.properties, area, false, frame);
}
}
fn render_reload_overlay(frame: &mut Frame) {
let area = OverlayBuilder::default()
.with_width(OverlaySize::Value(39))
.with_height(OverlaySize::Value(4))
.build(frame.area());
let reload_text = ReloadOverlay::paragraph();
UiWidget::render_overlay_widget("Reload", reload_text, area, false, frame);
}
fn render_fetch_overlay(frame: &mut Frame) {
let area = OverlayBuilder::default().build(frame.area());
let fetch_text = FetchOverlay::paragraph();
UiWidget::render_overlay_widget("Fetching", fetch_text, area, false, frame)
}
fn render_match_overlay(info: &EntityMatches, state: &mut WidgetState, frame: &mut Frame) {
let area = OverlayBuilder::default().build(frame.area());
let st = MatchOverlay::new(info, state);
UiWidget::render_overlay_list_widget(&st.matching, st.list, st.state, true, area, frame)
}
fn render_input_overlay(input: InputPublic, frame: &mut Frame) {
let area = OverlayBuilder::default()
.with_width(OverlaySize::MarginFactor(4))
.with_height(OverlaySize::Value(3))
.build(frame.area());
let input_text = InputOverlay::paragraph(input.value());
UiWidget::render_overlay_widget("Input", input_text, area, false, frame);
InputOverlay::place_cursor(input, area, frame);
}
fn render_error_overlay<S: AsRef<str>>(title: S, msg: S, frame: &mut Frame) {
let area = OverlayBuilder::default()
.with_height(OverlaySize::Value(4))
.build(frame.area());
let error_text = ErrorOverlay::paragraph(msg.as_ref());
UiWidget::render_overlay_widget(title.as_ref(), error_text, area, true, frame);
}
}
impl IUi for Ui {
fn render<APP: IAppAccess>(app: &mut APP, frame: &mut Frame) {
let app = app.get();
let collection = app.inner.collection;
let selection = app.inner.selection;
let state = app.state;
let areas = FrameArea::new(frame.area());
Self::render_browse_frame(collection, selection, areas.browse, frame);
Self::render_minibuffer(&state, areas.minibuffer, frame);
match state {
AppState::Info(()) => Self::render_info_overlay(collection, selection, frame),
AppState::Reload(()) => Self::render_reload_overlay(frame),
AppState::Fetch(()) => Self::render_fetch_overlay(frame),
AppState::Match(public) => {
Self::render_match_overlay(public.matches, public.state, frame)
}
AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame),
AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame),
_ => {}
}
if let Some(input) = app.input {
Self::render_input_overlay(input, frame);
}
}
}
#[cfg(test)]
mod tests {
use musichoard::collection::{
album::{AlbumDate, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
artist::{Artist, ArtistId, ArtistMeta},
musicbrainz::MbRefOption,
};
use crate::tui::{
app::{AppPublic, AppPublicInner, Delta, MatchStatePublic},
lib::interface::musicbrainz::api::Entity,
testmod::COLLECTION,
tests::terminal,
};
use super::*;
// Automock does not support returning types with generic lifetimes.
impl<'app> IAppAccess for AppPublic<'app> {
fn get(&mut self) -> AppPublic {
AppPublic {
inner: AppPublicInner {
collection: self.inner.collection,
selection: self.inner.selection,
},
state: match self.state {
AppState::Browse(()) => AppState::Browse(()),
AppState::Info(()) => AppState::Info(()),
AppState::Reload(()) => AppState::Reload(()),
AppState::Search(s) => AppState::Search(s),
AppState::Fetch(()) => AppState::Fetch(()),
AppState::Match(ref mut m) => AppState::Match(MatchStatePublic {
matches: m.matches,
state: m.state,
}),
AppState::Error(s) => AppState::Error(s),
AppState::Critical(s) => AppState::Critical(s),
},
input: self.input,
}
}
}
fn public_inner<'app>(
collection: &'app Collection,
selection: &'app mut Selection,
) -> AppPublicInner<'app> {
AppPublicInner {
collection,
selection,
}
}
fn draw_test_suite(collection: &Collection, selection: &mut Selection) {
let mut terminal = terminal();
let mut app = AppPublic {
inner: public_inner(collection, selection),
state: AppState::Browse(()),
input: None,
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Info(());
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Reload(());
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Search("");
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Fetch(());
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Error("get rekt scrub");
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
app.state = AppState::Critical("get critically rekt scrub");
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
}
#[test]
fn empty() {
let artists: Vec<Artist> = vec![];
let mut selection = Selection::new(&artists);
draw_test_suite(&artists, &mut selection);
}
#[test]
fn empty_album() {
let mut artists: Vec<Artist> = vec![Artist::new(ArtistId::new("An artist"))];
artists[0]
.albums
.push(Album::new("An album", AlbumDate::default(), None, vec![]));
let mut selection = Selection::new(&artists);
draw_test_suite(&artists, &mut selection);
}
#[test]
fn collection() {
let artists = &COLLECTION;
let mut selection = Selection::new(artists);
draw_test_suite(artists, &mut selection);
// Change the artist (which cannot have a MBID).
selection.increment_selection(artists, Delta::Line);
selection.increment_selection(artists, Delta::Line);
draw_test_suite(artists, &mut selection);
// Change the track (which has a different track format).
selection.decrement_selection(artists, Delta::Line);
selection.decrement_selection(artists, Delta::Line);
selection.increment_category();
selection.increment_category();
selection.increment_selection(artists, Delta::Line);
draw_test_suite(artists, &mut selection);
// Change the artist (which has a multi-link entry).
selection.decrement_category();
selection.decrement_category();
selection.increment_selection(artists, Delta::Line);
draw_test_suite(artists, &mut selection);
}
fn artist_meta() -> ArtistMeta {
ArtistMeta::new(ArtistId::new("an artist"))
}
fn artist_matches() -> EntityMatches {
let artist = artist_meta();
let artist_match = Entity::with_score(artist.clone(), 80);
let list = vec![artist_match.clone(), artist_match.clone()];
let mut info = EntityMatches::artist_search(artist, list);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
fn artist_lookup() -> EntityMatches {
let artist = artist_meta();
let artist_lookup = Entity::new(artist.clone());
let mut info = EntityMatches::artist_lookup(artist, artist_lookup);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
fn album_artist_id() -> ArtistId {
ArtistId::new("Artist")
}
fn album_id() -> AlbumId {
AlbumId::new("An Album")
}
fn album_meta(id: AlbumId) -> AlbumMeta {
AlbumMeta::new(
id,
AlbumDate::new(Some(1990), Some(5), None),
AlbumInfo::new(
MbRefOption::None,
Some(AlbumPrimaryType::Album),
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
),
)
}
fn album_matches() -> EntityMatches {
let artist_id = album_artist_id();
let album_id = album_id();
let album_meta = album_meta(album_id.clone());
let album_match = Entity::with_score(album_meta.clone(), 80);
let list = vec![album_match.clone(), album_match.clone()];
let mut info = EntityMatches::album_search(artist_id, album_id, list);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
fn album_lookup() -> EntityMatches {
let artist_id = album_artist_id();
let album_id = album_id();
let album_meta = album_meta(album_id.clone());
let album_lookup = Entity::new(album_meta.clone());
let mut info = EntityMatches::album_lookup(artist_id, album_id, album_lookup);
info.push_cannot_have_mbid();
info.push_manual_input_mbid();
info
}
#[test]
fn draw_matche_state_suite() {
let collection = &COLLECTION;
let mut selection = Selection::new(collection);
let mut terminal = terminal();
let match_state_infos = vec![
artist_matches(),
album_matches(),
artist_lookup(),
album_lookup(),
];
for matches in match_state_infos.iter() {
let mut widget_state = WidgetState::default().with_selected(Some(0));
let mut app = AppPublic {
inner: public_inner(collection, &mut selection),
state: AppState::Match(MatchStatePublic {
matches,
state: &mut widget_state,
}),
input: None,
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
let input = tui_input::Input::default();
app.input = Some(&input);
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
}
}
}

Some files were not shown because too many files have changed in this diff Show More