Repository: Spotifyd/spotifyd (language: rust)

Issue:
Refactor Config structs according to features
As it was stated in #394, structs like `SharedConfigValues` or
`SpotifydConfig` contain fields like `mixer`, `control` and `device`
which only apply if `alsa-backend` feature is enabled.

Therefore, it would be nice if these fields were only documented when
the feature is enabled as well as used.

Closes #394
fix inital_volume being a string

Credential cache is ignored by default
**Description**
I do not store any credentials in my configuration file because I manage it in a public repo. Previously, starting spotifyd once with credentials on the command line would be enough to stay logged in permanently. It seems #1121 changed this behavior.

This is particularly annoying, because the "username" spotifyd expects is **not** the email address that is associated with my account, even though that is a valid way to log in. Additionally, spotify does not actually let you choose your username, so it has to be looked up (either from the cache file, or on the spotify website).

I rely on explicit login because I use the website (open.spotify.com) to control playback on my desktop machines. The website is unable to use zeroconf to discover spotifyd, so this forces me to use my phone to initiate the playback session.

A workaround is to use jq to read the username from the cache like so:
```
username_cmd = "jq -r .username ${cache_path}/credentials.json"
```

**To Reproduce**
```
[global]
backend = "pulseaudio"
bitrate = 320
cache_path = "/home/novenary/.local/share/spotifyd"
no_audio_cache = true
normalisation_pregain = 0
use_mpris = true
volume_normalisation = true
zeroconf_port = 4444
```

```
$ spotifyd --no-daemon --config-path /nix/store/48rlijsg02v713kw5bmiw2675rv8d9yl-spotifyd.conf
```

**Expected behavior**
The change in behavior is a breaking change, hence this is filed as a bug and not a feature request. I don't particularly care to have it reverted, but one or more of the following solutions should be offered:
- remembering that the user explicitly logged in
- an explicit option to force the exclusive use of cached credentials or zeroconf
- allowing cached credentials to coexist with zeroconf in a graceful way, if possible (ideally, I should be able to "pair" spotifyd with my account by using zeroconf once, and then rely on cached credentials for future use)

**Logs**
<details><summary>Click to show logs</summary>

```
Loading config from "/nix/store/48rlijsg02v713kw5bmiw2675rv8d9yl-spotifyd.conf"
No username specified. Checking username_cmd
No username_cmd specified
No password specified. Checking password_cmd
No password_cmd specified
No proxy specified
Using software volume controller.
no usable credentials found, enabling discovery
```

</details>

<!-- if you compiled spotifyd yourself. Alsa backend enabled by default unless compiled with the `--no-default-features` flag -->
**Compilation flags** 
- [x] dbus_mpris
- [ ] dbus_keyring
- [x] alsa_backend
- [ ] portaudio_backend
- [ ] pulseaudio_backend
- [ ] rodio_backend

**Versions (please complete the following information):**
<!-- DO NOT use words like `latest`. Please specify the exact version/commit hash -->
- OS: NixOS 23.05
- Spotifyd: 0.3.5
- cargo: 1.69.0

<!-- **Additional context**
Add any other context about the problem here. -->



Discussion / hints:
> im not completely sure if changing the config layout like this is a breaking change or not.

I think so. Basically when you're now setting `None` for these config options, you should directly not specify them or some similar thing. I'd need to check if the configfiles parsing behaves on a friendly way with this. If not, I'll need to refactor this code too. 

If it's done by `clap` or some similar crate, then once compiled that will be handled automatically, otherways I'll need to edit also the configfile parsing.

Also will need to add docs for the different config options considering `alsa` and `non-alsa` features.
alright good to know, then we have to bump the version
We already have another MR that's "waiting" on 0.4 to be merged, we can add this one to the list.

Maybe we should make version branches to keep track?
So the macro stuff has been already addressed. And tested. 

I'm just concerned about one thing regarding the `SpotifyConfig` struct.

Should the `alsa_backend`-only fields be `Option<T>` or just T? Since as the issue mentions, they need to be expressed if the feature is set.

I made the refactor having the fields like:
```rust
#[cfg(feature = "alsa-backend")]
    pub(crate) audio_device: String,
    #[cfg(feature = "alsa-backend")]
    pub(crate) control_device: String,
    #[cfg(feature = "alsa-backend")]
    pub(crate) mixer: String,
```

But I can change them to be `Option<String>`.

Once this is clarified, It might need to be specified in the mdbook a bit more clearly IMO. Then the PR will be ready I think.
If it is a plain string, and i specify it in the config but not command line will it then fail?
> If it is a plain string, and i specify it in the config but not command line will it then fail?

Exactly! Good point! Let's make it an `Option` as it was in the beginning, I thought it was like this due to the backend possibilities but it's also because of being a config param that can be missing too.
I think this looks good! And we should release this in the next breaking change!
The breaking change concern could be addressed by explicitly telling `serde` to ignore unknown fields. I do remember there was a way but I can't write it down off the top of my head.
> 
> 
> So the macro stuff has been already addressed. And tested.
> 
> I'm just concerned about one thing regarding the `SpotifyConfig` struct.
> 
> Should the `alsa_backend`-only fields be `Option<T>` or just T? Since as the issue mentions, they need to be expressed if the feature is set.
> 
> I made the refactor having the fields like:
> 
> ```rust
> #[cfg(feature = "alsa-backend")]
>     pub(crate) audio_device: String,
>     #[cfg(feature = "alsa-backend")]
>     pub(crate) control_device: String,
>     #[cfg(feature = "alsa-backend")]
>     pub(crate) mixer: String,
> ```
> 
> But I can change them to be `Option<String>`.
> 
> Once this is clarified, It might need to be specified in the mdbook a bit more clearly IMO. Then the PR will be ready I think.

I do remember that spotifyd also had a `default` option that, when no config is supplied, it uses the `Default` implementation. Setting this to `String` would break it, as the CLI wouldn't work without the configuration values.

This is something we could (and I think even should!) consider for a large breaking change at some point. I'd rather have mandatory fields than some magic that works in the background (and not even on all machines) .
Sorry @SirWindfield but this got lost between tons of notifications. The attributes you mention are indeed `Option<String>` so I'm unsure if this needs any refactoring before it can be approved/landed.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@mainrs @robinvd is this something that is still not desired?
Hello! I do not have the time to maintain the project actively right now. I think it would still be a good idea to get this PR done, since it just does not make sense to have them in the same struct.

The current code base of spotifyd feels more like patch work then anything else to be honest. The tokio 1.0 rewrite did help though. I definitely am interested in seeing how librespot does these days. Maybe some changes allow us to make the whole client more lightweight in general. Or restructure some features/code modules all together.

I'll definitely investigate this. I cannot tell you when though.
Thanks for the review comments @eladyn 

Will wait to see if @mainrs has more time in the future and address this.

Thank you all! :)
unfortunately this is a breaking change, we could also change the readme to `initial_volume = "90"` and keep the weird beheviour but that isnt great either
That is true but we don't need to publish a release instantly. Most people will use the latest stable version and this new way of parsing the "initial_value" will probably come with other minor breaking changes in the next version.
yes that might be a good plan, the only thing just like the TOML pr is that people will copy the config from the readme, and that wont be compatible
I added this to the next breaking change milestone.

