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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Lint
on:
push:
branches:
- master
- main
pull_request:
workflow_dispatch:

Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Test

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
- name: Install Poetry
run: curl -sSL https://install.python-poetry.org | python3 -
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- run: poetry env use python
- run: poetry install
# Offline tests only, the remaining tests require a TIDAL account
- run: poetry run pytest tests/test_backoff.py tests/test_parsing.py tests/test_token_refresh.py
4 changes: 4 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ History

v0.9.0
------
* (BREAKING!) Drop support for python3.9 and older, python 3.10 or newer is now required - tehkillerbee_
* Added support for python 3.14 - tehkillerbee_
* Removed unused runtime dependency `pyaes` - tehkillerbee_
* Removed runtime dependency `typing-extensions`, no longer needed with python 3.10+ - tehkillerbee_
* Bugfix: Keep the rotated refresh token returned by TIDAL in `token_refresh`, previously users were logged out after a few days - tehkillerbee_
* Bugfix: Serialize concurrent access token refreshes, so parallel requests no longer refresh with an already invalidated refresh token - tehkillerbee_
* Bugfix: Refresh the access token shortly before it expires, instead of relying on the wording of the expired-token error - tehkillerbee_
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ format:
${POETRY} isort tidalapi tests
${POETRY} black tidalapi tests
${POETRY} docformatter -i tidalapi tests
# ${POETRY} ruff . # Don't autofix as ruff can break things
# ${POETRY} ruff check --fix tidalapi tests # Don't autofix as ruff can break things

lint:
${POETRY} isort --check tidalapi tests
${POETRY} black --check tidalapi tests
${POETRY} docformatter --check tidalapi tests
# ${POETRY} ruff .
${POETRY} ruff check tidalapi tests


test:
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ tidalapi

Unofficial Python API for TIDAL music streaming service.

Requires Python 3.9 or higher.
Requires Python 3.10 or higher.

Installation
------------
Expand Down
2 changes: 1 addition & 1 deletion docs/runtime.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.8
3.13
2,424 changes: 1,238 additions & 1,186 deletions poetry.lock

Large diffs are not rendered by default.

28 changes: 16 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,33 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]

[tool.poetry.dependencies]
python = "^3.8"
python = "^3.10"
requests = "^2.32.3"
python-dateutil = "^2.8.2"
typing-extensions = "^4.12.2"
isodate = "^0.7.2"
mpegdash = "^0.4.1"
pyaes = "^1.6.1"

[tool.poetry.group.dev.dependencies]
mypy = "^1.3.0"
mypy = "^2.4.0"
types-python-dateutil = "^2.8.19.13"
types-requests = "^2.31.0.1"
types-urllib3 = "^1.26.25.13"
pytest = "^7.4.0"
keyring = "^24.2.0"
pillow = "^10.0.0"
pytest = "^9.1.1"
keyring = "^25.7.0"
pillow = ">=10.0.0"
tox = "^4.6.4"
ffmpeg-python = "^0.2.0"
black = ">=23.3,<25.0"
isort = "^5.12.0"
ruff = "^0.0.277"
black = "^26.10.1"
isort = "^9.0.2"
ruff = "^0.17.0"
docformatter = { extras = ["tomli"], version = "^1.7.3" }
pytest-mock = "^3.11.1"

Expand All @@ -61,3 +58,10 @@ profile = "black"
[tool.docformatter]
recursive = true
black = true

[tool.ruff]
target-version = "py310"
line-length = 88

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
3 changes: 0 additions & 3 deletions tests/test_artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ def test_get_other(session):
session.album(327452387),
session.album(322406553),
]
other_albums = artist.get_other
# artist_item_ids = [item.id for item in other_albums()]
find_ids(albums, artist.get_other)


