spotify: replace username/password auth with access token.

Part-of: <https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs/-/merge_requests/1801>
This commit is contained in:
Guillaume Desmottes 2021-12-15 17:15:20 +01:00 committed by GStreamer Marge Bot
parent 13dd1b03c9
commit 9b4942c6dd
6 changed files with 901 additions and 311 deletions

1009
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,9 +11,9 @@ rust-version.workspace = true
[dependencies] [dependencies]
gst.workspace = true gst.workspace = true
gst-base.workspace = true gst-base.workspace = true
librespot-core = "0.4" librespot-core = "0.5"
librespot-playback = "0.4" librespot-playback = { version = "0.5", features = ['passthrough-decoder'] }
tokio = { version = "1", features = ["rt-multi-thread"] } tokio = { version = "1.0", features = ["rt-multi-thread"] }
futures = "0.3" futures = "0.3"
anyhow = "1.0" anyhow = "1.0"
url = "2.3" url = "2.3"

View file

@ -9,23 +9,36 @@ to respect their legal/licensing restrictions.
## Spotify Credentials ## Spotify Credentials
This plugin requires a [Spotify Premium](https://www.spotify.com/premium/) account. This plugin requires a [Spotify Premium](https://www.spotify.com/premium/) account.
If your account is linked with Facebook, you'll need to setup
a [device username and password](https://www.spotify.com/us/account/set-device-password/).
Those username and password are then set using the `username` and `password` properties. Provide a Spotify access token with 'streaming' scope using the `access-token` property. Such a token can be obtained by completing
[Spotify's OAuth flow](https://developer.spotify.com/documentation/web-api/concepts/authorization) or using the facility on their
[Web SDK getting started guide](https://developer.spotify.com/documentation/web-playback-sdk/tutorials/getting-started).
A token can also be obtained using [librespot-oauth](https://github.com/librespot-org/librespot/blob/dev/oauth/examples/oauth.rs):
You may also want to cache credentials and downloaded files, see the `cache-` properties on the element. ```console
cargo install librespot-oauth --example oauth && oauth
```
Note, Spotify access tokens are only valid for 1 hour and must be [refreshed](https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens)
for usage beyond that.
It is therefore advisable to also use the `cache-credentials` property. On first usage, your access token is exchanged for a reusable credentials blob and
stored at the location specified by this property. Once obtained, that credentials blob is used for login and any provided `access-token` is ignored.
Unlike Spotify access tokens, the user's credentials blob does not expire. Avoiding handling token refresh greatly simplifies plugin usage.
If you do not set `cache-credentials`, you must manage refreshing your Spotify access token so it's valid for login when the element starts.
You may also want to cache downloaded files, see the `cache-files` property.
## spotifyaudiosrc ## spotifyaudiosrc
The `spotifyaudiosrc` element can be used to play a song from Spotify using its [Spotify URI](https://community.spotify.com/t5/FAQs/What-s-a-Spotify-URI/ta-p/919201). The `spotifyaudiosrc` element can be used to play a song from Spotify using its [Spotify URI](https://community.spotify.com/t5/FAQs/What-s-a-Spotify-URI/ta-p/919201).
``` ```
gst-launch-1.0 spotifyaudiosrc username=$USERNAME password=$PASSWORD track=spotify:track:3i3P1mGpV9eRlfKccjDjwi ! oggdemux ! vorbisdec ! audioconvert ! autoaudiosink gst-launch-1.0 spotifyaudiosrc access-token=$ACCESS_TOKEN track=spotify:track:3i3P1mGpV9eRlfKccjDjwi ! oggdemux ! vorbisdec ! audioconvert ! autoaudiosink
``` ```
The element also implements an URI handler which accepts credentials and cache settings as URI parameters: The element also implements an URI handler which accepts credentials and cache settings as URI parameters:
```console ```console
gst-launch-1.0 playbin3 uri=spotify:track:3i3P1mGpV9eRlfKccjDjwi?username=$USERNAME\&password=$PASSWORD\&cache-credentials=cache\&cache-files=cache gst-launch-1.0 playbin3 uri=spotify:track:3i3P1mGpV9eRlfKccjDjwi?access-token=$ACCESS_TOKEN\&cache-credentials=cache\&cache-files=cache
``` ```

View file

@ -18,8 +18,7 @@ use librespot_core::{
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Settings { pub struct Settings {
username: String, access_token: String,
password: String,
cache_credentials: String, cache_credentials: String,
cache_files: String, cache_files: String,
cache_max_size: u64, cache_max_size: u64,
@ -28,52 +27,46 @@ pub struct Settings {
impl Settings { impl Settings {
pub fn properties() -> Vec<glib::ParamSpec> { pub fn properties() -> Vec<glib::ParamSpec> {
vec![glib::ParamSpecString::builder("username") vec![
.nick("Username") glib::ParamSpecString::builder("access-token")
.blurb("Spotify username, Facebook accounts need a device username from https://www.spotify.com/us/account/set-device-password/") .nick("Access token")
.default_value(Some("")) .blurb("Spotify access token, requires 'streaming' scope")
.mutable_ready() .default_value(Some(""))
.build(), .mutable_ready()
glib::ParamSpecString::builder("password") .build(),
.nick("Password") glib::ParamSpecString::builder("cache-credentials")
.blurb("Spotify password, Facebook accounts need a device password from https://www.spotify.com/us/account/set-device-password/") .nick("Credentials cache")
.default_value(Some("")) .blurb("Directory where to cache Spotify credentials")
.mutable_ready() .default_value(Some(""))
.build(), .mutable_ready()
glib::ParamSpecString::builder("cache-credentials") .build(),
.nick("Credentials cache") glib::ParamSpecString::builder("cache-files")
.blurb("Directory where to cache Spotify credentials") .nick("Files cache")
.default_value(Some("")) .blurb("Directory where to cache downloaded files from Spotify")
.mutable_ready() .default_value(Some(""))
.build(), .mutable_ready()
glib::ParamSpecString::builder("cache-files") .build(),
.nick("Files cache") glib::ParamSpecUInt64::builder("cache-max-size")
.blurb("Directory where to cache downloaded files from Spotify") .nick("Cache max size")
.default_value(Some("")) .blurb(
.mutable_ready() "The max allowed size of the cache, in bytes, or 0 to disable the cache limit",
.build(), )
glib::ParamSpecUInt64::builder("cache-max-size") .default_value(0)
.nick("Cache max size") .mutable_ready()
.blurb("The max allowed size of the cache, in bytes, or 0 to disable the cache limit") .build(),
.default_value(0) glib::ParamSpecString::builder("track")
.mutable_ready() .nick("Spotify URI")
.build(), .blurb("Spotify track URI, in the form 'spotify:track:$SPOTIFY_ID'")
glib::ParamSpecString::builder("track") .default_value(Some(""))
.nick("Spotify URI") .mutable_ready()
.blurb("Spotify track URI, in the form 'spotify:track:$SPOTIFY_ID'") .build(),
.default_value(Some("")) ]
.mutable_ready()
.build(),
]
} }
pub fn set_property(&mut self, value: &glib::Value, pspec: &glib::ParamSpec) { pub fn set_property(&mut self, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() { match pspec.name() {
"username" => { "access-token" => {
self.username = value.get().expect("type checked upstream"); self.access_token = value.get().expect("type checked upstream");
}
"password" => {
self.password = value.get().expect("type checked upstream");
} }
"cache-credentials" => { "cache-credentials" => {
self.cache_credentials = value.get().expect("type checked upstream"); self.cache_credentials = value.get().expect("type checked upstream");
@ -93,8 +86,7 @@ impl Settings {
pub fn property(&self, pspec: &glib::ParamSpec) -> glib::Value { pub fn property(&self, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() { match pspec.name() {
"username" => self.username.to_value(), "access-token" => self.access_token.to_value(),
"password" => self.password.to_value(),
"cache-credentials" => self.cache_credentials.to_value(), "cache-credentials" => self.cache_credentials.to_value(),
"cache-files" => self.cache_files.to_value(), "cache-files" => self.cache_files.to_value(),
"cache-max-size" => self.cache_max_size.to_value(), "cache-max-size" => self.cache_max_size.to_value(),
@ -132,32 +124,20 @@ impl Settings {
let cache = Cache::new(credentials_cache, None, files_cache, max_size)?; let cache = Cache::new(credentials_cache, None, files_cache, max_size)?;
if let Some(cached_cred) = cache.credentials() { if let Some(cached_cred) = cache.credentials() {
if !self.username.is_empty() && self.username != cached_cred.username { let cached_username = cached_cred
gst::debug!( .username
cat, .as_ref()
obj = &src, .map_or("UNKNOWN", |s| s.as_str());
"ignore cached credentials for user {} which mismatch user {}", gst::debug!(
cached_cred.username, cat,
self.username obj = &src,
); "reuse cached credentials for user {}",
} else { cached_username
gst::debug!( );
cat,
obj = &src, let session = Session::new(SessionConfig::default(), Some(cache));
"reuse cached credentials for user {}", session.connect(cached_cred, true).await?;
cached_cred.username return Ok(session);
);
if let Ok((session, _credentials)) = Session::connect(
SessionConfig::default(),
cached_cred,
Some(cache.clone()),
true,
)
.await
{
return Ok(session);
}
}
} }
gst::debug!( gst::debug!(
@ -166,17 +146,14 @@ impl Settings {
"credentials not in cache or cached credentials invalid", "credentials not in cache or cached credentials invalid",
); );
if self.username.is_empty() { if self.access_token.is_empty() {
bail!("username is not set and credentials are not in cache"); bail!("access-token is not set and credentials are not in cache");
}
if self.password.is_empty() {
bail!("password is not set and credentials are not in cache");
} }
let cred = Credentials::with_password(&self.username, &self.password); let cred = Credentials::with_access_token(&self.access_token);
let (session, _credentials) = let session = Session::new(SessionConfig::default(), Some(cache));
Session::connect(SessionConfig::default(), cred, Some(cache), true).await?; session.connect(cred, true).await?;
Ok(session) Ok(session)
} }
@ -185,9 +162,7 @@ impl Settings {
if self.track.is_empty() { if self.track.is_empty() {
bail!("track is not set"); bail!("track is not set");
} }
let track = SpotifyId::from_uri(&self.track).map_err(|_| { let track = SpotifyId::from_uri(&self.track)?;
anyhow::anyhow!("failed to create Spotify URI from track {}", self.track)
})?;
Ok(track) Ok(track)
} }

View file

@ -52,7 +52,7 @@ enum Message {
} }
struct State { struct State {
player: Player, player: Arc<Player>,
/// receiver sending buffer to streaming thread /// receiver sending buffer to streaming thread
receiver: mpsc::Receiver<Message>, receiver: mpsc::Receiver<Message>,
@ -321,11 +321,10 @@ struct BufferSink {
impl Sink for BufferSink { impl Sink for BufferSink {
fn write(&mut self, packet: AudioPacket, _converter: &mut Converter) -> SinkResult<()> { fn write(&mut self, packet: AudioPacket, _converter: &mut Converter) -> SinkResult<()> {
let oggdata = match packet { let buffer = match packet {
AudioPacket::OggData(data) => data, AudioPacket::Samples(_) => unreachable!(),
AudioPacket::Samples(_) => unimplemented!(), AudioPacket::Raw(ogg) => gst::Buffer::from_slice(ogg),
}; };
let buffer = gst::Buffer::from_slice(oggdata);
// ignore if sending fails as that means the source element is being shutdown // ignore if sending fails as that means the source element is being shutdown
let _ = self.sender.send(Message::Buffer(buffer)); let _ = self.sender.send(Message::Buffer(buffer));
@ -360,7 +359,7 @@ impl URIHandlerImpl for SpotifyAudioSrc {
// allow to configure auth and cache settings from the URI // allow to configure auth and cache settings from the URI
for (key, value) in url.query_pairs() { for (key, value) in url.query_pairs() {
match key.as_ref() { match key.as_ref() {
"username" | "password" | "cache-credentials" | "cache-files" => { "access-token" | "cache-credentials" | "cache-files" => {
self.obj().set_property(&key, value.as_ref()); self.obj().set_property(&key, value.as_ref());
} }
_ => { _ => {
@ -435,10 +434,10 @@ impl SpotifyAudioSrc {
let (sender, receiver) = mpsc::sync_channel(2); let (sender, receiver) = mpsc::sync_channel(2);
let sender_clone = sender.clone(); let sender_clone = sender.clone();
let (mut player, mut player_event_channel) = let player = Player::new(player_config, session, Box::new(NoOpVolume), || {
Player::new(player_config, session, Box::new(NoOpVolume), || { Box::new(BufferSink { sender })
Box::new(BufferSink { sender }) });
}); let mut player_event_channel = player.get_player_event_channel();
player.load(track, true, 0); player.load(track, true, 0);

View file

@ -12109,6 +12109,18 @@
} }
}, },
"properties": { "properties": {
"access-token": {
"blurb": "Spotify access token, requires 'streaming' scope",
"conditionally-available": false,
"construct": false,
"construct-only": false,
"controllable": false,
"default": "",
"mutable": "ready",
"readable": true,
"type": "gchararray",
"writable": true
},
"bitrate": { "bitrate": {
"blurb": "Spotify audio bitrate in kbit/s", "blurb": "Spotify audio bitrate in kbit/s",
"conditionally-available": false, "conditionally-available": false,