We should probably decide if we want to keep the incorrect documentation until then. 
We should update the documentation to work with the currently stable version and update it when we release the new version. We could probably just add a comment that with a git version of spotifyd, the value must be set differently.
Or we could point the default branch to the latest release
Yes I was thinking about this as well. Usually I'm not a fan of such a solution because it looks like nothing is developed anymore and most people see the master branch as nightly/latest version (as far as I noticed) but in such a case like this, it is probably a solution we should consider. 
Or we could just hold off on merging this until we're ready to release 0.4.
Ye, just keep the PR open and merge once `v0.4` is on the horizon. It's the easiest.
I noticed this and wanted to bring up [changing the default branch](https://docs.github.com/en/github/administering-a-repository/changing-the-default-branch) to point to v.0.3.0, whereby drive-by readme readers could be shown the readme for the stable release and anyone looking to compile the master branch could do so with a simple `git switch master`. A note could be added to the readme for anyone who doesn't immediately notice that the default branch points to a stable release.

That way, breaking changes could be included with a consistent readme in `master` for people who want to build the current development branch, leading to more bleeding-edge users successfully testing the development channel and finding documentation errors and bugs (especially those arising from merging multiple breaking changes all together at release time) before release.
Alternatively, do we publish the book anywhere? It would probably make more sense if we had properly versioned documentation without expecting users to realize the cargo.toml is a year out-of-date, lol 
I'm not sure if you mean that, but the book is published [here](https://spotifyd.github.io/spotifyd/Introduction.html). It seems, however, that it is updated together with the master branch. Maybe we could

- either maintain two versions of it: e.g. https://spotifyd.github.io/spotifyd/master and https://spotifyd.github.io/spotifyd/latest
- or change that to only update on releases
One could change this to be an enum with `String` and `u16` to smooth out the transition: Warn if the user provided a string but still allow it. This would allow users to smoothly change to `u16` for the value and in the next release support for `String` can get removed.
Thank you for raising this.

I generally see your point. However, I would argue that for your specific use case, there are already several options that should all fix your problem.

- Using the keyring. `spotifyd` supports using the system keyring for retreiving credentials through dbus. See [the docs](https://docs.spotifyd.rs/config/File.html#alternatives-to-storing-your-password-in-the-config-file----omit-in-toc---) for further details.
- By just providing the username, `spotifyd` still queries the cache for credentials (as you wrote).

Both solutions require you to somehow provide the username. If you don't want to put it directly in the config file, you can either use the workaround you posted or put it in a separate file and do a simple `cat $file` as `username_cmd`.

In theory, the third option you gave

> allowing cached credentials to coexist with zeroconf in a graceful way, if possible (ideally, I should be able to "pair" spotifyd with my account by using zeroconf once, and then rely on cached credentials for future use)

should already be possible today, but currently doesn't work due to a bug, as I just noticed. (We're not caching credentials anymore.) As soon as that is fixed, you should just be able to put your username or the line you posted in your config and `spotifyd` should start discovery if the cache doesn't exist (and save the credentials as soon as a connection comes in). Otherwise, it will just use the cached credentials. (Edit: See #1214)

Would any of these suit your needs?
Thank you for responding!

> * Using the keyring. `spotifyd` supports using the system keyring for retreiving credentials through dbus. See [the docs](https://docs.spotifyd.rs/config/File.html#alternatives-to-storing-your-password-in-the-config-file----omit-in-toc---) for further details.

The keyring is indeed appropriate for this but it's not viable in every scenario. I use sway, rather than a desktop environment, so I don't actually have a keyring implementation available for now. It is also not suitable when running spotifyd as a system daemon (in embedded or server setups).

As long as it's just one of the available options though, it is perfectly acceptable.
 
> * By just providing the username, `spotifyd` still queries the cache for credentials (as you wrote).
>
> Both solutions require you to somehow provide the username. If you don't want to put it directly in the config file, you can either use the workaround you posted or put it in a separate file and do a simple `cat $file` as `username_cmd`.

Yeah, the problem I have with that is, as I've explained, that the username isn't very good UX. It isn't the way most users log in, because it's a randomized and unchangeable string. I'm not strictly opposed to using it, but it would also be nicer not to have to hardcode it anywhere.

As far as reading the cache with jq is concerned, I don't think it's the best long-term solution:
- the cache file is an implementation detail rather than a public interface, so reading it directly feels like a hack (if it's guaranteed to be stable though, I don't hate it)
- spotifyd is reading that file anyway, so it should be able to figure out the username from there

In general, I think the behavior of comparing the username from the command line or config file to the one in cache is also a bit confusing compared to an explicit option to use cached credentials.

> As soon as that is fixed, you should just be able to put your username or the line you posted in your config and `spotifyd` should start discovery if the cache doesn't exist

I'm drifting a bit off-topic here, but is it possible to keep discovery enabled even when using explicit or cached credentials?

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
index c5c9d79d..6d967fdf 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.md
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -33,7 +33,6 @@ assignees: ''
 <!-- if you compiled spotifyd yourself. Alsa backend enabled by default unless compiled with the `--no-default-features` flag -->
 **Compilation flags** 
 - [ ] dbus_mpris
-- [ ] dbus_keyring
 - [x] alsa_backend
 - [ ] portaudio_backend
 - [ ] pulseaudio_backend
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 787cce72..a87b7d6d 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -18,9 +18,9 @@ jobs:
           - artifact_type: 'slim'               # Slim version has no features enabled by default.
             feature: ''
           - artifact_type: 'default'
-            feature: 'dbus_keyring,dbus_mpris'  # Default version has all extra features enabled
+            feature: 'dbus_mpris'  # Default version has all extra features enabled
           - artifact_type: 'full'
-            feature: 'dbus_keyring,dbus_mpris'  # Full version has all extra features and audio backends enabled
+            feature: 'dbus_mpris'  # Full version has all extra features and audio backends enabled
           - build_target: macos
             os: macos-latest
             artifact_prefix: macos
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d551d6e8..4b089568 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,9 +59,9 @@ jobs:
         os: [macos-latest, ubuntu-latest]
         include:
           - os: macos-latest
-            features: portaudio_backend,rodio_backend,dbus_keyring
+            features: portaudio_backend,rodio_backend
           - os: ubuntu-latest
-            features: alsa_backend,rodio_backend,dbus_keyring,dbus_mpris
+            features: alsa_backend,rodio_backend,dbus_mpris
 
     steps:
       - name: Installing Rust toolchain
diff --git a/Cargo.lock b/Cargo.lock
index 6b177e3f..7310f5d2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -74,15 +74,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "ansi_term"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
-dependencies = [
- "winapi",
-]
-
 [[package]]
 name = "anstream"
 version = "0.6.18"
@@ -161,17 +152,6 @@ version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
-[[package]]
-name = "atty"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
-dependencies = [
- "hermit-abi",
- "libc",
- "winapi",
-]
-
 [[package]]
 name = "autocfg"
 version = "1.4.0"
@@ -399,19 +379,44 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "2.34.0"
+version = "4.5.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
+checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84"
 dependencies = [
- "ansi_term",
- "atty",
- "bitflags 1.3.2",
- "strsim 0.8.0",
- "textwrap",
- "unicode-width",
- "vec_map",
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
 ]
 
+[[package]]
+name = "clap_derive"
+version = "4.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.93",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
+
 [[package]]
 name = "cmake"
 version = "0.1.52"
@@ -597,7 +602,7 @@ dependencies = [
  "ident_case",
  "proc-macro2",
  "quote",
- "strsim 0.11.1",
+ "strsim",
  "syn 2.0.93",
 ]
 
@@ -646,19 +651,6 @@ dependencies = [
  "dbus",
 ]
 
-[[package]]
-name = "dbus-secret-service"
-version = "4.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b"
-dependencies = [
- "dbus",
- "futures-util",
- "num",
- "once_cell",
- "rand",
-]
-
 [[package]]
 name = "dbus-tokio"
 version = "0.7.6"
@@ -1115,21 +1107,9 @@ dependencies = [
 
 [[package]]
 name = "heck"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
-dependencies = [
- "unicode-segmentation",
-]
-
-[[package]]
-name = "hermit-abi"
-version = "0.1.19"
+version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
-dependencies = [
- "libc",
-]
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
 [[package]]
 name = "hex"
@@ -1695,20 +1675,6 @@ dependencies = [
  "wasm-bindgen",
 ]
 
-[[package]]
-name = "keyring"
-version = "3.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f8fe839464d4e4b37d756d7e910063696af79a7e877282cb1825e4ec5f10833"
-dependencies = [
- "byteorder",
- "dbus-secret-service",
- "log",
- "security-framework 2.11.1",
- "security-framework 3.1.0",
- "windows-sys 0.59.0",
-]
-
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -2184,20 +2150,6 @@ dependencies = [
  "winapi",
 ]
 
-[[package]]
-name = "num"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
-dependencies = [
- "num-bigint",
- "num-complex",
- "num-integer",
- "num-iter",
- "num-rational",
- "num-traits",
-]
-
 [[package]]
 name = "num-bigint"
 version = "0.4.6"
@@ -2226,15 +2178,6 @@ dependencies = [
  "zeroize",
 ]
 
-[[package]]
-name = "num-complex"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
-dependencies = [
- "num-traits",
-]
-
 [[package]]
 name = "num-conv"
 version = "0.1.0"
@@ -2283,17 +2226,6 @@ dependencies = [
  "num-traits",
 ]
 
-[[package]]
-name = "num-rational"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
-dependencies = [
- "num-bigint",
- "num-integer",
- "num-traits",
-]
-
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -2597,30 +2529,6 @@ dependencies = [
  "toml_edit",
 ]
 
-[[package]]
-name = "proc-macro-error"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
-dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
-dependencies = [
- "proc-macro2",
- "quote",
- "version_check",
-]
-
 [[package]]
 name = "proc-macro2"
 version = "1.0.92"
@@ -3306,6 +3214,7 @@ version = "0.3.5"
 dependencies = [
  "alsa",
  "chrono",
+ "clap",
  "color-eyre",
  "daemonize",
  "dbus",
@@ -3317,20 +3226,19 @@ dependencies = [
  "futures",
  "gethostname",
  "hex",
- "keyring",
  "libc",
  "librespot-audio",
  "librespot-connect",
  "librespot-core",
  "librespot-discovery",
  "librespot-metadata",
+ "librespot-oauth",
  "librespot-playback",
  "librespot-protocol",
  "log",
  "pledge",
  "serde",
  "sha-1",
- "structopt",
  "syslog",
  "thiserror 2.0.9",
  "time",
@@ -3347,42 +3255,12 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
 
-[[package]]
-name = "strsim"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
-
 [[package]]
 name = "strsim"
 version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
-[[package]]
-name = "structopt"
-version = "0.3.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10"
-dependencies = [
- "clap",
- "lazy_static",
- "structopt-derive",
-]
-
-[[package]]
-name = "structopt-derive"
-version = "0.4.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0"
-dependencies = [
- "heck",
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
 [[package]]
 name = "subtle"
 version = "2.6.1"
@@ -3572,15 +3450,6 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
-[[package]]
-name = "textwrap"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
-dependencies = [
- "unicode-width",
-]
-
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -3903,18 +3772,6 @@ version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
 
-[[package]]
-name = "unicode-segmentation"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
-[[package]]
-name = "unicode-width"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
-
 [[package]]
 name = "untrusted"
 version = "0.9.0"
@@ -3973,12 +3830,6 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
 
-[[package]]
-name = "vec_map"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
-
 [[package]]
 name = "vergen"
 version = "9.0.2"
diff --git a/Cargo.toml b/Cargo.toml
index 66d3e156..142d0704 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,12 +18,10 @@ fern = { version = "0.7.0", features = ["syslog-6"] }
 futures = "0.3.15"
 gethostname = "0.5.0"
 hex = "0.4"
-keyring = { version = "3.5", optional = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
 libc = "0.2.82"
 log = "0.4.6"
 serde = { version = "1.0.115", features = ["derive"] }
 sha-1 = "0.10"
-structopt = "0.3.17"
 tokio = {version = "1.26.0", features = ["signal", "rt-multi-thread", "process", "io-std"] }
 tokio-stream = "0.1.7"
 url = "2.2.2"
@@ -34,11 +32,13 @@ librespot-discovery = "0.6"
 librespot-connect = "0.6"
 librespot-metadata = "0.6"
 librespot-protocol = "0.6"
+librespot-oauth = "0.6"
 toml = "0.8.19"
 color-eyre = "0.6"
 directories = "5.0.1"
 thiserror = "2.0"
 time = { version = "0.3.37", default-features = false, features = ["formatting"] }
+clap = { version = "4.5.23", features = ["derive"] }
 
 [target."cfg(unix)".dependencies]
 daemonize = "0.5"
@@ -55,7 +55,6 @@ env_logger = "0.11"
 
 [features]
 alsa_backend = ["librespot-playback/alsa-backend", "alsa"]
-dbus_keyring = ["keyring"]
 dbus_mpris = ["dbus", "dbus-tokio", "dbus-crossroads"]
 pipe_backend = []
 default = ["alsa_backend", "pipe_backend"]
@@ -66,7 +65,7 @@ rodiojack_backend = ["librespot-playback/rodiojack-backend"]
 
 [package.metadata.deb]
 depends = "$auto, systemd, pulseaudio"
-features = ["pulseaudio_backend", "dbus_keyring", "dbus_mpris"]
+features = ["pulseaudio_backend", "dbus_mpris"]
 assets = [
     ["target/release/spotifyd", "usr/bin/", "755"],
     ["README.md", "usr/share/doc/spotifyd/README", "644"],
diff --git a/hooks/pre-commit b/hooks/pre-commit
index c07342f9..e43d760e 100755
--- a/hooks/pre-commit
+++ b/hooks/pre-commit
@@ -34,16 +34,16 @@ for path in $(git diff --name-only --cached); do
 done
 
 echo "→ Building pre-commit build artifacts..."
-cargo check --quiet --no-default-features --features "rodio_backend,dbus_keyring"
+cargo check --quiet --no-default-features --features "rodio_backend"
 if [ $? -ne 0 ]; then
     exit 1
 fi
-cargo build --quiet --no-default-features --features "rodio_backend,dbus_keyring"
+cargo build --quiet --no-default-features --features "rodio_backend"
 
 # Linting is only done with the rodio backend and the keyring feature as those should be 
 # compilable for every supported platform without external library needs. 
 echo "→ Linting Rust code..."
-cargo clippy --no-default-features --features "rodio_backend,dbus_keyring" -- -D warnings
+cargo clippy --no-default-features --features "rodio_backend" -- -D warnings
 
 echo "→ Testing Rust code..."
-cargo test --no-default-features --features "rodio_backend,dbus_keyring"
+cargo test --no-default-features --features "rodio_backend"
diff --git a/src/config.rs b/src/config.rs
index 9c635777..7a31a097 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,147 +1,51 @@
-use crate::{
-    error::{Error as CrateError, ParseError},
-    process::run_program,
-    utils,
+use crate::utils;
+use clap::{
+    builder::{IntoResettable, PossibleValuesParser, TypedValueParser, ValueParser},
+    Args, Parser, Subcommand, ValueEnum,
 };
-use color_eyre::Report;
+use color_eyre::{
+    eyre::{bail, Context},
+    Report,
+};
+use directories::ProjectDirs;
 use gethostname::gethostname;
 use librespot_core::{cache::Cache, config::DeviceType as LSDeviceType, config::SessionConfig};
 use librespot_playback::{
+    audio_backend,
     config::{AudioFormat as LSAudioFormat, Bitrate as LSBitrate, PlayerConfig},
     dither::{mk_ditherer, DithererBuilder, TriangularDitherer},
 };
-use log::{error, info, warn};
-use serde::{de::Error, de::Unexpected, Deserialize, Deserializer};
+use log::{debug, error, info, warn};
+use serde::{
+    de::{self, Error, Unexpected},
+    Deserialize, Deserializer,
+};
 use sha1::{Digest, Sha1};
-use std::{fmt, fs, path::Path, path::PathBuf, str::FromStr};
-use structopt::{clap::AppSettings, StructOpt};
+use std::{
+    borrow::Cow,
+    convert::TryInto,
+    fs,
+    path::{Path, PathBuf},
+    str::FromStr,
+};
 use url::Url;
 
 const CONFIG_FILE_NAME: &str = "spotifyd.conf";
 
-#[cfg(not(any(
-    feature = "pulseaudio_backend",
-    feature = "portaudio_backend",
-    feature = "alsa_backend",
-    feature = "pipe_backend",
-    feature = "rodio_backend",
-    feature = "rodiojack_backend",
-)))]
-compile_error!("At least one of the backend features is required!");
-static BACKEND_VALUES: &[&str] = &[
-    #[cfg(feature = "alsa_backend")]
-    "alsa",
-    #[cfg(feature = "pulseaudio_backend")]
-    "pulseaudio",
-    #[cfg(feature = "portaudio_backend")]
-    "portaudio",
-    #[cfg(feature = "rodio_backend")]
-    "rodio",
-    #[cfg(feature = "pipe_backend")]
-    "pipe",
-    #[cfg(feature = "rodiojack_backend")]
-    "rodiojack",
-];
-
-/// The backend used by librespot
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, StructOpt)]
-#[serde(rename_all = "lowercase")]
-pub enum Backend {
-    Alsa,
-    PortAudio,
-    PulseAudio,
-    Rodio,
-    Pipe,
-    RodioJack,
-}
-
-fn default_backend() -> Backend {
-    Backend::from_str(BACKEND_VALUES.first().unwrap()).unwrap()
-}
-
-impl FromStr for Backend {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "alsa" => Ok(Backend::Alsa),
-            "portaudio" => Ok(Backend::PortAudio),
-            "pulseaudio" => Ok(Backend::PulseAudio),
-            "rodio" => Ok(Backend::Rodio),
-            "pipe" => Ok(Backend::Pipe),
-            "rodiojack" => Ok(Backend::RodioJack),
-            _ => unreachable!(),
-        }
-    }
-}
-
-impl fmt::Display for Backend {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        match self {
-            Backend::Alsa => write!(f, "alsa"),
-            Backend::PortAudio => write!(f, "portaudio"),
-            Backend::PulseAudio => write!(f, "pulseaudio"),
-            Backend::Rodio => write!(f, "rodio"),
-            Backend::Pipe => write!(f, "pipe"),
-            Backend::RodioJack => write!(f, "rodiojack"),
-        }
-    }
-}
-
-static VOLUME_CONTROLLER_VALUES: &[&str] = &[
-    "softvol",
-    #[cfg(feature = "alsa_backend")]
-    "alsa",
-    #[cfg(feature = "alsa_backend")]
-    "alsa_linear",
-    "none",
-];
-
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, StructOpt)]
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ValueEnum)]
 #[serde(rename_all = "snake_case")]
 pub enum VolumeController {
+    #[cfg(feature = "alsa_backend")]
     Alsa,
+    #[cfg(feature = "alsa_backend")]
     AlsaLinear,
     #[serde(rename = "softvol")]
     SoftVolume,
     None,
 }
 
