pylast-5.3.0/.codecov.yml 0000644 0000000 0000000 00000000360 14623446206 012212 0 ustar 00 # 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/.editorconfig 0000644 0000000 0000000 00000000471 14623446206 012447 0 ustar 00 # 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.yaml 0000644 0000000 0000000 00000003473 14623446206 014260 0 ustar 00 repos:
- 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.md 0000644 0000000 0000000 00000011243 14623446206 011602 0 ustar 00 # 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.md 0000644 0000000 0000000 00000001467 14623446206 011633 0 ustar 00 # 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`.
[](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.yaml 0000644 0000000 0000000 00000000206 14623446206 014740 0 ustar 00 username: 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.ini 0000644 0000000 0000000 00000000156 14623446206 012023 0 ustar 00 [pytest]
filterwarnings =
once::DeprecationWarning
once::PendingDeprecationWarning
xfail_strict=true
pylast-5.3.0/tox.ini 0000644 0000000 0000000 00000001226 14623446206 011304 0 ustar 00 [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.yml 0000644 0000000 0000000 00000000017 14623446206 013143 0 ustar 00 github: hugovk
pylast-5.3.0/.github/ISSUE_TEMPLATE.md 0000644 0000000 0000000 00000000672 14623446206 014042 0 ustar 00 ### 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.md 0000644 0000000 0000000 00000000075 14623446206 015133 0 ustar 00 Fixes #
Changes proposed in this pull request:
*
*
*
pylast-5.3.0/.github/labels.yml 0000644 0000000 0000000 00000005107 14623446206 013320 0 ustar 00 # 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.yml 0000644 0000000 0000000 00000001574 14623446206 015127 0 ustar 00 name-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.json 0000644 0000000 0000000 00000000536 14623446206 014052 0 ustar 00 {
"$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.yml 0000644 0000000 0000000 00000003275 14623446206 015413 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000000605 14623446206 015353 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000000571 14623446206 015061 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000002074 14623446206 017160 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000001005 14623446206 017254 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000002543 14623446206 015073 0 ustar 00 name: 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__.py 0000644 0000000 0000000 00000255367 14623446206 014226 0 ustar 00 #
# 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__.py 0000644 0000000 0000000 00000000000 14623446206 013231 0 ustar 00 pylast-5.3.0/tests/test_album.py 0000755 0000000 0000000 00000005771 14623446206 013660 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000017457 14623446206 014072 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000001643 14623446206 014255 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000002642 14623446206 014216 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000002163 14623446206 014170 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000031126 14623446206 014242 0 ustar 00 """
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.py 0000755 0000000 0000000 00000007623 14623446206 014072 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000002700 14623446206 013320 0 ustar 00 #!/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.py 0000755 0000000 0000000 00000014557 14623446206 013666 0 ustar 00 """
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.py 0000755 0000000 0000000 00000034127 14623446206 013533 0 ustar 00 #!/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.py 0000644 0000000 0000000 00000003562 14623446206 014177 0 ustar 00 from __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/.gitignore 0000644 0000000 0000000 00000001510 14623446206 011755 0 ustar 00 # 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/COPYING 0000644 0000000 0000000 00000021630 14623446206 011025 0 ustar 00 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.txt 0000644 0000000 0000000 00000026137 14623446206 011624 0 ustar 00 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.md 0000644 0000000 0000000 00000013245 14623446206 011254 0 ustar 00 # pyLast
[](https://pypi.org/project/pylast/)
[](https://pypi.org/project/pylast/)
[](https://pypistats.org/packages/pylast)
[](https://github.com/pylast/pylast/actions)
[](https://codecov.io/gh/pylast/pylast)
[](https://github.com/psf/black)
[](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.toml 0000644 0000000 0000000 00000004377 14623446206 012717 0 ustar 00 [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-INFO 0000644 0000000 0000000 00000016350 14623446206 011072 0 ustar 00 Metadata-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
[](https://pypi.org/project/pylast/)
[](https://pypi.org/project/pylast/)
[](https://pypistats.org/packages/pylast)
[](https://github.com/pylast/pylast/actions)
[](https://codecov.io/gh/pylast/pylast)
[](https://github.com/psf/black)
[](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.