pylast-5.3.0/.codecov.yml0000644000000000000000000000036014623446206012212 0ustar00# Documentation: https://docs.codecov.io/docs/codecov-yaml codecov: # Avoid "Missing base report" # https://github.com/codecov/support/issues/363 # https://docs.codecov.io/v4.3.6/docs/comparing-commits allow_coverage_offsets: true pylast-5.3.0/.editorconfig0000644000000000000000000000047114623446206012447 0ustar00# Top-most EditorConfig file root = true # Unix-style newlines with a newline ending every file [*] end_of_line = lf insert_final_newline = true charset = utf-8 # Four-space indentation [*.py] indent_size = 4 indent_style = space trim_trailing_whitespace = true # Two-space indentation [*.yml] indent_size = 2 pylast-5.3.0/.pre-commit-config.yaml0000644000000000000000000000347314623446206014260 0ustar00repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.4 hooks: - id: ruff args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.4.2 hooks: - id: black - repo: https://github.com/asottile/blacken-docs rev: 1.16.0 hooks: - id: blacken-docs args: [--target-version=py38] additional_dependencies: [black] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-json - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: forbid-submodules - id: trailing-whitespace exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.28.4 hooks: - id: check-github-workflows - id: check-renovate - repo: https://github.com/rhysd/actionlint rev: v1.7.0 hooks: - id: actionlint - repo: https://github.com/tox-dev/pyproject-fmt rev: 1.7.0 hooks: - id: pyproject-fmt additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject rev: v0.18 hooks: - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt rev: 1.3.1 hooks: - id: tox-ini-fmt - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier args: [--prose-wrap=always, --print-width=88] exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes ci: autoupdate_schedule: quarterly pylast-5.3.0/CHANGELOG.md0000644000000000000000000001124314623446206011602 0ustar00# Changelog This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 4.2.1 and newer See GitHub Releases: - https://github.com/pylast/pylast/releases ## [4.2.0] - 2021-03-14 ## Changed - Fix unsafe creation of temp file for caching, and improve exception raising (#356) @kvanzuijlen - [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci ## [4.1.0] - 2021-01-04 ## Added - Add support for streaming (#336) @kvanzuijlen - Add Python 3.9 final to Travis CI (#350) @sheetalsingala ## Changed - Update copyright year (#360) @hugovk - Replace Travis CI with GitHub Actions (#352) @hugovk - [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci ## Fixed - Set limit to 50 by default, not 1 (#355) @hugovk ## [4.0.0] - 2020-10-07 ## Added - Add support for Python 3.9 (#347) @hugovk ## Removed - Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and `STATUS_TOKEN_ERROR` (#348) @hugovk - Drop support for EOL Python 3.5 (#346) @hugovk ## [3.3.0] - 2020-06-25 ### Added - `User.get_now_playing`: Add album and cover image to info (#330) @hugovk ### Changed - Improve handling of error responses from the API (#327) @spiritualized ### Deprecated - Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) @hugovk ### Fixed - Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk ## [3.2.1] - 2020-03-05 ### Fixed - Only Python 3 is supported: don't create universal wheel (#318) @hugovk - Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk - Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk ## [3.2.0] - 2020-01-03 ### Added - Support for Python 3.8 - Store album art URLs when you call `GetTopAlbums` ([#307]) - Retry paging through results on exception ([#297]) - More error status codes from https://last.fm/api/errorcodes ([#297]) ### Changed - Respect `get_recent_tracks`' limit when there's a now playing track ([#310]) - Move installable code to `src/` ([#301]) - Update `get_weekly_artist_charts` docstring: only for `User` ([#311]) - Remove Python 2 warnings, `python_requires` should be enough ([#312]) - Use setuptools_scm to simplify versioning during release ([#316]) - Various lint and test updates ### Deprecated - Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer available. Last.fm returns a "Deprecated - This type of request is no longer supported" error when calling it. A future version of pylast will remove its `User.get_artist_tracks` altogether. ([#305]) - `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. Use `STATUS_OPERATION_FAILED` instead. ## [3.1.0] - 2019-03-07 ### Added - Extract username from session via new `SessionKeyGenerator.get_web_auth_session_key_username` ([#290]) - `User.get_track_scrobbles` ([#298]) ### Deprecated - `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement. ([#298]) ## [3.0.0] - 2019-01-01 ### Added - This changelog file ([#273]) ### Removed - Support for Python 2.7 ([#265]) - Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` and `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282]) ## [2.4.0] - 2018-08-08 ### Deprecated - Support for Python 2.7 ([#265]) [4.2.0]: https://github.com/pylast/pylast/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0 [3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0 [3.2.1]: https://github.com/pylast/pylast/compare/3.2.0...3.2.1 [3.2.0]: https://github.com/pylast/pylast/compare/3.1.0...3.2.0 [3.1.0]: https://github.com/pylast/pylast/compare/3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 [#282]: https://github.com/pylast/pylast/pull/282 [#290]: https://github.com/pylast/pylast/pull/290 [#297]: https://github.com/pylast/pylast/issues/297 [#298]: https://github.com/pylast/pylast/issues/298 [#301]: https://github.com/pylast/pylast/issues/301 [#305]: https://github.com/pylast/pylast/issues/305 [#307]: https://github.com/pylast/pylast/issues/307 [#310]: https://github.com/pylast/pylast/issues/310 [#311]: https://github.com/pylast/pylast/issues/311 [#312]: https://github.com/pylast/pylast/issues/312 [#316]: https://github.com/pylast/pylast/issues/316 [#346]: https://github.com/pylast/pylast/issues/346 [#347]: https://github.com/pylast/pylast/issues/347 [#348]: https://github.com/pylast/pylast/issues/348 pylast-5.3.0/RELEASING.md0000644000000000000000000000146714623446206011633 0ustar00# Release Checklist - [ ] Get `main` to the appropriate code release state. [GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for all merges to `main`. [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) - [ ] Edit release draft, adjust text if needed: https://github.com/pylast/pylast/releases - [ ] Check next tag is correct, amend if needed - [ ] Publish release - [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml) has deployed to [PyPI](https://pypi.org/project/pylast/#history) - [ ] Check installation: ```bash pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)" ``` pylast-5.3.0/example_test_pylast.yaml0000644000000000000000000000020614623446206014740 0ustar00username: TODO_ENTER_YOURS_HERE password_hash: TODO_ENTER_YOURS_HERE api_key: TODO_ENTER_YOURS_HERE api_secret: TODO_ENTER_YOURS_HERE pylast-5.3.0/pytest.ini0000644000000000000000000000015614623446206012023 0ustar00[pytest] filterwarnings = once::DeprecationWarning once::PendingDeprecationWarning xfail_strict=true pylast-5.3.0/tox.ini0000644000000000000000000000122614623446206011304 0ustar00[tox] requires = tox>=4.2 env_list = lint py{py3, 313, 312, 311, 310, 39, 38} [testenv] extras = tests pass_env = FORCE_COLOR PYLAST_API_KEY PYLAST_API_SECRET PYLAST_PASSWORD_HASH PYLAST_USERNAME commands = {envpython} -m pytest -v -s -W all \ --cov pylast \ --cov tests \ --cov-report html \ --cov-report term-missing \ --cov-report xml \ --random-order \ {posargs} [testenv:lint] skip_install = true deps = pre-commit pass_env = PRE_COMMIT_COLOR commands = pre-commit run --all-files --show-diff-on-failure [testenv:venv] deps = ipdb commands = {posargs} pylast-5.3.0/.github/FUNDING.yml0000644000000000000000000000001714623446206013143 0ustar00github: hugovk pylast-5.3.0/.github/ISSUE_TEMPLATE.md0000644000000000000000000000067214623446206014042 0ustar00### What did you do? ### What did you expect to happen? ### What actually happened? ### What versions are you using? * OS: * Python: * pylast: Please include **code** that reproduces the issue. The [best reproductions](https://stackoverflow.com/help/minimal-reproducible-example) are [self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) with minimal dependencies. ```python # code goes here ``` pylast-5.3.0/.github/PULL_REQUEST_TEMPLATE.md0000644000000000000000000000007514623446206015133 0ustar00Fixes # Changes proposed in this pull request: * * * pylast-5.3.0/.github/labels.yml0000644000000000000000000000510714623446206013320 0ustar00# Default GitHub labels - color: d73a4a description: "Something isn't working" name: bug - color: cfd3d7 description: "This issue or pull request already exists" name: duplicate - color: a2eeef description: "New feature or request" name: enhancement - color: 7057ff description: "Good for newcomers" name: good first issue - color: 008672 description: "Extra attention is needed" name: help wanted - color: e4e669 description: "This doesn't seem right" name: invalid - color: d876e3 description: "Further information is requested" name: question - color: ffffff description: "This will not be worked on" name: wontfix # Keep a Changelog labels # https://keepachangelog.com/en/1.0.0/ - color: 0e8a16 description: "For new features" name: "changelog: Added" - color: af99e5 description: "For changes in existing functionality" name: "changelog: Changed" - color: FFA500 description: "For soon-to-be removed features" name: "changelog: Deprecated" - color: 00A800 description: "For any bug fixes" name: "changelog: Fixed" - color: ff0000 description: "For now removed features" name: "changelog: Removed" - color: 045aa0 description: "In case of vulnerabilities" name: "changelog: Security" - color: fbca04 description: "Exclude PR from release draft" name: "changelog: skip" # Other labels - color: e11d21 description: "" name: Last.fm bug - color: FFFFFF description: "" name: Milestone-0.3 - color: FFFFFF description: "" name: Performance - color: FFFFFF description: "" name: Priority-High - color: FFFFFF description: "" name: Priority-Low - color: FFFFFF description: "" name: Priority-Medium - color: FFFFFF description: "" name: Type-Other - color: FFFFFF description: "" name: Type-Patch - color: FFFFFF description: "" name: Usability - color: 64c1c0 description: "" name: backwards incompatible - color: fef2c0 description: "" name: build - color: e99695 description: Feature that will be removed in the future name: deprecation - color: FFFFFF description: "" name: imported - color: b60205 description: Removal of a feature, usually done in major releases name: removal - color: 0366d6 description: "For dependencies" name: dependencies - color: 0052cc description: "Documentation" name: docs - color: f4660e description: "" name: Hacktoberfest - color: f4660e description: "To credit accepted Hacktoberfest PRs" name: hacktoberfest-accepted - color: d65e88 description: "Deploy and release" name: release - color: fef2c0 description: "Unit tests, linting, CI, etc." name: test pylast-5.3.0/.github/release-drafter.yml0000644000000000000000000000157414623446206015127 0ustar00name-template: "$RESOLVED_VERSION" tag-template: "$RESOLVED_VERSION" categories: - title: "Added" labels: - "changelog: Added" - "enhancement" - title: "Changed" label: "changelog: Changed" - title: "Deprecated" label: "changelog: Deprecated" - title: "Removed" label: "changelog: Removed" - title: "Fixed" labels: - "changelog: Fixed" - "bug" - title: "Security" label: "changelog: Security" exclude-labels: - "changelog: skip" autolabeler: - label: "changelog: skip" branch: - "/pre-commit-ci-update-config/" template: | $CHANGES version-resolver: major: labels: - "changelog: Removed" minor: labels: - "changelog: Added" - "changelog: Changed" - "changelog: Deprecated" - "enhancement" patch: labels: - "changelog: Fixed" - "bug" default: minor pylast-5.3.0/.github/renovate.json0000644000000000000000000000053614623446206014052 0ustar00{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:base"], "labels": ["changelog: skip", "dependencies"], "packageRules": [ { "groupName": "github-actions", "matchManagers": ["github-actions"], "separateMajorMinor": "false" } ], "schedule": ["on the first day of the month"] } pylast-5.3.0/.github/workflows/deploy.yml0000644000000000000000000000327514623446206015413 0ustar00name: Deploy on: push: branches: [main] tags: ["*"] pull_request: branches: [main] release: types: - published workflow_dispatch: permissions: contents: read jobs: # Always build & lint package. build-package: name: Build & verify package runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: hynek/build-and-inspect-python-package@v2 # Upload to Test PyPI on every commit on main. release-test-pypi: name: Publish in-dev package to test.pypi.org if: | github.repository_owner == 'pylast' && github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: build-package permissions: id-token: write steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Upload package to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ # Upload to real PyPI on GitHub Releases. release-pypi: name: Publish released package to pypi.org if: | github.repository_owner == 'pylast' && github.event.action == 'published' runs-on: ubuntu-latest needs: build-package permissions: id-token: write steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pylast-5.3.0/.github/workflows/labels.yml0000644000000000000000000000060514623446206015353 0ustar00name: Sync labels permissions: pull-requests: write on: push: branches: - main paths: - .github/labels.yml workflow_dispatch: jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: micnncim/action-label-syncer@v1 with: prune: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pylast-5.3.0/.github/workflows/lint.yml0000644000000000000000000000057114623446206015061 0ustar00name: Lint on: [push, pull_request, workflow_dispatch] env: FORCE_COLOR: 1 PIP_DISABLE_PIP_VERSION_CHECK: 1 permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" cache: pip - uses: pre-commit/action@v3.0.1 pylast-5.3.0/.github/workflows/release-drafter.yml0000644000000000000000000000207414623446206017160 0ustar00name: Release drafter on: push: # branches to consider in the event; optional, defaults to all branches: - main # pull_request event is required only for autolabeler pull_request: # Only following types are handled by the action, but one can default to all as well types: [opened, reopened, synchronize] # pull_request_target event is required for autolabeler to support PRs from forks # pull_request_target: # types: [opened, reopened, synchronize] workflow_dispatch: permissions: contents: read jobs: update_release_draft: if: github.repository_owner == 'pylast' permissions: # write permission is required to create a GitHub Release contents: write # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: write runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pylast-5.3.0/.github/workflows/require-pr-label.yml0000644000000000000000000000100514623446206017254 0ustar00name: Require PR label on: pull_request: types: [opened, reopened, labeled, unlabeled, synchronize] jobs: label: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: mheap/github-action-required-labels@v5 with: mode: minimum count: 1 labels: "changelog: Added, changelog: Changed, changelog: Deprecated, changelog: Fixed, changelog: Removed, changelog: Security, changelog: skip" pylast-5.3.0/.github/workflows/test.yml0000644000000000000000000000254314623446206015073 0ustar00name: Test on: [push, pull_request, workflow_dispatch] env: FORCE_COLOR: 1 jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U wheel python -m pip install -U tox - name: Tox tests run: | tox -e py env: PYLAST_API_KEY: ${{ secrets.PYLAST_API_KEY }} PYLAST_API_SECRET: ${{ secrets.PYLAST_API_SECRET }} PYLAST_PASSWORD_HASH: ${{ secrets.PYLAST_PASSWORD_HASH }} PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage uses: codecov/codecov-action@v3.1.5 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} success: needs: test runs-on: ubuntu-latest name: Test successful steps: - name: Success run: echo Test successful pylast-5.3.0/src/pylast/__init__.py0000644000000000000000000025536714623446206014226 0ustar00# # pylast - # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan # Copyright 2013-2021 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # https://github.com/pylast/pylast from __future__ import annotations import collections import hashlib import html.entities import importlib.metadata import logging import os import re import shelve import ssl import tempfile import time import xml.dom from urllib.parse import quote_plus from xml.dom import Node, minidom import httpx __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = importlib.metadata.version(__name__) # 1 : This error does not exist STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 STATUS_OPERATION_FAILED = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 STATUS_SUBSCRIBERS_ONLY = 12 STATUS_INVALID_SIGNATURE = 13 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 STATUS_TEMPORARILY_UNAVAILABLE = 16 STATUS_LOGIN_REQUIRED = 17 STATUS_TRIAL_EXPIRED = 18 # 19 : This error does not exist STATUS_NOT_ENOUGH_CONTENT = 20 STATUS_NOT_ENOUGH_MEMBERS = 21 STATUS_NOT_ENOUGH_FANS = 22 STATUS_NOT_ENOUGH_NEIGHBOURS = 23 STATUS_NO_PEAK_RADIO = 24 STATUS_RADIO_NOT_FOUND = 25 STATUS_API_KEY_SUSPENDED = 26 STATUS_DEPRECATED = 27 # 28 : This error is not documented STATUS_RATE_LIMIT_EXCEEDED = 29 PERIOD_OVERALL = "overall" PERIOD_7DAYS = "7day" PERIOD_1MONTH = "1month" PERIOD_3MONTHS = "3month" PERIOD_6MONTHS = "6month" PERIOD_12MONTHS = "12month" DOMAIN_ENGLISH = 0 DOMAIN_GERMAN = 1 DOMAIN_SPANISH = 2 DOMAIN_FRENCH = 3 DOMAIN_ITALIAN = 4 DOMAIN_POLISH = 5 DOMAIN_PORTUGUESE = 6 DOMAIN_SWEDISH = 7 DOMAIN_TURKISH = 8 DOMAIN_RUSSIAN = 9 DOMAIN_JAPANESE = 10 DOMAIN_CHINESE = 11 SIZE_SMALL = 0 SIZE_MEDIUM = 1 SIZE_LARGE = 2 SIZE_EXTRA_LARGE = 3 SIZE_MEGA = 4 IMAGES_ORDER_POPULARITY = "popularity" IMAGES_ORDER_DATE = "dateadded" SCROBBLE_SOURCE_USER = "P" SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST = "R" SCROBBLE_SOURCE_PERSONALIZED_BROADCAST = "E" SCROBBLE_SOURCE_LASTFM = "L" SCROBBLE_SOURCE_UNKNOWN = "U" SCROBBLE_MODE_PLAYED = "" SCROBBLE_MODE_LOVED = "L" SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" # Delay time in seconds from section 4.4 of https://www.last.fm/api/tos DELAY_TIME = 0.2 # Python >3.4 has sane defaults SSL_CONTEXT = ssl.create_default_context() HEADERS = { "Content-type": "application/x-www-form-urlencoded", "Accept-Charset": "utf-8", "User-Agent": f"pylast/{__version__}", } logger = logging.getLogger(__name__) logging.getLogger(__name__).addHandler(logging.NullHandler()) class _Network: """ A music social network website such as Last.fm or one with a Last.fm-compatible API. """ def __init__( self, name, homepage, ws_server, api_key, api_secret, session_key, username, password_hash, domain_names, urls, token=None, ) -> None: """ name: the name of the network homepage: the homepage URL ws_server: the URL of the webservices server api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password domain_names: a dict mapping each DOMAIN_* value to a string domain name urls: a dict mapping types to URLs token: an authentication token to retrieve a session if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. Either a valid session_key or a combination of username and password_hash must be present for scrobbling. You should use a preconfigured network object through a get_*_network(...) method instead of creating an object of this class, unless you know what you're doing. """ self.name = name self.homepage = homepage self.ws_server = ws_server self.api_key = api_key self.api_secret = api_secret self.session_key = session_key self.username = username self.password_hash = password_hash self.domain_names = domain_names self.urls = urls self.cache_backend = None self.proxy = None self.last_call_time: float = 0.0 self.limit_rate = False # Load session_key and username from authentication token if provided if token and not self.session_key: sk_gen = SessionKeyGenerator(self) self.session_key, self.username = sk_gen.get_web_auth_session_key_username( url=None, token=token ) # Generate a session_key if necessary if ( (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash) ): sk_gen = SessionKeyGenerator(self) self.session_key = sk_gen.get_session_key(self.username, self.password_hash) def __str__(self) -> str: return f"{self.name} Network" def get_artist(self, artist_name): """ Return an Artist object """ return Artist(artist_name, self) def get_track(self, artist, title): """ Return a Track object """ return Track(artist, title, self) def get_album(self, artist, title): """ Return an Album object """ return Album(artist, title, self) def get_authenticated_user(self): """ Returns the authenticated user """ return AuthenticatedUser(self) def get_country(self, country_name): """ Returns a country object """ return Country(country_name, self) def get_user(self, username): """ Returns a user object """ return User(username, self) def get_tag(self, name): """ Returns a tag object """ return Tag(name, self) def _get_language_domain(self, domain_language): """ Returns the mapped domain name of the network to a DOMAIN_* value """ if domain_language in self.domain_names: return self.domain_names[domain_language] def _get_url(self, domain, url_type) -> str: return f"https://{self._get_language_domain(domain)}/{self.urls[url_type]}" def _get_ws_auth(self): """ Returns an (API_KEY, API_SECRET, SESSION_KEY) tuple. """ return self.api_key, self.api_secret, self.session_key def _delay_call(self) -> None: """ Makes sure that web service calls are at least 0.2 seconds apart. """ now = time.time() time_since_last = now - self.last_call_time if time_since_last < DELAY_TIME: time.sleep(DELAY_TIME - time_since_last) self.last_call_time = now def get_top_artists(self, limit=None, cacheable: bool = True): """Returns the most played artists as a sequence of TopItem objects.""" params = {} if limit: params["limit"] = limit doc = _Request(self, "chart.getTopArtists", params).execute(cacheable) return _extract_top_artists(doc, self) def get_top_tracks(self, limit=None, cacheable: bool = True): """Returns the most played tracks as a sequence of TopItem objects.""" params = {} if limit: params["limit"] = limit doc = _Request(self, "chart.getTopTracks", params).execute(cacheable) seq = [] for node in doc.getElementsByTagName("track"): title = _extract(node, "name") artist = _extract(node, "name", 1) track = Track(artist, title, self) weight = _number(_extract(node, "playcount")) seq.append(TopItem(track, weight)) return seq def get_top_tags(self, limit=None, cacheable: bool = True): """Returns the most used tags as a sequence of TopItem objects.""" # Last.fm has no "limit" parameter for tag.getTopTags # so we need to get all (250) and then limit locally doc = _Request(self, "tag.getTopTags").execute(cacheable) seq: list[TopItem] = [] for node in doc.getElementsByTagName("tag"): if limit and len(seq) >= limit: break tag = Tag(_extract(node, "name"), self) weight = _number(_extract(node, "count")) seq.append(TopItem(tag, weight)) return seq def get_geo_top_artists(self, country, limit=None, cacheable: bool = True): """Get the most popular artists on Last.fm by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 country names standard. limit (Optional) : The number of results to fetch per page. Defaults to 50. """ params = {"country": country} if limit: params["limit"] = limit doc = _Request(self, "geo.getTopArtists", params).execute(cacheable) return _extract_top_artists(doc, self) def get_geo_top_tracks( self, country, location=None, limit=None, cacheable: bool = True ): """Get the most popular tracks on Last.fm last week by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 country names standard location (Optional) : A metro name, to fetch the charts for (must be within the country specified) limit (Optional) : The number of results to fetch per page. Defaults to 50. """ params = {"country": country} if location: params["location"] = location if limit: params["limit"] = limit doc = _Request(self, "geo.getTopTracks", params).execute(cacheable) tracks = doc.getElementsByTagName("track") seq = [] for track in tracks: title = _extract(track, "name") artist = _extract(track, "name", 1) listeners = _extract(track, "listeners") seq.append(TopItem(Track(artist, title, self), listeners)) return seq def enable_proxy(self, proxy: str | dict) -> None: """Enable default web proxy. Multiple proxies can be passed as a `dict`, see https://www.python-httpx.org/advanced/#http-proxying """ self.proxy = proxy def disable_proxy(self) -> None: """Disable using the web proxy""" self.proxy = None def is_proxy_enabled(self) -> bool: """Returns True if web proxy is enabled.""" return self.proxy is not None def enable_rate_limit(self) -> None: """Enables rate limiting for this network""" self.limit_rate = True def disable_rate_limit(self) -> None: """Disables rate limiting for this network""" self.limit_rate = False def is_rate_limited(self) -> bool: """Return True if web service calls are rate limited""" return self.limit_rate def enable_caching(self, file_path=None) -> None: """Enables caching request-wide for all cacheable calls. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ if not file_path: self.cache_backend = _ShelfCacheBackend.create_shelf() return self.cache_backend = _ShelfCacheBackend(file_path) def disable_caching(self) -> None: """Disables all caching features.""" self.cache_backend = None def is_caching_enabled(self) -> bool: """Returns True if caching is enabled.""" return self.cache_backend is not None def search_for_album(self, album_name): """Searches for an album by its name. Returns an AlbumSearch object. Use get_next_page() to retrieve sequences of results.""" return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): """Searches for an artist by its name. Returns an ArtistSearch object. Use get_next_page() to retrieve sequences of results.""" return ArtistSearch(artist_name, self) def search_for_track(self, artist_name, track_name): """Searches for a track by its name and its artist. Set artist to an empty string if not available. Returns a TrackSearch object. Use get_next_page() to retrieve sequences of results.""" return TrackSearch(artist_name, track_name, self) def get_track_by_mbid(self, mbid): """Looks up a track by its MusicBrainz ID""" params = {"mbid": mbid} doc = _Request(self, "track.getInfo", params).execute(True) return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) def get_artist_by_mbid(self, mbid): """Looks up an artist by its MusicBrainz ID""" params = {"mbid": mbid} doc = _Request(self, "artist.getInfo", params).execute(True) return Artist(_extract(doc, "name"), self) def get_album_by_mbid(self, mbid): """Looks up an album by its MusicBrainz ID""" params = {"mbid": mbid} doc = _Request(self, "album.getInfo", params).execute(True) return Album(_extract(doc, "artist"), _extract(doc, "name"), self) def update_now_playing( self, artist, title, album=None, album_artist=None, duration=None, track_number=None, mbid=None, context=None, ) -> None: """ Used to notify Last.fm that a user has started listening to a track. Parameters: artist (Required) : The artist name title (Required) : The track title album (Optional) : The album name. album_artist (Optional) : The album artist - if this differs from the track artist. duration (Optional) : The length of the track in seconds. track_number (Optional) : The track number of the track on the album. mbid (Optional) : The MusicBrainz Track ID. context (Optional) : Sub-client version (not public, only enabled for certain API keys) """ params = {"track": title, "artist": artist} if album: params["album"] = album if album_artist: params["albumArtist"] = album_artist if context: params["context"] = context if track_number: params["trackNumber"] = track_number if mbid: params["mbid"] = mbid if duration: params["duration"] = duration _Request(self, "track.updateNowPlaying", params).execute() def scrobble( self, artist: str, title: str, timestamp: int, album: str | None = None, album_artist: str | None = None, track_number: int | None = None, duration: int | None = None, stream_id: str | None = None, context: str | None = None, mbid: str | None = None, ): """Used to add a track-play to a user's profile. Parameters: artist (Required) : The artist name. title (Required) : The track name. timestamp (Required) : The time the track started playing, in Unix timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). album (Optional) : The album name. album_artist (Optional) : The album artist - if this differs from the track artist. context (Optional) : Sub-client version (not public, only enabled for certain API keys) stream_id (Optional) : The stream id for this track received from the radio.getPlaylist service. track_number (Optional) : The track number of the track on the album. mbid (Optional) : The MusicBrainz Track ID. duration (Optional) : The length of the track in seconds. """ return self.scrobble_many( ( { "artist": artist, "title": title, "timestamp": timestamp, "album": album, "album_artist": album_artist, "track_number": track_number, "duration": duration, "stream_id": stream_id, "context": context, "mbid": mbid, }, ) ) def scrobble_many(self, tracks) -> None: """ Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per track containing the keyword arguments as if passed to the scrobble() method. """ tracks_to_scrobble = tracks[:50] if len(tracks) > 50: remaining_tracks = tracks[50:] else: remaining_tracks = None params = {} for i in range(len(tracks_to_scrobble)): params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"] params[f"track[{i}]"] = tracks_to_scrobble[i]["title"] additional_args = ( "timestamp", "album", "album_artist", "context", "stream_id", "track_number", "mbid", "duration", ) args_map_to = { # so friggin lazy "album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID", } for arg in additional_args: if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]: if arg in args_map_to: maps_to = args_map_to[arg] else: maps_to = arg params[f"{maps_to}[{i}]"] = tracks_to_scrobble[i][arg] _Request(self, "track.scrobble", params).execute() if remaining_tracks: self.scrobble_many(remaining_tracks) class LastFMNetwork(_Network): """A Last.fm network object api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. Either a valid session_key or a combination of username and password_hash must be present for scrobbling. Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: https://www.last.fm/api/account """ def __init__( self, api_key: str = "", api_secret: str = "", session_key: str = "", username: str = "", password_hash: str = "", token: str = "", ) -> None: super().__init__( name="Last.fm", homepage="https://www.last.fm", ws_server=("ws.audioscrobbler.com", "/2.0/"), api_key=api_key, api_secret=api_secret, session_key=session_key, username=username, password_hash=password_hash, token=token, domain_names={ DOMAIN_ENGLISH: "www.last.fm", DOMAIN_GERMAN: "www.last.fm/de", DOMAIN_SPANISH: "www.last.fm/es", DOMAIN_FRENCH: "www.last.fm/fr", DOMAIN_ITALIAN: "www.last.fm/it", DOMAIN_POLISH: "www.last.fm/pl", DOMAIN_PORTUGUESE: "www.last.fm/pt", DOMAIN_SWEDISH: "www.last.fm/sv", DOMAIN_TURKISH: "www.last.fm/tr", DOMAIN_RUSSIAN: "www.last.fm/ru", DOMAIN_JAPANESE: "www.last.fm/ja", DOMAIN_CHINESE: "www.last.fm/zh", }, urls={ "album": "music/%(artist)s/%(album)s", "artist": "music/%(artist)s", "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "user": "user/%(name)s", }, ) def __repr__(self) -> str: return ( "pylast.LastFMNetwork(" f"'{self.api_key}', " f"'{self.api_secret}', " f"'{self.session_key}', " f"'{self.username}', " f"'{self.password_hash}'" ")" ) class LibreFMNetwork(_Network): """ A preconfigured _Network object for Libre.fm api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. """ def __init__( self, api_key: str = "", api_secret: str = "", session_key: str = "", username: str = "", password_hash: str = "", ) -> None: super().__init__( name="Libre.fm", homepage="https://libre.fm", ws_server=("libre.fm", "/2.0/"), api_key=api_key, api_secret=api_secret, session_key=session_key, username=username, password_hash=password_hash, domain_names={ DOMAIN_ENGLISH: "libre.fm", DOMAIN_GERMAN: "libre.fm", DOMAIN_SPANISH: "libre.fm", DOMAIN_FRENCH: "libre.fm", DOMAIN_ITALIAN: "libre.fm", DOMAIN_POLISH: "libre.fm", DOMAIN_PORTUGUESE: "libre.fm", DOMAIN_SWEDISH: "libre.fm", DOMAIN_TURKISH: "libre.fm", DOMAIN_RUSSIAN: "libre.fm", DOMAIN_JAPANESE: "libre.fm", DOMAIN_CHINESE: "libre.fm", }, urls={ "album": "artist/%(artist)s/album/%(album)s", "artist": "artist/%(artist)s", "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "user": "user/%(name)s", }, ) def __repr__(self) -> str: return ( "pylast.LibreFMNetwork(" f"'{self.api_key}', " f"'{self.api_secret}', " f"'{self.session_key}', " f"'{self.username}', " f"'{self.password_hash}'" ")" ) class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None, flag=None) -> None: if flag is not None: self.shelf = shelve.open(file_path, flag=flag) else: self.shelf = shelve.open(file_path) self.cache_keys = set(self.shelf.keys()) def __contains__(self, key) -> bool: return key in self.cache_keys def __iter__(self): return iter(self.shelf.keys()) def get_xml(self, key): return self.shelf[key] def set_xml(self, key, xml_string) -> None: self.cache_keys.add(key) self.shelf[key] = xml_string @classmethod def create_shelf(cls): file_descriptor, file_path = tempfile.mkstemp(prefix="pylast_tmp_") os.close(file_descriptor) return cls(file_path=file_path, flag="n") class _Request: """Representing an abstract web service operation.""" def __init__(self, network, method_name, params=None) -> None: logger.info(method_name) if params is None: params = {} self.network = network self.params = {} for key in params: self.params[key] = _unicode(params[key]) (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() self.params["api_key"] = self.api_key self.params["method"] = method_name if network.is_caching_enabled(): self.cache = network.cache_backend if self.session_key: self.params["sk"] = self.session_key self.sign_it() def sign_it(self) -> None: """Sign this request.""" if "api_sig" not in self.params.keys(): self.params["api_sig"] = self._get_signature() def _get_signature(self): """ Returns a 32-character hexadecimal md5 hash of the signature string. """ keys = list(self.params.keys()) keys.sort() string = "" for name in keys: string += name string += self.params[name] string += self.api_secret return md5(string) def _get_cache_key(self): """ The cache key is a string of concatenated sorted names and values. """ keys = list(self.params.keys()) keys.sort() cache_key = "" for key in keys: if key != "api_sig" and key != "api_key" and key != "sk": cache_key += key + self.params[key] return hashlib.sha1(cache_key.encode("utf-8")).hexdigest() def _get_cached_response(self): """Returns a file object of the cached response.""" if not self._is_cached(): response = self._download_response() self.cache.set_xml(self._get_cache_key(), response) return self.cache.get_xml(self._get_cache_key()) def _is_cached(self): """Returns True if the request is already in cache.""" return self._get_cache_key() in self.cache def _download_response(self): """Returns a response body string from the server.""" if self.network.limit_rate: self.network._delay_call() username = self.params.pop("username", None) username = "" if username is None else f"?username={username}" (host_name, host_subdir) = self.network.ws_server timeout = httpx.Timeout(5, read=10) if self.network.is_proxy_enabled(): client = httpx.Client( verify=SSL_CONTEXT, base_url=f"https://{host_name}", headers=HEADERS, proxies=self.network.proxy, timeout=timeout, ) else: client = httpx.Client( verify=SSL_CONTEXT, base_url=f"https://{host_name}", headers=HEADERS, timeout=timeout, ) try: response = client.post(f"{host_subdir}{username}", data=self.params) except Exception as e: raise NetworkError(self.network, e) from e if response.status_code in (500, 502, 503, 504): raise WSError( self.network, response.status_code, f"Connection to the API failed with HTTP code {response.status_code}", ) response_text = _unicode(response.read()) try: self._check_response_for_errors(response_text) finally: client.close() return response_text def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document: """Returns the XML DOM response of the POST Request from the server""" if self.network.is_caching_enabled() and cacheable: response = self._get_cached_response() else: response = self._download_response() return _parse_response(response) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" try: doc = _parse_response(response) except Exception as e: raise MalformedResponseError(self.network, e) from e element = doc.getElementsByTagName("lfm")[0] logger.debug(doc.toprettyxml()) if element.getAttribute("status") != "ok": element = doc.getElementsByTagName("error")[0] status = element.getAttribute("code") details = element.firstChild.data.strip() raise WSError(self.network, status, details) class SessionKeyGenerator: """Methods of generating a session key: 1) Web Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. sg = SessionKeyGenerator(network) c. url = sg.get_web_auth_url() d. Ask the user to open the URL and authorize you, and wait for it. e. session_key = sg.get_web_auth_session_key(url) 2) Username and Password Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. username = raw_input("Please enter your username: ") c. password_hash = pylast.md5(raw_input("Please enter your password: ") d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) A session key's lifetime is infinite, unless the user revokes the rights of the given API Key. If you create a Network object with just an API_KEY and API_SECRET and a username and a password_hash, a SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this manually, unless you want to. """ def __init__(self, network) -> None: self.network = network self.web_auth_tokens = {} def _get_web_auth_token(self): """ Retrieves a token from the network for web authentication. The token then has to be authorized from getAuthURL before creating session. """ request = _Request(self.network, "auth.getToken") # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() e = doc.getElementsByTagName("token")[0] return e.firstChild.data def get_web_auth_url(self): """ The user must open this page, and you first, then call get_web_auth_session_key(url) after that. """ token = self._get_web_auth_token() url = ( f"{self.network.homepage}/api/auth/" f"?api_key={self.network.api_key}" f"&token={token}" ) self.web_auth_tokens[url] = token return url def get_web_auth_session_key_username(self, url, token: str = ""): """ Retrieves the session key/username of a web authorization process by its URL. """ if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] request = _Request(self.network, "auth.getSession", {"token": token}) # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() session_key = doc.getElementsByTagName("key")[0].firstChild.data username = doc.getElementsByTagName("name")[0].firstChild.data return session_key, username def get_web_auth_session_key(self, url, token: str = ""): """ Retrieves the session key of a web authorization process by its URL. """ session_key, _username = self.get_web_auth_session_key_username(url, token) return session_key def get_session_key(self, username, password_hash): """ Retrieve a session key with a username and a md5 hash of the user's password. """ params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) # default action is that a request is signed only when # a session key is provided. request.sign_it() doc = request.execute() return _extract(doc, "key") TopItem = collections.namedtuple("TopItem", ["item", "weight"]) SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) PlayedTrack = collections.namedtuple( "PlayedTrack", ["track", "album", "playback_date", "timestamp"] ) LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) ImageSizes = collections.namedtuple( "ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"] ) Image = collections.namedtuple( "Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"] ) def _string_output(func): def r(*args): return str(func(*args)) return r class _BaseObject: """An abstract webservices object.""" network = None def __init__(self, network, ws_prefix) -> None: self.network = network self.ws_prefix = ws_prefix def _request(self, method_name, cacheable: bool = False, params=None): if not params: params = self._get_params() return _Request(self.network, method_name, params).execute(cacheable) def _get_params(self): """Returns the most common set of parameters between all objects.""" return {} def __hash__(self): # Convert any ints (or whatever) into strings values = map(str, self._get_params().values()) return hash(self.network) + hash( str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower() ) def _extract_cdata_from_request(self, method_name, tag_name, params): doc = self._request(method_name, True, params) first_child = doc.getElementsByTagName(tag_name)[0].firstChild if first_child is None: return None return first_child.wholeText.strip() def _get_things( self, method, thing_type, params=None, cacheable: bool = True, stream: bool = False, ): """Returns a list of the most played thing_types by this thing.""" def _stream_get_things(): limit = params.get("limit", 50) nodes = _collect_nodes( limit, self, self.ws_prefix + "." + method, cacheable, params, stream=stream, ) for node in nodes: title = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) yield TopItem(thing_type(artist, title, self.network), playcount) return _stream_get_things() if stream else list(_stream_get_things()) def get_wiki_published_date(self): """ Returns the date on which the wiki was published. Only for Album/Track. """ return self.get_wiki("published") def get_wiki_summary(self): """ Returns the summary of the wiki. Only for Album/Track. """ return self.get_wiki("summary") def get_wiki_content(self): """ Returns the content of the wiki. Only for Album/Track. """ return self.get_wiki("content") def get_wiki(self, section): """ Returns a section of the wiki. Only for Album/Track. section can be "content", "summary" or "published" (for published date) """ doc = self._request(self.ws_prefix + ".getInfo", True) if len(doc.getElementsByTagName("wiki")) == 0: return node = doc.getElementsByTagName("wiki")[0] return _extract(node, section) class _Chartable(_BaseObject): """Common functions for classes with charts.""" def __init__(self, network, ws_prefix) -> None: super().__init__(network=network, ws_prefix=ws_prefix) def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" doc = self._request(self.ws_prefix + ".getWeeklyChartList", True) seq = [] for node in doc.getElementsByTagName("chart"): seq.append((node.getAttribute("from"), node.getAttribute("to"))) return seq def get_weekly_album_charts(self, from_date=None, to_date=None): """ Returns the weekly album charts for the week starting from the from_date value to the to_date value. Only for User. """ return self.get_weekly_charts("album", from_date, to_date) def get_weekly_artist_charts(self, from_date=None, to_date=None): """ Returns the weekly artist charts for the week starting from the from_date value to the to_date value. Only for User. """ return self.get_weekly_charts("artist", from_date, to_date) def get_weekly_track_charts(self, from_date=None, to_date=None): """ Returns the weekly track charts for the week starting from the from_date value to the to_date value. Only for User. """ return self.get_weekly_charts("track", from_date, to_date) def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): """ Returns the weekly charts for the week starting from the from_date value to the to_date value. chart_kind should be one of "album", "artist" or "track" """ import sys method = ".getWeekly" + chart_kind.title() + "Chart" chart_type = getattr(sys.modules[__name__], chart_kind.title()) params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date doc = self._request(self.ws_prefix + method, True, params) seq = [] for node in doc.getElementsByTagName(chart_kind.lower()): if chart_kind == "artist": item = chart_type(_extract(node, "name"), self.network) else: item = chart_type( _extract(node, "artist"), _extract(node, "name"), self.network ) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq class _Taggable(_BaseObject): """Common functions for classes with tags.""" def __init__(self, network, ws_prefix) -> None: super().__init__(network=network, ws_prefix=ws_prefix) def add_tags(self, tags) -> None: """Adds one or several tags. * tags: A sequence of tag names or Tag objects. """ for tag in tags: self.add_tag(tag) def add_tag(self, tag) -> None: """Adds one tag. * tag: a tag name or a Tag object. """ if isinstance(tag, Tag): tag = tag.get_name() params = self._get_params() params["tags"] = tag self._request(self.ws_prefix + ".addTags", False, params) def remove_tag(self, tag) -> None: """Remove a user's tag from this object.""" if isinstance(tag, Tag): tag = tag.get_name() params = self._get_params() params["tag"] = tag self._request(self.ws_prefix + ".removeTag", False, params) def get_tags(self): """Returns a list of the tags set by the user to this object.""" # Uncacheable because it can be dynamically changed by the user. params = self._get_params() doc = self._request(self.ws_prefix + ".getTags", False, params) tag_names = _extract_all(doc, "name") tags = [] for tag in tag_names: tags.append(Tag(tag, self.network)) return tags def remove_tags(self, tags) -> None: """Removes one or several tags from this object. * tags: a sequence of tag names or Tag objects. """ for tag in tags: self.remove_tag(tag) def clear_tags(self) -> None: """Clears all the user-set tags.""" self.remove_tags(*(self.get_tags())) def set_tags(self, tags) -> None: """Sets this object's tags to only those tags. * tags: a sequence of tag names or Tag objects. """ c_old_tags = [] old_tags = [] c_new_tags = [] new_tags = [] to_remove = [] to_add = [] tags_on_server = self.get_tags() for tag in tags_on_server: c_old_tags.append(tag.get_name().lower()) old_tags.append(tag.get_name()) for tag in tags: c_new_tags.append(tag.lower()) new_tags.append(tag) for i in range(0, len(old_tags)): if c_old_tags[i] not in c_new_tags: to_remove.append(old_tags[i]) for i in range(0, len(new_tags)): if c_new_tags[i] not in c_old_tags: to_add.append(new_tags[i]) self.remove_tags(to_remove) self.add_tags(to_add) def get_top_tags(self, limit=None): """Returns a list of the most frequently used Tags on this object.""" doc = self._request(self.ws_prefix + ".getTopTags", True) elements = doc.getElementsByTagName("tag") seq = [] for element in elements: tag_name = _extract(element, "name") tag_count = _extract(element, "count") seq.append(TopItem(Tag(tag_name, self.network), tag_count)) if limit: seq = seq[:limit] return seq class PyLastError(Exception): """Generic exception raised by PyLast""" pass class WSError(PyLastError): """Exception related to the Network web service""" def __init__(self, network, status, details) -> None: self.status = status self.details = details self.network = network @_string_output def __str__(self) -> str: return self.details def get_id(self): """Returns the exception ID, from one of the following: STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 STATUS_OPERATION_FAILED = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 STATUS_SUBSCRIBERS_ONLY = 12 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 STATUS_TEMPORARILY_UNAVAILABLE = 16 STATUS_LOGIN_REQUIRED = 17 STATUS_TRIAL_EXPIRED = 18 STATUS_NOT_ENOUGH_CONTENT = 20 STATUS_NOT_ENOUGH_MEMBERS = 21 STATUS_NOT_ENOUGH_FANS = 22 STATUS_NOT_ENOUGH_NEIGHBOURS = 23 STATUS_NO_PEAK_RADIO = 24 STATUS_RADIO_NOT_FOUND = 25 STATUS_API_KEY_SUSPENDED = 26 STATUS_DEPRECATED = 27 STATUS_RATE_LIMIT_EXCEEDED = 29 """ return self.status class MalformedResponseError(PyLastError): """Exception conveying a malformed response from the music network.""" def __init__(self, network, underlying_error) -> None: self.network = network self.underlying_error = underlying_error def __str__(self) -> str: return ( f"Malformed response from {self.network.name}. " f"Underlying error: {self.underlying_error}" ) class NetworkError(PyLastError): """Exception conveying a problem in sending a request to Last.fm""" def __init__(self, network, underlying_error) -> None: self.network = network self.underlying_error = underlying_error def __str__(self) -> str: return f"NetworkError: {self.underlying_error}" class _Opus(_Taggable): """An album or track.""" artist = None title = None username = None __hash__ = _BaseObject.__hash__ def __init__( self, artist, title, network, ws_prefix, username=None, info=None ) -> None: """ Create an opus instance. # Parameters: * artist: An artist name or an Artist object. * title: The album or track title. * ws_prefix: 'album' or 'track' """ if info is None: info = {} super().__init__(network=network, ws_prefix=ws_prefix) if isinstance(artist, Artist): self.artist = artist else: self.artist = Artist(artist, self.network) self.title = title self.username = ( username if username else network.username ) # Default to current user self.info = info def __repr__(self) -> str: return ( f"pylast.{self.ws_prefix.title()}" f"({repr(self.artist.name)}, {repr(self.title)}, {repr(self.network)})" ) @_string_output def __str__(self) -> str: return f"{self.get_artist().get_name()} - {self.get_title()}" def __eq__(self, other): if type(self) is not type(other): return False a = self.get_title().lower() b = other.get_title().lower() c = self.get_artist().get_name().lower() d = other.get_artist().get_name().lower() return (a == b) and (c == d) def __ne__(self, other): return not self == other def _get_params(self): return { "artist": self.get_artist().get_name(), self.ws_prefix: self.get_title(), } def get_artist(self): """Returns the associated Artist object.""" return self.artist def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ Returns a URI to the cover image size can be one of: SIZE_EXTRA_LARGE SIZE_LARGE SIZE_MEDIUM SIZE_SMALL """ if "image" not in self.info: self.info["image"] = _extract_all( self._request(self.ws_prefix + ".getInfo", cacheable=True), "image" ) return self.info["image"][size] def get_title(self, properly_capitalized: bool = False): """Returns the album or track title.""" if properly_capitalized: self.title = _extract( self._request(self.ws_prefix + ".getInfo", True), "name" ) return self.title def get_name(self, properly_capitalized: bool = False): """Returns the album or track title (alias to get_title()).""" return self.get_title(properly_capitalized) def get_playcount(self): """Returns the number of plays on the network""" return _number( _extract( self._request(self.ws_prefix + ".getInfo", cacheable=True), "playcount" ) ) def get_userplaycount(self): """Returns the number of plays by a given username""" if not self.username: return params = self._get_params() params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) return _number(_extract(doc, "userplaycount")) def get_listener_count(self): """Returns the number of listeners on the network""" return _number( _extract( self._request(self.ws_prefix + ".getInfo", cacheable=True), "listeners" ) ) def get_mbid(self) -> str | None: """Returns the MusicBrainz ID of the album or track.""" doc = self._request(self.ws_prefix + ".getInfo", cacheable=True) try: lfm = doc.getElementsByTagName("lfm")[0] opus = next(self._get_children_by_tag_name(lfm, self.ws_prefix)) mbid = next(self._get_children_by_tag_name(opus, "mbid")) return mbid.firstChild.nodeValue if mbid.firstChild else None except StopIteration: return None def _get_children_by_tag_name(self, node, tag_name): for child in node.childNodes: if child.nodeType == child.ELEMENT_NODE and ( tag_name == "*" or child.tagName == tag_name ): yield child class Album(_Opus): """An album.""" __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None) -> None: super().__init__(artist, title, network, "album", username, info) def get_tracks(self): """Returns the list of Tracks on this album.""" return _extract_tracks( self._request(self.ws_prefix + ".getInfo", cacheable=True), self.network ) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the album or track page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) return self.network._get_url(domain_name, self.ws_prefix) % { "artist": artist, "album": title, } class Artist(_Taggable): """An artist.""" name = None username = None __hash__ = _BaseObject.__hash__ def __init__(self, name, network, username=None, info=None) -> None: """Create an artist object. # Parameters: * name str: The artist's name. """ if info is None: info = {} super().__init__(network=network, ws_prefix="artist") self.name = name self.username = username self.info = info def __repr__(self) -> str: return f"pylast.Artist({repr(self.get_name())}, {repr(self.network)})" def __unicode__(self): return str(self.get_name()) @_string_output def __str__(self) -> str: return self.__unicode__() def __eq__(self, other): if type(self) is type(other): return self.get_name().lower() == other.get_name().lower() else: return False def __ne__(self, other): return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized: bool = False): """Returns the name of the artist. If properly_capitalized was asserted then the name would be downloaded overwriting the given one.""" if properly_capitalized: self.name = _extract( self._request(self.ws_prefix + ".getInfo", True), "name" ) return self.name def get_correction(self): """Returns the corrected artist name.""" return _extract(self._request(self.ws_prefix + ".getCorrection"), "name") def get_playcount(self): """Returns the number of plays on the network.""" return _number( _extract(self._request(self.ws_prefix + ".getInfo", True), "playcount") ) def get_userplaycount(self): """Returns the number of plays by a given username""" if not self.username: return params = self._get_params() params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) return _number(_extract(doc, "userplaycount")) def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "mbid") def get_listener_count(self): """Returns the number of listeners on the network.""" if hasattr(self, "listener_count"): return self.listener_count else: self.listener_count = _number( _extract(self._request(self.ws_prefix + ".getInfo", True), "listeners") ) return self.listener_count def get_bio(self, section, language=None): """ Returns a section of the bio. section can be "content", "summary" or "published" (for published date) """ if language: params = self._get_params() params["lang"] = language else: params = None try: bio = self._extract_cdata_from_request( self.ws_prefix + ".getInfo", section, params ) except IndexError: bio = None return bio def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" return self.get_bio("published") def get_bio_summary(self, language=None): """Returns the summary of the artist's biography.""" return self.get_bio("summary", language) def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" return self.get_bio("content", language) def get_similar(self, limit=None): """Returns the similar artists on the network.""" params = self._get_params() if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getSimilar", True, params) names = _extract_all(doc, "name") matches = _extract_all(doc, "match") artists = [] for i in range(0, len(names)): artists.append( SimilarItem(Artist(names[i], self.network), _number(matches[i])) ) return artists def get_top_albums(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the top albums.""" params = self._get_params() if limit: params["limit"] = limit return self._get_things("getTopAlbums", Album, params, cacheable, stream=stream) def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the most played Tracks by this artist.""" params = self._get_params() if limit: params["limit"] = limit return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_name()) return self.network._get_url(domain_name, "artist") % {"artist": artist} class Country(_BaseObject): """A country at Last.fm.""" name = None __hash__ = _BaseObject.__hash__ def __init__(self, name, network) -> None: super().__init__(network=network, ws_prefix="geo") self.name = name def __repr__(self) -> str: return f"pylast.Country({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self) -> str: return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return not self == other def _get_params(self): # TODO can move to _BaseObject return {"country": self.get_name()} def get_name(self): """Returns the country name.""" return self.name def get_top_artists(self, limit=None, cacheable: bool = True): """Returns a sequence of the most played artists.""" params = self._get_params() if limit: params["limit"] = limit doc = self._request("geo.getTopArtists", cacheable, params) return _extract_top_artists(doc, self) def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a sequence of the most played tracks""" params = self._get_params() if limit: params["limit"] = limit return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ country_name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "country") % { "country_name": country_name } class Library(_BaseObject): """A user's Last.fm library.""" user = None __hash__ = _BaseObject.__hash__ def __init__(self, user, network) -> None: super().__init__(network=network, ws_prefix="library") if isinstance(user, User): self.user = user else: self.user = User(user, self.network) def __repr__(self) -> str: return f"pylast.Library({repr(self.user)}, {repr(self.network)})" @_string_output def __str__(self) -> str: return repr(self.get_user()) + "'s Library" def _get_params(self): return {"user": self.user.get_name()} def get_user(self): """Returns the user who owns this library.""" return self.user def get_artists( self, limit: int = 50, cacheable: bool = True, stream: bool = False ): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ def _get_artists(): for node in _collect_nodes( limit, self, self.ws_prefix + ".getArtists", cacheable, stream=stream ): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) yield LibraryItem(Artist(name, self.network), playcount, tagcount) return _get_artists() if stream else list(_get_artists()) class Tag(_Chartable): """A Last.fm object tag.""" name = None __hash__ = _BaseObject.__hash__ def __init__(self, name, network) -> None: super().__init__(network=network, ws_prefix="tag") self.name = name def __repr__(self) -> str: return f"pylast.Tag({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self) -> str: return self.get_name() def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized: bool = False): """Returns the name of the tag.""" if properly_capitalized: self.name = _extract( self._request(self.ws_prefix + ".getInfo", True), "name" ) return self.name def get_top_albums(self, limit=None, cacheable: bool = True): """Returns a list of the top albums.""" params = self._get_params() if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopAlbums", cacheable, params) return _extract_top_albums(doc, self.network) def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the most played Tracks for this tag.""" params = self._get_params() if limit: params["limit"] = limit return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) def get_top_artists(self, limit=None, cacheable: bool = True): """Returns a sequence of the most played artists.""" params = self._get_params() if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopArtists", cacheable, params) return _extract_top_artists(doc, self.network) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "tag") % {"name": name} class Track(_Opus): """A Last.fm track.""" __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None) -> None: super().__init__(artist, title, network, "track", username, info) def get_correction(self): """Returns the corrected track name.""" return _extract(self._request(self.ws_prefix + ".getCorrection"), "name") def get_duration(self): """Returns the track duration.""" doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "duration")) def get_userloved(self): """Whether the user loved this track""" if not self.username: return params = self._get_params() params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) loved = _number(_extract(doc, "userloved")) return bool(loved) def get_album(self): """Returns the album object of this track.""" if "album" in self.info and self.info["album"] is not None: return Album(self.artist, self.info["album"], self.network) doc = self._request(self.ws_prefix + ".getInfo", True) albums = doc.getElementsByTagName("album") if len(albums) == 0: return node = doc.getElementsByTagName("album")[0] return Album(_extract(node, "artist"), _extract(node, "title"), self.network) def love(self) -> None: """Adds the track to the user's loved tracks.""" self._request(self.ws_prefix + ".love") def unlove(self) -> None: """Remove the track to the user's loved tracks.""" self._request(self.ws_prefix + ".unlove") def get_similar(self, limit=None): """ Returns similar tracks for this track on the network, based on listening data. """ params = self._get_params() if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getSimilar", True, params) seq = [] for node in doc.getElementsByTagName(self.ws_prefix): title = _extract(node, "name") artist = _extract(node, "name", 1) match = _number(_extract(node, "match")) seq.append(SimilarItem(Track(artist, title, self.network), match)) return seq def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the album or track page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) return self.network._get_url(domain_name, self.ws_prefix) % { "artist": artist, "title": title, } class User(_Chartable): """A Last.fm user.""" name = None __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network) -> None: super().__init__(network=network, ws_prefix="user") self.name = user_name def __repr__(self) -> str: return f"pylast.User({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self) -> str: return self.get_name() def __eq__(self, other): if isinstance(other, User): return self.get_name() == other.get_name() else: return False def __ne__(self, other): return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} def _extract_played_track(self, track_node): title = _extract(track_node, "name") track_artist = _extract(track_node, "artist") date = _extract(track_node, "date") album = _extract(track_node, "album") timestamp = track_node.getElementsByTagName("date")[0].getAttribute("uts") return PlayedTrack( Track(track_artist, title, self.network), album, date, timestamp ) def get_name(self, properly_capitalized: bool = False): """Returns the user name.""" if properly_capitalized: self.name = _extract( self._request(self.ws_prefix + ".getInfo", True), "name" ) return self.name def get_friends( self, limit: int = 50, cacheable: bool = False, stream: bool = False ): """Returns a list of the user's friends.""" def _get_friends(): for node in _collect_nodes( limit, self, self.ws_prefix + ".getFriends", cacheable, stream=stream ): yield User(_extract(node, "name"), self.network) return _get_friends() if stream else list(_get_friends()) def get_loved_tracks( self, limit: int = 50, cacheable: bool = True, stream: bool = False ): """ Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. If stream=True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. """ def _get_loved_tracks(): params = self._get_params() if limit: params["limit"] = limit for track in _collect_nodes( limit, self, self.ws_prefix + ".getLovedTracks", cacheable, params, stream=stream, ): try: artist = _extract(track, "name", 1) except IndexError: # pragma: no cover continue title = _extract(track, "name") date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") yield LovedTrack(Track(artist, title, self.network), date, timestamp) return _get_loved_tracks() if stream else list(_get_loved_tracks()) def get_now_playing(self): """ Returns the currently playing track, or None if nothing is playing. """ params = self._get_params() params["limit"] = "1" doc = self._request(self.ws_prefix + ".getRecentTracks", False, params) tracks = doc.getElementsByTagName("track") if len(tracks) == 0: return None e = tracks[0] if not e.hasAttribute("nowplaying"): return None artist = _extract(e, "artist") title = _extract(e, "name") info = {"album": _extract(e, "album"), "image": _extract_all(e, "image")} return Track(artist, title, self.network, self.name, info=info) def get_recent_tracks( self, limit: int = 10, cacheable: bool = True, time_from: int | None = None, time_to: int | None = None, stream: bool = False, now_playing: bool = False, ): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. Parameters: limit : If None, it will try to pull all the available data. from (Optional) : Beginning timestamp of a range - only display scrobbles after this time, in Unix timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). to (Optional) : End timestamp of a range - only display scrobbles before this time, in Unix timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). stream: If True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. """ def _get_recent_tracks(): params = self._get_params() if limit: params["limit"] = limit + 1 # in case we remove the now playing track if time_from: params["from"] = time_from if time_to: params["to"] = time_to track_count = 0 for track_node in _collect_nodes( limit + 1 if limit else None, self, self.ws_prefix + ".getRecentTracks", cacheable, params, stream=stream, ): if track_node.hasAttribute("nowplaying") and not now_playing: continue # to prevent the now playing track from sneaking in if limit and track_count >= limit: break yield self._extract_played_track(track_node=track_node) track_count += 1 return _get_recent_tracks() if stream else list(_get_recent_tracks()) def get_country(self): """Returns the name of the country of the user.""" doc = self._request(self.ws_prefix + ".getInfo", True) country = _extract(doc, "country") if country is None or country == "None": return None else: return Country(country, self.network) def is_subscriber(self): """Returns whether the user is a subscriber or not. True or False.""" doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "subscriber") == "1" def get_playcount(self): """Returns the user's playcount so far.""" doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "playcount")) def get_registered(self): """Returns the user's registration date.""" doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "registered") def get_unixtime_registered(self): """Returns the user's registration date as a Unix timestamp.""" doc = self._request(self.ws_prefix + ".getInfo", True) return int(doc.getElementsByTagName("registered")[0].getAttribute("unixtime")) def get_tagged_albums(self, tag, limit=None, cacheable: bool = True): """Returns the albums tagged by a user.""" params = self._get_params() params["tag"] = tag params["taggingtype"] = "album" if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params) return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): """Returns the artists tagged by a user.""" params = self._get_params() params["tag"] = tag params["taggingtype"] = "artist" if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getpersonaltags", True, params) return _extract_artists(doc, self.network) def get_tagged_tracks(self, tag, limit=None, cacheable: bool = True): """Returns the tracks tagged by a user.""" params = self._get_params() params["tag"] = tag params["taggingtype"] = "track" if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params) return _extract_tracks(doc, self.network) def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable: bool = True): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params["period"] = period if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopAlbums", cacheable, params) return _extract_top_albums(doc, self.network) def get_top_artists(self, period=PERIOD_OVERALL, limit=None): """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params["period"] = period if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopArtists", True, params) return _extract_top_artists(doc, self.network) def get_top_tags(self, limit=None, cacheable: bool = True): """ Returns a sequence of the top tags used by this user with their counts as TopItem objects. * limit: The limit of how many tags to return. * cacheable: Whether to cache results. """ params = self._get_params() if limit: params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopTags", cacheable, params) seq = [] for node in doc.getElementsByTagName("tag"): seq.append( TopItem( Tag(_extract(node, "name"), self.network), _extract(node, "count") ) ) return seq def get_top_tracks( self, period=PERIOD_OVERALL, limit=None, cacheable: bool = True, stream: bool = False, ): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS """ params = self._get_params() params["period"] = period params["limit"] = limit return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) def get_track_scrobbles( self, artist, track, cacheable: bool = False, stream: bool = False ): """ Get a list of this user's scrobbles of this artist's track, including scrobble time. """ params = self._get_params() params["artist"] = artist params["track"] = track def _get_track_scrobbles(): for track_node in _collect_nodes( None, self, self.ws_prefix + ".getTrackScrobbles", cacheable, params, stream=stream, ): yield self._extract_played_track(track_node) return _get_track_scrobbles() if stream else list(_get_track_scrobbles()) def get_image(self, size=SIZE_EXTRA_LARGE): """ Returns the user's avatar size can be one of: SIZE_EXTRA_LARGE SIZE_LARGE SIZE_MEDIUM SIZE_SMALL """ doc = self._request(self.ws_prefix + ".getInfo", True) return _extract_all(doc, "image")[size] def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN o DOMAIN_SPANISH o DOMAIN_FRENCH o DOMAIN_ITALIAN o DOMAIN_POLISH o DOMAIN_PORTUGUESE o DOMAIN_SWEDISH o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) return self.network._get_url(domain_name, "user") % {"name": name} def get_library(self): """Returns the associated Library object.""" return Library(self, self.network) class AuthenticatedUser(User): def __init__(self, network) -> None: super().__init__(user_name=network.username, network=network) def _get_params(self): return {"user": self.get_name()} def get_name(self, properly_capitalized: bool = False): """Returns the name of the authenticated user.""" return super().get_name(properly_capitalized=properly_capitalized) class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" def __init__(self, ws_prefix, search_terms, network) -> None: super().__init__(network, ws_prefix) self._ws_prefix = ws_prefix self.search_terms = search_terms self._last_page_index = 0 def _get_params(self): params = {} for key in self.search_terms.keys(): params[key] = self.search_terms[key] return params def get_total_result_count(self): """Returns the total count of all the results.""" doc = self._request(self._ws_prefix + ".search", True) return _extract(doc, "totalResults") def _retrieve_page(self, page_index): """Returns the node of matches to be processed""" params = self._get_params() params["page"] = str(page_index) doc = self._request(self._ws_prefix + ".search", True, params) return doc.getElementsByTagName(self._ws_prefix + "matches")[0] def _retrieve_next_page(self): self._last_page_index += 1 return self._retrieve_page(self._last_page_index) class AlbumSearch(_Search): """Search for an album by name.""" def __init__(self, album_name, network) -> None: super().__init__( ws_prefix="album", search_terms={"album": album_name}, network=network ) def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("album"): seq.append( Album( _extract(node, "artist"), _extract(node, "name"), self.network, info={"image": _extract_all(node, "image")}, ) ) return seq class ArtistSearch(_Search): """Search for an artist by artist name.""" def __init__(self, artist_name, network) -> None: super().__init__( ws_prefix="artist", search_terms={"artist": artist_name}, network=network ) def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("artist"): artist = Artist( _extract(node, "name"), self.network, info={"image": _extract_all(node, "image")}, ) artist.listener_count = _number(_extract(node, "listeners")) seq.append(artist) return seq class TrackSearch(_Search): """ Search for a track by track title. If you don't want to narrow the results down by specifying the artist name, set it to empty string. """ def __init__(self, artist_name, track_title, network) -> None: super().__init__( ws_prefix="track", search_terms={"track": track_title, "artist": artist_name}, network=network, ) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("track"): track = Track( _extract(node, "artist"), _extract(node, "name"), self.network, info={"image": _extract_all(node, "image")}, ) track.listener_count = _number(_extract(node, "listeners")) seq.append(track) return seq def md5(text): """Returns the md5 hash of a string.""" h = hashlib.md5() h.update(_unicode(text).encode("utf-8")) return h.hexdigest() def _unicode(text): if isinstance(text, bytes): return str(text, "utf-8") else: return str(text) def cleanup_nodes(doc): """ Remove text nodes containing only whitespace """ for node in doc.documentElement.childNodes: if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace(): doc.documentElement.removeChild(node) return doc def _collect_nodes( limit, sender, method_name, cacheable, params=None, stream: bool = False ): """ Returns a sequence of dom.Node objects about as close to limit as possible """ if not params: params = sender._get_params() def _stream_collect_nodes(): node_count = 0 page = 1 end_of_pages = False while not end_of_pages and (not limit or (limit and node_count < limit)): params["page"] = str(page) tries = 1 while True: try: doc = sender._request(method_name, cacheable, params) break # success except Exception as e: if tries >= 3: raise PyLastError() from e # Wait and try again time.sleep(1) tries += 1 doc = cleanup_nodes(doc) # break if there are no child nodes if not doc.documentElement.childNodes: break main = doc.documentElement.childNodes[0] if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"): total_pages = _number( main.getAttribute("totalPages") or main.getAttribute("totalpages") ) else: msg = "No total pages attribute" raise PyLastError(msg) for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and ( not limit or (node_count < limit) ): node_count += 1 yield node end_of_pages = page >= total_pages page += 1 return _stream_collect_nodes() if stream else list(_stream_collect_nodes()) def _extract(node, name, index: int = 0): """Extracts a value from the xml string""" nodes = node.getElementsByTagName(name) if len(nodes): if nodes[index].firstChild: return _unescape_htmlentity(nodes[index].firstChild.data.strip()) else: return None def _extract_all(node, name, limit_count=None): """Extracts all the values from the xml string. returning a list.""" seq = [] for i in range(0, len(node.getElementsByTagName(name))): if len(seq) == limit_count: break seq.append(_extract(node, name, i)) return seq def _extract_top_artists(doc, network): # TODO Maybe include the _request here too? seq = [] for node in doc.getElementsByTagName("artist"): name = _extract(node, "name") playcount = _extract(node, "playcount") seq.append(TopItem(Artist(name, network), playcount)) return seq def _extract_top_albums(doc, network): # TODO Maybe include the _request here too? seq = [] for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") info = {"image": _extract_all(node, "image")} seq.append(TopItem(Album(artist, name, network, info=info), playcount)) return seq def _extract_artists(doc, network): seq = [] for node in doc.getElementsByTagName("artist"): seq.append(Artist(_extract(node, "name"), network)) return seq def _extract_albums(doc, network): seq = [] for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) seq.append(Album(artist, name, network)) return seq def _extract_tracks(doc, network): seq = [] for node in doc.getElementsByTagName("track"): name = _extract(node, "name") artist = _extract(node, "name", 1) seq.append(Track(artist, name, network)) return seq def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a URL.""" return quote_plus(quote_plus(str(text))).lower() def _number(string): """ Extracts an int from a string. Returns a 0 if None or an empty string was passed. """ if not string: return 0 else: try: return int(string) except ValueError: return float(string) def _unescape_htmlentity(string): mapping = html.entities.name2codepoint for key in mapping: string = string.replace(f"&{key};", chr(mapping[key])) return string def _parse_response(response: str) -> xml.dom.minidom.Document: response = str(response).replace("opensearch:", "") try: doc = minidom.parseString(response) except xml.parsers.expat.ExpatError: # Try again. For performance, we only remove when needed in rare cases. doc = minidom.parseString(_remove_invalid_xml_chars(response)) return doc def _remove_invalid_xml_chars(string: str) -> str: return re.sub( r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string ) # End of file pylast-5.3.0/tests/__init__.py0000644000000000000000000000000014623446206013231 0ustar00pylast-5.3.0/tests/test_album.py0000755000000000000000000000577114623446206013660 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import pylast from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): def test_album_tags_are_topitems(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") # Act tags = album.get_top_tags(limit=1) # Assert assert len(tags) > 0 assert isinstance(tags[0], pylast.TopItem) def test_album_is_hashable(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") # Act/Assert self.helper_is_thing_hashable(album) def test_album_in_recent_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) # Act # limit=2 to ignore now-playing: track = list(lastfm_user.get_recent_tracks(limit=2))[0] # Assert assert hasattr(track, "album") def test_album_wiki_content(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) # Act wiki = album.get_wiki_content() # Assert assert wiki is not None assert len(wiki) >= 1 def test_album_wiki_published_date(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) # Act wiki = album.get_wiki_published_date() # Assert assert wiki is not None assert len(wiki) >= 1 def test_album_wiki_summary(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) # Act wiki = album.get_wiki_summary() # Assert assert wiki is not None assert len(wiki) >= 1 def test_album_eq_none_is_false(self) -> None: # Arrange album1 = None album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert assert album1 != album2 def test_album_ne_none_is_true(self) -> None: # Arrange album1 = None album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert assert album1 != album2 def test_get_cover_image(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") # Act image = album.get_cover_image() # Assert assert image.startswith("https://") assert image.endswith(".gif") or image.endswith(".png") def test_mbid(self) -> None: # Arrange album = self.network.get_album("Radiohead", "OK Computer") # Act mbid = album.get_mbid() # Assert assert mbid == "0b6b4ba0-d36f-47bd-b4ea-6a5b91842d29" def test_no_mbid(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") # Act mbid = album.get_mbid() # Assert assert mbid is None pylast-5.3.0/tests/test_artist.py0000755000000000000000000001745714623446206014072 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import pytest import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastArtist(TestPyLastWithLastFm): def test_repr(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) # Act representation = repr(artist) # Assert assert representation.startswith("pylast.Artist('Test Artist',") def test_artist_is_hashable(self) -> None: # Arrange test_artist = self.network.get_artist("Radiohead") artist = test_artist.get_similar(limit=2)[0].item assert isinstance(artist, pylast.Artist) # Act/Assert self.helper_is_thing_hashable(artist) def test_bio_published_date(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) # Act bio = artist.get_bio_published_date() # Assert assert bio is not None assert len(bio) >= 1 def test_bio_content(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) # Act bio = artist.get_bio_content(language="en") # Assert assert bio is not None assert len(bio) >= 1 def test_bio_content_none(self) -> None: # Arrange # An artist with no biography, with "" in the API XML artist = pylast.Artist("Mr Sizef + Unquote", self.network) # Act bio = artist.get_bio_content() # Assert assert bio is None def test_bio_summary(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) # Act bio = artist.get_bio_summary(language="en") # Assert assert bio is not None assert len(bio) >= 1 def test_artist_top_tracks(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item # Act things = artist.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) def test_artist_top_albums(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item # Act things = list(artist.get_top_albums(limit=2)) # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) @pytest.mark.parametrize("test_limit", [1, 50, 100]) def test_artist_top_albums_limit(self, test_limit: int) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item # Act things = artist.get_top_albums(limit=test_limit) # Assert assert len(things) == test_limit def test_artist_top_albums_limit_default(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item # Act things = artist.get_top_albums() # Assert assert len(things) == 50 def test_artist_listener_count(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") # Act count = artist.get_listener_count() # Assert assert isinstance(count, int) assert count > 0 @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_tag_artist(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") # artist.clear_tags() # Act artist.add_tag("testing") # Assert tags = artist.get_tags() assert len(tags) > 0 found = any(tag.name == "testing" for tag in tags) assert found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tag_of_type_text(self) -> None: # Arrange tag = "testing" # text artist = self.network.get_artist("Test Artist") artist.add_tag(tag) # Act artist.remove_tag(tag) # Assert tags = artist.get_tags() found = any(tag.name == "testing" for tag in tags) assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tag_of_type_tag(self) -> None: # Arrange tag = pylast.Tag("testing", self.network) # Tag artist = self.network.get_artist("Test Artist") artist.add_tag(tag) # Act artist.remove_tag(tag) # Assert tags = artist.get_tags() found = any(tag.name == "testing" for tag in tags) assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tags(self) -> None: # Arrange tags = ["removetag1", "removetag2"] artist = self.network.get_artist("Test Artist") artist.add_tags(tags) artist.add_tags("1more") tags_before = artist.get_tags() # Act artist.remove_tags(tags) # Assert tags_after = artist.get_tags() assert len(tags_after) == len(tags_before) - 2 found1 = any(tag.name == "removetag1" for tag in tags_after) found2 = any(tag.name == "removetag2" for tag in tags_after) assert not found1 assert not found2 @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_set_tags(self) -> None: # Arrange tags = ["sometag1", "sometag2"] artist = self.network.get_artist("Test Artist 2") artist.add_tags(tags) tags_before = artist.get_tags() new_tags = ["settag1", "settag2"] # Act artist.set_tags(new_tags) # Assert tags_after = artist.get_tags() assert tags_before != tags_after assert len(tags_after) == 2 found1, found2 = False, False for tag in tags_after: if tag.name == "settag1": found1 = True elif tag.name == "settag2": found2 = True assert found1 assert found2 def test_artists(self) -> None: # Arrange artist1 = self.network.get_artist("Radiohead") artist2 = self.network.get_artist("Portishead") # Act url = artist1.get_url() mbid = artist1.get_mbid() playcount = artist1.get_playcount() name = artist1.get_name(properly_capitalized=False) name_cap = artist1.get_name(properly_capitalized=True) # Assert assert playcount > 1 assert artist1 != artist2 assert name.lower() == name_cap.lower() assert url == "https://www.last.fm/music/radiohead" assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711" def test_artist_eq_none_is_false(self) -> None: # Arrange artist1 = None artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert assert artist1 != artist2 def test_artist_ne_none_is_true(self) -> None: # Arrange artist1 = None artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert assert artist1 != artist2 def test_artist_get_correction(self) -> None: # Arrange artist = pylast.Artist("guns and roses", self.network) # Act corrected_artist_name = artist.get_correction() # Assert assert corrected_artist_name == "Guns N' Roses" def test_get_userplaycount(self) -> None: # Arrange artist = pylast.Artist("John Lennon", self.network, username=self.username) # Act playcount = artist.get_userplaycount() # Assert assert playcount >= 0 pylast-5.3.0/tests/test_country.py0000755000000000000000000000164314623446206014255 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import pylast from .test_pylast import TestPyLastWithLastFm class TestPyLastCountry(TestPyLastWithLastFm): def test_country_is_hashable(self) -> None: # Arrange country = self.network.get_country("Italy") # Act/Assert self.helper_is_thing_hashable(country) def test_countries(self) -> None: # Arrange country1 = pylast.Country("Italy", self.network) country2 = pylast.Country("Finland", self.network) # Act text = str(country1) rep = repr(country1) url = country1.get_url() # Assert assert "Italy" in rep assert "pylast.Country" in rep assert text == "Italy" assert country1 == country1 assert country1 != country2 assert url == "https://www.last.fm/place/italy" pylast-5.3.0/tests/test_library.py0000755000000000000000000000264214623446206014216 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import pylast from .test_pylast import TestPyLastWithLastFm class TestPyLastLibrary(TestPyLastWithLastFm): def test_repr(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) # Act representation = repr(library) # Assert assert representation.startswith("pylast.Library(") def test_str(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) # Act string = str(library) # Assert assert string.endswith("'s Library") def test_library_is_hashable(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) # Act/Assert self.helper_is_thing_hashable(library) def test_cacheable_library(self) -> None: # Arrange library = pylast.Library(self.username, self.network) # Act/Assert self.helper_validate_cacheable(library, "get_artists") def test_get_user(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) user_to_get = self.network.get_user(self.username) # Act library_user = library.get_user() # Assert assert library_user == user_to_get pylast-5.3.0/tests/test_librefm.py0000755000000000000000000000216314623446206014170 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations from flaky import flaky import pylast from .test_pylast import load_secrets @flaky(max_runs=3, min_passes=1) class TestPyLastWithLibreFm: """Own class for Libre.fm because we don't need the Last.fm setUp""" def test_libre_fm(self) -> None: # Arrange secrets = load_secrets() username = secrets["username"] password_hash = secrets["password_hash"] # Act network = pylast.LibreFMNetwork(password_hash=password_hash, username=username) artist = network.get_artist("Radiohead") name = artist.get_name() # Assert assert name == "Radiohead" def test_repr(self) -> None: # Arrange secrets = load_secrets() username = secrets["username"] password_hash = secrets["password_hash"] network = pylast.LibreFMNetwork(password_hash=password_hash, username=username) # Act representation = repr(network) # Assert assert representation.startswith("pylast.LibreFMNetwork(") pylast-5.3.0/tests/test_network.py0000755000000000000000000003112614623446206014242 0ustar00""" Integration (not unit) tests for pylast.py """ from __future__ import annotations import re import time import pytest import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_scrobble(self) -> None: # Arrange artist = "test artist" title = "test title" timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) # Act self.network.scrobble(artist=artist, title="test title 2", timestamp=timestamp) self.network.scrobble(artist=artist, title=title, timestamp=timestamp) # Assert # limit=2 to ignore now-playing: last_scrobble = list(lastfm_user.get_recent_tracks(limit=2))[0] assert str(last_scrobble.track.artist).lower() == artist assert str(last_scrobble.track.title).lower() == title @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_update_now_playing(self) -> None: # Arrange artist = "Test Artist" title = "test title" album = "Test Album" track_number = 1 lastfm_user = self.network.get_user(self.username) # Act self.network.update_now_playing( artist=artist, title=title, album=album, track_number=track_number ) # Assert current_track = lastfm_user.get_now_playing() assert current_track is not None assert str(current_track.title).lower() == "test title" assert str(current_track.artist).lower() == "test artist" assert current_track.info["album"] == "Test Album" assert current_track.get_album().title == "Test Album" assert len(current_track.info["image"]) assert re.search(r"^http.+$", current_track.info["image"][pylast.SIZE_LARGE]) def test_enable_rate_limiting(self) -> None: # Arrange assert not self.network.is_rate_limited() # Act self.network.enable_rate_limit() then = time.time() # Make some network call, limit not applied first time self.network.get_top_artists() # Make a second network call, limiting should be applied self.network.get_top_artists() now = time.time() # Assert assert self.network.is_rate_limited() assert now - then >= 0.2 def test_disable_rate_limiting(self) -> None: # Arrange self.network.enable_rate_limit() assert self.network.is_rate_limited() # Act self.network.disable_rate_limit() # Make some network call, limit not applied first time self.network.get_user(self.username) # Make a second network call, limiting should be applied self.network.get_top_artists() # Assert assert not self.network.is_rate_limited() def test_lastfm_network_name(self) -> None: # Act name = str(self.network) # Assert assert name == "Last.fm Network" def test_geo_get_top_artists(self) -> None: # Arrange # Act artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1) # Assert assert len(artists) == 1 assert isinstance(artists[0], pylast.TopItem) assert isinstance(artists[0].item, pylast.Artist) def test_geo_get_top_tracks(self) -> None: # Arrange # Act tracks = self.network.get_geo_top_tracks( country="United Kingdom", location="Manchester", limit=1 ) # Assert assert len(tracks) == 1 assert isinstance(tracks[0], pylast.TopItem) assert isinstance(tracks[0].item, pylast.Track) def test_network_get_top_artists_with_limit(self) -> None: # Arrange # Act artists = self.network.get_top_artists(limit=1) # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) def test_network_get_top_tags_with_limit(self) -> None: # Arrange # Act tags = self.network.get_top_tags(limit=1) # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) def test_network_get_top_tags_with_no_limit(self) -> None: # Arrange # Act tags = self.network.get_top_tags() # Assert self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) def test_network_get_top_tracks_with_limit(self) -> None: # Arrange # Act tracks = self.network.get_top_tracks(limit=1) # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) def test_country_top_tracks(self) -> None: # Arrange country = self.network.get_country("Croatia") # Act things = country.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) def test_country_network_top_tracks(self) -> None: # Arrange # Act things = self.network.get_geo_top_tracks("Croatia", limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) def test_tag_top_tracks(self) -> None: # Arrange tag = self.network.get_tag("blues") # Act things = tag.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) def test_album_data(self) -> None: # Arrange thing = self.network.get_album("Test Artist", "Test Album") # Act stringed = str(thing) rep = thing.__repr__() title = thing.get_title() name = thing.get_name() playcount = thing.get_playcount() url = thing.get_url() # Assert assert stringed == "Test Artist - Test Album" assert "pylast.Album('Test Artist', 'Test Album'," in rep assert title == name assert isinstance(playcount, int) assert playcount > 1 assert "https://www.last.fm/music/test%2bartist/test%2balbum" == url def test_track_data(self) -> None: # Arrange thing = self.network.get_track("Test Artist", "test title") # Act stringed = str(thing) rep = thing.__repr__() title = thing.get_title() name = thing.get_name() playcount = thing.get_playcount() url = thing.get_url(pylast.DOMAIN_FRENCH) # Assert assert stringed == "Test Artist - test title" assert "pylast.Track('Test Artist', 'test title'," in rep assert title == "test title" assert title == name assert isinstance(playcount, int) assert playcount > 1 assert "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle" == url def test_country_top_artists(self) -> None: # Arrange country = self.network.get_country("Ukraine") # Act artists = country.get_top_artists(limit=1) # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) def test_caching(self) -> None: # Arrange user = self.network.get_user("RJ") # Act self.network.enable_caching() tags1 = user.get_top_tags(limit=1, cacheable=True) tags2 = user.get_top_tags(limit=1, cacheable=True) # Assert assert self.network.is_caching_enabled() assert tags1 == tags2 self.network.disable_caching() assert not self.network.is_caching_enabled() def test_album_mbid(self) -> None: # Arrange mbid = "03c91c40-49a6-44a7-90e7-a700edf97a62" # Act album = self.network.get_album_by_mbid(mbid) album_mbid = album.get_mbid() # Assert assert isinstance(album, pylast.Album) assert album.title == "Believe" assert album_mbid == mbid def test_artist_mbid(self) -> None: # Arrange mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" # Act artist = self.network.get_artist_by_mbid(mbid) # Assert assert isinstance(artist, pylast.Artist) assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist") def test_track_mbid(self) -> None: # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" # Act track = self.network.get_track_by_mbid(mbid) track_mbid = track.get_mbid() # Assert assert isinstance(track, pylast.Track) assert track.title == "first" assert track_mbid == mbid def test_init_with_token(self) -> None: # Arrange/Act msg = None try: pylast.LastFMNetwork( api_key=self.__class__.secrets["api_key"], api_secret=self.__class__.secrets["api_secret"], token="invalid", ) except pylast.WSError as exc: msg = str(exc) # Assert assert msg == "Unauthorized Token - This token has not been issued" def test_proxy(self) -> None: # Arrange proxy = "http://example.com:1234" # Act / Assert self.network.enable_proxy(proxy) assert self.network.is_proxy_enabled() assert self.network.proxy == "http://example.com:1234" self.network.disable_proxy() assert not self.network.is_proxy_enabled() def test_album_search(self) -> None: # Arrange album = "Nevermind" # Act search = self.network.search_for_album(album) results = search.get_next_page() # Assert assert isinstance(results, list) assert isinstance(results[0], pylast.Album) def test_album_search_images(self) -> None: # Arrange album = "Nevermind" search = self.network.search_for_album(album) # Act results = search.get_next_page() images = results[0].info["image"] # Assert assert len(images) == 4 assert images[pylast.SIZE_SMALL].startswith("https://") assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_artist_search(self) -> None: # Arrange artist = "Nirvana" # Act search = self.network.search_for_artist(artist) results = search.get_next_page() # Assert assert isinstance(results, list) assert isinstance(results[0], pylast.Artist) def test_artist_search_images(self) -> None: # Arrange artist = "Nirvana" search = self.network.search_for_artist(artist) # Act results = search.get_next_page() images = results[0].info["image"] # Assert assert len(images) == 5 assert images[pylast.SIZE_SMALL].startswith("https://") assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_track_search(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" # Act search = self.network.search_for_track(artist, track) results = search.get_next_page() # Assert assert isinstance(results, list) assert isinstance(results[0], pylast.Track) def test_track_search_images(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" search = self.network.search_for_track(artist, track) # Act results = search.get_next_page() images = results[0].info["image"] # Assert assert len(images) == 4 assert images[pylast.SIZE_SMALL].startswith("https://") assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_search_get_total_result_count(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" search = self.network.search_for_track(artist, track) # Act total = search.get_total_result_count() # Assert assert int(total) > 10000 pylast-5.3.0/tests/test_pylast.py0000755000000000000000000000762314623446206014072 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import os import time import pytest from flaky import flaky import pylast WRITE_TEST = False def load_secrets(): # pragma: no cover secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): import yaml # pip install pyyaml with open(secrets_file) as f: # see example_test_pylast.yaml doc = yaml.load(f) else: doc = {} try: doc["username"] = os.environ["PYLAST_USERNAME"].strip() doc["password_hash"] = os.environ["PYLAST_PASSWORD_HASH"].strip() doc["api_key"] = os.environ["PYLAST_API_KEY"].strip() doc["api_secret"] = os.environ["PYLAST_API_SECRET"].strip() except KeyError: pytest.skip("Missing environment variables: PYLAST_USERNAME etc.") return doc def _no_xfail_rerun_filter(err, name, test, plugin) -> bool: for _ in test.iter_markers(name="xfail"): return False @flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter) class TestPyLastWithLastFm: secrets = None @staticmethod def unix_timestamp() -> int: return int(time.time()) @classmethod def setup_class(cls) -> None: if cls.secrets is None: cls.secrets = load_secrets() cls.username = cls.secrets["username"] password_hash = cls.secrets["password_hash"] api_key = cls.secrets["api_key"] api_secret = cls.secrets["api_secret"] cls.network = pylast.LastFMNetwork( api_key=api_key, api_secret=api_secret, username=cls.username, password_hash=password_hash, ) @staticmethod def helper_is_thing_hashable(thing) -> None: # Arrange things = set() # Act things.add(thing) # Assert assert thing is not None assert len(things) == 1 @staticmethod def helper_validate_results(a, b, c) -> None: # Assert assert a is not None assert b is not None assert c is not None assert isinstance(len(a), int) assert isinstance(len(b), int) assert isinstance(len(c), int) assert a == b assert b == c def helper_validate_cacheable(self, thing, function_name) -> None: # Arrange # get thing.function_name() func = getattr(thing, function_name, None) # Act result1 = func(limit=1, cacheable=False) result2 = func(limit=1, cacheable=True) result3 = list(func(limit=1)) # Assert self.helper_validate_results(result1, result2, result3) @staticmethod def helper_at_least_one_thing_in_top_list(things, expected_type) -> None: # Assert assert len(things) > 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) @staticmethod def helper_only_one_thing_in_top_list(things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) @staticmethod def helper_only_one_thing_in_list(things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], expected_type) @staticmethod def helper_two_different_things_in_top_list(things, expected_type) -> None: # Assert assert len(things) == 2 thing1 = things[0] thing2 = things[1] assert isinstance(thing1, pylast.TopItem) assert isinstance(thing2, pylast.TopItem) assert isinstance(thing1.item, expected_type) assert isinstance(thing2.item, expected_type) assert thing1 != thing2 pylast-5.3.0/tests/test_tag.py0000755000000000000000000000270014623446206013320 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import pylast from .test_pylast import TestPyLastWithLastFm class TestPyLastTag(TestPyLastWithLastFm): def test_tag_is_hashable(self) -> None: # Arrange tag = self.network.get_top_tags(limit=1)[0] # Act/Assert self.helper_is_thing_hashable(tag) def test_tag_top_artists(self) -> None: # Arrange tag = self.network.get_tag("blues") # Act artists = tag.get_top_artists(limit=1) # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) def test_tag_top_albums(self) -> None: # Arrange tag = self.network.get_tag("blues") # Act albums = tag.get_top_albums(limit=1) # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) def test_tags(self) -> None: # Arrange tag1 = self.network.get_tag("blues") tag2 = self.network.get_tag("rock") # Act tag_repr = repr(tag1) tag_str = str(tag1) name = tag1.get_name(properly_capitalized=True) url = tag1.get_url() # Assert assert "blues" == tag_str assert "pylast.Tag" in tag_repr assert "blues" in tag_repr assert "blues" == name assert tag1 == tag1 assert tag1 != tag2 assert url == "https://www.last.fm/tag/blues" pylast-5.3.0/tests/test_track.py0000755000000000000000000001455714623446206013666 0ustar00""" Integration (not unit) tests for pylast.py """ from __future__ import annotations import time import pytest import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_love(self) -> None: # Arrange artist = "Test Artist" title = "test title" track = self.network.get_track(artist, title) lastfm_user = self.network.get_user(self.username) # Act track.love() # Assert loved = list(lastfm_user.get_loved_tracks(limit=1)) assert str(loved[0].track.artist).lower() == "test artist" assert str(loved[0].track.title).lower() == "test title" @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_unlove(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) title = "test title" track = pylast.Track(artist, title, self.network) lastfm_user = self.network.get_user(self.username) track.love() # Act track.unlove() time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert loved = list(lastfm_user.get_loved_tracks(limit=1)) if len(loved): # OK to be empty but if not: assert str(loved[0].track.artist) != "Test Artist" assert str(loved[0].track.title) != "test title" def test_user_play_count_in_track_info(self) -> None: # Arrange artist = "Test Artist" title = "test title" track = pylast.Track( artist=artist, title=title, network=self.network, username=self.username ) # Act count = track.get_userplaycount() # Assert assert count >= 0 def test_user_loved_in_track_info(self) -> None: # Arrange artist = "Test Artist" title = "test title" track = pylast.Track( artist=artist, title=title, network=self.network, username=self.username ) # Act loved = track.get_userloved() # Assert assert loved is not None assert isinstance(loved, bool) assert not isinstance(loved, str) def test_track_is_hashable(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") track = artist.get_top_tracks(stream=False)[0].item assert isinstance(track, pylast.Track) # Act/Assert self.helper_is_thing_hashable(track) def test_track_wiki_content(self) -> None: # Arrange track = pylast.Track("Test Artist", "test title", self.network) # Act wiki = track.get_wiki_content() # Assert assert wiki is not None assert len(wiki) >= 1 def test_track_wiki_summary(self) -> None: # Arrange track = pylast.Track("Test Artist", "test title", self.network) # Act wiki = track.get_wiki_summary() # Assert assert wiki is not None assert len(wiki) >= 1 def test_track_get_duration(self) -> None: # Arrange track = pylast.Track("Daft Punk", "Something About Us", self.network) # Act duration = track.get_duration() # Assert assert duration >= 100000 def test_track_get_album(self) -> None: # Arrange track = pylast.Track("Nirvana", "Lithium", self.network) # Act album = track.get_album() # Assert assert str(album) == "Nirvana - Nevermind" def test_track_get_similar(self) -> None: # Arrange track = pylast.Track("Cher", "Believe", self.network) # Act similar = track.get_similar() # Assert found = any(str(track.item) == "Cher - Strong Enough" for track in similar) assert found def test_track_get_similar_limits(self) -> None: # Arrange track = pylast.Track("Cher", "Believe", self.network) # Act/Assert assert len(track.get_similar(limit=20)) == 20 assert len(track.get_similar(limit=10)) <= 10 assert len(track.get_similar(limit=None)) >= 23 assert len(track.get_similar(limit=0)) >= 23 def test_tracks_notequal(self) -> None: # Arrange track1 = pylast.Track("Test Artist", "test title", self.network) track2 = pylast.Track("Test Artist", "Test Track", self.network) # Act # Assert assert track1 != track2 def test_track_title_prop_caps(self) -> None: # Arrange track = pylast.Track("test artist", "test title", self.network) # Act title = track.get_title(properly_capitalized=True) # Assert assert title == "Test Title" def test_track_listener_count(self) -> None: # Arrange track = pylast.Track("test artist", "test title", self.network) # Act count = track.get_listener_count() # Assert assert count > 21 def test_album_tracks(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test", self.network) # Act tracks = album.get_tracks() url = tracks[0].get_url() # Assert assert isinstance(tracks, list) assert isinstance(tracks[0], pylast.Track) assert len(tracks) == 1 assert url.startswith("https://www.last.fm/music/test") def test_track_eq_none_is_false(self) -> None: # Arrange track1 = None track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert assert track1 != track2 def test_track_ne_none_is_true(self) -> None: # Arrange track1 = None track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert assert track1 != track2 def test_track_get_correction(self) -> None: # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) # Act corrected_track_name = track.get_correction() # Assert assert corrected_track_name == "Mr. Brownstone" def test_track_with_no_mbid(self) -> None: # Arrange track = pylast.Track("Static-X", "Set It Off", self.network) # Act mbid = track.get_mbid() # Assert assert mbid is None pylast-5.3.0/tests/test_user.py0000755000000000000000000003412714623446206013533 0ustar00#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ from __future__ import annotations import calendar import datetime as dt import inspect import os import re import pytest import pylast from .test_pylast import TestPyLastWithLastFm class TestPyLastUser(TestPyLastWithLastFm): def test_repr(self) -> None: # Arrange user = self.network.get_user("RJ") # Act representation = repr(user) # Assert assert representation.startswith("pylast.User('RJ',") def test_str(self) -> None: # Arrange user = self.network.get_user("RJ") # Act string = str(user) # Assert assert string == "RJ" def test_equality(self) -> None: # Arrange user_1a = self.network.get_user("RJ") user_1b = self.network.get_user("RJ") user_2 = self.network.get_user("Test User") not_a_user = self.network # Act / Assert assert user_1a == user_1b assert user_1a != user_2 assert user_1a != not_a_user def test_get_name(self) -> None: # Arrange user = self.network.get_user("RJ") # Act name = user.get_name(properly_capitalized=True) # Assert assert name == "RJ" def test_get_user_registration(self) -> None: # Arrange user = self.network.get_user("RJ") # Act registered = user.get_registered() # Assert if int(registered): # Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp assert registered == "1037793040" else: # pragma: no cover # Old way # Just check date because of timezones assert "2002-11-20 " in registered def test_get_user_unixtime_registration(self) -> None: # Arrange user = self.network.get_user("RJ") # Act unixtime_registered = user.get_unixtime_registered() # Assert # Just check date because of timezones assert unixtime_registered == 1037793040 def test_get_countryless_user(self) -> None: # Arrange # Currently test_user has no country set: lastfm_user = self.network.get_user("test_user") # Act country = lastfm_user.get_country() # Assert assert country is None def test_user_get_country(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") # Act country = lastfm_user.get_country() # Assert assert str(country) == "United Kingdom" def test_user_equals_none(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) # Act value = lastfm_user is None # Assert assert not value def test_user_not_equal_to_none(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) # Act value = lastfm_user is not None # Assert assert value def test_now_playing_user_with_no_scrobbles(self) -> None: # Arrange # Currently test-account has no scrobbles: user = self.network.get_user("test-account") # Act current_track = user.get_now_playing() # Assert assert current_track is None def test_love_limits(self) -> None: # Arrange # Currently test-account has at least 23 loved tracks: user = self.network.get_user("test-user") # Act/Assert assert len(user.get_loved_tracks(limit=20)) == 20 assert len(user.get_loved_tracks(limit=100)) <= 100 assert len(user.get_loved_tracks(limit=None)) >= 23 assert len(user.get_loved_tracks(limit=0)) >= 23 def test_user_is_hashable(self) -> None: # Arrange user = self.network.get_user(self.username) # Act/Assert self.helper_is_thing_hashable(user) # Commented out because (a) it'll take a long time and (b) it strangely # fails due Last.fm's complaining of hitting the rate limit, even when # limited to one call per second. The ToS allows 5 calls per second. # def test_get_all_scrobbles(self): # # Arrange # lastfm_user = self.network.get_user("RJ") # self.network.enable_rate_limit() # this is going to be slow... # # # Act # tracks = lastfm_user.get_recent_tracks(limit=None) # # # Assert # self.assertGreaterEqual(len(tracks), 0) def test_pickle(self) -> None: # Arrange import pickle lastfm_user = self.network.get_user(self.username) filename = str(self.unix_timestamp()) + ".pkl" # Act with open(filename, "wb") as f: pickle.dump(lastfm_user, f) with open(filename, "rb") as f: loaded_user = pickle.load(f) os.remove(filename) # Assert assert lastfm_user == loaded_user @pytest.mark.xfail def test_cacheable_user(self) -> None: # Arrange lastfm_user = self.network.get_authenticated_user() # Act/Assert self.helper_validate_cacheable(lastfm_user, "get_friends") # no cover whilst xfail: self.helper_validate_cacheable( # pragma: no cover lastfm_user, "get_loved_tracks" ) self.helper_validate_cacheable( # pragma: no cover lastfm_user, "get_recent_tracks" ) def test_user_get_top_tags_with_limit(self) -> None: # Arrange user = self.network.get_user("RJ") # Act tags = user.get_top_tags(limit=1) # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) def test_user_top_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") # Act things = lastfm_user.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) def helper_assert_chart(self, chart, expected_type) -> None: # Assert assert chart is not None assert len(chart) > 0 assert isinstance(chart[0], pylast.TopItem) assert isinstance(chart[0].item, expected_type) def helper_get_assert_charts(self, thing, date) -> None: # Arrange album_chart, track_chart = None, None (from_date, to_date) = date # Act artist_chart = thing.get_weekly_artist_charts(from_date, to_date) if type(thing) is not pylast.Tag: album_chart = thing.get_weekly_album_charts(from_date, to_date) track_chart = thing.get_weekly_track_charts(from_date, to_date) # Assert self.helper_assert_chart(artist_chart, pylast.Artist) if type(thing) is not pylast.Tag: self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) def helper_dates_valid(self, dates) -> None: # Assert assert len(dates) >= 1 assert isinstance(dates[0], tuple) (start, end) = dates[0] assert start < end def test_user_charts(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") dates = lastfm_user.get_weekly_chart_dates() self.helper_dates_valid(dates) # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[0]) def test_user_top_artists(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) # Act artists = lastfm_user.get_top_artists(limit=1) # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) def test_user_top_albums(self) -> None: # Arrange user = self.network.get_user("RJ") # Act albums = user.get_top_albums(limit=1) # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) top_album = albums[0].item assert len(top_album.info["image"]) assert re.search(r"^http.+$", top_album.info["image"][pylast.SIZE_LARGE]) def test_user_tagged_artists(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["artisttagola"] artist = self.network.get_artist("Test Artist") artist.add_tags(tags) # Act artists = lastfm_user.get_tagged_artists("artisttagola", limit=1) # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) def test_user_tagged_albums(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["albumtagola"] album = self.network.get_album("Test Artist", "Test Album") album.add_tags(tags) # Act albums = lastfm_user.get_tagged_albums("albumtagola", limit=1) # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) def test_user_tagged_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["tracktagola"] track = self.network.get_track("Test Artist", "test title") track.add_tags(tags) # Act tracks = lastfm_user.get_tagged_tracks("tracktagola", limit=1) # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) def test_user_subscriber(self) -> None: # Arrange subscriber = self.network.get_user("RJ") non_subscriber = self.network.get_user("Test User") # Act subscriber_is_subscriber = subscriber.is_subscriber() non_subscriber_is_subscriber = non_subscriber.is_subscriber() # Assert assert subscriber_is_subscriber assert not non_subscriber_is_subscriber def test_user_get_image(self) -> None: # Arrange user = self.network.get_user("RJ") # Act url = user.get_image() # Assert assert url.startswith("https://") def test_user_get_library(self) -> None: # Arrange user = self.network.get_user(self.username) # Act library = user.get_library() # Assert assert isinstance(library, pylast.Library) def test_get_recent_tracks_from_to(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") start = dt.datetime(2011, 7, 21, 15, 10) end = dt.datetime(2011, 7, 21, 15, 15) utc_start = calendar.timegm(start.utctimetuple()) utc_end = calendar.timegm(end.utctimetuple()) # Act tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end) # Assert assert len(tracks) == 1 assert str(tracks[0].track.artist) == "Johnny Cash" assert str(tracks[0].track.title) == "Ring of Fire" def test_get_recent_tracks_limit_none(self) -> None: # Arrange lastfm_user = self.network.get_user("bbc6music") start = dt.datetime(2020, 2, 15, 15, 00) end = dt.datetime(2020, 2, 15, 15, 40) utc_start = calendar.timegm(start.utctimetuple()) utc_end = calendar.timegm(end.utctimetuple()) # Act tracks = lastfm_user.get_recent_tracks( time_from=utc_start, time_to=utc_end, limit=None ) # Assert assert len(tracks) == 11 assert str(tracks[0].track.artist) == "Seun Kuti & Egypt 80" assert str(tracks[0].track.title) == "Struggles Sounds" def test_get_recent_tracks_is_streamable(self) -> None: # Arrange lastfm_user = self.network.get_user("bbc6music") start = dt.datetime(2020, 2, 15, 15, 00) end = dt.datetime(2020, 2, 15, 15, 40) utc_start = calendar.timegm(start.utctimetuple()) utc_end = calendar.timegm(end.utctimetuple()) # Act tracks = lastfm_user.get_recent_tracks( time_from=utc_start, time_to=utc_end, limit=None, stream=True ) # Assert assert inspect.isgenerator(tracks) def test_get_playcount(self) -> None: # Arrange user = self.network.get_user("RJ") # Act playcount = user.get_playcount() # Assert assert playcount >= 128387 def test_get_image(self) -> None: # Arrange user = self.network.get_user("RJ") # Act image = user.get_image() # Assert assert image.startswith("https://") assert image.endswith(".png") def test_get_url(self) -> None: # Arrange user = self.network.get_user("RJ") # Act url = user.get_url() # Assert assert url == "https://www.last.fm/user/rj" def test_get_weekly_artist_charts(self) -> None: # Arrange user = self.network.get_user("bbc6music") # Act charts = user.get_weekly_artist_charts() artist, weight = charts[0] # Assert assert artist is not None assert isinstance(artist.network, pylast.LastFMNetwork) def test_get_weekly_track_charts(self) -> None: # Arrange user = self.network.get_user("bbc6music") # Act charts = user.get_weekly_track_charts() track, weight = charts[0] # Assert assert track is not None assert isinstance(track.network, pylast.LastFMNetwork) def test_user_get_track_scrobbles(self) -> None: # Arrange artist = "France Gall" title = "Laisse Tomber Les Filles" user = self.network.get_user("bbc6music") # Act scrobbles = user.get_track_scrobbles(artist, title) # Assert assert len(scrobbles) > 0 assert str(scrobbles[0].track.artist) == "France Gall" assert scrobbles[0].track.title == "Laisse Tomber Les Filles" def test_cacheable_user_get_track_scrobbles(self) -> None: # Arrange artist = "France Gall" title = "Laisse Tomber Les Filles" user = self.network.get_user("bbc6music") # Act result1 = user.get_track_scrobbles(artist, title, cacheable=False) result2 = list(user.get_track_scrobbles(artist, title, cacheable=True)) result3 = list(user.get_track_scrobbles(artist, title)) # Assert self.helper_validate_results(result1, result2, result3) pylast-5.3.0/tests/unicode_test.py0000644000000000000000000000356214623446206014177 0ustar00from __future__ import annotations from unittest import mock import pytest import pylast def mock_network(): return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", ""))) @pytest.mark.parametrize( "artist", [ "\xe9lafdasfdsafdsa", "ééééééé", pylast.Artist("B\xe9l", mock_network()), "fdasfdsafsaf not unicode", ], ) def test_get_cache_key(artist) -> None: request = pylast._Request(mock_network(), "some_method", params={"artist": artist}) request._get_cache_key() @pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())]) def test_cast_and_hash(obj) -> None: assert isinstance(str(obj), str) assert isinstance(hash(obj), int) @pytest.mark.parametrize( "test_input, expected", [ ( # Plain text 'test album name', 'test album name', ), ( # Contains Unicode ENQ Enquiry control character 'test album \u0005name', 'test album name', ), ], ) def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None: assert pylast._remove_invalid_xml_chars(test_input) == expected @pytest.mark.parametrize( "test_input, expected", [ ( # Plain text 'test album name', 'test album name', ), ( # Contains Unicode ENQ Enquiry control character 'test album \u0005name', 'test album name', ), ], ) def test__parse_response(test_input: str, expected: str) -> None: doc = pylast._parse_response(test_input) assert doc.toxml() == expected pylast-5.3.0/.gitignore0000644000000000000000000000151014623446206011755 0ustar00# User Credentials test_pylast.yaml .envrc # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ .venv/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ # JetBrains .idea/ # Clone Digger output.html pylast-5.3.0/COPYING0000644000000000000000000002163014623446206011025 0ustar00 Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. pylast-5.3.0/LICENSE.txt0000644000000000000000000002613714623446206011624 0ustar00 Apache License Version 2.0, January 2004 https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pylast-5.3.0/README.md0000644000000000000000000001324514623446206011254 0ustar00# pyLast [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/main/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). Use the pydoc utility for help on usage or see [tests/](tests/) for examples. ## Installation Install via pip: ```sh python3 -m pip install pylast ``` Install latest development version: ```sh python3 -m pip install -U git+https://github.com/pylast/pylast ``` Or from requirements.txt: ```txt -e https://github.com/pylast/pylast.git#egg=pylast ``` Note: - pyLast 5.3+ supports Python 3.8-3.13. - pyLast 5.2+ supports Python 3.8-3.12. - pyLast 5.1 supports Python 3.7-3.11. - pyLast 5.0 supports Python 3.7-3.10. - pyLast 4.3 - 4.5 supports Python 3.6-3.10. - pyLast 4.0 - 4.2 supports Python 3.6-3.9. - pyLast 3.2 - 3.3 supports Python 3.5-3.8. - pyLast 3.0 - 3.1 supports Python 3.5-3.7. - pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. - pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6. - pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6. - pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4. - pyLast 0.5 supports Python 2, 3. - pyLast < 0.5 supports Python 2. ## Features - Simple public interface. - Access to all the data exposed by the Last.fm web services. - Scrobbling support. - Full object-oriented design. - Proxy support. - Internal caching support for some web services calls (disabled by default). - Support for other API-compatible networks like Libre.fm. ## Getting started Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: ```python import pylast # You have to have your own unique two values for API_KEY and API_SECRET # Obtain yours from https://www.last.fm/api/account/create for Last.fm API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key API_SECRET = "425b55975eed76058ac220b7b4e8a054" # In order to perform a write operation you need to authenticate yourself username = "your_user_name" password_hash = pylast.md5("your_password") network = pylast.LastFMNetwork( api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash, ) ``` Alternatively, instead of creating `network` with a username and password, you can authenticate with a session key: ```python import pylast SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key") network = pylast.LastFMNetwork(API_KEY, API_SECRET) if not os.path.exists(SESSION_KEY_FILE): skg = pylast.SessionKeyGenerator(network) url = skg.get_web_auth_url() print(f"Please authorize this script to access your account: {url}\n") import time import webbrowser webbrowser.open(url) while True: try: session_key = skg.get_web_auth_session_key(url) with open(SESSION_KEY_FILE, "w") as f: f.write(session_key) break except pylast.WSError: time.sleep(1) else: session_key = open(SESSION_KEY_FILE).read() network.session_key = session_key ``` And away we go: ```python # Now you can use that object everywhere track = network.get_track("Iron Maiden", "The Nomad") track.love() track.add_tags(("awesome", "favorite")) # Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter # to get more help about anything and see examples of how it works ``` More examples in hugovk/lastfm-tools and [tests/](https://github.com/pylast/pylast/tree/main/tests). ## Testing The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` To run all unit and integration tests: ```sh python3 -m pip install -e ".[tests]" pytest ``` Or run just one test case: ```sh pytest -k test_scrobble ``` To run with coverage: ```sh pytest -v --cov pylast --cov-report term-missing coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html ``` ## Logging To enable from your own code: ```python import logging import pylast logging.basicConfig(level=logging.INFO) network = pylast.LastFMNetwork(...) ``` To enable from pytest: ```sh pytest --log-cli-level info -k test_album_search_images ``` To also see data returned from the API, use `level=logging.DEBUG` or `--log-cli-level debug` instead. pylast-5.3.0/pyproject.toml0000644000000000000000000000437714623446206012717 0ustar00[build-system] build-backend = "hatchling.build" requires = [ "hatch-vcs", "hatchling", ] [project] name = "pylast" description = "A Python interface to Last.fm and Libre.fm" readme = "README.md" keywords = [ "Last.fm", "music", "scrobble", "scrobbling", ] license = {text = "Apache-2.0"} maintainers = [{name = "Hugo van Kemenade"}] authors = [{name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com"}] requires-python = ">=3.8" classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "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 :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet", "Topic :: Multimedia :: Sound/Audio", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = [ "version", ] dependencies = [ "httpx", ] [project.optional-dependencies] tests = [ "flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml", ] [project.urls] Changelog = "https://github.com/pylast/pylast/releases" Homepage = "https://github.com/pylast/pylast" Source = "https://github.com/pylast/pylast" [tool.hatch] version.source = "vcs" [tool.hatch.version.raw-options] local_scheme = "no-local-version" [tool.ruff.lint] select = [ "C4", # flake8-comprehensions "E", # pycodestyle errors "EM", # flake8-errmsg "F", # pyflakes errors "I", # isort "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "PGH", # pygrep-hooks "RUF100", # unused noqa (yesqa) "RUF022", # unsorted-dunder-all "UP", # pyupgrade "W", # pycodestyle warnings "YTT", # flake8-2020 ] extend-ignore = [ "E203", # Whitespace before ':' "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' ] [tool.ruff.lint.isort] known-first-party = ["pylast"] required-imports = ["from __future__ import annotations"] pylast-5.3.0/PKG-INFO0000644000000000000000000001635014623446206011072 0ustar00Metadata-Version: 2.3 Name: pylast Version: 5.3.0 Summary: A Python interface to Last.fm and Libre.fm Project-URL: Changelog, https://github.com/pylast/pylast/releases Project-URL: Homepage, https://github.com/pylast/pylast Project-URL: Source, https://github.com/pylast/pylast Author-email: "Amr Hassan and Contributors" Maintainer: Hugo van Kemenade License: Apache-2.0 License-File: COPYING License-File: LICENSE.txt Keywords: Last.fm,music,scrobble,scrobbling Classifier: Development Status :: 5 - Production/Stable Classifier: License :: OSI Approved :: Apache Software License Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet Classifier: Topic :: Multimedia :: Sound/Audio Classifier: Topic :: Software Development :: Libraries :: Python Modules Requires-Python: >=3.8 Requires-Dist: httpx Provides-Extra: tests Requires-Dist: flaky; extra == 'tests' Requires-Dist: pytest; extra == 'tests' Requires-Dist: pytest-cov; extra == 'tests' Requires-Dist: pytest-random-order; extra == 'tests' Requires-Dist: pyyaml; extra == 'tests' Description-Content-Type: text/markdown # pyLast [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/main/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). Use the pydoc utility for help on usage or see [tests/](tests/) for examples. ## Installation Install via pip: ```sh python3 -m pip install pylast ``` Install latest development version: ```sh python3 -m pip install -U git+https://github.com/pylast/pylast ``` Or from requirements.txt: ```txt -e https://github.com/pylast/pylast.git#egg=pylast ``` Note: - pyLast 5.3+ supports Python 3.8-3.13. - pyLast 5.2+ supports Python 3.8-3.12. - pyLast 5.1 supports Python 3.7-3.11. - pyLast 5.0 supports Python 3.7-3.10. - pyLast 4.3 - 4.5 supports Python 3.6-3.10. - pyLast 4.0 - 4.2 supports Python 3.6-3.9. - pyLast 3.2 - 3.3 supports Python 3.5-3.8. - pyLast 3.0 - 3.1 supports Python 3.5-3.7. - pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. - pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6. - pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6. - pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4. - pyLast 0.5 supports Python 2, 3. - pyLast < 0.5 supports Python 2. ## Features - Simple public interface. - Access to all the data exposed by the Last.fm web services. - Scrobbling support. - Full object-oriented design. - Proxy support. - Internal caching support for some web services calls (disabled by default). - Support for other API-compatible networks like Libre.fm. ## Getting started Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: ```python import pylast # You have to have your own unique two values for API_KEY and API_SECRET # Obtain yours from https://www.last.fm/api/account/create for Last.fm API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key API_SECRET = "425b55975eed76058ac220b7b4e8a054" # In order to perform a write operation you need to authenticate yourself username = "your_user_name" password_hash = pylast.md5("your_password") network = pylast.LastFMNetwork( api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash, ) ``` Alternatively, instead of creating `network` with a username and password, you can authenticate with a session key: ```python import pylast SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key") network = pylast.LastFMNetwork(API_KEY, API_SECRET) if not os.path.exists(SESSION_KEY_FILE): skg = pylast.SessionKeyGenerator(network) url = skg.get_web_auth_url() print(f"Please authorize this script to access your account: {url}\n") import time import webbrowser webbrowser.open(url) while True: try: session_key = skg.get_web_auth_session_key(url) with open(SESSION_KEY_FILE, "w") as f: f.write(session_key) break except pylast.WSError: time.sleep(1) else: session_key = open(SESSION_KEY_FILE).read() network.session_key = session_key ``` And away we go: ```python # Now you can use that object everywhere track = network.get_track("Iron Maiden", "The Nomad") track.love() track.add_tags(("awesome", "favorite")) # Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter # to get more help about anything and see examples of how it works ``` More examples in hugovk/lastfm-tools and [tests/](https://github.com/pylast/pylast/tree/main/tests). ## Testing The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` To run all unit and integration tests: ```sh python3 -m pip install -e ".[tests]" pytest ``` Or run just one test case: ```sh pytest -k test_scrobble ``` To run with coverage: ```sh pytest -v --cov pylast --cov-report term-missing coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html ``` ## Logging To enable from your own code: ```python import logging import pylast logging.basicConfig(level=logging.INFO) network = pylast.LastFMNetwork(...) ``` To enable from pytest: ```sh pytest --log-cli-level info -k test_album_search_images ``` To also see data returned from the API, use `level=logging.DEBUG` or `--log-cli-level debug` instead.