-impl FromStr for VolumeController {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "alsa" => Ok(VolumeController::Alsa),
-            "alsa_linear" => Ok(VolumeController::AlsaLinear),
-            "softvol" => Ok(VolumeController::SoftVolume),
-            "none" => Ok(VolumeController::None),
-            _ => unreachable!(),
-        }
-    }
-}
-
-static DEVICETYPE_VALUES: &[&str] = &[
-    "computer",
-    "tablet",
-    "smartphone",
-    "speaker",
-    "tv",
-    "avr",
-    "stb",
-    "audiodongle",
-    "gameconsole",
-    "castaudio",
-    "castvideo",
-    "automobile",
-    "smartwatch",
-    "chromebook",
-    "carthing",
-    "homething",
-];
-
 // Spotify's device type (copied from it's config.rs)
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, StructOpt)]
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ValueEnum)]
 #[serde(rename_all = "snake_case")]
 pub enum DeviceType {
     Unknown,
@@ -168,34 +72,8 @@ pub enum DeviceType {
     HomeThing,
 }
 
-impl From<LSDeviceType> for DeviceType {
-    fn from(item: LSDeviceType) -> Self {
-        match item {
-            LSDeviceType::Unknown => DeviceType::Unknown,
-            LSDeviceType::Computer => DeviceType::Computer,
-            LSDeviceType::Tablet => DeviceType::Tablet,
-            LSDeviceType::Smartphone => DeviceType::Smartphone,
-            LSDeviceType::Speaker => DeviceType::Speaker,
-            LSDeviceType::Tv => DeviceType::Tv,
-            LSDeviceType::Avr => DeviceType::Avr,
-            LSDeviceType::Stb => DeviceType::Stb,
-            LSDeviceType::AudioDongle => DeviceType::AudioDongle,
-            LSDeviceType::GameConsole => DeviceType::GameConsole,
-            LSDeviceType::CastAudio => DeviceType::CastAudio,
-            LSDeviceType::CastVideo => DeviceType::CastVideo,
-            LSDeviceType::Automobile => DeviceType::Automobile,
-            LSDeviceType::Smartwatch => DeviceType::Smartwatch,
-            LSDeviceType::Chromebook => DeviceType::Chromebook,
-            LSDeviceType::UnknownSpotify => DeviceType::UnknownSpotify,
-            LSDeviceType::CarThing => DeviceType::CarThing,
-            LSDeviceType::Observer => DeviceType::Observer,
-            LSDeviceType::HomeThing => DeviceType::HomeThing,
-        }
-    }
-}
-
-impl From<&DeviceType> for LSDeviceType {
-    fn from(item: &DeviceType) -> Self {
+impl From<DeviceType> for LSDeviceType {
+    fn from(item: DeviceType) -> Self {
         match item {
             DeviceType::Unknown => LSDeviceType::Unknown,
             DeviceType::Computer => LSDeviceType::Computer,
@@ -220,26 +98,18 @@ impl From<&DeviceType> for LSDeviceType {
     }
 }
 
-impl FromStr for DeviceType {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        let dt = LSDeviceType::from_str(s).unwrap();
-        Ok(dt.into())
-    }
-}
-
-impl fmt::Display for DeviceType {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let dt: LSDeviceType = self.into();
-        write!(f, "{dt}")
-    }
+fn bitrate_parser() -> impl IntoResettable<ValueParser> {
+    let possible_values: PossibleValuesParser = ["96", "160", "320"].into();
+    possible_values.map(|val| match val.as_str() {
+        "96" => Bitrate::Bitrate96,
+        "160" => Bitrate::Bitrate160,
+        "320" => Bitrate::Bitrate320,
+        _ => unreachable!(),
+    })
 }
 
-static BITRATE_VALUES: &[&str] = &["96", "160", "320"];
-
 /// Spotify's audio bitrate
-#[derive(Clone, Copy, Debug, PartialEq, Eq, StructOpt)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
 pub enum Bitrate {
     Bitrate96,
     Bitrate160,
@@ -261,19 +131,6 @@ impl<'de> Deserialize<'de> for Bitrate {
     }
 }
 
-impl FromStr for Bitrate {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "96" => Ok(Bitrate::Bitrate96),
-            "160" => Ok(Bitrate::Bitrate160),
-            "320" => Ok(Bitrate::Bitrate320),
-            _ => unreachable!(),
-        }
-    }
-}
-
 impl From<Bitrate> for LSBitrate {
     fn from(bitrate: Bitrate) -> Self {
         match bitrate {
@@ -284,41 +141,14 @@ impl From<Bitrate> for LSBitrate {
     }
 }
 
-#[cfg(feature = "dbus_mpris")]
-static DBUSTYPE_VALUES: &[&str] = &["session", "system"];
-
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, StructOpt)]
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ValueEnum)]
 #[serde(rename_all = "snake_case")]
 pub enum DBusType {
     Session,
     System,
 }
 
