Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/locallibrary/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ ignored = [
"locallibrary-plugin-archive",
"locallibrary-plugin-coverart",
"locallibrary-plugin-demos",
"locallibrary-plugin-discogs",
"locallibrary-plugin-interop",
"locallibrary-plugin-mixcloud",
"locallibrary-plugin-musicbrainz",
Expand Down Expand Up @@ -134,6 +135,7 @@ locallibrary-plugin-archive = { version = "0.1.0", path = "../../plugins/archive
locallibrary-plugin-youtube = { version = "0.1.0", path = "../../plugins/youtube" }
locallibrary-plugin-mixcloud = { version = "0.1.0", path = "../../plugins/mixcloud" }
locallibrary-plugin-soundcloud = { version = "0.1.0", path = "../../plugins/soundcloud" }
locallibrary-plugin-discogs = { version = "0.1.0", path = "../../plugins/discogs" }
# The player asks the terminal for its real colours (OSC 10 and 11) as it starts.
terminal-colorsaurus = { version = "1.0.3", optional = true }
# Cover art in the graphics a terminal has. Pinned: its API changes between versions. No default
Expand Down
9 changes: 9 additions & 0 deletions apps/locallibrary/changes/discogs-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Added

- A discogs plugin finds front covers (`art fetch --from discogs.covers`) and an album's title,
year, record label and style (`track fetch --from discogs.tags`) on Discogs, which lists many
white labels and vinyl-only releases that MusicBrainz doesn't. Each album is searched by its name, artist and
label, then by name and artist, and a track without an album by its title. It searches with
your Discogs account: make a personal access token at discogs.com/settings/developers and sign
in with `locallibrary account discogs sign-in --for discogs` (the new discogs-account plugin).
Searches go a second apart, and wait when Discogs says the account is over its limit.
3 changes: 2 additions & 1 deletion apps/locallibrary/docs/diagrams/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@ flowchart LR
sound["sound"] --> soundadds["Analyzer tempo-key-loudness<br/>events: suggests analysing new tracks"]
musicbrainz["musicbrainz"] --> mbadds["Analyzer ids<br/>musicbrainz.org"]
coverart["coverart"] --> coveradds["Providers covers, tags<br/>events: suggests fetching covers<br/>musicbrainz.org, coverartarchive.org,<br/>archive.org, *.archive.org"]
discogs["discogs"] --> discogsadds["Providers covers, tags<br/>discogs account: a personal access token<br/>api.discogs.com, i.discogs.com"]
demos["demos"] --> demoadds["Check tracks, operation clean"]
tracklist["tracklist"] --> trackadds["Operation import: playlist import<br/>Importer and source for rekordbox XML"]
interop["interop"] --> interopadds["Exporters traktor, serato, engine, rekordbox<br/>Importer and source for Traktor"]
```

Source: [`plugins/sound/locallibrary-plugin.toml`](../../../../plugins/sound/locallibrary-plugin.toml), [`plugins/musicbrainz/locallibrary-plugin.toml`](../../../../plugins/musicbrainz/locallibrary-plugin.toml), [`plugins/coverart/locallibrary-plugin.toml`](../../../../plugins/coverart/locallibrary-plugin.toml), [`plugins/demos/locallibrary-plugin.toml`](../../../../plugins/demos/locallibrary-plugin.toml), [`plugins/tracklist/locallibrary-plugin.toml`](../../../../plugins/tracklist/locallibrary-plugin.toml), [`plugins/interop/locallibrary-plugin.toml`](../../../../plugins/interop/locallibrary-plugin.toml)
Source: [`plugins/sound/locallibrary-plugin.toml`](../../../../plugins/sound/locallibrary-plugin.toml), [`plugins/musicbrainz/locallibrary-plugin.toml`](../../../../plugins/musicbrainz/locallibrary-plugin.toml), [`plugins/coverart/locallibrary-plugin.toml`](../../../../plugins/coverart/locallibrary-plugin.toml), [`plugins/discogs/locallibrary-plugin.toml`](../../../../plugins/discogs/locallibrary-plugin.toml), [`plugins/demos/locallibrary-plugin.toml`](../../../../plugins/demos/locallibrary-plugin.toml), [`plugins/tracklist/locallibrary-plugin.toml`](../../../../plugins/tracklist/locallibrary-plugin.toml), [`plugins/interop/locallibrary-plugin.toml`](../../../../plugins/interop/locallibrary-plugin.toml)

## Built-in plugins that publish a set

Expand Down
4 changes: 2 additions & 2 deletions apps/locallibrary/docs/diagrams/publishing-and-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,13 @@ flowchart LR
host["Plugin host"] --> exchange["localnetwork.computer:<br/>token exchange"]
host --> oauth["Google, SoundCloud,<br/>Mixcloud: sign-in"]
host --> upload["YouTube, SoundCloud, Mixcloud,<br/>plyr.fm, Internet Archive: uploads"]
host --> mb["MusicBrainz, Cover Art<br/>Archive: lookups"]
host --> mb["MusicBrainz, Cover Art<br/>Archive, Discogs: lookups"]
browser["Your browser"] --> oauth
browser --> pds["The account's server: sign-in"]
browser --> github["GitHub's bug form"]
```

Source: [`crates/locallibrary/src/plugin/net.rs`](../../../../crates/locallibrary/src/plugin/net.rs), [`crates/locallibrary/src/plugin/accounts.rs`](../../../../crates/locallibrary/src/plugin/accounts.rs), [`src/bug_cmd.rs`](../../src/bug_cmd.rs), [`plugins/accounts`](../../../../plugins/accounts)
Source: [`crates/locallibrary/src/plugin/net.rs`](../../../../crates/locallibrary/src/plugin/net.rs), [`crates/locallibrary/src/plugin/accounts.rs`](../../../../crates/locallibrary/src/plugin/accounts.rs), [`src/bug_cmd.rs`](../../src/bug_cmd.rs), [`plugins/accounts`](../../../../plugins/accounts), [`plugins/discogs/locallibrary-plugin.toml`](../../../../plugins/discogs/locallibrary-plugin.toml)

## Programs on this computer

Expand Down
9 changes: 5 additions & 4 deletions apps/locallibrary/docs/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,11 +429,12 @@ up before it's written, and it's recorded with the picture kept on the drive
after rekordbox exports. `art auto` given
no drive takes each library plugged in (and each rekordbox drive of none) but archive and retired
drives: it shows each plan and asks once. `art fetch` asks the plugins' providers (the coverart
plugin's: MusicBrainz and the Cover Art Archive) for tracks without artwork (`--replace`: any), and
plugin's: MusicBrainz and the Cover Art Archive; and the discogs plugin's, once you sign in to
Discogs) for tracks without artwork (`--replace`: any), and
asks you which cover when an album could be several; it's an edit like `art set` (see
[Providers](plugins.md#providers)). `track fetch` asks the same plugin, from the same MusicBrainz
lookups, for the details tracks lack: the album's title, year and genre, its label when it was
found by name, and a track's own title and artist when its ISRC found it. `check` and `doctor`
[Providers](plugins.md#providers)). `track fetch` asks the same plugins, from the same lookups,
for the details tracks lack: the album's title, year and genre, its label (from MusicBrainz when it
was found by name), and a track's own title and artist when its ISRC found it on MusicBrainz. `check` and `doctor`
name `art fetch` beside `art auto` for tracks without artwork while a plugin finds cover art.

## Clean
Expand Down
5 changes: 5 additions & 0 deletions apps/locallibrary/docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ plugins/accounts/ the account plugins: a manifest each and no crate, w
and never starts
archive-account/ the `archive` account type, signed in to with the account's S3 keys; its scope
`upload`
discogs-account/ the `discogs` account type, signed in to with a personal access token; its scope
`database`
google-account/ the `google` account type, signed in to through Local Network's token exchange;
its scope `youtube-upload`
mixcloud-account/ the `mixcloud` account type, through the token exchange; its scope `upload`
Expand All @@ -61,6 +63,9 @@ plugins/coverart/ the coverart plugin: front covers and track details
the Cover Art Archive, offered as candidates locallibrary plans the edits of
plugins/demos/ the demos plugin: rekordbox's demo tracks on a drive, found by its check and
removed by `clean` as one `delete_unbacked` edit
plugins/discogs/ the discogs plugin: front covers and track details for tracks lacking them (or all,
asked to), each album searched on Discogs with the user's Discogs account,
offered as candidates locallibrary plans the edits of
plugins/interop/ the interop plugin: exporters for Traktor, Serato, Engine DJ and rekordbox XML,
writing a drive's library with loclib-interop on this computer, and an importer
and a library source reading Traktor's collection.nml back
Expand Down
36 changes: 30 additions & 6 deletions apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ are:
- **coverart**: finds front covers on MusicBrainz and the Cover Art Archive for `locallibrary art
fetch`, and track details on MusicBrainz for `locallibrary track fetch` (see
[Providers](#providers)).
- **discogs**: finds front covers on Discogs for `locallibrary art fetch`, and an album's title,
year, record label and style for `locallibrary track fetch` (see [Providers](#providers)).
Discogs answers searches only for a signed-in account: make a personal access token at
discogs.com/settings/developers and sign in with `locallibrary account discogs sign-in --for
discogs`. Until then it finds nothing and says so.
- **musicbrainz**: looks up a track's MusicBrainz recording, release and artist IDs by its ISRC
(`musicbrainz.recording`, `musicbrainz.release`, `musicbrainz.artists`, with the ISRC as
`musicbrainz.isrc` and the names the artists' go with as `musicbrainz.credits`). The recording
Expand Down Expand Up @@ -158,6 +163,9 @@ are:
`archive` account type, signed in to with the pair of S3 keys from
archive.org/account/s3.php. The Archive doesn't say whose keys they are, so you name the
account. Its scope `upload` uploads items to the Internet Archive.
- **discogs-account**: an account plugin, the `discogs` account type, signed in to with a
personal access token from discogs.com/settings/developers. Its scope `database` searches the
Discogs database.
- **google-account**: an account plugin, the `google` account type, signed in to through Local
Network's token exchange. Its scope `youtube-upload` uploads videos to YouTube.
- **mixcloud-account**: an account plugin, the `mixcloud` account type, signed in to through the
Expand All @@ -168,10 +176,11 @@ are:
the token exchange. Its scopes are `upload`, for uploading tracks to SoundCloud, and `read`, for
reading from SoundCloud as you.

coverart, musicbrainz, plyr, archive, youtube, mixcloud and soundcloud are the only ones that go
online, and
coverart, musicbrainz, discogs, plyr, archive, youtube, mixcloud and soundcloud are the only ones
that go online, and
only for these, through locallibrary (see [Safety](#safety)), a request a second to MusicBrainz
between the first two, as it asks: `art fetch` and `track fetch`; `sets publish` when it's about to
between the first two and to Discogs' API from the third, as each asks: `art fetch` and `track
fetch`; `sets publish` when it's about to
publish (signed in, with `--yes` or a terminal to ask in), for the tracks it names without IDs,
unless `--no-lookup` (or `plugin roles recording --none`, for good); `analyze --plugin
musicbrainz`; and `sets publish --to plyr:track`, `--to archive:item`, `--to youtube:video`,
Expand Down Expand Up @@ -207,13 +216,14 @@ signed in, and the scopes a plugin may be given, each with the requests it cover
code, so locallibrary never starts one: it runs every sign-in from the manifest and keeps the
tokens itself (see [the decision](adr/0001-account-plugins-are-manifests.md)). Only the ones
locallibrary ships, in `plugins/accounts/`, may use Local Network's token exchange:
google-account, mixcloud-account and soundcloud-account do, and archive-account and plyr-account
take keys you enter instead (see [the decision](adr/0002-keys-accounts.md)). `locallibrary plugin` lists the account type each adds, and `plugin
google-account, mixcloud-account and soundcloud-account do, and archive-account, discogs-account
and plyr-account take keys you enter instead (see [the decision](adr/0002-keys-accounts.md)). `locallibrary plugin` lists the account type each adds, and `plugin
info` its scopes.

A plugin asks for accounts of a type with `[[accounts]]`, naming the scopes it needs and why:
youtube asks for a google account to upload videos with; archive, mixcloud, plyr and soundcloud
each for an account of their own type to upload with.
each for an account of their own type to upload with; discogs for a discogs account to search
with.

## Accounts

Expand Down Expand Up @@ -549,6 +559,20 @@ album's details alone. A track without an album is looked up by its title, and g
from a single of that name, never from an album it's the title track of. One release group found
by an ISRC scores 0.95, by name 0.9, and one of several 0.5, for you to choose.

The discogs plugin has one of each too, and searches Discogs once for each album: by its name,
artist and record label when one of its tracks has a label (read from the library), then by its
name and artist when that finds nothing, as tags spell labels many ways. A track without an album
is looked up by its title and artist, as the releases it's on. Only releases titled as the album
is count, and an album's editions (a master's releases) are one. `discogs.covers` offers the
front cover of each, and `discogs.tags` its title, the earliest year of its editions, its first
label and its first style (else its genre); each candidate shows its label and catalogue number.
One release found by its label scores 0.95, by name 0.9, by a track's title 0.6, and one of
several 0.5. Searches go a second apart. When Discogs says the account is over its limit (60
requests a minute), a search waits as long as Discogs asks (else a minute) and goes again, a few
times at most, and an album it would wait over two minutes for is left out, saying so. Without a
signed-in Discogs account, or with a token Discogs refuses, it finds nothing and says how to sign
in.

## Roles

An analyzer's manifest may say what some of its fields are to locallibrary: a track's key, its
Expand Down
2 changes: 1 addition & 1 deletion apps/locallibrary/src/tui/plugins/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ fn plugins_commands_are_in_the_palette() {
press(&mut app, ":");
let (item, why) = entry(&app, "answer the params…");
assert_eq!((item.detail.as_str(), why), ("Echo", Some("open a drive first")));
assert_eq!(entry(&app, "fetch cover art…").0.detail, "Cover art");
assert_eq!(entry(&app, "fetch cover art…").0.detail, "Cover art, Discogs");
let p = picker(&app);
let echo = p.items.iter().position(|i| i.label == "answer the params…").unwrap();
assert!(p.items[..echo].iter().any(|i| i.label == "quit"), "the player's own first");
Expand Down
5 changes: 3 additions & 2 deletions apps/locallibrary/src/tui/plugins/tests/asking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ fn fetch_details(app: &mut App, root: &Path) {
for needle in ["Fetch track details", "from", "replace", "Only from this provider", "‹ none ›"] {
assert!(s.contains(needle), "{needle:?} isn't in\n{s}\n{}", last_message(app));
}
// The third provider (after none, coverart's, which would go online, and echo's own), and
// over what they have.
// The fourth provider (after none, and coverart's and discogs', which would go online, echo's
// own), and over what they have.
key(app, KeyCode::Right);
key(app, KeyCode::Right);
key(app, KeyCode::Right);
key(app, KeyCode::Right);
Expand Down
6 changes: 4 additions & 2 deletions apps/locallibrary/tests/it/plugins/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@ fn track_fetch_in_json_from_one_provider() {
);
assert_eq!(years(&usb)[0], (ids[0], 1984));
let err = env.fails(1, &["track", "fetch", "USB", "--from", "nope"]);
assert!(err.contains("nope provides no metadata: coverart.tags, echo.found do"), "{err}");
assert!(err.contains("nope provides no metadata: coverart.tags, discogs.tags, echo.found do"), "{err}");
let err = env.fails(1, &["art", "fetch", "USB", "--from", "echo.found"]);
assert!(err.contains("echo.found provides no artwork: coverart.covers, echo.covers do"), "{err}");
assert!(err.contains("echo.found provides no artwork: coverart.covers, discogs.covers, echo.covers do"), "{err}");
}

/// The cover an artwork provider finds is set on the tracks it's for; once they have one, they're
Expand Down Expand Up @@ -242,6 +242,8 @@ fn plugin_providers_shows_and_sets_the_auto_pick() {
for line in [
"coverart.covers artwork Cover Art Archive",
"coverart.tags metadata MusicBrainz",
"discogs.covers artwork Discogs",
"discogs.tags metadata Discogs",
"echo.found metadata Found",
"echo.covers artwork Covers",
] {
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/budgets/deps.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"tolerance_percent": 0,
"metrics": {
"locallibrary-cli": {
"limit": 581,
"limit": 582,
"unit": "packages",
"measures": "Packages linked into the CLI with default features on Linux, by cargo tree: proc macros and what only they use are left out, as the host decides those."
}
Expand Down
22 changes: 22 additions & 0 deletions plugins/accounts/discogs-account/locallibrary-plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
id = "discogs-account"
name = "Discogs Account"
version = "0.1.0"
protocol = "^0.2"

# Discogs takes a personal access token, made at discogs.com/settings/developers, in the
# Authorization header as `Discogs token=<token>`, and says whose it is at `/oauth/identity`.
[[contributes.account_types]]
type = "discogs"
title = "Discogs"
kind = "keys"
keys_url = "https://www.discogs.com/settings/developers"
authorization = "Discogs token={token}"
whoami = { url = "https://api.discogs.com/oauth/identity", subject = "/id", name = "/username" }

[contributes.account_types.keys.token]
title = "Personal access token"

# A search of the database: the releases it finds, with their labels, years, styles and covers.
[contributes.account_types.scopes.database]
title = "search the Discogs database"
requests = [{ method = "GET", url = "https://api.discogs.com/database/search*" }]
28 changes: 28 additions & 0 deletions plugins/discogs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "locallibrary-plugin-discogs"
description = "locallibrary's Discogs plugin: front covers and track details found on Discogs for tracks, with a Discogs account's personal access token"
publish = false
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
form_urlencoded = "1.2.2"
image.workspace = true
indexmap.workspace = true
loclib-model.workspace = true
loclib-plugin.workspace = true
loclib-progress.workspace = true
serde.workspace = true
serde_json.workspace = true

[lints]
workspace = true

[dev-dependencies]
hegeltest.workspace = true
loclib-model = { workspace = true, features = ["test-util"] }
loclib-plugin = { workspace = true, features = ["test-util"] }
url = "2.5"
31 changes: 31 additions & 0 deletions plugins/discogs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# locallibrary-plugin-discogs

locallibrary's Discogs plugin, built into `locallibrary`. It has two providers:

- `discogs.covers` finds front covers for `locallibrary art fetch`
- `discogs.tags` finds an album's title, year, record label and style for `locallibrary track fetch`

Discogs only answers searches for a signed-in account, so make a personal access token at
https://www.discogs.com/settings/developers and sign in with it:

```bash
locallibrary account discogs sign-in --for discogs
```

The `discogs` account type comes from the discogs-account plugin
(`plugins/accounts/discogs-account/`). The token goes only into searches of
`api.discogs.com/database/search`, filled in by locallibrary: the plugin never sees it. Covers come
from `i.discogs.com` without it.

How it looks albums up, scores what it finds and keeps to Discogs' limit is in
[Providers](../../apps/locallibrary/docs/plugins.md#providers).

## Working on it

Its tests run it against `loclib_plugin::test_util::FakeHost`, with Discogs' answers scripted from
the search results in `fixtures/` (shaped as Discogs answers, with made-up ids and image
addresses), so nothing goes online:

```bash
cargo nextest run -p locallibrary-plugin-discogs
```
Loading
Loading