Expand Down Expand Up @@ -164,6 +162,5 @@ def find_ids(items, function):
:param items: A list of items that you expect in the list returned by `function`
:param function: A function that returns a list of items of the same type as items.
"""

artist_item_ids = [item.id for item in function()]
assert all(item.id in artist_item_ids for item in items)
1 change: 0 additions & 1 deletion tests/test_genres.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

from io import BytesIO

import pytest
from PIL import Image

import tidalapi
Expand Down
20 changes: 10 additions & 10 deletions tests/test_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,8 @@ def validate_stream(stream, is_hi_res_lossless: bool = False):
assert stream.audio_mode == AudioMode.stereo
if not is_hi_res_lossless:
assert stream.audio_quality == Quality.low_320k
assert stream.is_bts == True
assert stream.is_mpd == False
assert stream.is_bts is True
assert stream.is_mpd is False
assert stream.bit_depth == 16
assert stream.sample_rate == 44100
assert stream.manifest_mime_type == ManifestMimeType.BTS
Expand All @@ -607,8 +607,8 @@ def validate_stream(stream, is_hi_res_lossless: bool = False):
assert audio_resolution[1] == 44100
else:
assert stream.audio_quality == Quality.hi_res_lossless
assert stream.is_bts == False
assert stream.is_mpd == True
assert stream.is_bts is False
assert stream.is_mpd is True
assert stream.bit_depth == 24
assert stream.sample_rate == 192000 # HI_RES_LOSSLESS: 24bit/192kHz
assert stream.manifest_mime_type == ManifestMimeType.MPD
Expand All @@ -622,26 +622,26 @@ def validate_stream(stream, is_hi_res_lossless: bool = False):

def validate_stream_manifest(manifest, is_hi_res_lossless: bool = False):
if not is_hi_res_lossless:
assert manifest.is_bts == True
assert manifest.is_mpd == False
assert manifest.is_bts is True
assert manifest.is_mpd is False
assert manifest.codecs == Codec.MP4A
assert manifest.dash_info is None
assert manifest.encryption_key is None
assert manifest.encryption_type == "NONE"
assert manifest.file_extension == AudioExtensions.M4A
assert manifest.is_encrypted == False
assert manifest.is_encrypted is False
assert manifest.manifest_mime_type == ManifestMimeType.BTS
assert manifest.mime_type == MimeType.audio_mp4
assert manifest.sample_rate == 44100
else:
assert manifest.is_bts == False
assert manifest.is_mpd == True
assert manifest.is_bts is False
assert manifest.is_mpd is True
assert manifest.codecs == Codec.FLAC
assert manifest.dash_info is not None
assert manifest.encryption_key is None
assert manifest.encryption_type == "NONE"
assert manifest.file_extension == AudioExtensions.M4A
assert manifest.is_encrypted == False
assert manifest.is_encrypted is False
assert manifest.manifest_mime_type == ManifestMimeType.MPD
assert manifest.mime_type == MimeType.audio_mp4
assert manifest.sample_rate == 192000
Expand Down
8 changes: 4 additions & 4 deletions tests/test_mix.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,17 @@ def test_image(session):

def test_mix_unavailable(session):
with pytest.raises(ObjectNotFound):
mix = session.mix("12345678")
session.mix("12345678")


def test_mixv2_unavailable(session):
with pytest.raises(ObjectNotFound):
mix = session.mixv2("12345678")
session.mixv2("12345678")


def test_mix_available(session):
mix = session.mix("001cb879e927219fc3322cb03aed01")
session.mix("001cb879e927219fc3322cb03aed01")


def test_mixv2_available(session):
mix = session.mixv2("001cb879e927219fc3322cb03aed01")
session.mixv2("001cb879e927219fc3322cb03aed01")
3 changes: 1 addition & 2 deletions tests/test_playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def test_playlist_categories(session):
# Check if playlist is found in list of public playlists
public_playlists = session.user.public_playlists()
playlist_ids = [playlist.id for playlist in public_playlists]
assert not playlist_id in playlist_ids
assert playlist_id not in playlist_ids
playlist.set_playlist_public()

# Check if playlist is found in list of public playlists
Expand Down Expand Up @@ -235,7 +235,6 @@ def test_playlist_move_by_index(session):
first_track_id = track_ids[0]
playlist.move_by_index(index=0, position=playlist.num_tracks - 2)
# Last track(-2) should now match the previous first track
tracks = playlist.tracks()
track_ids = [track.id for track in playlist.tracks()]
assert track_ids.index(first_track_id) == playlist.num_tracks - 2
playlist.delete()
Expand Down
9 changes: 5 additions & 4 deletions tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,11 +616,11 @@ def get_album_ids(**kwargs) -> list[str]:
assert list_mismatch_count(ids_date_created_asc, ids_date_created_desc, True) < 3

# Release date ascending vs. descending
ids_rel_date_asc = get_album_ids(
ids_rel_date_asc = get_album_ids( # noqa: F841
order=AlbumOrder.ReleaseDate,
order_direction=OrderDirection.Ascending,
)
ids_rel_date_desc = get_album_ids(
ids_rel_date_desc = get_album_ids( # noqa: F841
order=AlbumOrder.ReleaseDate,
order_direction=OrderDirection.Descending,
)
Expand Down Expand Up @@ -776,8 +776,9 @@ def list_mismatch_count(a, b, reverse=False):


def add_remove(object_id, add, remove, objects):
"""Add and remove an item from favorites. Skips the test if the item was already in
your favorites.
"""Add and remove an item from favorites.

Skips the test if the item was already in your favorites.

:param object_id: Identifier of the object
:param add: Function to add object to favorites
Expand Down
6 changes: 3 additions & 3 deletions tidalapi/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,6 @@ def image(

Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280, 3000x3000 (origin)
"""