-impl FromStr for DBusType {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "session" => Ok(DBusType::Session),
-            "system" => Ok(DBusType::System),
-            _ => unreachable!(),
-        }
-    }
-}
-
-impl fmt::Display for DBusType {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        match self {
-            DBusType::Session => write!(f, "session"),
-            DBusType::System => write!(f, "system"),
-        }
-    }
-}
-
-/// LibreSpot supported audio formats
-static AUDIO_FORMAT_VALUES: &[&str] = &["F32", "S32", "S24", "S24_3", "S16"];
-
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, StructOpt)]
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, ValueEnum)]
 pub enum AudioFormat {
     F32,
     S32,
@@ -327,33 +157,6 @@ pub enum AudioFormat {
     S16,
 }
 
-impl FromStr for AudioFormat {
-    type Err = ParseError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "F32" => Ok(AudioFormat::F32),
-            "S32" => Ok(AudioFormat::S32),
-            "S24" => Ok(AudioFormat::S24),
-            "S24_3" => Ok(AudioFormat::S24_3),
-            "S16" => Ok(AudioFormat::S16),
-            _ => unreachable!(),
-        }
-    }
-}
-
-impl fmt::Display for AudioFormat {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        match self {
-            AudioFormat::F32 => write!(f, "F32"),
-            AudioFormat::S32 => write!(f, "S32"),
-            AudioFormat::S24 => write!(f, "S24"),
-            AudioFormat::S24_3 => write!(f, "S24_3"),
-            AudioFormat::S16 => write!(f, "S16"),
-        }
-    }
-}
-
 impl From<AudioFormat> for LSAudioFormat {
     fn from(audio_format: AudioFormat) -> Self {
         match audio_format {
@@ -366,177 +169,237 @@ impl From<AudioFormat> for LSAudioFormat {
     }
 }
 
-#[derive(Debug, Default, StructOpt)]
-#[structopt(
-    about = "A Spotify daemon",
-    author,
-    name = "spotifyd",
-    setting(AppSettings::ColoredHelp)
-)]
+fn possible_backends() -> Vec<&'static str> {
+    audio_backend::BACKENDS.iter().map(|b| b.0).collect()
+}
+
+fn deserialize_backend<'de, D>(de: D) -> Result<Option<String>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let backend = String::deserialize(de)?;
+    let possible_backends = possible_backends();
+    if possible_backends.contains(&backend.as_str()) {
+        Ok(Some(backend))
+    } else {
+        Err(de::Error::invalid_value(
+            Unexpected::Str(&backend),
+            &format!(
+                "a valid backend (available: {})",
+                possible_backends.join(", ")
+            )
+            .as_str(),
+        ))
+    }
+}
+
+fn number_or_string<'de, D>(de: D) -> Result<Option<u8>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let val = toml::Value::deserialize(de)?;
+
+    let unexpected = match val {
+        toml::Value::Integer(num) => {
+            let num: u8 = num.try_into().map_err(de::Error::custom)?;
+            return Ok(Some(num));
+        }
+        toml::Value::String(num) => {
+            return u8::from_str(&num)
+                .map(Some)
+                .inspect(|_| warn!("Configuration Warning: `initial_volume` should be a number rather than a string, this will become a hard error in the future"))
+                .map_err(de::Error::custom)
+        }
+        toml::Value::Float(f) => Unexpected::Float(f),
+        toml::Value::Boolean(b) => Unexpected::Bool(b),
+        toml::Value::Datetime(_) => Unexpected::Other("datetime"),
+        toml::Value::Array(_) => Unexpected::Seq,
+        toml::Value::Table(_) => Unexpected::Map,
+    };
+    Err(de::Error::invalid_type(unexpected, &"number"))
+}
+
+#[derive(Debug, Default, Parser)]
+#[command(version, about, long_about = None, args_conflicts_with_subcommands = true)]
 pub struct CliConfig {
     /// The path to the config file to use
-    #[structopt(long, value_name = "string")]
+    #[arg(long, value_name = "PATH", global = true)]
     pub config_path: Option<PathBuf>,
 
+    /// Prints more verbose output
+    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
+    pub verbose: u8,
+
     /// If set, starts spotifyd without detaching
-    #[structopt(long)]
+    #[arg(long)]
     pub no_daemon: bool,
 
-    /// Prints more verbose output
-    #[structopt(long)]
-    pub verbose: bool,
-
     /// Path to PID file.
-    #[structopt(long)]
+    #[cfg(unix)]
+    #[arg(long, value_name = "PATH")]
     pub pid: Option<PathBuf>,
 
-    #[structopt(flatten)]
+    #[command(subcommand)]
+    pub mode: Option<ExecutionMode>,
+
+    #[command(flatten)]
     pub shared_config: SharedConfigValues,
 }
 
+#[derive(Debug, Subcommand)]
+pub enum ExecutionMode {
+    #[command(visible_alias = "auth")]
+    Authenticate {
+        /// The port to use for the OAuth redirect
+        #[arg(long, default_value_t = 8000)]
+        oauth_port: u16,
+    },
+}
+
 // A struct that holds all allowed config fields.
 // The actual config file is made up of two sections, spotifyd and global.
-#[derive(Clone, Default, Deserialize, PartialEq, StructOpt)]
+#[derive(Clone, Default, Debug, Deserialize, PartialEq, Args)]
 pub struct SharedConfigValues {
-    /// The Spotify account user name
-    #[structopt(conflicts_with = "username_cmd", long, short, value_name = "string")]
-    username: Option<String>,
-
-    /// A command that can be used to retrieve the Spotify account username
-    #[structopt(
-        conflicts_with = "username",
-        long,
-        short = "U",
-        value_name = "string",
-        visible_alias = "username_cmd"
-    )]
-    username_cmd: Option<String>,
-
-    /// The Spotify account password
-    #[structopt(conflicts_with = "password_cmd", long, short, value_name = "string")]
-    password: Option<String>,
-
-    /// Enables keyring password access
-    #[cfg_attr(
-        feature = "dbus_keyring",
-        structopt(long),
-        serde(alias = "use-keyring", default)
-    )]
-    #[cfg_attr(not(feature = "dbus_keyring"), structopt(skip), serde(skip))]
-    use_keyring: bool,
-
-    /// Enables the MPRIS interface
-    #[cfg_attr(
-        feature = "dbus_mpris",
-        structopt(long),
-        serde(alias = "use-mpris", default)
-    )]
-    #[cfg_attr(not(feature = "dbus_mpris"), structopt(skip), serde(skip))]
-    use_mpris: Option<bool>,
-
-    /// The Bus-type to use for the MPRIS interface
-    #[cfg_attr(
-        feature = "dbus_mpris",
-        structopt(long, possible_values = &DBUSTYPE_VALUES, value_name = "string")
-    )]
-    #[cfg_attr(not(feature = "dbus_mpris"), structopt(skip), serde(skip))]
-    dbus_type: Option<DBusType>,
-
-    /// A command that can be used to retrieve the Spotify account password
-    #[structopt(
-        conflicts_with = "password",
-        long,
-        short = "P",
-        value_name = "string",
-        visible_alias = "password_cmd"
-    )]
-    password_cmd: Option<String>,
-
-    /// Whether the credentials should be debugged.
-    #[structopt(long)]
-    #[serde(skip)]
-    debug_credentials: bool,
-
     /// A script that gets evaluated in the user's shell when the song changes
-    #[structopt(visible_alias = "onevent", long, value_name = "string")]
+    #[arg(visible_alias = "onevent", long, value_name = "CMD")]
     #[serde(alias = "onevent")]
     on_song_change_hook: Option<String>,
 
     /// The cache path used to store credentials and music file artifacts
-    #[structopt(long, parse(from_os_str), short, value_name = "string")]
+    #[arg(long, short, value_name = "PATH", global = true)]
     cache_path: Option<PathBuf>,
 
     /// The maximal cache size in bytes
-    #[structopt(long)]
+    #[arg(long, value_name = "BYTES")]
     max_cache_size: Option<u64>,
 
     /// Disable the use of audio cache
-    #[structopt(long)]
-    #[serde(default)]
-    no_audio_cache: bool,
+    #[arg(
+        long,
+        default_missing_value("true"),
+        require_equals = true,
+        num_args(0..=1),
+        value_name = "BOOL"
+    )]
+    no_audio_cache: Option<bool>,
 
     /// The audio backend to use
-    #[structopt(long, short, possible_values = &BACKEND_VALUES, value_name = "string")]
-    backend: Option<Backend>,
+    #[arg(long, short, value_parser = possible_backends())]
+    #[serde(deserialize_with = "deserialize_backend")]
+    backend: Option<String>,
 
     /// The volume controller to use
-    #[structopt(long, short, possible_values = &VOLUME_CONTROLLER_VALUES, visible_alias = "volume-control")]
+    #[arg(value_enum, long, visible_alias = "volume-control")]
     #[serde(alias = "volume-control")]
     volume_controller: Option<VolumeController>,
 
-    /// The audio device (or file handle if using pipe backend)
-    #[structopt(long, value_name = "string")]
+    /// The audio device (or pipe file)
+    #[arg(long)]
     device: Option<String>,
 
-    /// The control device
-    #[structopt(long, value_name = "string")]
-    control: Option<String>,
-
-    /// The mixer to use
-    #[structopt(long, value_name = "string")]
-    mixer: Option<String>,
-
     /// The device name displayed in Spotify
