Skip to content

Commit

Permalink
Support short links (#301)
Browse files Browse the repository at this point in the history
  • Loading branch information
felix-hilden authored Nov 5, 2023
1 parent 1689194 commit a0f0ca5
Show file tree
Hide file tree
Showing 10 changed files with 113 additions and 1 deletion.
16 changes: 16 additions & 0 deletions docs/src/advanced_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ Combined with refreshing the token on arrival to have the full scope and
additionally redirecting the user back after authorisation, even static
HTML applications using a Python backend become simple to implement.

Short links
-----------
The Spotify emits shortened URLs for e.g. playlists when sharing them
through the mobile application. Unfortunately, they are not usable as
they are, but they can be expanded to their full form. Tekore provides
two utilities for processing them: :func:`is_short_link` and
:meth:`Spotify.follow_short_link`.

.. autolink-preface:: from tekore import Spotify as spotify, is_short_link, from_url
.. code:: python
link = '...'
if is_short_link(link):
link = spotify.follow_short_link(link)
type, id_ = from_url(link)
Localisation
------------
Many API calls that retrieve track information accept a ``market`` or
Expand Down
4 changes: 4 additions & 0 deletions docs/src/reference/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,16 @@ Non-endpoint methods
Spotify.chunked
Spotify.max_limits
Spotify.token_as
Spotify.follow_short_link
is_short_link
Spotify.send
Spotify.close

.. automethod:: Spotify.chunked
.. automethod:: Spotify.max_limits
.. automethod:: Spotify.token_as
.. automethod:: Spotify.follow_short_link
.. autofunction:: is_short_link
.. automethod:: Spotify.send
.. automethod:: Spotify.close

Expand Down
5 changes: 5 additions & 0 deletions docs/src/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ Fixed
class to a nullable string, complying with the `API Documentation
<https://developer.spotify.com/documentation/web-api/reference/get-an-episode>`_

Added
*****
- Add support for short URLs as :func:`is_short_link` and
:meth:`follow_short_link <Spotify.follow_short_link>` (:issue:`301`)

5.1.1 (2023-10-14)
------------------
Fixed
Expand Down
2 changes: 1 addition & 1 deletion src/tekore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
request_client_token,
scope,
)
from ._client import Spotify
from ._client import Spotify, is_short_link
from ._config import (
MissingConfigurationWarning,
config_from_environment,
Expand Down
1 change: 1 addition & 0 deletions src/tekore/_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .full import Spotify
from .short_link import is_short_link
2 changes: 2 additions & 0 deletions src/tekore/_client/full.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SpotifyUser,
)
from .paging import SpotifyPaging
from .short_link import SpotifyShortLink


class Spotify(
Expand All @@ -40,6 +41,7 @@ class Spotify(
SpotifyTrack,
SpotifyUser,
SpotifyPaging,
SpotifyShortLink,
):
"""
Bases: :class:`tekore.Client`.
Expand Down
39 changes: 39 additions & 0 deletions src/tekore/_client/short_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from .._sender import Request, Response, send_and_process
from .._sender.base import Sender
from .base import SpotifyBase


def is_short_link(url: str) -> bool:
"""Determine if URL is a Spotify short link."""
return "spotify.link/" in url


def _process_short_link(request: Request, response: Response) -> str:
if response.status_code == 307:
return response.headers["location"]
else:
return request.url


@send_and_process(_process_short_link)
def _send_short_link(self: Sender, request: Request):
return request


class SpotifyShortLink(SpotifyBase):
"""Spotify short link."""

def follow_short_link(self, link: str) -> str:
"""
Follow redirect of a short link to get the underlying resource URL.
Safely also accept a direct link, request a redirect and return
the original URL. Also use the underlying sender for an unauthenticated request.
Returns
-------
url
result of the short link redirect
"""
request = self._request("HEAD", link)
return _send_short_link(self.sender, request)
2 changes: 2 additions & 0 deletions tests/client/_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
user_id_hash = "a#a"
user_ids = [user_id, "samsmithworld"]

short_link = "https://spotify.link/hRkBrwub9xb"

show_id = "0Lsi13D8nWRkwbEkfNeItS"
show_ids = [show_id, "7Arl2fVUFHCGyNZwM39bJO"]
episode_id = "3YPKYYsqrWZUdZTzF6dZ3F"
Expand Down
1 change: 1 addition & 0 deletions tests/client/full.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def test_all_endpoints_have_scope_attributes(self, client):
"chunked",
"max_limits",
"token_as",
"follow_short_link",
}
for name, method in getmembers(client, predicate=ismethod):
if name.startswith("_") or name in skips:
Expand Down
42 changes: 42 additions & 0 deletions tests/client/short_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import pytest

from tekore import from_url, is_short_link

from ._resources import short_link, track_id


class TestSpotifyShortLink:
def test_random_string_is_not_short_link(self):
assert not is_short_link("asdf")

def test_random_url_is_not_short_link(self):
assert not is_short_link("https://spotify.org/resource")

def test_valid_short_link(self):
assert is_short_link("https://spotify.link/resource")

def test_short_link_does_not_need_https(self):
assert is_short_link("spotify.link/resource")

def test_follow_short_link(self, app_client):
resolved = app_client.follow_short_link(short_link)
assert short_link != resolved
from_url(resolved)

@pytest.mark.asyncio
async def test_async_follow_short_link(self, app_aclient):
resolved = await app_aclient.follow_short_link(short_link)
assert short_link != resolved
from_url(resolved)

def test_follow_short_link_already_resolved(self, app_client):
link = "https://open.spotify.com/track/" + track_id
resolved = app_client.follow_short_link(link)
assert link == resolved
from_url(resolved)

def test_follow_short_link_does_not_authorise(self, app_client, httpx_mock):
httpx_mock.add_response(200)
app_client.follow_short_link(short_link)
(request,) = httpx_mock.get_requests()
assert "authorization" not in request.headers

0 comments on commit a0f0ca5

Please sign in to comment.