if dimensions not in [80, 160, 320, 640, 1280, "origin"]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))

Expand Down Expand Up @@ -316,8 +315,9 @@ def page(self) -> "Page":
return self.session.page.get("pages/album", params={"albumId": self.id})

def similar(self) -> List["Album"]:
"""Retrieve albums similar to the current one. MetadataNotAvailable is raised,
when no similar albums exist.
"""Retrieve albums similar to the current one.

MetadataNotAvailable is raised, when no similar albums exist.

:return: A :any:`list` of similar albums
"""
Expand Down
20 changes: 11 additions & 9 deletions tidalapi/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@
import copy
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, List, Mapping, Optional, Union, cast
from typing import TYPE_CHECKING, List, Mapping, NoReturn, Optional, Union, cast
from warnings import warn

import dateutil.parser
from typing_extensions import NoReturn

from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj
Expand Down Expand Up @@ -74,8 +73,9 @@ def __init__(self, session: "Session", artist_id: Optional[str]):
self.request.map_json(request.json(), parse=self.parse_artist)

def parse_artist(self, json_obj: JsonObj) -> "Artist":
"""Parses a TIDAL artist, replaces the current :class:`Artist` object. Made for
use within the python tidalapi module.
"""Parses a TIDAL artist, replaces the current :class:`Artist` object.

Made for use within the python tidalapi module.

:param json_obj: :class:`JsonObj` containing the artist metadata
:return: Returns a copy of the :class:`Artist` object
Expand Down Expand Up @@ -113,7 +113,8 @@ def parse_artists(self, json_obj: List[JsonObj]) -> List["Artist"]:
"""Parses a list of TIDAL artists, returns a list of :class:`Artist` objects
Made for use within the python tidalapi module.

:param List[JsonObj] json_obj: List of :class:`JsonObj` containing the artist metadata for each artist
:param List[JsonObj] json_obj: List of :class:`JsonObj` containing the artist
metadata for each artist
:return: Returns a list of :class:`Artist` objects
"""
return list(map(self.parse_artist, json_obj))
Expand Down Expand Up @@ -258,9 +259,9 @@ def get_radio_mix(self) -> mix.Mix:
this artist.

:return: A :class:`Mix <tidalapi.mix.Mix>`
:raises: A :class:`exceptions.MetadataNotAvailable` if no track radio mix is available
:raises: A :class:`exceptions.MetadataNotAvailable` if no track radio mix is
available
"""

try:
request = self.request.request("GET", "artists/%s/mix" % self.id)
except ObjectNotFound:
Expand All @@ -273,8 +274,9 @@ def get_radio_mix(self) -> mix.Mix:
return self.session.mix(json_obj.get("id"))

def items(self) -> List[NoReturn]:
"""The artist page does not supply any items. This only exists for symmetry with
other model types.
"""The artist page does not supply any items.

This only exists for symmetry with other model types.

:return: An empty list.
"""
Expand Down
Loading
Loading