-    #[structopt(long, short, value_name = "string")]
+    #[arg(long, short)]
     device_name: Option<String>,
 
     /// The bitrate of the streamed audio data
-    #[structopt(long, short = "B", possible_values = &BITRATE_VALUES, value_name = "number")]
+    #[arg(long, short = 'B', value_parser = bitrate_parser())]
     bitrate: Option<Bitrate>,
 
     /// The audio format of the streamed audio data
-    #[structopt(long, possible_values = &AUDIO_FORMAT_VALUES, value_name = "string")]
+    #[arg(value_enum, long)]
     audio_format: Option<AudioFormat>,
 
     /// Initial volume between 0 and 100
-    #[structopt(long, value_name = "initial_volume")]
-    initial_volume: Option<String>,
+    #[arg(long)]
+    #[serde(deserialize_with = "number_or_string", default)]
+    initial_volume: Option<u8>,
 
     /// Enable to normalize the volume during playback
-    #[structopt(long)]
-    #[serde(default)]
-    volume_normalisation: bool,
+    #[arg(
+        long,
+        default_missing_value("true"),
+        require_equals = true,
+        num_args(0..=1),
+        value_name = "BOOL"
+    )]
+    volume_normalisation: Option<bool>,
 
     /// A custom pregain applied before sending the audio to the output device
-    #[structopt(long, value_name = "number")]
+    #[arg(long)]
     normalisation_pregain: Option<f64>,
 
+    #[arg(
+        long,
+        default_missing_value("true"),
+        require_equals = true,
+        num_args(0..=1),
+        value_name = "BOOL"
+    )]
+    disable_discovery: Option<bool>,
+
     /// The port used for the Spotify Connect discovery
-    #[structopt(long, value_name = "number")]
+    #[arg(long)]
     zeroconf_port: Option<u16>,
 
     /// The proxy used to connect to spotify's servers
-    #[structopt(long, value_name = "string")]
+    #[arg(long, value_name = "URL")]
     proxy: Option<String>,
 
     /// The device type shown to clients
-    #[structopt(long, possible_values = &DEVICETYPE_VALUES, value_name = "string")]
+    #[arg(value_enum, long)]
     device_type: Option<DeviceType>,
 
     /// Start playing similar songs after your music has ended
-    #[structopt(long)]
+    #[arg(
+        long,
+        default_missing_value("true"),
+        require_equals = true,
+        num_args(0..=1),
+        value_name = "BOOL"
+    )]
     #[serde(default)]
-    autoplay: bool,
+    autoplay: Option<bool>,
+
+    #[cfg(feature = "alsa_backend")]
+    #[command(flatten)]
+    #[serde(flatten)]
+    alsa_config: AlsaConfig,
+
+    #[cfg(feature = "dbus_mpris")]
+    #[command(flatten)]
+    #[serde(flatten)]
+    mpris_config: MprisConfig,
+}
+
+#[cfg(feature = "dbus_mpris")]
+#[derive(Debug, Default, Clone, Deserialize, Args, PartialEq, Eq)]
+pub struct MprisConfig {
+    /// Enables the MPRIS interface
+    #[arg(
+        long,
+        default_missing_value("true"),
+        require_equals = true,
+        num_args(0..=1),
+        value_name = "BOOL"
+    )]
+    #[serde(alias = "use-mpris")]
+    pub(crate) use_mpris: Option<bool>,
+
+    /// The Bus-type to use for the MPRIS interface
+    #[arg(value_enum, long)]
+    pub(crate) dbus_type: Option<DBusType>,
+}
+
+#[cfg(feature = "alsa_backend")]
+#[derive(Debug, Default, Clone, Deserialize, Args, PartialEq, Eq)]
+pub struct AlsaConfig {
+    /// The control device
+    #[arg(long)]
+    pub(crate) control: Option<String>,
+
+    /// The mixer to use
+    #[arg(long)]
+    pub(crate) mixer: Option<String>,
 }
 
 #[derive(Debug, Default, Deserialize)]
@@ -547,84 +410,13 @@ pub struct FileConfig {
 
 impl FileConfig {
     pub fn get_merged_sections(self) -> Option<SharedConfigValues> {
-        let global_config_section = self.global;
-        let spotifyd_config_section = self.spotifyd;
-
-        let merged_config: Option<SharedConfigValues>;
-        // First merge the two sections together. The spotifyd has priority over global
-        // section.
-        if let Some(mut spotifyd_section) = spotifyd_config_section {
-            // spotifyd section exists. Try to merge it with global section.
-            #[allow(clippy::branches_sharing_code)]
-            if let Some(global_section) = global_config_section {
-                spotifyd_section.merge_with(global_section);
-                merged_config = Some(spotifyd_section);
-            } else {
-                // There is no global section. Just use the spotifyd section.
-                merged_config = Some(spotifyd_section);
+        match (self.global, self.spotifyd) {
+            (Some(global), Some(mut spotifyd)) => {
+                spotifyd.merge_with(global);
+                Some(spotifyd)
             }
-        } else {
-            // No spotifyd config available. Check for global and use that, if both are
-            // none, use none.
-            merged_config = global_config_section;
+            (global, spotifyd) => global.or(spotifyd),
         }
-
-        merged_config
-    }
-}
-
-impl fmt::Debug for SharedConfigValues {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let placeholder = "taken out for privacy";
-
-        macro_rules! extract_credential {
-            ( $e:expr ) => {
-                match $e {
-                    Some(s) => match self.debug_credentials {
-                        true => Some(s.as_str()),
-                        false => Some(placeholder),
-                    },
-                    None => None,
-                }
-            };
-        }
-
-        let password_value = extract_credential!(&self.password);
-
-        let password_cmd_value = extract_credential!(&self.password_cmd);
-
-        let username_value = extract_credential!(&self.username);
-
-        let username_cmd_value = extract_credential!(&self.username_cmd);
-
-        f.debug_struct("SharedConfigValues")
-            .field("username", &username_value)
-            .field("username_cmd", &username_cmd_value)
-            .field("password", &password_value)
-            .field("password_cmd", &password_cmd_value)
-            .field("use_keyring", &self.use_keyring)
-            .field("use_mpris", &self.use_mpris)
-            .field("dbus_type", &self.dbus_type)
-            .field("on_song_change_hook", &self.on_song_change_hook)
-            .field("cache_path", &self.cache_path)
-            .field("no-audio-cache", &self.no_audio_cache)
-            .field("backend", &self.backend)
-            .field("volume_controller", &self.volume_controller)
-            .field("device", &self.device)
-            .field("control", &self.control)
-            .field("mixer", &self.mixer)
-            .field("device_name", &self.device_name)
-            .field("bitrate", &self.bitrate)
-            .field("audio_format", &self.audio_format)
-            .field("initial_volume", &self.initial_volume)
-            .field("volume_normalisation", &self.volume_normalisation)
-            .field("normalisation_pregain", &self.normalisation_pregain)
-            .field("zeroconf_port", &self.zeroconf_port)
-            .field("proxy", &self.proxy)
-            .field("device_type", &self.device_type)
-            .field("autoplay", &self.autoplay)
-            .field("max_cache_size", &self.max_cache_size)
-            .finish()
     }
 }
 
@@ -659,52 +451,96 @@ impl CliConfig {
 }
 
 impl SharedConfigValues {
-    pub fn merge_with(&mut self, other: SharedConfigValues) {
+    pub fn get_cache(&self, for_oauth: bool) -> color_eyre::Result<Cache> {
+        let Some(cache_path) = self.cache_path.as_deref().map(Cow::Borrowed).or_else(|| {
+            ProjectDirs::from("", "", "spotifyd")
+                .map(|dirs| Cow::Owned(dirs.cache_dir().to_path_buf()))
+        }) else {
+            bail!("Failed to determine cache directory, please specify one manually");
+        };
+
+        if for_oauth {
+            let mut creds_path = cache_path.into_owned();
+            creds_path.push("oauth");
+            Cache::new(Some(creds_path), None, None, None)
+        } else {
+            let audio_cache = !self.no_audio_cache.unwrap_or(false);
+
+            let mut creds_path = cache_path.to_path_buf();
+            creds_path.push("zeroconf");
+            Cache::new(
+                Some(creds_path.as_path()),
+                Some(cache_path.as_ref()),
+                audio_cache.then_some(cache_path.as_ref()),
+                self.max_cache_size,
+            )
+        }
+        .wrap_err("Failed to initialize cache")
+    }
+
+    pub fn proxy_url(&self) -> Option<Url> {
+        match &self.proxy {
+            Some(s) => match Url::parse(s) {
+                Ok(url) => {
+                    if url.scheme() != "http" {
+                        error!("Only HTTP proxies are supported!");
+                        None
+                    } else {
+                        Some(url)
+                    }
+                }
+                Err(err) => {
+                    error!("Invalid proxy URL: {}", err);
+                    None
+                }
+            },
+            None => {
+                debug!("No proxy specified");
+                None
+            }
+        }
+    }
+
+    pub fn merge_with(&mut self, mut other: SharedConfigValues) {
         macro_rules! merge {
-            ($($x:ident),+) => {
-                $(self.$x = self.$x.clone().or_else(|| other.$x.clone());)+
+            ($a:expr; and $b:expr => {$($x:ident),+}) => {
+                $($a.$x = $a.$x.take().or_else(|| $b.$x.take());)+
             }
         }
 
         // Handles Option<T> merging.
-        merge!(
+        merge!(self; and other => {
             backend,
-            username,
-            username_cmd,
-            password,
-            password_cmd,
+            volume_normalisation,
             normalisation_pregain,
             bitrate,
             initial_volume,
             device_name,
-            mixer,
-            control,
             device,
             volume_controller,
             cache_path,
+            no_audio_cache,
             on_song_change_hook,
+            disable_discovery,
             zeroconf_port,
             proxy,
             device_type,
-            use_mpris,
             max_cache_size,
-            dbus_type,
-            audio_format
-        );
-
-        // Handles boolean merging.
-        self.use_keyring |= other.use_keyring;
-        self.volume_normalisation |= other.volume_normalisation;
-        self.no_audio_cache |= other.no_audio_cache;
-        self.autoplay |= other.autoplay;
+            audio_format,
+            autoplay
+        });
+
+        #[cfg(feature = "dbus_mpris")]
+        merge!(self.mpris_config; and other.mpris_config => {use_mpris, dbus_type});
+        #[cfg(feature = "alsa_backend")]
+        merge!(self.alsa_config; and other.alsa_config => {mixer, control});
     }
 }
 
 pub(crate) fn get_config_file() -> Option<PathBuf> {
     let etc_conf = format!("/etc/{}", CONFIG_FILE_NAME);
-    let dirs = directories::BaseDirs::new()?;
+    let dirs = directories::ProjectDirs::from("", "", "spotifyd")?;
     let mut path = dirs.config_dir().to_path_buf();
-    path.push("spotifyd");
     path.push(CONFIG_FILE_NAME);
 
     if path.exists() {
@@ -722,54 +558,43 @@ fn device_id(name: &str) -> String {
 }
 
 pub(crate) struct SpotifydConfig {
-    pub(crate) username: Option<String>,
-    pub(crate) password: Option<String>,
-    #[allow(unused)]
-    pub(crate) use_keyring: bool,
-    pub(crate) use_mpris: bool,
-    pub(crate) dbus_type: DBusType,
     pub(crate) cache: Option<Cache>,
+    pub(crate) oauth_cache: Option<Cache>,
     pub(crate) backend: Option<String>,
     pub(crate) audio_device: Option<String>,
     pub(crate) audio_format: LSAudioFormat,
-    #[allow(unused)]
-    pub(crate) control_device: Option<String>,
-    #[allow(unused)]
-    pub(crate) mixer: Option<String>,
-    #[allow(unused)]
     pub(crate) volume_controller: VolumeController,
     pub(crate) initial_volume: Option<u16>,
     pub(crate) device_name: String,
     pub(crate) player_config: PlayerConfig,
     pub(crate) session_config: SessionConfig,
     pub(crate) onevent: Option<String>,
-    #[allow(unused)]
+    #[cfg(unix)]
     pub(crate) pid: Option<String>,
     pub(crate) shell: String,
+    pub(crate) discovery: bool,
     pub(crate) zeroconf_port: Option<u16>,
-    pub(crate) device_type: String,
+    pub(crate) device_type: LSDeviceType,
+    #[cfg(feature = "dbus_mpris")]
+    pub(crate) mpris: MprisConfig,
+    #[cfg(feature = "alsa_backend")]
+    pub(crate) alsa_config: AlsaConfig,
 }
 
 pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
-    let audio_cache = !config.shared_config.no_audio_cache;
-
-    let size_limit = config.shared_config.max_cache_size;
-    let cache = config
-        .shared_config
-        .cache_path
-        .map(|path| {
-            Cache::new(
-                Some(&path),
-                Some(&path),
-                audio_cache.then_some(&path),
-                size_limit,
-            )
-        })
-        .transpose()
-        .unwrap_or_else(|e| {
-            warn!("Cache couldn't be initialized: {e}");
-            None
-        });
+    let (cache, oauth_cache) = match (
+        config.shared_config.get_cache(false),
+        config.shared_config.get_cache(true),
+    ) {
+        (Ok(cache), Ok(oauth_cache)) => (Some(cache), Some(oauth_cache)),
+        (a, b) => {
+            // at least one of the results are err
+            let err = a.or(b).map(|_| ()).unwrap_err();
+            warn!("{err}");
+            (None, None)
+        }
+    };
+    let proxy_url = config.shared_config.proxy_url();
 
     let bitrate: LSBitrate = config
         .shared_config
@@ -783,12 +608,6 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
         .unwrap_or(AudioFormat::S16)
         .into();
 
-    let backend = config
-        .shared_config
-        .backend
-        .unwrap_or_else(default_backend)
-        .to_string();
-
     let volume_controller = config
         .shared_config
         .volume_controller
@@ -797,14 +616,15 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
     let initial_volume: Option<u16> = config
         .shared_config
         .initial_volume
-        .and_then(|input| match input.parse::<i16>() {
-            Ok(v) if (0..=100).contains(&v) => Some(v),
-            _ => {
-                warn!("Could not parse initial_volume (must be in the range 0-100)");
-                None
+        .filter(|val| {
+            if (0..=100).contains(val) {
+                true
+            } else {
+                warn!("initial_volume must be in range 0..100");
+                false
             }
         })
-        .map(|volume| (volume as i32 * 0xFFFF / 100) as u16);
+        .map(|volume| (volume as i32 * (u16::MAX as i32) / 100) as u16);
 
     let device_name = config
         .shared_config
@@ -816,15 +636,13 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
 
     let normalisation_pregain = config.shared_config.normalisation_pregain.unwrap_or(0.0);
 
-    let dbus_type = config.shared_config.dbus_type.unwrap_or(DBusType::Session);
-    let autoplay = config.shared_config.autoplay;
-
     let device_type = config
         .shared_config
         .device_type
         .unwrap_or(DeviceType::Speaker)
-        .to_string();
+        .into();
 
+    #[cfg(unix)]
     let pid = config.pid.map(|f| {
         f.into_os_string()
             .into_string()
@@ -836,45 +654,6 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
         "sh".to_string()
     });
 
-    let mut username = config.shared_config.username;
-    if username.is_none() {
-        info!("No username specified. Checking username_cmd");
-        match config.shared_config.username_cmd {
-            Some(ref cmd) => match run_program(&shell, cmd) {
-                Ok(s) => username = Some(s.trim().to_string()),
-                Err(e) => error!("{}", CrateError::subprocess_with_err(&shell, cmd, e)),
-            },
-            None => info!("No username_cmd specified"),
-        }
-    }
-
-    let mut password = config.shared_config.password;
-    if password.is_none() {
-        info!("No password specified. Checking password_cmd");
-
-        match config.shared_config.password_cmd {
-            Some(ref cmd) => match run_program(&shell, cmd) {
-                Ok(s) => password = Some(s.trim().to_string()),
-                Err(e) => error!("{}", CrateError::subprocess_with_err(&shell, cmd, e)),
-            },
-            None => info!("No password_cmd specified"),
-        }
-    }
-    let mut proxy_url = None;
-    match config.shared_config.proxy {
-        Some(s) => match Url::parse(&s) {
-            Ok(url) => {
-                if url.scheme() != "http" {
-                    error!("Only HTTP proxies are supported!");
-                } else {
-                    proxy_url = Some(url);
-                }
-            }
-            Err(err) => error!("Invalid proxy URL: {}", err),
-        },
-        None => info!("No proxy specified"),
-    }
-
     // choose default ditherer the same way librespot does
     let ditherer: Option<DithererBuilder> = match audio_format {
         LSAudioFormat::S16 | LSAudioFormat::S24 | LSAudioFormat::S24_3 => {
@@ -888,7 +667,7 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
     //  should consider adding them to Spotifyd's config system.
     let pc = PlayerConfig {
         bitrate,
-        normalisation: config.shared_config.volume_normalisation,
+        normalisation: config.shared_config.volume_normalisation.unwrap_or(false),
         normalisation_pregain_db: normalisation_pregain,
         gapless: true,
         ditherer,
@@ -896,33 +675,33 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
     };
 
     SpotifydConfig {
-        username,
-        password,
-        use_keyring: config.shared_config.use_keyring,
-        use_mpris: config.shared_config.use_mpris.unwrap_or(true),
-        dbus_type,
         cache,
-        backend: Some(backend),
+        oauth_cache,
+        backend: config.shared_config.backend,
         audio_device: config.shared_config.device,
         audio_format,
-        control_device: config.shared_config.control,
-        mixer: config.shared_config.mixer,
         volume_controller,
         initial_volume,
         device_name,
         player_config: pc,
         session_config: SessionConfig {
-            autoplay: Some(autoplay),
+            autoplay: config.shared_config.autoplay,
             device_id,
             proxy: proxy_url,
             ap_port: Some(443),
             ..Default::default()
         },
         onevent: config.shared_config.on_song_change_hook,
-        pid,
         shell,
+        discovery: !config.shared_config.disable_discovery.unwrap_or(false),
         zeroconf_port: config.shared_config.zeroconf_port,
         device_type,
+        #[cfg(unix)]
+        pid,
+        #[cfg(feature = "dbus_mpris")]
+        mpris: config.shared_config.mpris_config,
+        #[cfg(feature = "alsa_backend")]
+        alsa_config: config.shared_config.alsa_config,
     }
 }
 
@@ -933,12 +712,12 @@ mod tests {
     #[test]
     fn test_section_merging() {
         let mut spotifyd_section = SharedConfigValues {
-            password: Some("123456".to_string()),
+            device_type: Some(DeviceType::Computer),
             ..Default::default()
         };
 
         let global_section = SharedConfigValues {
-            username: Some("testUserName".to_string()),
+            device_name: Some("spotifyd-test".to_string()),
             ..Default::default()
         };
 
@@ -952,15 +731,7 @@ mod tests {
         let merged_config = file_config.get_merged_sections().unwrap();
 
         // Add the new field to spotifyd section.
-        spotifyd_section.username = Some("testUserName".to_string());
+        spotifyd_section.device_name = Some("spotifyd-test".to_string());
         assert_eq!(merged_config, spotifyd_section);
     }
-    #[test]
-    fn test_default_backend() {
-        let spotifyd_config = get_internal_config(CliConfig::default());
-        assert_eq!(
-            spotifyd_config.backend.unwrap(),
-            default_backend().to_string()
-        );
-    }
 }
diff --git a/src/main.rs b/src/main.rs
index a61068c1..f041ab91 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,20 +1,19 @@
 use crate::config::CliConfig;
+use clap::Parser;
 #[cfg(unix)]
 use color_eyre::eyre::eyre;
-use color_eyre::{
-    eyre::{self, Context},
-    Help, SectionExt,
-};
+use color_eyre::eyre::{self, Context};
+use config::ExecutionMode;
 #[cfg(unix)]
 use daemonize::Daemonize;
 #[cfg(unix)]
 use log::error;
 use log::{info, trace, LevelFilter};
+use oauth::run_oauth;
 #[cfg(target_os = "openbsd")]
 use pledge::pledge;
 #[cfg(windows)]
 use std::fs;
-use structopt::StructOpt;
 use tokio::runtime::Runtime;
 
 #[cfg(feature = "alsa_backend")]
@@ -25,6 +24,7 @@ mod dbus_mpris;
 mod error;
 mod main_loop;
 mod no_mixer;
+mod oauth;
 mod process;
 mod setup;
 mod utils;
@@ -34,16 +34,15 @@ enum LogTarget {
     Syslog,
 }
 
-fn setup_logger(log_target: LogTarget, verbose: bool) -> eyre::Result<()> {
-    let log_level = if verbose {
-        LevelFilter::Trace
-    } else {
-        LevelFilter::Info
+fn setup_logger(log_target: LogTarget, verbose: u8) -> eyre::Result<()> {
+    let log_level = match verbose {
+        0 => LevelFilter::Info,
+        1 => LevelFilter::Debug,
+        2.. => LevelFilter::Trace,
     };
-
     let mut logger = fern::Dispatch::new().level(log_level);
 
-    logger = if verbose {
+    logger = if verbose > 0 {
         logger.format(|out, message, record| {
             out.finish(format_args!(
                 "[{} {}] {}",
@@ -64,7 +63,7 @@ fn setup_logger(log_target: LogTarget, verbose: bool) -> eyre::Result<()> {
                 facility: syslog::Facility::LOG_DAEMON,
                 hostname: None,
                 process: "spotifyd".to_owned(),
-                pid: 0,
+                pid: std::process::id(),
             };
             logger.chain(
                 syslog::unix(log_format)
@@ -107,8 +106,15 @@ fn main() -> eyre::Result<()> {
 
     color_eyre::install().wrap_err("Couldn't initialize error reporting")?;
 
-    let mut cli_config: CliConfig = CliConfig::from_args();
+    let cli_config = CliConfig::parse();
 
+    match cli_config.mode {
+        None => run_daemon(cli_config),
+        Some(ExecutionMode::Authenticate { oauth_port }) => run_oauth(cli_config, oauth_port),
+    }
+}
+
+fn run_daemon(mut cli_config: CliConfig) -> eyre::Result<()> {
     let is_daemon = !cli_config.no_daemon;
 
     let log_target = if is_daemon {
@@ -139,14 +145,7 @@ fn main() -> eyre::Result<()> {
 
     cli_config
         .load_config_file_values()
-        .wrap_err("could not load the config file")
-        .with_section(|| {
-            concat!(
-                "the config format should be valid TOML\n",
-                "we recently changed the config format, see https://github.com/Spotifyd/spotifyd/issues/765"
-            )
-            .header("note:")
-        })?;
+        .wrap_err("could not load the config file")?;
     trace!("{:?}", &cli_config);
 
     // Returns the old SpotifydConfig struct used within the rest of the daemon.
@@ -218,9 +217,8 @@ fn main() -> eyre::Result<()> {
 
     let runtime = Runtime::new().unwrap();
     runtime.block_on(async {
-        let initial_state = setup::initial_state(internal_config);
+        let initial_state = setup::initial_state(internal_config)?;
         initial_state.run().await;
-    });
-
-    Ok(())
+        Ok(())
+    })
 }
diff --git a/src/main_loop.rs b/src/main_loop.rs
index 0155c4bf..39a22c9e 100644
--- a/src/main_loop.rs
+++ b/src/main_loop.rs
@@ -1,4 +1,5 @@
-use crate::config::DBusType;
+#[cfg(feature = "dbus_mpris")]
+use crate::config::{DBusType, MprisConfig};
 #[cfg(feature = "dbus_mpris")]
 use crate::dbus_mpris::DbusServer;
 use crate::process::spawn_program_on_event;
@@ -31,28 +32,35 @@ use std::sync::Arc;
 type DbusServer = Pending<()>;
 
 pub(crate) enum CredentialsProvider {
-    Discovery(Peekable<Discovery>),
-    SpotifyCredentials(Credentials),
-}
-
-impl From<Discovery> for CredentialsProvider {
-    fn from(stream: Discovery) -> Self {
-        CredentialsProvider::Discovery(stream.peekable())
-    }
+    Discovery {
+        stream: Peekable<Discovery>,
+        last_credentials: Option<Credentials>,
+    },
+    CredentialsOnly(Credentials),
 }
 
 impl CredentialsProvider {
     async fn get_credentials(&mut self) -> Credentials {
         match self {
-            CredentialsProvider::Discovery(stream) => stream.next().await.unwrap(),
-            CredentialsProvider::SpotifyCredentials(creds) => creds.clone(),
+            CredentialsProvider::Discovery {
+                stream,
+                last_credentials,
+            } => {
+                let new_creds = match last_credentials.take() {
+                    Some(creds) => stream.next().now_or_never().flatten().unwrap_or(creds),
+                    None => stream.next().await.unwrap(),
+                };
+                *last_credentials = Some(new_creds.clone());
+                new_creds
+            }
+            CredentialsProvider::CredentialsOnly(creds) => creds.clone(),
         }
     }
 
     // wait for an incoming connection if the underlying provider is a discovery stream
     async fn incoming_connection(&mut self) {
         match self {
-            CredentialsProvider::Discovery(stream) => {
+            CredentialsProvider::Discovery { stream, .. } => {
                 let peeked = Pin::new(stream).peek().await;
                 if peeked.is_none() {
                     future::pending().await
@@ -77,11 +85,9 @@ pub(crate) struct MainLoop {
     pub(crate) device_type: DeviceType,
     pub(crate) device_name: String,
     pub(crate) player_event_program: Option<String>,
-    #[cfg_attr(not(feature = "dbus_mpris"), allow(unused))]
-    pub(crate) use_mpris: bool,
-    #[cfg_attr(not(feature = "dbus_mpris"), allow(unused))]
-    pub(crate) dbus_type: DBusType,
     pub(crate) credentials_provider: CredentialsProvider,
+    #[cfg(feature = "dbus_mpris")]
+    pub(crate) mpris_config: MprisConfig,
 }
 
 struct ConnectionInfo<SpircTask: Future<Output = ()>> {
@@ -140,9 +146,12 @@ impl MainLoop {
         }
 
         #[cfg(feature = "dbus_mpris")]
-        let mpris_event_tx = if self.use_mpris {
+        let mpris_event_tx = if self.mpris_config.use_mpris.unwrap_or(true) {
             let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
-            *dbus_server.as_mut() = Either::Left(DbusServer::new(rx, self.dbus_type));
+            *dbus_server.as_mut() = Either::Left(DbusServer::new(
+                rx,
+                self.mpris_config.dbus_type.unwrap_or(DBusType::Session),
+            ));
             Some(tx)
         } else {
             None
@@ -151,9 +160,6 @@ impl MainLoop {
         'mainloop: loop {
             let connection = tokio::select!(
                 _ = &mut ctrl_c => {
-                    if let CredentialsProvider::Discovery(stream) = self.credentials_provider {
-                        let _ = stream.into_inner().shutdown().await;
-                    }
                     break 'mainloop;
                 }
                 connection = self.get_connection() => {
@@ -251,6 +257,9 @@ impl MainLoop {
                 }
             }
         }
+        if let CredentialsProvider::Discovery { stream, .. } = self.credentials_provider {
+            let _ = stream.into_inner().shutdown().await;
+        }
         #[cfg(feature = "dbus_mpris")]
         if let Either::Left(dbus_server) = Either::as_pin_mut(dbus_server.as_mut()) {
             if dbus_server.shutdown() {
diff --git a/src/oauth.rs b/src/oauth.rs
new file mode 100644
index 00000000..1effeee7
--- /dev/null
+++ b/src/oauth.rs
@@ -0,0 +1,75 @@
+use color_eyre::{
+    eyre::{self, Context as _},
+    Section as _,
+};
+use librespot_core::SessionConfig;
+use librespot_core::{authentication::Credentials, Session};
+use log::info;
+use tokio::runtime::Runtime;
+
+use crate::{config::CliConfig, setup_logger, LogTarget};
+
+pub(crate) fn run_oauth(mut cli_config: CliConfig, oauth_port: u16) -> eyre::Result<()> {
+    setup_logger(LogTarget::Terminal, cli_config.verbose)?;
+
+    cli_config
+        .load_config_file_values()
+        .wrap_err("failed to read config file")?;
+
+    let cache = cli_config
+        .shared_config
+        .get_cache(true)
+        .with_note(|| "The result of the authentication needs to be cached to be usable later.")?;
+
+    const OAUTH_SCOPES: &[&str] = &[
+        "app-remote-control",
+        "playlist-modify",
+        "playlist-modify-private",
+        "playlist-modify-public",
+        "playlist-read",
+        "playlist-read-collaborative",
+        "playlist-read-private",
+        "streaming",
+        "ugc-image-upload",
+        "user-follow-modify",
+        "user-follow-read",
+        "user-library-modify",
+        "user-library-read",
+        "user-modify",
+        "user-modify-playback-state",
+        "user-modify-private",
+        "user-personalized",
+        "user-read-birthdate",
+        "user-read-currently-playing",
+        "user-read-email",
+        "user-read-play-history",
+        "user-read-playback-position",
+        "user-read-playback-state",
+        "user-read-private",
+        "user-read-recently-played",
+        "user-top-read",
+    ];
+
+    let session_config = SessionConfig {
+        proxy: cli_config.shared_config.proxy_url(),
+        ..Default::default()
+    };
+
+    let token = librespot_oauth::get_access_token(
+        &session_config.client_id,
+        &format!("http://127.0.0.1:{oauth_port}/login"),
+        OAUTH_SCOPES.to_vec(),
+    )
+    .wrap_err("token retrieval failed")?;
+
+    let creds = Credentials::with_access_token(token.access_token);
+
+    Runtime::new().unwrap().block_on(async move {
+        let session = Session::new(session_config, Some(cache));
+        session.connect(creds, true).await
+    })?;
+
+    info!("\nLogin successful! You are now ready to run spotifyd.");
+
+    Ok(())
+}
diff --git a/src/process.rs b/src/process.rs
index 39d92ac4..3d781e6c 100644
--- a/src/process.rs
+++ b/src/process.rs
@@ -8,24 +8,6 @@ use tokio::{
     process::{self, Command},
 };
 
-/// Blocks while provided command is run in a subprocess using the provided
-/// shell. If successful, returns the contents of the subprocess's `stdout` as a
-/// `String`.
-pub(crate) fn run_program(shell: &str, cmd: &str) -> Result<String, Error> {
-    info!("Running {:?} using {:?}", cmd, shell);
-    let output = std::process::Command::new(shell)
-        .arg("-c")
-        .arg(cmd)
-        .output()
-        .map_err(|e| Error::subprocess_with_err(shell, cmd, e))?;
-    if !output.status.success() {
-        let s = std::str::from_utf8(&output.stderr).map_err(|_| Error::subprocess(shell, cmd))?;
-        return Err(Error::subprocess_with_str(shell, cmd, s));
-    }
-    let s = String::from_utf8(output.stdout).map_err(|_| Error::subprocess(shell, cmd))?;
-    Ok(s)
-}
-
 /// Spawns provided command in a subprocess using the provided shell.
 fn spawn_program(shell: &str, cmd: &str, env: HashMap<&str, String>) -> Result<Child, Error> {
     info!(
diff --git a/src/setup.rs b/src/setup.rs
index 735c9ca7..2d271ab3 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -4,20 +4,18 @@ use crate::{
     config,
     main_loop::{self, CredentialsProvider},
 };
-#[cfg(feature = "dbus_keyring")]
-use keyring::Entry;
-use librespot_core::{authentication::Credentials, cache::Cache, config::DeviceType};
-use librespot_playback::mixer::MixerConfig;
+use color_eyre::{eyre::eyre, Section};
+use futures::StreamExt as _;
 use librespot_playback::{
-    audio_backend::{Sink, BACKENDS},
-    config::AudioFormat,
-    mixer::{self, Mixer},
+    audio_backend::{self},
+    mixer::{self, Mixer, MixerConfig},
 };
-#[allow(unused_imports)] // cfg
-use log::{debug, error, info, warn};
-use std::{str::FromStr, sync::Arc, thread, time::Duration};
+use log::{debug, error, info};
+use std::{sync::Arc, thread, time::Duration};
 
-pub(crate) fn initial_state(config: config::SpotifydConfig) -> main_loop::MainLoop {
+pub(crate) fn initial_state(
+    config: config::SpotifydConfig,
+) -> color_eyre::Result<main_loop::MainLoop> {
     let mixer: Arc<dyn Mixer> = {
         match config.volume_controller {
             config::VolumeController::None => {
@@ -27,8 +25,8 @@ pub(crate) fn initial_state(config: config::SpotifydConfig) -> main_loop::MainLo
             #[cfg(feature = "alsa_backend")]
             config::VolumeController::Alsa | config::VolumeController::AlsaLinear => {
                 let audio_device = config.audio_device.clone();
-                let control_device = config.control_device.clone();
-                let mixer = config.mixer.clone();
+                let control_device = config.alsa_config.control.clone();
+                let mixer = config.alsa_config.mixer.clone();
                 info!("Using alsa volume controller.");
                 let linear = matches!(
                     config.volume_controller,
@@ -50,7 +48,6 @@ pub(crate) fn initial_state(config: config::SpotifydConfig) -> main_loop::MainLo
         }
     };
 
-    let cache = config.cache;
     let player_config = config.player_config;
     let session_config = config.session_config;
     let backend = config.backend.clone();
@@ -59,73 +56,79 @@ pub(crate) fn initial_state(config: config::SpotifydConfig) -> main_loop::MainLo
 
     let zeroconf_port = config.zeroconf_port.unwrap_or(0);
 
-    let device_type: DeviceType = DeviceType::from_str(&config.device_type).unwrap_or_default();
-
-    let username = config.username;
-    #[allow(unused_mut)] // mut is needed behind the dbus_keyring flag.
-    let mut password = config.password;
+    let creds = if let Some(creds) = config.oauth_cache.as_ref().and_then(|c| c.credentials()) {
+        info!(
+            "Login via OAuth as user {}.",
+            creds.username.as_deref().unwrap_or("unknown")
+        );
+        Some(creds)
+    } else if let Some(creds) = config.cache.as_ref().and_then(|c| c.credentials()) {
+        info!(
+            "Restoring previous login as user {}.",
+            creds.username.as_deref().unwrap_or("unknown")
+        );
+        Some(creds)
+    } else {
+        None
+    };
 
-    #[cfg(feature = "dbus_keyring")]
-    if config.use_keyring {
-        match (&username, &password) {
-            (None, _) => warn!("Can't query the keyring without a username"),
-            (Some(_), Some(_)) => {
-                info!("Keyring is ignored, since you already configured a password")
-            }
-            (Some(username), None) => {
-                info!("Checking keyring for password");
-                let entry = Entry::new("spotifyd", username);
-                match entry.and_then(|e| e.get_password()) {
-                    Ok(retrieved_password) => password = Some(retrieved_password),
-                    Err(e) => error!("Keyring did not return any results: {e}"),
+    let discovery = if config.discovery {
+        info!("Starting zeroconf server to advertise on local network.");
+        debug!("Using device id '{}'", session_config.device_id);
+        const RETRY_MAX: u8 = 4;
+        let mut retry_counter = 0;
+        let mut backoff = Duration::from_secs(5);
+        loop {
+            match librespot_discovery::Discovery::builder(
+                session_config.device_id.clone(),
+                session_config.client_id.clone(),
+            )
+            .name(config.device_name.clone())
+            .device_type(config.device_type)
+            .port(zeroconf_port)
+            .launch()
+            {
+                Ok(discovery_stream) => break Some(discovery_stream),
+                Err(err) => {
+                    error!("failed to enable discovery: {err}");
+                    if retry_counter >= RETRY_MAX {
+                        error!("maximum amount of retries exceeded");
+                        break None;
+                    }
+                    info!("retrying discovery in {} seconds", backoff.as_secs());
+                    thread::sleep(backoff);
+                    retry_counter += 1;
+                    backoff *= 2;
+                    info!("trying to enable discovery (retry {retry_counter}/{RETRY_MAX})");
                 }
             }
         }
-    }
+    } else {
+        None
+    };
 
-    let credentials_provider =
-        if let Some(credentials) = get_credentials(&cache, &username, &password) {
-            CredentialsProvider::SpotifyCredentials(credentials)
-        } else {
-            info!("no usable credentials found, enabling discovery");
-            debug!("Using device id '{}'", session_config.device_id);
-            const RETRY_MAX: u8 = 4;
-            let mut retry_counter = 0;
-            let mut backoff = Duration::from_secs(5);
-            let discovery_stream = loop {
-                match librespot_discovery::Discovery::builder(
-                    session_config.device_id.clone(),
-                    session_config.client_id.clone(),
-                )
-                .name(config.device_name.clone())
-                .device_type(device_type)
-                .port(zeroconf_port)
-                .launch()
-                {
-                    Ok(discovery_stream) => break discovery_stream,
-                    Err(err) => {
-                        error!("failed to enable discovery: {err}");
-                        if retry_counter >= RETRY_MAX {
-                            panic!("failed to enable discovery (and no credentials provided)");
-                        }
-                        info!("retrying discovery in {} seconds", backoff.as_secs());
-                        thread::sleep(backoff);
-                        retry_counter += 1;
-                        backoff *= 2;
-                        info!("trying to enable discovery (retry {retry_counter}/{RETRY_MAX})");
-                    }
-                }
-            };
-            discovery_stream.into()
-        };
+    let credentials_provider = match (discovery, creds) {
+        (Some(stream), creds) => CredentialsProvider::Discovery {
+            stream: stream.peekable(),
+            last_credentials: creds,
+        },
+        (None, Some(creds)) => CredentialsProvider::CredentialsOnly(creds),
+        (None, None) => {
+            return Err(
+                eyre!("Discovery unavailable and no credentials found.").with_suggestion(|| {
+                    "Try enabling discovery or logging in first with `spotifyd authenticate`."
+                }),
+            );
+        }
+    };
 
-    let backend = find_backend(backend.as_ref().map(String::as_ref));
+    let backend = audio_backend::find(backend).expect("available backends should match ours");
 
-    main_loop::MainLoop {
+    Ok(main_loop::MainLoop {
         credentials_provider,
         mixer,
         session_config,
-        cache,
+        cache: config.cache,
         audio_device: config.audio_device,
         audio_format: config.audio_format,
         player_config,
@@ -133,48 +136,10 @@ pub(crate) fn initial_state(config: config::SpotifydConfig) -> main_loop::MainLo
         initial_volume: config.initial_volume,
         has_volume_ctrl,
         shell: config.shell,
-        device_type,
+        device_type: config.device_type,
         device_name: config.device_name,
         player_event_program: config.onevent,
-        use_mpris: config.use_mpris,
-        dbus_type: config.dbus_type,
-    }
-}
-
-fn get_credentials(
-    cache: &Option<Cache>,
-    username: &Option<String>,
-    password: &Option<String>,
-) -> Option<Credentials> {
-    if let Some(credentials) = cache.as_ref().and_then(Cache::credentials) {
-        if Option::zip(username.as_deref(), credentials.username.as_deref())
-            .is_some_and(|(user_config, user_cached)| user_config == user_cached)
-        {
-            return Some(credentials);
-        }
-    }
-
-    Some(Credentials::with_password(
-        username.as_ref()?,
-        password.as_ref()?,
-    ))
-}
-
-fn find_backend(name: Option<&str>) -> fn(Option<String>, AudioFormat) -> Box<dyn Sink> {
-    match name {
-        Some(name) => {
-            BACKENDS
-                .iter()
-                .find(|backend| name == backend.0)
-                .unwrap_or_else(|| panic!("Unknown backend: {}.", name))
-                .1
-        }
-        None => {
-            let &(name, back) = BACKENDS
-                .first()
-                .expect("No backends were enabled at build time");
-            info!("No backend specified, defaulting to: {}.", name);
-            back
-        }
-    }
+        #[cfg(feature = "dbus_mpris")]
+        mpris_config: config.mpris,
+    })
 }


Test patch: