diff --git a/.codecov.yml b/.codecov.yml
deleted file mode 100644
index 7c94328..0000000
--- a/.codecov.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-# 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
diff --git a/.editorconfig b/.editorconfig
index 179fd45..b71c07e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,6 +11,7 @@ charset = utf-8
[*.py]
indent_size = 4
indent_style = space
+
trim_trailing_whitespace = true
# Two-space indentation
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index ac20214..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1 +0,0 @@
-github: hugovk
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index 5b2dc65..0000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,22 +0,0 @@
-### 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
-```
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 225fdc9..0000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,7 +0,0 @@
-Fixes #
-
-Changes proposed in this pull request:
-
- *
- *
- *
diff --git a/.github/labels.yml b/.github/labels.yml
deleted file mode 100644
index 090914a..0000000
--- a/.github/labels.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-# 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
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
deleted file mode 100644
index ba26220..0000000
--- a/.github/release-drafter.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-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
diff --git a/.github/renovate.json b/.github/renovate.json
deleted file mode 100644
index 2d2f276..0000000
--- a/.github/renovate.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "$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"]
-}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
deleted file mode 100644
index 8b9a278..0000000
--- a/.github/workflows/deploy.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-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
diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml
deleted file mode 100644
index 859c948..0000000
--- a/.github/workflows/labels.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-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 }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
deleted file mode 100644
index d553e49..0000000
--- a/.github/workflows/lint.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-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
diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml
deleted file mode 100644
index 0910f73..0000000
--- a/.github/workflows/release-drafter.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-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 }}
diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml
deleted file mode 100644
index 0d910db..0000000
--- a/.github/workflows/require-pr-label.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-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"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index 7f09cba..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-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
diff --git a/.gitignore b/.gitignore
index 8e4ffaf..51a71f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,9 +47,8 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
-*.cover
+*,cover
.hypothesis/
-.pytest_cache/
# Translations
*.mo
@@ -66,6 +65,3 @@ target/
# JetBrains
.idea/
-
-# Clone Digger
-output.html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
deleted file mode 100644
index 477419b..0000000
--- a/.pre-commit-config.yaml
+++ /dev/null
@@ -1,74 +0,0 @@
-repos:
- - repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.5.0
- 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.18.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.6
- hooks:
- - id: check-github-workflows
- - id: check-renovate
-
- - repo: https://github.com/rhysd/actionlint
- rev: v1.7.1
- hooks:
- - id: actionlint
-
- - repo: https://github.com/tox-dev/pyproject-fmt
- rev: 2.1.3
- hooks:
- - id: pyproject-fmt
-
- - 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
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
new file mode 100644
index 0000000..43dbfa3
--- /dev/null
+++ b/.scrutinizer.yml
@@ -0,0 +1,9 @@
+checks:
+ python:
+ code_rating: true
+ duplicate_code: true
+filter:
+ excluded_paths:
+ - '*/test/*'
+tools:
+ external_code_coverage: true
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..e10837f
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,51 @@
+language: python
+
+cache: pip
+
+env:
+ global:
+ - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY=
+ - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c=
+ - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k=
+ - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o=
+ - secure: DxBvGGoIgbAeuuU3A6+J1HBbmUAEvqdmK73etw+yNKDLGvvukgTL33dNCr8CZXLKRRvfhrjU7Q01GUpOTxrVQ9nJgsD55kwx0wPtuBWIF80M2m4SPsiVLlwP/LFYD5JMDTDWjFTlVahma8P7qoLjCc7b/RgigWLidH19snQmjdY=
+ - secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs=
+ - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y=
+ - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic=
+
+matrix:
+ include:
+ - python: 2.7
+ env: TOXENV=py2lint
+ - python: 2.7
+ env: TOXENV=py27
+ - python: 3.6
+ env: TOXENV=py3lint
+ - python: 3.6
+ env: TOXENV=py36
+ - python: 3.5
+ env: TOXENV=py35
+ - python: 3.4
+ env: TOXENV=py34
+ - python: pypy3
+ env: TOXENV=pypy3
+ - python: pypy
+ env: TOXENV=pypy
+ allow_failures:
+ - env: TOXENV=pypy
+ - env: TOXENV=pypy3
+ fast_finish: true
+
+sudo: false
+
+install:
+- travis_retry pip install tox
+- travis_retry pip install coverage
+
+script: tox
+
+after_success:
+- travis_retry pip install coveralls && coveralls
+- travis_retry pip install codecov && codecov
+- travis_retry pip install scrutinizer-ocular && ocular
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index d424974..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,159 +0,0 @@
-# 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
diff --git a/COPYING b/COPYING
index 5b651ea..eec88ff 100644
--- a/COPYING
+++ b/COPYING
@@ -1,6 +1,6 @@
Apache License
Version 2.0, January 2004
-https://www.apache.org/licenses/
+http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
@@ -32,11 +32,11 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
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 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 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
+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.
diff --git a/INSTALL b/INSTALL
new file mode 100644
index 0000000..f664153
--- /dev/null
+++ b/INSTALL
@@ -0,0 +1,4 @@
+Installation Instructions
+=========================
+
+Execute "python setup.py install" as a super user.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100755
index 0000000..c778b80
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,6 @@
+include pylast/__init__.py
+include setup.py
+include README.md
+include COPYING
+include INSTALL
+recursive-include tests *.py
diff --git a/README.md b/README.md
index c22fbec..8e55b29 100644
--- a/README.md
+++ b/README.md
@@ -1,65 +1,50 @@
-# pyLast
+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)
+[](https://travis-ci.org/pylast/pylast)
+[](https://pypi.python.org/pypi/pylast/)
+
+[](https://codecov.io/gh/pylast/pylast)
+[](https://coveralls.io/github/pylast/pylast?branch=develop)
+[](https://landscape.io/github/hugovk/pylast/develop)
-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.
+A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/).
-## Installation
+Try using the pydoc utility for help on usage or see [test_pylast.py](tests/test_pylast.py) for examples.
-Install latest development version:
+Installation
+------------
-```sh
-python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast
-```
+Install via pip:
-Or from requirements.txt:
-
-```txt
--e https://git.hirad.it/Hirad/pylast#egg=pylast
-```
+ pip install 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.
+* pyLast >= 2.0.0 supports Python 2.7.10+ and 3.4, 3.5, 3.6.
+* pyLast >= 1.7.0 < 2.0.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6.
+* pyLast >= 1.0.0 < 1.7.0 supports Python 2.7, 3.3, 3.4.
+* pyLast >= 0.5 < 1.0.0 supports Python 2, 3.
+* pyLast < 0.5 supports Python 2.
-## Features
+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.
+ * 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.
+ * Python 3-friendly (Starting from 0.5).
-## 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:
+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
@@ -73,50 +58,14 @@ API_SECRET = "425b55975eed76058ac220b7b4e8a054"
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,
-)
-```
+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
+artist = network.get_artist("System of a Down")
+artist.shout("<3")
+
+
track = network.get_track("Iron Maiden", "The Nomad")
track.love()
track.add_tags(("awesome", "favorite"))
@@ -125,20 +74,14 @@ track.add_tags(("awesome", "favorite"))
# 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).
+More examples in hugovk/lastfm-tools and [test_pylast.py](test_pylast.py).
-## Testing
+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.
+[tests/test_pylast.py](tests/test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. Unit tests are also in the [tests/](tests/) directory.
-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:
+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](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
@@ -148,46 +91,20 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE
```
To run all unit and integration tests:
-
```sh
-python3 -m pip install -e ".[tests]"
-pytest
+pip install pytest flaky mock
+py.test
```
Or run just one test case:
-
```sh
-pytest -k test_scrobble
+py.test -k test_scrobble
```
To run with coverage:
-
```sh
-pytest -v --cov pylast --cov-report term-missing
+py.test -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.
diff --git a/RELEASING.md b/RELEASING.md
index 9b2e38a..af42567 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,23 +1,31 @@
# 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:
-
+* [ ] Get [master to the appropriate code release state](https://github.com/pylast/pylast/compare/master...develop?expand=1). [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master.
+* [ ] Update version in `pylast/__init__.py` and `setup.py` and commit:
```bash
-pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)"
+git checkout master
+edit pylast/__init__.py setup.py
+git add pylast/__init__.py setup.py
+git commit -m "Release 1.8.0"
+```
+* [ ] Tag the last commit with the version number:
+```bash
+git tag -a 1.8.0 -m "Release 1.8.0"
+```
+* [ ] Create a distribution and release on PyPI:
+```bash
+python setup.py sdist --format=gztar
+twine upload dist/pylast-1.8.0.tar.gz
+```
+* [ ] Check installation: `pip install -U pylast`
+* [ ] Push: `git push`
+* [ ] Push tags: `git push --tags`
+* [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new
+ * Tag: Pick existing tag "1.8.0"
+ * Title: "Release 1.8.0"
+* [ ] Update develop branch from master:
+```bash
+git checkout develop
+git merge master --ff-only
+git push
```
diff --git a/clonedigger.sh b/clonedigger.sh
new file mode 100755
index 0000000..96dc493
--- /dev/null
+++ b/clonedigger.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+clonedigger pylast
+grep -E "Clones detected|lines are duplicates" output.html
+exit 0
diff --git a/example_test_pylast.yaml b/example_test_pylast.yaml
index 00b09f1..a8fa045 100644
--- a/example_test_pylast.yaml
+++ b/example_test_pylast.yaml
@@ -1,4 +1,4 @@
-username: TODO_ENTER_YOURS_HERE
-password_hash: TODO_ENTER_YOURS_HERE
-api_key: TODO_ENTER_YOURS_HERE
-api_secret: TODO_ENTER_YOURS_HERE
+username: TODO_ENTER_YOURS_HERE
+password_hash: TODO_ENTER_YOURS_HERE
+api_key: TODO_ENTER_YOURS_HERE
+api_secret: TODO_ENTER_YOURS_HERE
diff --git a/pylast/__init__.py b/pylast/__init__.py
new file mode 100644
index 0000000..abf626e
--- /dev/null
+++ b/pylast/__init__.py
@@ -0,0 +1,4288 @@
+# -*- coding: utf-8 -*-
+#
+# pylast -
+# A Python interface to Last.fm and Libre.fm
+#
+# Copyright 2008-2010 Amr Hassan
+# Copyright 2013-2017 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 xml.dom import minidom, Node
+import collections
+import re
+import hashlib
+import shelve
+import six
+import ssl
+import sys
+import tempfile
+import time
+import xml.dom
+
+__version__ = '1.9.0'
+__author__ = 'Amr Hassan, hugovk, Mice Pápai'
+__copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk, '
+ '2017 Mice Pápai')
+__license__ = "apache2"
+__email__ = 'amr.hassan@gmail.com'
+
+
+if sys.version_info[0] == 3:
+ import html.entities as htmlentitydefs
+ from http.client import HTTPSConnection
+ from urllib.parse import splithost as url_split_host
+ from urllib.parse import quote_plus as url_quote_plus
+
+ unichr = chr
+
+elif sys.version_info[0] == 2:
+ import htmlentitydefs
+ from httplib import HTTPSConnection
+ from urllib import splithost as url_split_host
+ from urllib import quote_plus as url_quote_plus
+
+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_TOKEN_ERROR = 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
+
+EVENT_ATTENDING = '0'
+EVENT_MAYBE_ATTENDING = '1'
+EVENT_NOT_ATTENDING = '2'
+
+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
+
+COVER_SMALL = 0
+COVER_MEDIUM = 1
+COVER_LARGE = 2
+COVER_EXTRA_LARGE = 3
+COVER_MEGA = 4
+
+IMAGES_ORDER_POPULARITY = "popularity"
+IMAGES_ORDER_DATE = "dateadded"
+
+
+USER_MALE = 'Male'
+USER_FEMALE = 'Female'
+
+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"
+
+# From http://boodebr.org/main/python/all-about-python-and-unicode#UNI_XML
+RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' +
+ u'|' +
+ u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])'
+ %
+ (unichr(0xd800), unichr(0xdbff), unichr(0xdc00),
+ unichr(0xdfff), unichr(0xd800), unichr(0xdbff),
+ unichr(0xdc00), unichr(0xdfff), unichr(0xd800),
+ unichr(0xdbff), unichr(0xdc00), unichr(0xdfff)))
+
+XML_ILLEGAL = re.compile(RE_XML_ILLEGAL)
+
+# Python >3.4 and >2.7.9 has sane defaults
+SSL_CONTEXT = ssl.create_default_context()
+
+
+class _Network(object):
+ """
+ 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,
+ submission_server, username, password_hash, domain_names, urls,
+ token=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
+ submission_server: the URL of the server to which tracks are
+ submitted (scrobbled)
+ 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.submission_server = submission_server
+ self.username = username
+ self.password_hash = password_hash
+ self.domain_names = domain_names
+ self.urls = urls
+
+ self.cache_backend = None
+ self.proxy_enabled = False
+ self.proxy = None
+ self.last_call_time = 0
+ self.limit_rate = False
+
+ # Load session_key from authentication token if provided
+ if token and not self.session_key:
+ sk_gen = SessionKeyGenerator(self)
+ self.session_key = sk_gen.get_web_auth_session_key(
+ 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):
+ return "%s Network" % self.name
+
+ 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_metro(self, metro_name, country_name):
+ """
+ Returns a metro object
+ """
+
+ return Metro(metro_name, country_name, self)
+
+ def get_group(self, name):
+ """
+ Returns a Group object
+ """
+
+ return Group(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):
+ return "https://%s/%s" % (
+ 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):
+ """
+ Makes sure that web service calls are at least 0.2 seconds apart.
+ """
+
+ # Delay time in seconds from section 4.4 of https://www.last.fm/api/tos
+ DELAY_TIME = 0.2
+ 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 create_new_playlist(self, title, description):
+ """
+ Creates a playlist for the authenticated user and returns it
+ title: The title of the new playlist.
+ description: The description of the new playlist.
+ """
+
+ params = {}
+ params['title'] = title
+ params['description'] = description
+
+ doc = _Request(self, 'playlist.create', params).execute(False)
+
+ e_id = doc.getElementsByTagName("id")[0].firstChild.data
+ user = doc.getElementsByTagName('playlists')[0].getAttribute('user')
+
+ return Playlist(user, e_id, self)
+
+ def get_top_artists(self, limit=None, cacheable=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=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=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 = []
+ 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_events(
+ self, longitude=None, latitude=None, location=None, distance=None,
+ tag=None, festivalsonly=None, limit=None, cacheable=True):
+ """
+ Returns all events in a specific location by country or city name.
+ Parameters:
+ longitude (Optional) : Specifies a longitude value to retrieve events
+ for (service returns nearby events by default)
+ latitude (Optional) : Specifies a latitude value to retrieve events for
+ (service returns nearby events by default)
+ location (Optional) : Specifies a location to retrieve events for
+ (service returns nearby events by default)
+ distance (Optional) : Find events within a specified radius
+ (in kilometres)
+ tag (Optional) : Specifies a tag to filter by.
+ festivalsonly[0|1] (Optional) : Whether only festivals should be
+ returned, or all events.
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 10.
+ """
+
+ params = {}
+
+ if longitude:
+ params["long"] = longitude
+ if latitude:
+ params["lat"] = latitude
+ if location:
+ params["location"] = location
+ if limit:
+ params["limit"] = limit
+ if distance:
+ params["distance"] = distance
+ if tag:
+ params["tag"] = tag
+ if festivalsonly:
+ params["festivalsonly"] = 1
+ elif not festivalsonly:
+ params["festivalsonly"] = 0
+
+ doc = _Request(self, "geo.getEvents", params).execute(cacheable)
+
+ return _extract_events_from_doc(doc, self)
+
+ def get_metro_weekly_chart_dates(self, cacheable=True):
+ """
+ Returns a list of From and To tuples for the available metro charts.
+ """
+
+ doc = _Request(self, "geo.getMetroWeeklyChartlist").execute(cacheable)
+
+ seq = []
+ for node in doc.getElementsByTagName("chart"):
+ seq.append((node.getAttribute("from"), node.getAttribute("to")))
+
+ return seq
+
+ def get_metros(self, country=None, cacheable=True):
+ """
+ Get a list of valid countries and metros for use in the other
+ webservices.
+ Parameters:
+ country (Optional) : Optionally restrict the results to those Metros
+ from a particular country, as defined by the ISO 3166-1 country
+ names standard.
+ """
+ params = {}
+
+ if country:
+ params["country"] = country
+
+ doc = _Request(self, "geo.getMetros", params).execute(cacheable)
+
+ metros = doc.getElementsByTagName("metro")
+ seq = []
+
+ for metro in metros:
+ name = _extract(metro, "name")
+ country = _extract(metro, "country")
+
+ seq.append(Metro(name, country, self))
+
+ return seq
+
+ def get_geo_top_artists(self, country, limit=None, cacheable=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=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, host, port):
+ """Enable a default web proxy"""
+
+ self.proxy = [host, _number(port)]
+ self.proxy_enabled = True
+
+ def disable_proxy(self):
+ """Disable using the web proxy"""
+
+ self.proxy_enabled = False
+
+ def is_proxy_enabled(self):
+ """Returns True if a web proxy is enabled."""
+
+ return self.proxy_enabled
+
+ def _get_proxy(self):
+ """Returns proxy details."""
+
+ return self.proxy
+
+ def enable_rate_limit(self):
+ """Enables rate limiting for this network"""
+ self.limit_rate = True
+
+ def disable_rate_limit(self):
+ """Disables rate limiting for this network"""
+ self.limit_rate = False
+
+ def is_rate_limited(self):
+ """Return True if web service calls are rate limited"""
+ return self.limit_rate
+
+ def enable_caching(self, file_path=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:
+ file_path = tempfile.mktemp(prefix="pylast_tmp_")
+
+ self.cache_backend = _ShelfCacheBackend(file_path)
+
+ def disable_caching(self):
+ """Disables all caching features."""
+
+ self.cache_backend = None
+
+ def is_caching_enabled(self):
+ """Returns True if caching is enabled."""
+
+ return not (self.cache_backend is None)
+
+ def _get_cache_backend(self):
+
+ return self.cache_backend
+
+ def search_for_album(self, album_name):
+ """Searches for an album by its name. Returns a AlbumSearch object.
+ Use get_next_page() to retrieve sequences of results."""
+
+ return AlbumSearch(album_name, self)
+
+ def search_for_artist(self, artist_name):
+ """Searches of an artist by its name. Returns a ArtistSearch object.
+ Use get_next_page() to retrieve sequences of results."""
+
+ return ArtistSearch(artist_name, self)
+
+ def search_for_tag(self, tag_name):
+ """Searches of a tag by its name. Returns a TagSearch object.
+ Use get_next_page() to retrieve sequences of results."""
+
+ return TagSearch(tag_name, self)
+
+ def search_for_track(self, artist_name, track_name):
+ """Searches of 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 search_for_venue(self, venue_name, country_name):
+ """Searches of a venue by its name and its country. Set country_name to
+ an empty string if not available.
+ Returns a VenueSearch object.
+ Use get_next_page() to retrieve sequences of results."""
+
+ return VenueSearch(venue_name, country_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):
+ """
+ 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, title, timestamp, album=None, album_artist=None,
+ track_number=None, duration=None, stream_id=None, context=None,
+ mbid=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). This must be in the UTC time zone.
+ 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):
+ """
+ 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["artist[%d]" % i] = tracks_to_scrobble[i]["artist"]
+ params["track[%d]" % 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[
+ "%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg]
+
+ _Request(self, "track.scrobble", params).execute()
+
+ if remaining_tracks:
+ self.scrobble_many(remaining_tracks)
+
+ def get_play_links(self, link_type, things, cacheable=True):
+ method = link_type + ".getPlaylinks"
+ params = {}
+
+ for i, thing in enumerate(things):
+ if link_type == "artist":
+ params['artist[' + str(i) + ']'] = thing
+ elif link_type == "album":
+ params['artist[' + str(i) + ']'] = thing.artist
+ params['album[' + str(i) + ']'] = thing.title
+ elif link_type == "track":
+ params['artist[' + str(i) + ']'] = thing.artist
+ params['track[' + str(i) + ']'] = thing.title
+
+ doc = _Request(self, method, params).execute(cacheable)
+
+ seq = []
+
+ for node in doc.getElementsByTagName("externalids"):
+ spotify = _extract(node, "spotify")
+ seq.append(spotify)
+
+ return seq
+
+ def get_artist_play_links(self, artists, cacheable=True):
+ return self.get_play_links("artist", artists, cacheable)
+
+ def get_album_play_links(self, albums, cacheable=True):
+ return self.get_play_links("album", albums, cacheable)
+
+ def get_track_play_links(self, tracks, cacheable=True):
+ return self.get_play_links("track", tracks, cacheable)
+
+
+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="", api_secret="", session_key="", username="",
+ password_hash="", token=""):
+ _Network.__init__(
+ self,
+ 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,
+ submission_server="http://post.audioscrobbler.com:80/",
+ 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",
+ "event": "event/%(id)s",
+ "country": "place/%(country_name)s",
+ "playlist": "user/%(user)s/library/playlists/%(appendix)s",
+ "tag": "tag/%(name)s",
+ "track": "music/%(artist)s/_/%(title)s",
+ "group": "group/%(name)s",
+ "user": "user/%(name)s",
+ }
+ )
+
+ def __repr__(self):
+ return "pylast.LastFMNetwork(%s)" % (", ".join(
+ ("'%s'" % self.api_key,
+ "'%s'" % self.api_secret,
+ "'%s'" % self.session_key,
+ "'%s'" % self.username,
+ "'%s'" % 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="", api_secret="", session_key="", username="",
+ password_hash=""):
+
+ _Network.__init__(
+ self,
+ 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,
+ submission_server="http://turtle.libre.fm:80/",
+ 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",
+ "event": "event/%(id)s",
+ "country": "place/%(country_name)s",
+ "playlist": "user/%(user)s/library/playlists/%(appendix)s",
+ "tag": "tag/%(name)s",
+ "track": "music/%(artist)s/_/%(title)s",
+ "group": "group/%(name)s",
+ "user": "user/%(name)s",
+ }
+ )
+
+ def __repr__(self):
+ return "pylast.LibreFMNetwork(%s)" % (", ".join(
+ ("'%s'" % self.api_key,
+ "'%s'" % self.api_secret,
+ "'%s'" % self.session_key,
+ "'%s'" % self.username,
+ "'%s'" % self.password_hash)))
+
+
+class _ShelfCacheBackend(object):
+ """Used as a backend for caching cacheable requests."""
+ def __init__(self, file_path=None):
+ self.shelf = shelve.open(file_path)
+
+ def __iter__(self):
+ return iter(self.shelf.keys())
+
+ def get_xml(self, key):
+ return self.shelf[key]
+
+ def set_xml(self, key, xml_string):
+ self.shelf[key] = xml_string
+
+
+class _Request(object):
+ """Representing an abstract web service operation."""
+
+ def __init__(self, network, method_name, 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._get_cache_backend()
+
+ if self.session_key:
+ self.params["sk"] = self.session_key
+ self.sign_it()
+
+ def sign_it(self):
+ """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 = str()
+
+ 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()
+
+ data = []
+ for name in self.params.keys():
+ data.append('='.join((
+ name, url_quote_plus(_string(self.params[name])))))
+ data = '&'.join(data)
+
+ headers = {
+ "Content-type": "application/x-www-form-urlencoded",
+ 'Accept-Charset': 'utf-8',
+ 'User-Agent': "pylast" + '/' + __version__
+ }
+
+ (HOST_NAME, HOST_SUBDIR) = self.network.ws_server
+
+ if self.network.is_proxy_enabled():
+ conn = HTTPSConnection(
+ context=SSL_CONTEXT,
+ host=self.network._get_proxy()[0],
+ port=self.network._get_proxy()[1])
+
+ try:
+ conn.request(
+ method='POST', url="http://" + HOST_NAME + HOST_SUBDIR,
+ body=data, headers=headers)
+ except Exception as e:
+ raise NetworkError(self.network, e)
+
+ else:
+ conn = HTTPSConnection(context=SSL_CONTEXT, host=HOST_NAME)
+
+ try:
+ conn.request(
+ method='POST', url=HOST_SUBDIR, body=data, headers=headers)
+ except Exception as e:
+ raise NetworkError(self.network, e)
+
+ try:
+ response_text = _unicode(conn.getresponse().read())
+ except Exception as e:
+ raise MalformedResponseError(self.network, e)
+
+ response_text = XML_ILLEGAL.sub("?", response_text)
+
+ self._check_response_for_errors(response_text)
+ conn.close()
+ return response_text
+
+ def execute(self, cacheable=False):
+ """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 minidom.parseString(_string(response).replace(
+ "opensearch:", ""))
+
+ def _check_response_for_errors(self, response):
+ """Checks the response for errors and raises one if any exists."""
+
+ try:
+ doc = minidom.parseString(_string(response).replace(
+ "opensearch:", ""))
+ except Exception as e:
+ raise MalformedResponseError(self.network, e)
+
+ e = doc.getElementsByTagName('lfm')[0]
+
+ if e.getAttribute('status') != "ok":
+ e = doc.getElementsByTagName('error')[0]
+ status = e.getAttribute('code')
+ details = e.firstChild.data.strip()
+ raise WSError(self.network, status, details)
+
+
+class SessionKeyGenerator(object):
+ """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 a 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):
+ 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 = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \
+ {"homepage": self.network.homepage,
+ "api": self.network.api_key, "token": token}
+
+ self.web_auth_tokens[url] = token
+
+ return url
+
+ def get_web_auth_session_key(self, url, token=""):
+ """
+ Retrieves the session key of a web authorization process by its url.
+ """
+
+ if url in self.web_auth_tokens.keys():
+ token = self.web_auth_tokens[url]
+ else:
+ # This will raise a WSError if token is blank or unauthorized
+ token = token
+
+ 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()
+
+ return doc.getElementsByTagName('key')[0].firstChild.data
+
+ 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"])
+Shout = collections.namedtuple(
+ "Shout", ["body", "author", "date"])
+
+
+def _string_output(func):
+ def r(*args):
+ return _string(func(*args))
+
+ return r
+
+
+def _pad_list(given_list, desired_length, padding=None):
+ """
+ Pads a list to be of the desired_length.
+ """
+
+ while len(given_list) < desired_length:
+ given_list.append(padding)
+
+ return given_list
+
+
+class _BaseObject(object):
+ """An abstract webservices object."""
+
+ network = None
+
+ def __init__(self, network, ws_prefix):
+ self.network = network
+ self.ws_prefix = ws_prefix
+
+ def _request(self, method_name, cacheable=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(six.text_type, self._get_params().values())
+
+ return hash(self.network) + hash(six.text_type(type(self)) + _casefold(
+ "".join(list(self._get_params().keys()) + list(values))))
+
+ def _extract_cdata_from_request(self, method_name, tag_name, params):
+ doc = self._request(method_name, True, params)
+
+ return doc.getElementsByTagName(
+ tag_name)[0].firstChild.wholeText.strip()
+
+ def _get_things(
+ self, method, thing, thing_type, params=None, cacheable=True):
+ """Returns a list of the most played thing_types by this thing."""
+
+ doc = self._request(
+ self.ws_prefix + "." + method, cacheable, params)
+
+ seq = []
+ for node in doc.getElementsByTagName(thing):
+ title = _extract(node, "name")
+ artist = _extract(node, "name", 1)
+ playcount = _number(_extract(node, "playcount"))
+
+ seq.append(TopItem(
+ thing_type(artist, title, self.network), playcount))
+
+ return seq
+
+ def get_top_fans(self, limit=None, cacheable=True):
+ """Returns a list of the Users who played this the most.
+ # Parameters:
+ * limit int: Max elements.
+ # For Artist/Track
+ """
+
+ doc = self._request(self.ws_prefix + '.getTopFans', cacheable)
+
+ seq = []
+
+ elements = doc.getElementsByTagName('user')
+
+ for element in elements:
+ if limit and len(seq) >= limit:
+ break
+
+ name = _extract(element, 'name')
+ weight = _number(_extract(element, 'weight'))
+
+ seq.append(TopItem(User(name, self.network), weight))
+
+ return seq
+
+ def share(self, users, message=None):
+ """
+ Shares this (sends out recommendations).
+ Parameters:
+ * users [User|str,]: A list that can contain usernames, emails,
+ User objects, or all of them.
+ * message str: A message to include in the recommendation message.
+ Only for Artist/Event/Track.
+ """
+
+ # Last.fm currently accepts a max of 10 recipient at a time
+ while len(users) > 10:
+ section = users[0:9]
+ users = users[9:]
+ self.share(section, message)
+
+ user_names = []
+ for user in users:
+ if isinstance(user, User):
+ user_names.append(user.get_name())
+ else:
+ user_names.append(user)
+
+ params = self._get_params()
+ recipients = ','.join(user_names)
+ params['recipient'] = recipients
+ if message:
+ params['message'] = message
+
+ self._request(self.ws_prefix + '.share', False, params)
+
+ def get_wiki_published_date(self):
+ """
+ Returns the summary of the wiki.
+ 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 summary 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)
+
+ def get_shouts(self, limit=50, cacheable=False):
+ """
+ Returns a sequence of Shout objects
+ """
+
+ shouts = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getShouts",
+ cacheable):
+ shouts.append(
+ Shout(
+ _extract(node, "body"),
+ User(_extract(node, "author"), self.network),
+ _extract(node, "date")
+ )
+ )
+ return shouts
+
+
+class _Chartable(object):
+ """Common functions for classes with charts."""
+
+ def __init__(self, ws_prefix):
+ self.ws_prefix = ws_prefix # TODO move to _BaseObject?
+
+ 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 Group or 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 Group, Tag or 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 Group or 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"
+ """
+ method = ".getWeekly" + chart_kind.title() + "Chart"
+ chart_type = eval(chart_kind.title()) # string to type
+
+ 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(_casefold(chart_kind)):
+ 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(object):
+ """Common functions for classes with tags."""
+
+ def __init__(self, ws_prefix):
+ self.ws_prefix = ws_prefix # TODO move to _BaseObject
+
+ def add_tags(self, tags):
+ """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):
+ """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):
+ """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):
+ """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):
+ """Clears all the user-set tags. """
+
+ self.remove_tags(*(self.get_tags()))
+
+ def set_tags(self, tags):
+ """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(_casefold(tag.get_name()))
+ old_tags.append(tag.get_name())
+
+ for tag in tags:
+ c_new_tags.append(_casefold(tag))
+ new_tags.append(tag)
+
+ for i in range(0, len(old_tags)):
+ if not c_old_tags[i] in c_new_tags:
+ to_remove.append(old_tags[i])
+
+ for i in range(0, len(new_tags)):
+ if not c_new_tags[i] 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')
+ tagcount = _extract(element, 'count')
+
+ seq.append(TopItem(Tag(tag_name, self.network), tagcount))
+
+ if limit:
+ seq = seq[:limit]
+
+ return seq
+
+
+class WSError(Exception):
+ """Exception related to the Network web service"""
+
+ def __init__(self, network, status, details):
+ self.status = status
+ self.details = details
+ self.network = network
+
+ @_string_output
+ def __str__(self):
+ 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_TOKEN_ERROR = 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
+ """
+
+ return self.status
+
+
+class MalformedResponseError(Exception):
+ """Exception conveying a malformed response from the music network."""
+
+ def __init__(self, network, underlying_error):
+ self.network = network
+ self.underlying_error = underlying_error
+
+ def __str__(self):
+ return "Malformed response from {}. Underlying error: {}".format(
+ self.network.name, str(self.underlying_error))
+
+
+class NetworkError(Exception):
+ """Exception conveying a problem in sending a request to Last.fm"""
+
+ def __init__(self, network, underlying_error):
+ self.network = network
+ self.underlying_error = underlying_error
+
+ def __str__(self):
+ return "NetworkError: %s" % str(self.underlying_error)
+
+
+class _Opus(_BaseObject, _Taggable):
+ """An album or track."""
+
+ artist = None
+ title = None
+ username = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, artist, title, network, ws_prefix, username=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'
+ """
+
+ _BaseObject.__init__(self, network, ws_prefix)
+ _Taggable.__init__(self, ws_prefix)
+
+ if isinstance(artist, Artist):
+ self.artist = artist
+ else:
+ self.artist = Artist(artist, self.network)
+
+ self.title = title
+ self.username = username
+
+ def __repr__(self):
+ return "pylast.%s(%s, %s, %s)" % (
+ self.ws_prefix.title(), repr(self.artist.name),
+ repr(self.title), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return _unicode("%s - %s") % (
+ self.get_artist().get_name(), self.get_title())
+
+ def __eq__(self, other):
+ if type(self) != type(other):
+ return False
+ return (_casefold_equal(self.get_title(),
+ other.get_title()) and
+ _casefold_equal(self.get_artist().get_name(),
+ other.get_artist().get_name()))
+
+ def __ne__(self, other):
+ return not self.__eq__(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_title(self, properly_capitalized=False):
+ """Returns the artist 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=False):
+ """Returns the album or track title (alias to get_title())."""
+
+ return self.get_title(properly_capitalized)
+
+ def get_id(self):
+ """Returns the ID on the network."""
+
+ return _extract(
+ self._request(self.ws_prefix + ".getInfo", cacheable=True), "id")
+
+ 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):
+ """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
+ 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):
+ super(Album, self).__init__(artist, title, network, "album", username)
+
+ def get_release_date(self):
+ """Returns the release date of the album."""
+
+ return _extract(self._request(
+ self.ws_prefix + ".getInfo", cacheable=True), "releasedate")
+
+ def get_cover_image(self, size=COVER_EXTRA_LARGE):
+ """
+ Returns a uri to the cover image
+ size can be one of:
+ COVER_EXTRA_LARGE
+ COVER_LARGE
+ COVER_MEDIUM
+ COVER_SMALL
+ """
+
+ return _extract_all(
+ self._request(
+ self.ws_prefix + ".getInfo", cacheable=True), 'image')[size]
+
+ 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(_BaseObject, _Taggable):
+ """An artist."""
+
+ name = None
+ username = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, name, network, username=None):
+ """Create an artist object.
+ # Parameters:
+ * name str: The artist's name.
+ """
+
+ _BaseObject.__init__(self, network, 'artist')
+ _Taggable.__init__(self, 'artist')
+
+ self.name = name
+ self.username = username
+
+ def __repr__(self):
+ return "pylast.Artist(%s, %s)" % (
+ repr(self.get_name()), repr(self.network))
+
+ def __unicode__(self):
+ return six.text_type(self.get_name())
+
+ @_string_output
+ def __str__(self):
+ return self.__unicode__()
+
+ def __eq__(self, other):
+ if type(self) is type(other):
+ return _casefold_equal(self.get_name(), other.get_name())
+ else:
+ return False
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def _get_params(self):
+ return {self.ws_prefix: self.get_name()}
+
+ def get_name(self, properly_capitalized=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_cover_image(self, size=COVER_MEGA):
+ """
+ Returns a uri to the cover image
+ size can be one of:
+ COVER_MEGA
+ COVER_EXTRA_LARGE
+ COVER_LARGE
+ COVER_MEDIUM
+ COVER_SMALL
+ """
+
+ return _extract_all(
+ self._request(self.ws_prefix + ".getInfo", True), "image")[size]
+
+ 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 is_streamable(self):
+ """Returns True if the artist is streamable."""
+
+ return bool(_number(_extract(
+ self._request(self.ws_prefix + ".getInfo", True), "streamable")))
+
+ 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
+
+ return self._extract_cdata_from_request(
+ self.ws_prefix + ".getInfo", section, params)
+
+ 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_upcoming_events(self):
+ """Returns a list of the upcoming Events for this artist."""
+
+ doc = self._request(self.ws_prefix + '.getEvents', True)
+
+ return _extract_events_from_doc(doc, self.network)
+
+ 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=True):
+ """Returns a list of the top albums."""
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+
+ return self._get_things(
+ "getTopAlbums", "album", Album, params, cacheable)
+
+ def get_top_tracks(self, limit=None, cacheable=True):
+ """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", Track, params, cacheable)
+
+ 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}
+
+ def shout(self, message):
+ """
+ Post a shout
+ """
+
+ params = self._get_params()
+ params["message"] = message
+
+ self._request("artist.Shout", False, params)
+
+ def get_band_members(self):
+ """Returns a list of band members or None if unknown."""
+
+ names = None
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ for node in doc.getElementsByTagName("bandmembers"):
+ names = _extract_all(node, "name")
+
+ return names
+
+
+class Event(_BaseObject):
+ """An event."""
+
+ id = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, event_id, network):
+ _BaseObject.__init__(self, network, 'event')
+
+ self.id = event_id
+
+ def __repr__(self):
+ return "pylast.Event(%s, %s)" % (repr(self.id), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return "Event #" + str(self.get_id())
+
+ def __eq__(self, other):
+ if type(self) is type(other):
+ return self.get_id() == other.get_id()
+ else:
+ return False
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def _get_params(self):
+ return {'event': self.get_id()}
+
+ def attend(self, attending_status):
+ """Sets the attending status.
+ * attending_status: The attending status. Possible values:
+ o EVENT_ATTENDING
+ o EVENT_MAYBE_ATTENDING
+ o EVENT_NOT_ATTENDING
+ """
+
+ params = self._get_params()
+ params['status'] = attending_status
+
+ self._request('event.attend', False, params)
+
+ def get_attendees(self):
+ """
+ Get a list of attendees for an event
+ """
+
+ doc = self._request("event.getAttendees", False)
+
+ users = []
+ for name in _extract_all(doc, "name"):
+ users.append(User(name, self.network))
+
+ return users
+
+ def get_id(self):
+ """Returns the id of the event on the network. """
+
+ return self.id
+
+ def get_title(self):
+ """Returns the title of the event. """
+
+ doc = self._request("event.getInfo", True)
+
+ return _extract(doc, "title")
+
+ def get_headliner(self):
+ """Returns the headliner of the event. """
+
+ doc = self._request("event.getInfo", True)
+
+ return Artist(_extract(doc, "headliner"), self.network)
+
+ def get_artists(self):
+ """Returns a list of the participating Artists. """
+
+ doc = self._request("event.getInfo", True)
+ names = _extract_all(doc, "artist")
+
+ artists = []
+ for name in names:
+ artists.append(Artist(name, self.network))
+
+ return artists
+
+ def get_venue(self):
+ """Returns the venue where the event is held."""
+
+ doc = self._request("event.getInfo", True)
+
+ v = doc.getElementsByTagName("venue")[0]
+ venue_id = _number(_extract(v, "id"))
+
+ return Venue(venue_id, self.network, venue_element=v)
+
+ def get_start_date(self):
+ """Returns the date when the event starts."""
+
+ doc = self._request("event.getInfo", True)
+
+ return _extract(doc, "startDate")
+
+ def get_description(self):
+ """Returns the description of the event. """
+
+ doc = self._request("event.getInfo", True)
+
+ return _extract(doc, "description")
+
+ def get_cover_image(self, size=COVER_MEGA):
+ """
+ Returns a uri to the cover image
+ size can be one of:
+ COVER_MEGA
+ COVER_EXTRA_LARGE
+ COVER_LARGE
+ COVER_MEDIUM
+ COVER_SMALL
+ """
+
+ doc = self._request("event.getInfo", True)
+
+ return _extract_all(doc, "image")[size]
+
+ def get_attendance_count(self):
+ """Returns the number of attending people. """
+
+ doc = self._request("event.getInfo", True)
+
+ return _number(_extract(doc, "attendance"))
+
+ def get_review_count(self):
+ """Returns the number of available reviews for this event. """
+
+ doc = self._request("event.getInfo", True)
+
+ return _number(_extract(doc, "reviews"))
+
+ def get_url(self, domain_name=DOMAIN_ENGLISH):
+ """Returns the url of the event 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
+ """
+
+ return self.network._get_url(
+ domain_name, "event") % {'id': self.get_id()}
+
+ def shout(self, message):
+ """
+ Post a shout
+ """
+
+ params = self._get_params()
+ params["message"] = message
+
+ self._request("event.Shout", False, params)
+
+
+class Country(_BaseObject):
+ """A country at Last.fm."""
+
+ name = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, name, network):
+ _BaseObject.__init__(self, network, "geo")
+
+ self.name = name
+
+ def __repr__(self):
+ return "pylast.Country(%s, %s)" % (repr(self.name), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name()
+
+ def __eq__(self, other):
+ return _casefold_equal(self.get_name(), other.get_name())
+
+ def __ne__(self, other):
+ # TODO _casefold name?
+ return self.get_name() != other.get_name()
+
+ def _get_params(self): # TODO can move to _BaseObject
+ return {'country': self.get_name()}
+
+ def _get_name_from_code(self, alpha2code):
+ # TODO: Have this function lookup the alpha-2 code and return the
+ # country name.
+
+ return alpha2code
+
+ def get_name(self):
+ """Returns the country name. """
+
+ return self.name
+
+ def get_top_artists(self, limit=None, cacheable=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=True):
+ """Returns a sequence of the most played tracks"""
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+
+ return self._get_things(
+ "getTopTracks", "track", Track, params, cacheable)
+
+ def get_url(self, domain_name=DOMAIN_ENGLISH):
+ """Returns the url of the event 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 Metro(_BaseObject):
+ """A metro at Last.fm."""
+
+ name = None
+ country = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, name, country, network):
+ _BaseObject.__init__(self, network, None)
+
+ self.name = name
+ self.country = country
+
+ def __repr__(self):
+ return "pylast.Metro(%s, %s, %s)" % (
+ repr(self.name), repr(self.country), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name() + ", " + self.get_country()
+
+ def __eq__(self, other):
+ return (_casefold_equal(self.get_name(), other.get_name()) and
+ _casefold_equal(self.get_country(), other.get_country()))
+
+ def __ne__(self, other):
+ # TODO _casefold name?
+ return (self.get_name() != other.get_name() or
+ _casefold_not_equal(self.get_country(), other.get_country()))
+
+ def _get_params(self):
+ return {'metro': self.get_name(), 'country': self.get_country()}
+
+ def get_name(self):
+ """Returns the metro name."""
+
+ return self.name
+
+ def get_country(self):
+ """Returns the metro country."""
+
+ return self.country
+
+ def _get_chart(
+ self, method, tag="artist", limit=None, from_date=None,
+ to_date=None, cacheable=True):
+ """Internal helper for getting geo charts."""
+ params = self._get_params()
+ if limit:
+ params["limit"] = limit
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request(method, cacheable, params)
+
+ seq = []
+ for node in doc.getElementsByTagName(tag):
+ if tag == "artist":
+ item = Artist(_extract(node, "name"), self.network)
+ elif tag == "track":
+ title = _extract(node, "name")
+ artist = _extract_element_tree(node).get('artist')['name']
+ item = Track(artist, title, self.network)
+ else:
+ return None
+ weight = _number(_extract(node, "listeners"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ def get_artist_chart(
+ self, tag="artist", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of artists for a metro.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroArtistChart", tag=tag, limit=limit,
+ from_date=from_date, to_date=to_date, cacheable=cacheable)
+
+ def get_hype_artist_chart(
+ self, tag="artist", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of hyped (up and coming) artists for a metro.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroHypeArtistChart", tag=tag, limit=limit,
+ from_date=from_date, to_date=to_date, cacheable=cacheable)
+
+ def get_unique_artist_chart(
+ self, tag="artist", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of the artists which make that metro unique.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroUniqueArtistChart", tag=tag, limit=limit,
+ from_date=from_date, to_date=to_date, cacheable=cacheable)
+
+ def get_track_chart(
+ self, tag="track", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of tracks for a metro.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroTrackChart", tag=tag, limit=limit,
+ from_date=from_date, to_date=to_date, cacheable=cacheable)
+
+ def get_hype_track_chart(
+ self, tag="track", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of tracks for a metro.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroHypeTrackChart", tag=tag,
+ limit=limit, from_date=from_date, to_date=to_date,
+ cacheable=cacheable)
+
+ def get_unique_track_chart(
+ self, tag="track", limit=None, from_date=None, to_date=None,
+ cacheable=True):
+ """Get a chart of tracks for a metro.
+ Parameters:
+ from_date (Optional) : Beginning timestamp of the weekly range
+ requested
+ to_date (Optional) : Ending timestamp of the weekly range requested
+ limit (Optional) : The number of results to fetch per page.
+ Defaults to 50.
+ """
+ return self._get_chart(
+ "geo.getMetroUniqueTrackChart", tag=tag, limit=limit,
+ from_date=from_date, to_date=to_date, cacheable=cacheable)
+
+
+class Library(_BaseObject):
+ """A user's Last.fm library."""
+
+ user = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, user, network):
+ _BaseObject.__init__(self, network, 'library')
+
+ if isinstance(user, User):
+ self.user = user
+ else:
+ self.user = User(user, self.network)
+
+ self._albums_index = 0
+ self._artists_index = 0
+ self._tracks_index = 0
+
+ def __repr__(self):
+ return "pylast.Library(%s, %s)" % (repr(self.user), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ 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 add_album(self, album):
+ """Add an album to this library."""
+
+ params = self._get_params()
+ params["artist"] = album.get_artist().get_name()
+ params["album"] = album.get_name()
+
+ self._request("library.addAlbum", False, params)
+
+ def remove_album(self, album):
+ """Remove an album from this library."""
+
+ params = self._get_params()
+ params["artist"] = album.get_artist().get_name()
+ params["album"] = album.get_name()
+
+ self._request(self.ws_prefix + ".removeAlbum", False, params)
+
+ def add_artist(self, artist):
+ """Add an artist to this library."""
+
+ params = self._get_params()
+ if type(artist) == str:
+ params["artist"] = artist
+ else:
+ params["artist"] = artist.get_name()
+
+ self._request(self.ws_prefix + ".addArtist", False, params)
+
+ def remove_artist(self, artist):
+ """Remove an artist from this library."""
+
+ params = self._get_params()
+ if type(artist) == str:
+ params["artist"] = artist
+ else:
+ params["artist"] = artist.get_name()
+
+ self._request(self.ws_prefix + ".removeArtist", False, params)
+
+ def add_track(self, track):
+ """Add a track to this library."""
+
+ params = self._get_params()
+ params["track"] = track.get_title()
+
+ self._request(self.ws_prefix + ".addTrack", False, params)
+
+ def get_albums(self, artist=None, limit=50, cacheable=True):
+ """
+ Returns a sequence of Album objects
+ If no artist is specified, it will return all, sorted by decreasing
+ play count.
+ If limit==None it will return all (may take a while)
+ """
+
+ params = self._get_params()
+ if artist:
+ params["artist"] = artist
+
+ seq = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getAlbums",
+ cacheable,
+ params):
+ name = _extract(node, "name")
+ artist = _extract(node, "name", 1)
+ playcount = _number(_extract(node, "playcount"))
+ tagcount = _number(_extract(node, "tagcount"))
+
+ seq.append(LibraryItem(
+ Album(artist, name, self.network), playcount, tagcount))
+
+ return seq
+
+ def get_artists(self, limit=50, cacheable=True):
+ """
+ Returns a sequence of Album objects
+ if limit==None it will return all (may take a while)
+ """
+
+ seq = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getArtists",
+ cacheable):
+ name = _extract(node, "name")
+
+ playcount = _number(_extract(node, "playcount"))
+ tagcount = _number(_extract(node, "tagcount"))
+
+ seq.append(LibraryItem(
+ Artist(name, self.network), playcount, tagcount))
+
+ return seq
+
+ def get_tracks(self, artist=None, album=None, limit=50, cacheable=True):
+ """
+ Returns a sequence of Album objects
+ If limit==None it will return all (may take a while)
+ """
+
+ params = self._get_params()
+ if artist:
+ params["artist"] = artist
+ if album:
+ params["album"] = album
+
+ seq = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getTracks",
+ cacheable,
+ params):
+ name = _extract(node, "name")
+ artist = _extract(node, "name", 1)
+ playcount = _number(_extract(node, "playcount"))
+ tagcount = _number(_extract(node, "tagcount"))
+
+ seq.append(LibraryItem(
+ Track(artist, name, self.network), playcount, tagcount))
+
+ return seq
+
+ def remove_scrobble(self, artist, title, timestamp):
+ """Remove a scrobble from a user's Last.fm library. Parameters:
+ artist (Required) : The artist that composed the track
+ title (Required) : The name of the track
+ timestamp (Required) : The unix timestamp of the scrobble
+ that you wish to remove
+ """
+
+ params = self._get_params()
+ params["artist"] = artist
+ params["track"] = title
+ params["timestamp"] = timestamp
+
+ self._request(self.ws_prefix + ".removeScrobble", False, params)
+
+
+class Playlist(_BaseObject):
+ """A Last.fm user playlist."""
+
+ id = None
+ user = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, user, playlist_id, network):
+ _BaseObject.__init__(self, network, "playlist")
+
+ if isinstance(user, User):
+ self.user = user
+ else:
+ self.user = User(user, self.network)
+
+ self.id = playlist_id
+
+ @_string_output
+ def __str__(self):
+ return repr(self.user) + "'s playlist # " + repr(self.id)
+
+ def _get_info_node(self):
+ """
+ Returns the node from user.getPlaylists where this playlist's info is.
+ """
+
+ doc = self._request("user.getPlaylists", True)
+
+ for node in doc.getElementsByTagName("playlist"):
+ if _extract(node, "id") == str(self.get_id()):
+ return node
+
+ def _get_params(self):
+ return {'user': self.user.get_name(), 'playlistID': self.get_id()}
+
+ def get_id(self):
+ """Returns the playlist ID."""
+
+ return self.id
+
+ def get_user(self):
+ """Returns the owner user of this playlist."""
+
+ return self.user
+
+ def get_tracks(self):
+ """Returns a list of the tracks on this user playlist."""
+
+ uri = _unicode('lastfm://playlist/%s') % self.get_id()
+
+ return XSPF(uri, self.network).get_tracks()
+
+ def add_track(self, track):
+ """Adds a Track to this Playlist."""
+
+ params = self._get_params()
+ params['artist'] = track.get_artist().get_name()
+ params['track'] = track.get_title()
+
+ self._request('playlist.addTrack', False, params)
+
+ def get_title(self):
+ """Returns the title of this playlist."""
+
+ return _extract(self._get_info_node(), "title")
+
+ def get_creation_date(self):
+ """Returns the creation date of this playlist."""
+
+ return _extract(self._get_info_node(), "date")
+
+ def get_size(self):
+ """Returns the number of tracks in this playlist."""
+
+ return _number(_extract(self._get_info_node(), "size"))
+
+ def get_description(self):
+ """Returns the description of this playlist."""
+
+ return _extract(self._get_info_node(), "description")
+
+ def get_duration(self):
+ """Returns the duration of this playlist in milliseconds."""
+
+ return _number(_extract(self._get_info_node(), "duration"))
+
+ def is_streamable(self):
+ """
+ Returns True if the playlist is streamable.
+ For a playlist to be streamable, it needs at least 45 tracks by 15
+ different artists."""
+
+ if _extract(self._get_info_node(), "streamable") == '1':
+ return True
+ else:
+ return False
+
+ def has_track(self, track):
+ """Checks to see if track is already in the playlist.
+ * track: Any Track object.
+ """
+
+ return track in self.get_tracks()
+
+ def get_cover_image(self, size=COVER_EXTRA_LARGE):
+ """
+ Returns a uri to the cover image
+ size can be one of:
+ COVER_MEGA
+ COVER_EXTRA_LARGE
+ COVER_LARGE
+ COVER_MEDIUM
+ COVER_SMALL
+ """
+
+ return _extract(self._get_info_node(), "image")[size]
+
+ def get_url(self, domain_name=DOMAIN_ENGLISH):
+ """Returns the url of the playlist 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
+ """
+
+ english_url = _extract(self._get_info_node(), "url")
+ appendix = english_url[english_url.rfind("/") + 1:]
+
+ return self.network._get_url(domain_name, "playlist") % {
+ 'appendix': appendix, "user": self.get_user().get_name()}
+
+
+class Tag(_BaseObject, _Chartable):
+ """A Last.fm object tag."""
+
+ name = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, name, network):
+ _BaseObject.__init__(self, network, 'tag')
+ _Chartable.__init__(self, 'tag')
+
+ self.name = name
+
+ def __repr__(self):
+ return "pylast.Tag(%s, %s)" % (repr(self.name), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name()
+
+ def __eq__(self, other):
+ return _casefold_equal(self.get_name(), other.get_name())
+
+ def __ne__(self, other):
+ return _casefold_not_equal(self.get_name(), other.get_name())
+
+ def _get_params(self):
+ return {self.ws_prefix: self.get_name()}
+
+ def get_name(self, properly_capitalized=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_similar(self):
+ """Returns the tags similar to this one, ordered by similarity. """
+
+ doc = self._request(self.ws_prefix + '.getSimilar', True)
+
+ seq = []
+ names = _extract_all(doc, 'name')
+ for name in names:
+ seq.append(Tag(name, self.network))
+
+ return seq
+
+ def get_top_albums(self, limit=None, cacheable=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=True):
+ """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", Track, params, cacheable)
+
+ def get_top_artists(self, limit=None, cacheable=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):
+ super(Track, self).__init__(artist, title, network, "track", username)
+
+ 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 is_streamable(self):
+ """Returns True if the track is available at Last.fm."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+ return _extract(doc, "streamable") == "1"
+
+ def is_fulltrack_available(self):
+ """Returns True if the full track is available for streaming."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+ return doc.getElementsByTagName(
+ "streamable")[0].getAttribute("fulltrack") == "1"
+
+ def get_album(self):
+ """Returns the album object of this track."""
+
+ 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):
+ """Adds the track to the user's loved tracks. """
+
+ self._request(self.ws_prefix + '.love')
+
+ def unlove(self):
+ """Remove the track to the user's loved tracks. """
+
+ self._request(self.ws_prefix + '.unlove')
+
+ def ban(self):
+ """Ban this track from ever playing on the radio. """
+
+ self._request(self.ws_prefix + '.ban')
+
+ def get_similar(self):
+ """
+ Returns similar tracks for this track on the network,
+ based on listening data.
+ """
+
+ doc = self._request(self.ws_prefix + '.getSimilar', True)
+
+ 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 Group(_BaseObject, _Chartable):
+ """A Last.fm group."""
+
+ name = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, name, network):
+ _BaseObject.__init__(self, network, 'group')
+ _Chartable.__init__(self, 'group')
+
+ self.name = name
+
+ def __repr__(self):
+ return "pylast.Group(%s, %s)" % (repr(self.name), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name()
+
+ def __eq__(self, other):
+ return _casefold_equal(self.get_name(), other.get_name())
+
+ def __ne__(self, other):
+ # TODO _casefold name?
+ return self.get_name() != other.get_name()
+
+ def _get_params(self):
+ return {self.ws_prefix: self.get_name()}
+
+ def get_name(self):
+ """Returns the group name. """
+ return self.name
+
+ def get_url(self, domain_name=DOMAIN_ENGLISH):
+ """Returns the url of the group 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, "group") % {'name': name}
+
+ def get_members(self, limit=50, cacheable=False):
+ """
+ Returns a sequence of User objects
+ if limit==None it will return all
+ """
+
+ nodes = _collect_nodes(
+ limit, self, self.ws_prefix + ".getMembers", cacheable)
+
+ users = []
+
+ for node in nodes:
+ users.append(User(_extract(node, "name"), self.network))
+
+ return users
+
+
+class XSPF(_BaseObject):
+ "A Last.fm XSPF playlist."""
+
+ uri = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, uri, network):
+ _BaseObject.__init__(self, network, None)
+
+ self.uri = uri
+
+ def _get_params(self):
+ return {'playlistURL': self.get_uri()}
+
+ @_string_output
+ def __str__(self):
+ return self.get_uri()
+
+ def __eq__(self, other):
+ return self.get_uri() == other.get_uri()
+
+ def __ne__(self, other):
+ return self.get_uri() != other.get_uri()
+
+ def get_uri(self):
+ """Returns the Last.fm playlist URI. """
+
+ return self.uri
+
+ def get_tracks(self):
+ """Returns the tracks on this playlist."""
+
+ doc = self._request('playlist.fetch', True)
+
+ seq = []
+ for node in doc.getElementsByTagName('track'):
+ title = _extract(node, 'title')
+ artist = _extract(node, 'creator')
+
+ seq.append(Track(artist, title, self.network))
+
+ return seq
+
+
+class User(_BaseObject, _Chartable):
+ """A Last.fm user."""
+
+ name = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, user_name, network):
+ _BaseObject.__init__(self, network, 'user')
+ _Chartable.__init__(self, 'user')
+
+ self.name = user_name
+
+ self._past_events_index = 0
+ self._recommended_events_index = 0
+ self._recommended_artists_index = 0
+
+ def __repr__(self):
+ return "pylast.User(%s, %s)" % (repr(self.name), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name()
+
+ def __eq__(self, another):
+ if isinstance(another, User):
+ return self.get_name() == another.get_name()
+ else:
+ return False
+
+ def __ne__(self, another):
+ if isinstance(another, User):
+ return self.get_name() != another.get_name()
+ else:
+ return True
+
+ def _get_params(self):
+ return {self.ws_prefix: self.get_name()}
+
+ def get_name(self, properly_capitalized=False):
+ """Returns the user name."""
+
+ if properly_capitalized:
+ self.name = _extract(
+ self._request(self.ws_prefix + ".getInfo", True), "name")
+
+ return self.name
+
+ def get_upcoming_events(self):
+ """Returns all the upcoming events for this user."""
+
+ doc = self._request(self.ws_prefix + '.getEvents', True)
+
+ return _extract_events_from_doc(doc, self.network)
+
+ def get_artist_tracks(self, artist, cacheable=False):
+ """
+ Get a list of tracks by a given artist scrobbled by this user,
+ including scrobble time.
+ """
+ # Not implemented:
+ # "Can be limited to specific timeranges, defaults to all time."
+
+ params = self._get_params()
+ params['artist'] = artist
+
+ seq = []
+ for track in _collect_nodes(
+ None,
+ self,
+ self.ws_prefix + ".getArtistTracks",
+ cacheable,
+ params):
+ title = _extract(track, "name")
+ artist = _extract(track, "artist")
+ date = _extract(track, "date")
+ album = _extract(track, "album")
+ timestamp = track.getElementsByTagName(
+ "date")[0].getAttribute("uts")
+
+ seq.append(PlayedTrack(
+ Track(artist, title, self.network), album, date, timestamp))
+
+ return seq
+
+ def get_friends(self, limit=50, cacheable=False):
+ """Returns a list of the user's friends. """
+
+ seq = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getFriends",
+ cacheable):
+ seq.append(User(_extract(node, "name"), self.network))
+
+ return seq
+
+ def get_loved_tracks(self, limit=50, cacheable=True):
+ """
+ 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.
+
+ This method uses caching. Enable caching only if you're pulling a
+ large amount of data.
+
+ Use extract_items() with the return of this function to
+ get only a sequence of Track objects with no playback dates.
+ """
+
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+
+ seq = []
+ for track in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getLovedTracks",
+ cacheable,
+ params):
+ title = _extract(track, "name")
+ artist = _extract(track, "name", 1)
+ date = _extract(track, "date")
+ timestamp = track.getElementsByTagName(
+ "date")[0].getAttribute("uts")
+
+ seq.append(LovedTrack(
+ Track(artist, title, self.network), date, timestamp))
+
+ return seq
+
+ def get_neighbours(self, limit=50, cacheable=True):
+ """Returns a list of the user's friends."""
+
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+
+ doc = self._request(
+ self.ws_prefix + '.getNeighbours', cacheable, params)
+
+ seq = []
+ names = _extract_all(doc, 'name')
+
+ for name in names:
+ seq.append(User(name, self.network))
+
+ return seq
+
+ def get_past_events(self, limit=50, cacheable=False):
+ """
+ Returns a sequence of Event objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for node in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getPastEvents",
+ cacheable):
+ seq.append(Event(_extract(node, "id"), self.network))
+
+ return seq
+
+ def get_playlists(self):
+ """Returns a list of Playlists that this user owns."""
+
+ doc = self._request(self.ws_prefix + ".getPlaylists", True)
+
+ playlists = []
+ for playlist_id in _extract_all(doc, "id"):
+ playlists.append(
+ Playlist(self.get_name(), playlist_id, self.network))
+
+ return playlists
+
+ 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')
+
+ return Track(artist, title, self.network, self.name)
+
+ def get_recent_tracks(self, limit=10, cacheable=True,
+ time_from=None, time_to=None):
+ """
+ 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). This
+ must be in the UTC time zone.
+ 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). This must be in
+ the UTC time zone.
+
+ This method uses caching. Enable caching only if you're pulling a
+ large amount of data.
+
+ Use extract_items() with the return of this function to
+ get only a sequence of Track objects with no playback dates.
+ """
+
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+ if time_from:
+ params['from'] = time_from
+ if time_to:
+ params['to'] = time_to
+
+ seq = []
+ for track in _collect_nodes(
+ limit,
+ self,
+ self.ws_prefix + ".getRecentTracks",
+ cacheable,
+ params):
+
+ if track.hasAttribute('nowplaying'):
+ continue # to prevent the now playing track from sneaking in
+
+ title = _extract(track, "name")
+ artist = _extract(track, "artist")
+ date = _extract(track, "date")
+ album = _extract(track, "album")
+ timestamp = track.getElementsByTagName(
+ "date")[0].getAttribute("uts")
+
+ seq.append(PlayedTrack(
+ Track(artist, title, self.network), album, date, timestamp))
+
+ return seq
+
+ def get_id(self):
+ """Returns the user ID."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ return _extract(doc, "id")
+
+ def get_language(self):
+ """Returns the language code of the language used by the user."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ return _extract(doc, "lang")
+
+ 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:
+ return None
+ else:
+ return Country(country, self.network)
+
+ def get_age(self):
+ """Returns the user's age."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ return _number(_extract(doc, "age"))
+
+ def get_gender(self):
+ """Returns the user's gender. Either USER_MALE or USER_FEMALE."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ value = _extract(doc, "gender")
+
+ if value == 'm':
+ return USER_MALE
+ elif value == 'f':
+ return USER_FEMALE
+
+ return None
+
+ 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 doc.getElementsByTagName(
+ "registered")[0].getAttribute("unixtime")
+
+ def get_tagged_albums(self, tag, limit=None, cacheable=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=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=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=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=True):
+ """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
+ if limit:
+ params['limit'] = limit
+
+ return self._get_things(
+ "getTopTracks", "track", Track, params, cacheable)
+
+ def compare_with_user(self, user, shared_artists_limit=None):
+ """
+ Compare this user with another Last.fm user.
+ Returns a sequence:
+ (tasteometer_score, (shared_artist1, shared_artist2, ...))
+ user: A User object or a username string/unicode object.
+ """
+
+ if isinstance(user, User):
+ user = user.get_name()
+
+ params = self._get_params()
+ if shared_artists_limit:
+ params['limit'] = shared_artists_limit
+ params['type1'] = 'user'
+ params['type2'] = 'user'
+ params['value1'] = self.get_name()
+ params['value2'] = user
+
+ doc = self._request('tasteometer.compare', False, params)
+
+ score = _extract(doc, 'score')
+
+ artists = doc.getElementsByTagName('artists')[0]
+ shared_artists_names = _extract_all(artists, 'name')
+
+ shared_artists_seq = []
+
+ for name in shared_artists_names:
+ shared_artists_seq.append(Artist(name, self.network))
+
+ return (score, shared_artists_seq)
+
+ def get_image(self):
+ """Returns the user's avatar."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+
+ return _extract(doc, "image")
+
+ 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)
+
+ def shout(self, message):
+ """
+ Post a shout
+ """
+
+ params = self._get_params()
+ params["message"] = message
+
+ self._request(self.ws_prefix + ".Shout", False, params)
+
+
+class AuthenticatedUser(User):
+ def __init__(self, network):
+ User.__init__(self, "", network)
+
+ def _get_params(self):
+ return {"user": self.get_name()}
+
+ def get_name(self):
+ """Returns the name of the authenticated user."""
+
+ doc = self._request("user.getInfo", True, {"user": ""}) # hack
+
+ self.name = _extract(doc, "name")
+ return self.name
+
+ def get_recommended_events(self, limit=50, cacheable=False):
+ """
+ Returns a sequence of Event objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for node in _collect_nodes(
+ limit, self, "user.getRecommendedEvents", cacheable):
+ seq.append(Event(_extract(node, "id"), self.network))
+
+ return seq
+
+ def get_recommended_artists(self, limit=50, cacheable=False):
+ """
+ Returns a sequence of Artist objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for node in _collect_nodes(
+ limit, self, "user.getRecommendedArtists", cacheable):
+ seq.append(Artist(_extract(node, "name"), self.network))
+
+ return seq
+
+
+class _Search(_BaseObject):
+ """An abstract class. Use one of its derivatives."""
+
+ def __init__(self, ws_prefix, search_terms, network):
+ _BaseObject.__init__(self, 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, "opensearch: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):
+
+ _Search.__init__(self, "album", {"album": album_name}, 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))
+
+ return seq
+
+
+class ArtistSearch(_Search):
+ """Search for an artist by artist name."""
+
+ def __init__(self, artist_name, network):
+ _Search.__init__(self, "artist", {"artist": artist_name}, 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)
+ artist.listener_count = _number(_extract(node, "listeners"))
+ seq.append(artist)
+
+ return seq
+
+
+class TagSearch(_Search):
+ """Search for a tag by tag name."""
+
+ def __init__(self, tag_name, network):
+
+ _Search.__init__(self, "tag", {"tag": tag_name}, network)
+
+ def get_next_page(self):
+ """Returns the next page of results as a sequence of Tag objects."""
+
+ master_node = self._retrieve_next_page()
+
+ seq = []
+ for node in master_node.getElementsByTagName("tag"):
+ tag = Tag(_extract(node, "name"), self.network)
+ tag.tag_count = _number(_extract(node, "count"))
+ seq.append(tag)
+
+ 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):
+
+ _Search.__init__(
+ self,
+ "track",
+ {"track": track_title, "artist": artist_name},
+ 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)
+ track.listener_count = _number(_extract(node, "listeners"))
+ seq.append(track)
+
+ return seq
+
+
+class VenueSearch(_Search):
+ """
+ Search for a venue by its name. If you don't want to narrow the results
+ down by specifying a country, set it to empty string.
+ """
+
+ def __init__(self, venue_name, country_name, network):
+
+ _Search.__init__(
+ self,
+ "venue",
+ {"venue": venue_name, "country": country_name},
+ 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("venue"):
+ seq.append(Venue(_extract(node, "id"), self.network))
+
+ return seq
+
+
+class Venue(_BaseObject):
+ """A venue where events are held."""
+
+ # TODO: waiting for a venue.getInfo web service to use.
+ # TODO: As an intermediate use case, can pass the venue DOM element when
+ # using Event.get_venue() to populate the venue info, if the venue.getInfo
+ # API call becomes available this workaround should be removed
+
+ id = None
+ info = None
+ name = None
+ location = None
+ url = None
+
+ __hash__ = _BaseObject.__hash__
+
+ def __init__(self, netword_id, network, venue_element=None):
+ _BaseObject.__init__(self, network, "venue")
+
+ self.id = _number(netword_id)
+ if venue_element is not None:
+ self.info = _extract_element_tree(venue_element)
+ self.name = self.info.get('name')
+ self.url = self.info.get('url')
+ self.location = self.info.get('location')
+
+ def __repr__(self):
+ return "pylast.Venue(%s, %s)" % (repr(self.id), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return "Venue #" + str(self.id)
+
+ def __eq__(self, other):
+ return self.get_id() == other.get_id()
+
+ def _get_params(self):
+ return {self.ws_prefix: self.get_id()}
+
+ def get_id(self):
+ """Returns the id of the venue."""
+
+ return self.id
+
+ def get_name(self):
+ """Returns the name of the venue."""
+
+ return self.name
+
+ def get_url(self):
+ """Returns the URL of the venue page."""
+
+ return self.url
+
+ def get_location(self):
+ """Returns the location of the venue (dictionary)."""
+
+ return self.location
+
+ def get_upcoming_events(self):
+ """Returns the upcoming events in this venue."""
+
+ doc = self._request(self.ws_prefix + ".getEvents", True)
+
+ return _extract_events_from_doc(doc, self.network)
+
+ def get_past_events(self):
+ """Returns the past events held in this venue."""
+
+ doc = self._request(self.ws_prefix + ".getEvents", True)
+
+ return _extract_events_from_doc(doc, self.network)
+
+
+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, six.binary_type):
+ return six.text_type(text, "utf-8")
+ elif isinstance(text, six.text_type):
+ return text
+ else:
+ return six.text_type(text)
+
+
+def _string(string):
+ """For Python2 routines that can only process str type."""
+ if isinstance(string, str):
+ return string
+ casted = six.text_type(string)
+ if sys.version_info[0] == 2:
+ casted = casted.encode("utf-8")
+ return casted
+
+
+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):
+ """
+ Returns a sequence of dom.Node objects about as close to limit as possible
+ """
+
+ if not params:
+ params = sender._get_params()
+
+ nodes = []
+ page = 1
+ end_of_pages = False
+
+ while not end_of_pages and (not limit or (limit and len(nodes) < limit)):
+ params["page"] = str(page)
+ doc = sender._request(method_name, cacheable, params)
+ doc = cleanup_nodes(doc)
+
+ main = doc.documentElement.childNodes[0]
+
+ if main.hasAttribute("totalPages"):
+ total_pages = _number(main.getAttribute("totalPages"))
+ elif main.hasAttribute("totalpages"):
+ total_pages = _number(main.getAttribute("totalpages"))
+ else:
+ raise Exception("No total pages attribute")
+
+ for node in main.childNodes:
+ if not node.nodeType == xml.dom.Node.TEXT_NODE and (
+ not limit or (len(nodes) < limit)):
+ nodes.append(node)
+
+ if page >= total_pages:
+ end_of_pages = True
+
+ page += 1
+
+ return nodes
+
+
+def _extract(node, name, index=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_element_tree(node):
+ """Extract an element tree into a multi-level dictionary
+
+ NB: If any elements have text nodes as well as nested
+ elements this will ignore the text nodes"""
+
+ def _recurse_build_tree(rootNode, targetDict):
+ """Recursively build a multi-level dict"""
+
+ def _has_child_elements(rootNode):
+ """Check if an element has any nested (child) elements"""
+
+ for node in rootNode.childNodes:
+ if node.nodeType == node.ELEMENT_NODE:
+ return True
+ return False
+
+ for node in rootNode.childNodes:
+ if node.nodeType == node.ELEMENT_NODE:
+ if _has_child_elements(node):
+ targetDict[node.tagName] = {}
+ _recurse_build_tree(node, targetDict[node.tagName])
+ else:
+ val = None if node.firstChild is None else \
+ _unescape_htmlentity(node.firstChild.data.strip())
+ targetDict[node.tagName] = val
+ return targetDict
+
+ return _recurse_build_tree(node, {})
+
+
+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")
+
+ seq.append(TopItem(Album(artist, name, network), 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 _extract_events_from_doc(doc, network):
+ events = []
+ for node in doc.getElementsByTagName("event"):
+ events.append(Event(_extract(node, "id"), network))
+ return events
+
+
+def _url_safe(text):
+ """Does all kinds of tricks on a text to make it safe to use in a url."""
+
+ return _casefold(url_quote_plus(url_quote_plus(_string(text))))
+
+
+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
+ elif string == "":
+ return 0
+ else:
+ try:
+ return int(string)
+ except ValueError:
+ return float(string)
+
+
+def _unescape_htmlentity(string):
+
+ # string = _unicode(string)
+
+ mapping = htmlentitydefs.name2codepoint
+ for key in mapping:
+ string = string.replace("&%s;" % key, unichr(mapping[key]))
+
+ return string
+
+
+def _casefold(s):
+ """Casefold or lower text for caseless comparison"""
+ try:
+ # casefold new in Python 3.3
+ return s._casefold()
+ except AttributeError:
+ return s.lower()
+
+
+def _casefold_equal(a, b):
+ """Casefolded comparison"""
+ return _casefold(a) == _casefold(b)
+
+
+def _casefold_not_equal(a, b):
+ """Casefolded comparison"""
+ return _casefold(a) != _casefold(b)
+
+
+def extract_items(top_items_or_library_items):
+ """
+ Extracts a sequence of items from a sequence of TopItem or
+ LibraryItem objects.
+ """
+
+ seq = []
+ for i in top_items_or_library_items:
+ seq.append(i.item)
+
+ return seq
+
+
+class ScrobblingError(Exception):
+ def __init__(self, message):
+ Exception.__init__(self)
+ self.message = message
+
+ @_string_output
+ def __str__(self):
+ return self.message
+
+
+class BannedClientError(ScrobblingError):
+ def __init__(self):
+ ScrobblingError.__init__(
+ self, "This version of the client has been banned")
+
+
+class BadAuthenticationError(ScrobblingError):
+ def __init__(self):
+ ScrobblingError.__init__(self, "Bad authentication token")
+
+
+class BadTimeError(ScrobblingError):
+ def __init__(self):
+ ScrobblingError.__init__(
+ self, "Time provided is not close enough to current time")
+
+
+class BadSessionError(ScrobblingError):
+ def __init__(self):
+ ScrobblingError.__init__(
+ self, "Bad session id, consider re-handshaking")
+
+
+class _ScrobblerRequest(object):
+
+ def __init__(self, url, params, network, request_type="POST"):
+
+ for key in params:
+ params[key] = str(params[key])
+
+ self.params = params
+ self.type = request_type
+ (self.hostname, self.subdir) = url_split_host(url[len("http:"):])
+ self.network = network
+
+ def execute(self):
+ """Returns a string response of this request."""
+
+ connection = HTTPSConnection(context=SSL_CONTEXT, host=self.hostname)
+
+ data = []
+ for name in self.params.keys():
+ value = url_quote_plus(self.params[name])
+ data.append('='.join((name, value)))
+ data = "&".join(data)
+
+ headers = {
+ "Content-type": "application/x-www-form-urlencoded",
+ "Accept-Charset": "utf-8",
+ "User-Agent": "pylast" + "/" + __version__,
+ "HOST": self.hostname
+ }
+
+ if self.type == "GET":
+ connection.request(
+ "GET", self.subdir + "?" + data, headers=headers)
+ else:
+ connection.request("POST", self.subdir, data, headers)
+ response = _unicode(connection.getresponse().read())
+
+ self._check_response_for_errors(response)
+
+ return response
+
+ def _check_response_for_errors(self, response):
+ """
+ When passed a string response it checks for errors, raising any
+ exceptions as necessary.
+ """
+
+ lines = response.split("\n")
+ status_line = lines[0]
+
+ if status_line == "OK":
+ return
+ elif status_line == "BANNED":
+ raise BannedClientError()
+ elif status_line == "BADAUTH":
+ raise BadAuthenticationError()
+ elif status_line == "BADTIME":
+ raise BadTimeError()
+ elif status_line == "BADSESSION":
+ raise BadSessionError()
+ elif status_line.startswith("FAILED "):
+ reason = status_line[status_line.find("FAILED ") + len("FAILED "):]
+ raise ScrobblingError(reason)
+
+
+# End of file
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 0586bb1..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,97 +0,0 @@
-[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",
-]
-optional-dependencies.tests = [
- "flaky",
- "pytest",
- "pytest-cov",
- "pytest-random-order",
- "pyyaml",
-]
-urls.Changelog = "https://github.com/pylast/pylast/releases"
-urls.Homepage = "https://github.com/pylast/pylast"
-urls.Source = "https://github.com/pylast/pylast"
-
-[tool.hatch]
-version.source = "vcs"
-
-[tool.hatch.version.raw-options]
-local_scheme = "no-local-version"
-
-[tool.ruff]
-fix = true
-
-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
- "RUF022", # unsorted-dunder-all
- "RUF100", # unused noqa (yesqa)
- "UP", # pyupgrade
- "W", # pycodestyle warnings
- "YTT", # flake8-2020
-]
-lint.extend-ignore = [
- "E203", # Whitespace before ':'
- "E221", # Multiple spaces before operator
- "E226", # Missing whitespace around arithmetic operator
- "E241", # Multiple spaces after ','
-]
-lint.isort.known-first-party = [
- "pylast",
-]
-lint.isort.required-imports = [
- "from __future__ import annotations",
-]
-
-[tool.pyproject-fmt]
-max_supported_python = "3.13"
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 3f83bd3..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,6 +0,0 @@
-[pytest]
-filterwarnings =
- once::DeprecationWarning
- once::PendingDeprecationWarning
-
-xfail_strict=true
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..6d21e2a
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python
+from setuptools import setup, find_packages
+
+
+setup(
+ name="pylast",
+ version="1.9.0",
+ author="Amr Hassan ",
+ install_requires=['six'],
+ tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'],
+ description=("A Python interface to Last.fm and Libre.fm"),
+ author_email="amr.hassan@gmail.com",
+ url="https://github.com/pylast/pylast",
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "License :: OSI Approved :: Apache Software License",
+ "Topic :: Internet",
+ "Topic :: Multimedia :: Sound/Audio",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Programming Language :: Python :: 2",
+ "Programming Language :: Python :: 2.7",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.4",
+ "Programming Language :: Python :: 3.5",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ ],
+ keywords=["Last.fm", "music", "scrobble", "scrobbling"],
+ packages=find_packages(exclude=('tests*',)),
+ license="Apache2"
+)
+
+# End of file
diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py
deleted file mode 100644
index bd856bc..0000000
--- a/src/pylast/__init__.py
+++ /dev/null
@@ -1,2921 +0,0 @@
-#
-# pylast -
-# A Python interface to Last.fm and music.lonestar.it
-#
-# 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 music.lonestar.it
-
- 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="music.lonestar.it",
- homepage="https://music.lonestar.it",
- ws_server=("music.lonestar.it", "/2.0/"),
- api_key=api_key,
- api_secret=api_secret,
- session_key=session_key,
- username=username,
- password_hash=password_hash,
- domain_names={
- DOMAIN_ENGLISH: "music.lonestar.it",
- DOMAIN_GERMAN: "music.lonestar.it",
- DOMAIN_SPANISH: "music.lonestar.it",
- DOMAIN_FRENCH: "music.lonestar.it",
- DOMAIN_ITALIAN: "music.lonestar.it",
- DOMAIN_POLISH: "music.lonestar.it",
- DOMAIN_PORTUGUESE: "music.lonestar.it",
- DOMAIN_SWEDISH: "music.lonestar.it",
- DOMAIN_TURKISH: "music.lonestar.it",
- DOMAIN_RUSSIAN: "music.lonestar.it",
- DOMAIN_JAPANESE: "music.lonestar.it",
- DOMAIN_CHINESE: "music.lonestar.it",
- },
- 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
diff --git a/tests/test_album.py b/tests/test_album.py
deleted file mode 100755
index 1146f12..0000000
--- a/tests/test_album.py
+++ /dev/null
@@ -1,120 +0,0 @@
-#!/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
diff --git a/tests/test_artist.py b/tests/test_artist.py
deleted file mode 100755
index d4f9134..0000000
--- a/tests/test_artist.py
+++ /dev/null
@@ -1,278 +0,0 @@
-#!/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
diff --git a/tests/test_country.py b/tests/test_country.py
deleted file mode 100755
index 1636b96..0000000
--- a/tests/test_country.py
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/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"
diff --git a/tests/test_library.py b/tests/test_library.py
deleted file mode 100755
index 592436d..0000000
--- a/tests/test_library.py
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/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
diff --git a/tests/test_librefm.py b/tests/test_librefm.py
deleted file mode 100755
index 0d9e839..0000000
--- a/tests/test_librefm.py
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/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(")
diff --git a/tests/test_network.py b/tests/test_network.py
deleted file mode 100755
index 05672d6..0000000
--- a/tests/test_network.py
+++ /dev/null
@@ -1,420 +0,0 @@
-"""
-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
diff --git a/tests/test_pylast.py b/tests/test_pylast.py
index c06a9c3..d6fe823 100755
--- a/tests/test_pylast.py
+++ b/tests/test_pylast.py
@@ -2,71 +2,392 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
-import os
-import time
-
-import pytest
from flaky import flaky
+import os
+import pytest
+from random import choice
+import time
+import unittest
import pylast
-WRITE_TEST = False
-
-def load_secrets(): # pragma: no cover
+def load_secrets():
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
+ with open(secrets_file, "r") 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()
+ 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
+def handle_lastfm_exceptions(f):
+ """Skip exceptions caused by Last.fm's broken API"""
+ def wrapper(*args, **kw):
+ try:
+ return f(*args, **kw)
+ except pylast.WSError as e:
+ if (str(e) == "Invalid Method - "
+ "No method with that name in this package"):
+ msg = "Ignore broken Last.fm API: " + str(e)
+ print(msg)
+ pytest.skip(msg)
+ else:
+ raise(e)
+ return wrapper
-@flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter)
-class TestPyLastWithLastFm:
+@flaky(max_runs=5, min_passes=1)
+class TestPyLast(unittest.TestCase):
+
secrets = None
- @staticmethod
- def unix_timestamp() -> int:
+ def unix_timestamp(self):
return int(time.time())
- @classmethod
- def setup_class(cls) -> None:
- if cls.secrets is None:
- cls.secrets = load_secrets()
+ def setUp(self):
+ if self.__class__.secrets is None:
+ self.__class__.secrets = load_secrets()
- cls.username = cls.secrets["username"]
- password_hash = cls.secrets["password_hash"]
+ self.username = self.__class__.secrets["username"]
+ password_hash = self.__class__.secrets["password_hash"]
- api_key = cls.secrets["api_key"]
- api_secret = cls.secrets["api_secret"]
+ API_KEY = self.__class__.secrets["api_key"]
+ API_SECRET = self.__class__.secrets["api_secret"]
- cls.network = pylast.LastFMNetwork(
- api_key=api_key,
- api_secret=api_secret,
- username=cls.username,
- password_hash=password_hash,
- )
+ self.network = pylast.LastFMNetwork(
+ api_key=API_KEY, api_secret=API_SECRET,
+ username=self.username, password_hash=password_hash)
- @staticmethod
- def helper_is_thing_hashable(thing) -> None:
+ def skip_if_lastfm_api_broken(self, value):
+ """Skip things not yet restored in Last.fm's broken API"""
+ if value is None or len(value) == 0:
+ pytest.skip("Last.fm API is broken.")
+
+ @handle_lastfm_exceptions
+ def test_scrobble(self):
+ # 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=title, timestamp=timestamp)
+
+ # Assert
+ # limit=2 to ignore now-playing:
+ last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0]
+ self.assertEqual(str(last_scrobble.track.artist), str(artist))
+ self.assertEqual(str(last_scrobble.track.title), str(title))
+ self.assertEqual(str(last_scrobble.timestamp), str(timestamp))
+
+ @handle_lastfm_exceptions
+ def test_unscrobble(self):
+ # Arrange
+ artist = "Test Artist 2"
+ title = "Test Title 2"
+ timestamp = self.unix_timestamp()
+ library = pylast.Library(user=self.username, network=self.network)
+ self.network.scrobble(artist=artist, title=title, timestamp=timestamp)
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ library.remove_scrobble(
+ artist=artist, title=title, timestamp=timestamp)
+
+ # Assert
+ # limit=2 to ignore now-playing:
+ last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0]
+ self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp))
+
+ @handle_lastfm_exceptions
+ def test_add_album(self):
+ # Arrange
+ library = pylast.Library(user=self.username, network=self.network)
+ album = self.network.get_album("Test Artist", "Test Album")
+
+ # Act
+ library.add_album(album)
+
+ # Assert
+ my_albums = library.get_albums()
+ for my_album in my_albums:
+ value = (album == my_album[0])
+ if value:
+ break
+ self.assertTrue(value)
+
+ @handle_lastfm_exceptions
+ def test_remove_album(self):
+ # Arrange
+ library = pylast.Library(user=self.username, network=self.network)
+ # Pick an artist with plenty of albums
+ artist = self.network.get_top_artists(limit=1)[0].item
+ albums = artist.get_top_albums()
+ # Pick a random one to avoid problems running concurrent tests
+ album = choice(albums)[0]
+ library.add_album(album)
+
+ # Act
+ library.remove_album(album)
+
+ # Assert
+ my_albums = library.get_albums()
+ for my_album in my_albums:
+ value = (album == my_album[0])
+ if value:
+ break
+ self.assertFalse(value)
+
+ @handle_lastfm_exceptions
+ def test_add_artist(self):
+ # Arrange
+ artist = "Test Artist 2"
+ library = pylast.Library(user=self.username, network=self.network)
+
+ # Act
+ library.add_artist(artist)
+
+ # Assert
+ artists = library.get_artists()
+ for artist in artists:
+ value = (str(artist[0]) == "Test Artist 2")
+ if value:
+ break
+ self.assertTrue(value)
+
+ @handle_lastfm_exceptions
+ def test_remove_artist(self):
+ # Arrange
+ # Get plenty of artists
+ artists = self.network.get_top_artists()
+ # Pick a random one to avoid problems running concurrent tests
+ my_artist = choice(artists).item
+ library = pylast.Library(user=self.username, network=self.network)
+ library.add_artist(my_artist)
+
+ # Act
+ library.remove_artist(my_artist)
+
+ # Assert
+ artists = library.get_artists()
+ for artist in artists:
+ value = (artist[0] == my_artist)
+ if value:
+ break
+ self.assertFalse(value)
+
+ @handle_lastfm_exceptions
+ def test_get_venue(self):
+ # Arrange
+ venue_name = "Last.fm Office"
+ country_name = "United Kingdom"
+
+ # Act
+ venue_search = self.network.search_for_venue(venue_name, country_name)
+ venue = venue_search.get_next_page()[0]
+
+ # Assert
+ self.assertEqual(str(venue.id), "8778225")
+
+ @handle_lastfm_exceptions
+ def test_get_user_registration(self):
+ # Arrange
+ username = "RJ"
+ user = self.network.get_user(username)
+
+ # Act
+ registered = user.get_registered()
+
+ # Assert
+ # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp
+ if int(registered):
+ pytest.skip("Last.fm API is broken.")
+
+ # Just check date because of timezones
+ self.assertIn(u"2002-11-20 ", registered)
+
+ @handle_lastfm_exceptions
+ def test_get_user_unixtime_registration(self):
+ # Arrange
+ username = "RJ"
+ user = self.network.get_user(username)
+
+ # Act
+ unixtime_registered = user.get_unixtime_registered()
+
+ # Assert
+ # Just check date because of timezones
+ self.assertEqual(unixtime_registered, u"1037793040")
+
+ @handle_lastfm_exceptions
+ def test_get_genderless_user(self):
+ # Arrange
+ # Currently test_user has no gender set:
+ lastfm_user = self.network.get_user("test_user")
+
+ # Act
+ gender = lastfm_user.get_gender()
+
+ # Assert
+ self.assertIsNone(gender)
+
+ @handle_lastfm_exceptions
+ def test_get_countryless_user(self):
+ # Arrange
+ # Currently test_user has no country set:
+ lastfm_user = self.network.get_user("test_user")
+
+ # Act
+ country = lastfm_user.get_country()
+
+ # Assert
+ self.assertIsNone(country)
+
+ @handle_lastfm_exceptions
+ def test_love(self):
+ # 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 = lastfm_user.get_loved_tracks(limit=1)
+ self.assertEqual(str(loved[0].track.artist), "Test Artist")
+ self.assertEqual(str(loved[0].track.title), "test title")
+
+ @handle_lastfm_exceptions
+ def test_unlove(self):
+ # 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()
+
+ # Assert
+ loved = lastfm_user.get_loved_tracks(limit=1)
+ if len(loved): # OK to be empty but if not:
+ self.assertNotEqual(str(loved.track.artist), "Test Artist")
+ self.assertNotEqual(str(loved.track.title), "test title")
+
+ @handle_lastfm_exceptions
+ def test_get_100_albums(self):
+ # Arrange
+ library = pylast.Library(user=self.username, network=self.network)
+
+ # Act
+ albums = library.get_albums(limit=100)
+
+ # Assert
+ self.assertGreaterEqual(len(albums), 0)
+
+ @handle_lastfm_exceptions
+ def test_get_limitless_albums(self):
+ # Arrange
+ library = pylast.Library(user=self.username, network=self.network)
+
+ # Act
+ albums = library.get_albums(limit=None)
+
+ # Assert
+ self.assertGreaterEqual(len(albums), 0)
+
+ @handle_lastfm_exceptions
+ def test_user_equals_none(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ value = (lastfm_user is None)
+
+ # Assert
+ self.assertFalse(value)
+
+ @handle_lastfm_exceptions
+ def test_user_not_equal_to_none(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ value = (lastfm_user is not None)
+
+ # Assert
+ self.assertTrue(value)
+
+ @handle_lastfm_exceptions
+ def test_now_playing_user_with_no_scrobbles(self):
+ # Arrange
+ # Currently test-account has no scrobbles:
+ user = self.network.get_user('test-account')
+
+ # Act
+ current_track = user.get_now_playing()
+
+ # Assert
+ self.assertIsNone(current_track)
+
+ @handle_lastfm_exceptions
+ def test_love_limits(self):
+ # Arrange
+ # Currently test-account has at least 23 loved tracks:
+ user = self.network.get_user("test-user")
+
+ # Act/Assert
+ self.assertEqual(len(user.get_loved_tracks(limit=20)), 20)
+ self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100)
+ self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23)
+ self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23)
+
+ @handle_lastfm_exceptions
+ def test_update_now_playing(self):
+ # 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()
+ self.assertIsNotNone(current_track)
+ self.assertEqual(str(current_track.title), "test title")
+ self.assertEqual(str(current_track.artist), "Test Artist")
+
+ @handle_lastfm_exceptions
+ def test_album_tags_are_topitems(self):
+ # Arrange
+ albums = self.network.get_user('RJ').get_top_albums()
+
+ # Act
+ tags = albums[0].item.get_top_tags(limit=1)
+
+ # Assert
+ self.assertGreater(len(tags), 0)
+ self.assertIsInstance(tags[0], pylast.TopItem)
+
+ def helper_is_thing_hashable(self, thing):
# Arrange
things = set()
@@ -74,22 +395,448 @@ class TestPyLastWithLastFm:
things.add(thing)
# Assert
- assert thing is not None
- assert len(things) == 1
+ self.assertIsNotNone(thing)
+ self.assertEqual(len(things), 1)
+
+ @handle_lastfm_exceptions
+ def test_album_is_hashable(self):
+ # Arrange
+ album = self.network.get_album("Test Artist", "Test Album")
+
+ # Act/Assert
+ self.helper_is_thing_hashable(album)
+
+ @handle_lastfm_exceptions
+ def test_artist_is_hashable(self):
+ # Arrange
+ test_artist = self.network.get_artist("Test Artist")
+ artist = test_artist.get_similar(limit=2)[0].item
+ self.assertIsInstance(artist, pylast.Artist)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(artist)
+
+ @handle_lastfm_exceptions
+ def test_country_is_hashable(self):
+ # Arrange
+ country = self.network.get_country("Italy")
+
+ # Act/Assert
+ self.helper_is_thing_hashable(country)
+
+ @handle_lastfm_exceptions
+ def test_metro_is_hashable(self):
+ # Arrange
+ metro = self.network.get_metro("Helsinki", "Finland")
+
+ # Act/Assert
+ self.helper_is_thing_hashable(metro)
+
+ @handle_lastfm_exceptions
+ def test_event_is_hashable(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+ event = user.get_past_events(limit=1)[0]
+
+ # Act/Assert
+ self.helper_is_thing_hashable(event)
+
+ @handle_lastfm_exceptions
+ def test_group_is_hashable(self):
+ # Arrange
+ group = self.network.get_group("Audioscrobbler Beta")
+
+ # Act/Assert
+ self.helper_is_thing_hashable(group)
+
+ @handle_lastfm_exceptions
+ def test_library_is_hashable(self):
+ # Arrange
+ library = pylast.Library(user=self.username, network=self.network)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(library)
+
+ @handle_lastfm_exceptions
+ def test_playlist_is_hashable(self):
+ # Arrange
+ playlist = pylast.Playlist(
+ user="RJ", playlist_id="1k1qp_doglist", network=self.network)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(playlist)
+
+ @handle_lastfm_exceptions
+ def test_tag_is_hashable(self):
+ # Arrange
+ tag = self.network.get_top_tags(limit=1)[0]
+
+ # Act/Assert
+ self.helper_is_thing_hashable(tag)
+
+ @handle_lastfm_exceptions
+ def test_track_is_hashable(self):
+ # Arrange
+ artist = self.network.get_artist("Test Artist")
+ track = artist.get_top_tracks()[0].item
+ self.assertIsInstance(track, pylast.Track)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(track)
+
+ @handle_lastfm_exceptions
+ def test_user_is_hashable(self):
+ # Arrange
+ artist = self.network.get_artist("Test Artist")
+ user = artist.get_top_fans(limit=1)[0].item
+ self.assertIsInstance(user, pylast.User)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(user)
+
+ @handle_lastfm_exceptions
+ def test_venue_is_hashable(self):
+ # Arrange
+ venue_id = "8778225" # Last.fm office
+ venue = pylast.Venue(venue_id, self.network)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(venue)
+
+ @handle_lastfm_exceptions
+ def test_xspf_is_hashable(self):
+ # Arrange
+ xspf = pylast.XSPF(
+ uri="lastfm://playlist/1k1qp_doglist", network=self.network)
+
+ # Act/Assert
+ self.helper_is_thing_hashable(xspf)
+
+ @handle_lastfm_exceptions
+ def test_invalid_xml(self):
+ # Arrange
+ # Currently causes PCDATA invalid Char value 25
+ artist = "Blind Willie Johnson"
+ title = "It's nobody's fault but mine"
+
+ # Act
+ search = self.network.search_for_track(artist, title)
+ total = search.get_total_result_count()
- @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
+ self.skip_if_lastfm_api_broken(total)
+ self.assertGreaterEqual(int(total), 0)
- def helper_validate_cacheable(self, thing, function_name) -> None:
+ @handle_lastfm_exceptions
+ def test_user_play_count_in_track_info(self):
+ # 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
+ self.assertGreaterEqual(count, 0)
+
+ @handle_lastfm_exceptions
+ def test_user_loved_in_track_info(self):
+ # 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
+ self.assertIsNotNone(loved)
+ self.assertIsInstance(loved, bool)
+ self.assertNotIsInstance(loved, str)
+
+ @handle_lastfm_exceptions
+ def test_album_in_recent_tracks(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ # limit=2 to ignore now-playing:
+ track = lastfm_user.get_recent_tracks(limit=2)[0]
+
+ # Assert
+ self.assertTrue(hasattr(track, 'album'))
+
+ @handle_lastfm_exceptions
+ def test_album_in_artist_tracks(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ track = lastfm_user.get_artist_tracks(artist="Test Artist")[0]
+
+ # Assert
+ self.assertTrue(hasattr(track, 'album'))
+
+ @handle_lastfm_exceptions
+ def test_enable_rate_limiting(self):
+ # Arrange
+ self.assertFalse(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_user(self.username)
+ # Make a second network call, limiting should be applied
+ self.network.get_top_artists()
+ now = time.time()
+
+ # Assert
+ self.assertTrue(self.network.is_rate_limited())
+ self.assertGreaterEqual(now - then, 0.2)
+
+ @handle_lastfm_exceptions
+ def test_disable_rate_limiting(self):
+ # Arrange
+ self.network.enable_rate_limit()
+ self.assertTrue(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
+ self.assertFalse(self.network.is_rate_limited())
+
+ # 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 helper_past_events_have_valid_ids(self, thing):
+ # Act
+ events = thing.get_past_events()
+
+ # Assert
+ self.helper_assert_events_have_valid_ids(events)
+
+ def helper_upcoming_events_have_valid_ids(self, thing):
+ # Act
+ events = thing.get_upcoming_events()
+
+ # Assert
+ self.helper_assert_events_have_valid_ids(events)
+
+ def helper_assert_events_have_valid_ids(self, events):
+ # Assert
+ # If fails, add past/future event for user/Test Artist:
+ self.assertGreaterEqual(len(events), 1)
+ for event in events[:2]: # checking first two should be enough
+ self.assertIsInstance(event.get_headliner(), pylast.Artist)
+
+ @handle_lastfm_exceptions
+ def test_artist_upcoming_events_returns_valid_ids(self):
+ # Arrange
+ artist = pylast.Artist("Test Artist", self.network)
+
+ # Act/Assert
+ self.helper_upcoming_events_have_valid_ids(artist)
+
+ @handle_lastfm_exceptions
+ def test_user_past_events_returns_valid_ids(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act/Assert
+ self.helper_past_events_have_valid_ids(lastfm_user)
+
+ @handle_lastfm_exceptions
+ def test_user_recommended_events_returns_valid_ids(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ events = lastfm_user.get_upcoming_events()
+
+ # Assert
+ self.helper_assert_events_have_valid_ids(events)
+
+ @handle_lastfm_exceptions
+ def test_user_upcoming_events_returns_valid_ids(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act/Assert
+ self.helper_upcoming_events_have_valid_ids(lastfm_user)
+
+ @handle_lastfm_exceptions
+ def test_venue_past_events_returns_valid_ids(self):
+ # Arrange
+ venue_id = "8778225" # Last.fm office
+ venue = pylast.Venue(venue_id, self.network)
+
+ # Act/Assert
+ self.helper_past_events_have_valid_ids(venue)
+
+ @handle_lastfm_exceptions
+ def test_venue_upcoming_events_returns_valid_ids(self):
+ # Arrange
+ venue_id = "8778225" # Last.fm office
+ venue = pylast.Venue(venue_id, self.network)
+
+ # Act/Assert
+ self.helper_upcoming_events_have_valid_ids(venue)
+
+ @handle_lastfm_exceptions
+ def test_pickle(self):
+ # 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
+ self.assertEqual(lastfm_user, loaded_user)
+
+ @handle_lastfm_exceptions
+ def test_bio_published_date(self):
+ # Arrange
+ artist = pylast.Artist("Test Artist", self.network)
+
+ # Act
+ bio = artist.get_bio_published_date()
+
+ # Assert
+ self.assertIsNotNone(bio)
+ self.assertGreaterEqual(len(bio), 1)
+
+ @handle_lastfm_exceptions
+ def test_bio_content(self):
+ # Arrange
+ artist = pylast.Artist("Test Artist", self.network)
+
+ # Act
+ bio = artist.get_bio_content(language="en")
+
+ # Assert
+ self.assertIsNotNone(bio)
+ self.assertGreaterEqual(len(bio), 1)
+
+ @handle_lastfm_exceptions
+ def test_bio_summary(self):
+ # Arrange
+ artist = pylast.Artist("Test Artist", self.network)
+
+ # Act
+ bio = artist.get_bio_summary(language="en")
+
+ # Assert
+ self.assertIsNotNone(bio)
+ self.assertGreaterEqual(len(bio), 1)
+
+ @handle_lastfm_exceptions
+ def test_album_wiki_content(self):
+ # Arrange
+ album = pylast.Album("Test Artist", "Test Album", self.network)
+
+ # Act
+ wiki = album.get_wiki_content()
+
+ # Assert
+ self.assertIsNotNone(wiki)
+ self.assertGreaterEqual(len(wiki), 1)
+
+ @handle_lastfm_exceptions
+ def test_album_wiki_published_date(self):
+ # Arrange
+ album = pylast.Album("Test Artist", "Test Album", self.network)
+
+ # Act
+ wiki = album.get_wiki_published_date()
+
+ # Assert
+ self.assertIsNotNone(wiki)
+ self.assertGreaterEqual(len(wiki), 1)
+
+ @handle_lastfm_exceptions
+ def test_album_wiki_summary(self):
+ # Arrange
+ album = pylast.Album("Test Artist", "Test Album", self.network)
+
+ # Act
+ wiki = album.get_wiki_summary()
+
+ # Assert
+ self.assertIsNotNone(wiki)
+ self.assertGreaterEqual(len(wiki), 1)
+
+ @handle_lastfm_exceptions
+ def test_track_wiki_content(self):
+ # Arrange
+ track = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act
+ wiki = track.get_wiki_content()
+
+ # Assert
+ self.assertIsNotNone(wiki)
+ self.assertGreaterEqual(len(wiki), 1)
+
+ @handle_lastfm_exceptions
+ def test_track_wiki_summary(self):
+ # Arrange
+ track = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act
+ wiki = track.get_wiki_summary()
+
+ # Assert
+ self.assertIsNotNone(wiki)
+ self.assertGreaterEqual(len(wiki), 1)
+
+ @handle_lastfm_exceptions
+ def test_lastfm_network_name(self):
+ # Act
+ name = str(self.network)
+
+ # Assert
+ self.assertEqual(name, "Last.fm Network")
+
+ def helper_validate_results(self, a, b, c):
+ # Assert
+ self.assertIsNotNone(a)
+ self.assertIsNotNone(b)
+ self.assertIsNotNone(c)
+ self.assertGreaterEqual(len(a), 0)
+ self.assertGreaterEqual(len(b), 0)
+ self.assertGreaterEqual(len(c), 0)
+ self.assertEqual(a, b)
+ self.assertEqual(b, c)
+
+ def helper_validate_cacheable(self, thing, function_name):
# Arrange
# get thing.function_name()
func = getattr(thing, function_name, None)
@@ -97,42 +844,1359 @@ class TestPyLastWithLastFm:
# Act
result1 = func(limit=1, cacheable=False)
result2 = func(limit=1, cacheable=True)
- result3 = list(func(limit=1))
+ result3 = 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)
+ @handle_lastfm_exceptions
+ def test_cacheable_artist_get_shouts(self):
+ # Arrange
+ artist = self.network.get_artist("Test Artist")
- @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)
+ # Act/Assert
+ self.helper_validate_cacheable(artist, "get_shouts")
- @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)
+ @handle_lastfm_exceptions
+ def test_cacheable_event_get_shouts(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+ event = user.get_past_events(limit=1)[0]
+
+ # Act/Assert
+ self.helper_validate_cacheable(event, "get_shouts")
+
+ @handle_lastfm_exceptions
+ def test_cacheable_track_get_shouts(self):
+ # Arrange
+ track = self.network.get_top_tracks()[0].item
+
+ # Act/Assert
+ self.helper_validate_cacheable(track, "get_shouts")
+
+ @handle_lastfm_exceptions
+ def test_cacheable_group_get_members(self):
+ # Arrange
+ group = self.network.get_group("Audioscrobbler Beta")
+
+ # Act/Assert
+ self.helper_validate_cacheable(group, "get_members")
+
+ @handle_lastfm_exceptions
+ def test_cacheable_library(self):
+ # Arrange
+ library = pylast.Library(self.username, self.network)
+
+ # Act/Assert
+ self.helper_validate_cacheable(library, "get_albums")
+ self.helper_validate_cacheable(library, "get_artists")
+ self.helper_validate_cacheable(library, "get_tracks")
+
+ @handle_lastfm_exceptions
+ def test_cacheable_user_artist_tracks(self):
+ # Arrange
+ lastfm_user = self.network.get_authenticated_user()
+
+ # Act
+ result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False)
+ result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True)
+ result3 = lastfm_user.get_artist_tracks("Test Artist")
- @staticmethod
- def helper_two_different_things_in_top_list(things, expected_type) -> None:
# Assert
- assert len(things) == 2
+ self.helper_validate_results(result1, result2, result3)
+
+ @handle_lastfm_exceptions
+ def test_cacheable_user(self):
+ # Arrange
+ lastfm_user = self.network.get_authenticated_user()
+
+ # Act/Assert
+ # Skip the first one because Last.fm API is broken
+ # self.helper_validate_cacheable(lastfm_user, "get_friends")
+ self.helper_validate_cacheable(lastfm_user, "get_loved_tracks")
+ self.helper_validate_cacheable(lastfm_user, "get_neighbours")
+ self.helper_validate_cacheable(lastfm_user, "get_past_events")
+ self.helper_validate_cacheable(lastfm_user, "get_recent_tracks")
+ self.helper_validate_cacheable(lastfm_user, "get_recommended_artists")
+ self.helper_validate_cacheable(lastfm_user, "get_recommended_events")
+ self.helper_validate_cacheable(lastfm_user, "get_shouts")
+
+ @handle_lastfm_exceptions
+ def test_geo_get_events_in_location(self):
+ # Arrange
+ # Act
+ events = self.network.get_geo_events(
+ location="London", tag="blues", limit=1)
+
+ # Assert
+ self.assertEqual(len(events), 1)
+ event = events[0]
+ self.assertIsInstance(event, pylast.Event)
+ self.assertIn(event.get_venue().location['city'],
+ ["London", "Camden"])
+
+ @handle_lastfm_exceptions
+ def test_geo_get_events_in_latlong(self):
+ # Arrange
+ # Act
+ events = self.network.get_geo_events(
+ latitude=53.466667, longitude=-2.233333, distance=5, limit=1)
+
+ # Assert
+ self.assertEqual(len(events), 1)
+ event = events[0]
+ self.assertIsInstance(event, pylast.Event)
+ self.assertEqual(event.get_venue().location['city'], "Manchester")
+
+ @handle_lastfm_exceptions
+ def test_geo_get_events_festival(self):
+ # Arrange
+ # Act
+ events = self.network.get_geo_events(
+ location="Reading", festivalsonly=True, limit=1)
+
+ # Assert
+ self.assertEqual(len(events), 1)
+ event = events[0]
+ self.assertIsInstance(event, pylast.Event)
+ self.assertEqual(event.get_venue().location['city'], "Reading")
+
+ def helper_dates_valid(self, dates):
+ # Assert
+ self.assertGreaterEqual(len(dates), 1)
+ self.assertIsInstance(dates[0], tuple)
+ (start, end) = dates[0]
+ self.assertLess(start, end)
+
+ @handle_lastfm_exceptions
+ def test_get_metro_weekly_chart_dates(self):
+ # Arrange
+ # Act
+ dates = self.network.get_metro_weekly_chart_dates()
+
+ # Assert
+ self.helper_dates_valid(dates)
+
+ def helper_geo_chart(self, function_name, expected_type=pylast.Artist):
+ # Arrange
+ metro = self.network.get_metro("Madrid", "Spain")
+ dates = self.network.get_metro_weekly_chart_dates()
+ (from_date, to_date) = dates[0]
+
+ # get metro.function_name()
+ func = getattr(metro, function_name, None)
+
+ # Act
+ chart = func(from_date=from_date, to_date=to_date, limit=1)
+
+ # Assert
+ self.assertEqual(len(chart), 1)
+ self.assertIsInstance(chart[0], pylast.TopItem)
+ self.assertIsInstance(chart[0].item, expected_type)
+
+ @handle_lastfm_exceptions
+ def test_get_metro_artist_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart("get_artist_chart")
+
+ @handle_lastfm_exceptions
+ def test_get_metro_hype_artist_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart("get_hype_artist_chart")
+
+ @handle_lastfm_exceptions
+ def test_get_metro_unique_artist_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart("get_unique_artist_chart")
+
+ @handle_lastfm_exceptions
+ def test_get_metro_track_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart("get_track_chart", expected_type=pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_get_metro_hype_track_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart(
+ "get_hype_track_chart", expected_type=pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_get_metro_unique_track_chart(self):
+ # Arrange/Act/Assert
+ self.helper_geo_chart(
+ "get_unique_track_chart", expected_type=pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_geo_get_metros(self):
+ # Arrange
+ # Act
+ metros = self.network.get_metros(country="Poland")
+
+ # Assert
+ self.assertGreaterEqual(len(metros), 1)
+ self.assertIsInstance(metros[0], pylast.Metro)
+ self.assertEqual(metros[0].get_country(), "Poland")
+
+ @handle_lastfm_exceptions
+ def test_geo_get_top_artists(self):
+ # Arrange
+ # Act
+ artists = self.network.get_geo_top_artists(
+ country="United Kingdom", limit=1)
+
+ # Assert
+ self.assertEqual(len(artists), 1)
+ self.assertIsInstance(artists[0], pylast.TopItem)
+ self.assertIsInstance(artists[0].item, pylast.Artist)
+
+ @handle_lastfm_exceptions
+ def test_geo_get_top_tracks(self):
+ # Arrange
+ # Act
+ tracks = self.network.get_geo_top_tracks(
+ country="United Kingdom", location="Manchester", limit=1)
+
+ # Assert
+ self.assertEqual(len(tracks), 1)
+ self.assertIsInstance(tracks[0], pylast.TopItem)
+ self.assertIsInstance(tracks[0].item, pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_metro_class(self):
+ # Arrange
+ # Act
+ metro = self.network.get_metro("Bergen", "Norway")
+
+ # Assert
+ self.assertEqual(metro.get_name(), "Bergen")
+ self.assertEqual(metro.get_country(), "Norway")
+ self.assertEqual(str(metro), "Bergen, Norway")
+ self.assertEqual(metro, pylast.Metro("Bergen", "Norway", self.network))
+ self.assertNotEqual(
+ metro,
+ pylast.Metro("Wellington", "New Zealand", self.network))
+
+ @handle_lastfm_exceptions
+ def test_get_album_play_links(self):
+ # Arrange
+ album1 = self.network.get_album("Portishead", "Dummy")
+ album2 = self.network.get_album("Radiohead", "OK Computer")
+ albums = [album1, album2]
+
+ # Act
+ links = self.network.get_album_play_links(albums)
+
+ # Assert
+ self.assertIsInstance(links, list)
+ self.assertEqual(len(links), 2)
+ self.assertIn("spotify:album:", links[0])
+ self.assertIn("spotify:album:", links[1])
+
+ @handle_lastfm_exceptions
+ def test_get_artist_play_links(self):
+ # Arrange
+ artists = ["Portishead", "Radiohead"]
+ # Act
+ links = self.network.get_artist_play_links(artists)
+
+ # Assert
+ self.assertIsInstance(links, list)
+ self.assertEqual(len(links), 2)
+ self.assertIn("spotify:artist:", links[0])
+ self.assertIn("spotify:artist:", links[1])
+
+ @handle_lastfm_exceptions
+ def test_get_track_play_links(self):
+ # Arrange
+ track1 = self.network.get_track(artist="Portishead", title="Mysterons")
+ track2 = self.network.get_track(artist="Radiohead", title="Creep")
+ tracks = [track1, track2]
+
+ # Act
+ links = self.network.get_track_play_links(tracks)
+
+ # Assert
+ self.assertIsInstance(links, list)
+ self.assertEqual(len(links), 2)
+ self.assertIn("spotify:track:", links[0])
+ self.assertIn("spotify:track:", links[1])
+
+ def helper_at_least_one_thing_in_top_list(self, things, expected_type):
+ # Assert
+ self.assertGreater(len(things), 1)
+ self.assertIsInstance(things, list)
+ self.assertIsInstance(things[0], pylast.TopItem)
+ self.assertIsInstance(things[0].item, expected_type)
+
+ def helper_only_one_thing_in_top_list(self, things, expected_type):
+ # Assert
+ self.assertEqual(len(things), 1)
+ self.assertIsInstance(things, list)
+ self.assertIsInstance(things[0], pylast.TopItem)
+ self.assertIsInstance(things[0].item, expected_type)
+
+ def helper_only_one_thing_in_list(self, things, expected_type):
+ # Assert
+ self.assertEqual(len(things), 1)
+ self.assertIsInstance(things, list)
+ self.assertIsInstance(things[0], expected_type)
+
+ def helper_two_different_things_in_top_list(self, things, expected_type):
+ # Assert
+ self.assertEqual(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
+ self.assertIsInstance(thing1, pylast.TopItem)
+ self.assertIsInstance(thing2, pylast.TopItem)
+ self.assertIsInstance(thing1.item, expected_type)
+ self.assertIsInstance(thing2.item, expected_type)
+ self.assertNotEqual(thing1, thing2)
+
+ def helper_two_things_in_list(self, things, expected_type):
+ # Assert
+ self.assertEqual(len(things), 2)
+ self.assertIsInstance(things, list)
+ thing1 = things[0]
+ thing2 = things[1]
+ self.assertIsInstance(thing1, expected_type)
+ self.assertIsInstance(thing2, expected_type)
+
+ @handle_lastfm_exceptions
+ def test_user_get_top_tags_with_limit(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+
+ # Act
+ tags = user.get_top_tags(limit=1)
+
+ # Assert
+ self.skip_if_lastfm_api_broken(tags)
+ self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
+
+ @handle_lastfm_exceptions
+ def test_network_get_top_artists_with_limit(self):
+ # Arrange
+ # Act
+ artists = self.network.get_top_artists(limit=1)
+
+ # Assert
+ self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
+
+ @handle_lastfm_exceptions
+ def test_network_get_top_tags_with_limit(self):
+ # Arrange
+ # Act
+ tags = self.network.get_top_tags(limit=1)
+
+ # Assert
+ self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
+
+ @handle_lastfm_exceptions
+ def test_network_get_top_tags_with_no_limit(self):
+ # Arrange
+ # Act
+ tags = self.network.get_top_tags()
+
+ # Assert
+ self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag)
+
+ @handle_lastfm_exceptions
+ def test_network_get_top_tracks_with_limit(self):
+ # Arrange
+ # Act
+ tracks = self.network.get_top_tracks(limit=1)
+
+ # Assert
+ self.helper_only_one_thing_in_top_list(tracks, pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_artist_top_tracks(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_artist_top_albums(self):
+ # 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=2)
+
+ # Assert
+ self.helper_two_different_things_in_top_list(things, pylast.Album)
+
+ @handle_lastfm_exceptions
+ def test_artist_top_fans(self):
+ # Arrange
+ # Pick an artist with plenty of plays
+ artist = self.network.get_top_artists(limit=1)[0].item
+
+ # Act
+ things = artist.get_top_fans(limit=2)
+
+ # Assert
+ self.helper_two_different_things_in_top_list(things, pylast.User)
+
+ @handle_lastfm_exceptions
+ def test_country_top_tracks(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_country_network_top_tracks(self):
+ # Arrange
+ # Act
+ things = self.network.get_geo_top_tracks("Croatia", limit=2)
+
+ # Assert
+ self.helper_two_different_things_in_top_list(things, pylast.Track)
+
+ @handle_lastfm_exceptions
+ def test_tag_top_tracks(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_top_tracks(self):
+ # Arrange
+ lastfm_user = self.network.get_user(self.username)
+
+ # 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):
+ # Assert
+ self.assertIsNotNone(chart)
+ self.assertGreater(len(chart), 0)
+ self.assertIsInstance(chart[0], pylast.TopItem)
+ self.assertIsInstance(chart[0].item, expected_type)
+
+ def helper_get_assert_charts(self, thing, date):
+ # Arrange
+ (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)
+
+ @handle_lastfm_exceptions
+ def test_group_charts(self):
+ # Arrange
+ group = self.network.get_group("mnml")
+ dates = group.get_weekly_chart_dates()
+ self.helper_dates_valid(dates)
+
+ # Act/Assert
+ self.helper_get_assert_charts(group, dates[-2])
+
+ @handle_lastfm_exceptions
+ def test_tag_charts(self):
+ # Arrange
+ tag = self.network.get_tag("rock")
+ dates = tag.get_weekly_chart_dates()
+ self.helper_dates_valid(dates)
+
+ # Act/Assert
+ self.helper_get_assert_charts(tag, dates[-2])
+
+ @handle_lastfm_exceptions
+ def test_user_charts(self):
+ # 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])
+
+ @handle_lastfm_exceptions
+ def test_track_top_fans(self):
+ # Arrange
+ track = self.network.get_track("The Cinematic Orchestra", "Postlude")
+
+ # Act
+ fans = track.get_top_fans()
+
+ # Assert
+ self.helper_at_least_one_thing_in_top_list(fans, pylast.User)
+
+ # Commented out to avoid spamming
+ # def test_share_spam(self):
+ # # Arrange
+ # users_to_spam = [TODO_ENTER_SPAMEES_HERE]
+ # spam_message = "Dig the krazee sound!"
+ # artist = self.network.get_top_artists(limit=1)[0].item
+ # track = artist.get_top_tracks(limit=1)[0].item
+ # event = artist.get_upcoming_events()[0]
+
+ # # Act
+ # artist.share(users_to_spam, spam_message)
+ # track.share(users_to_spam, spam_message)
+ # event.share(users_to_spam, spam_message)
+
+ # Assert
+ # Check inbox for spam!
+
+ # album/artist/event/track/user
+
+ @handle_lastfm_exceptions
+ def test_album_shouts(self):
+ # Arrange
+ # Pick an artist with plenty of plays
+ artist = self.network.get_top_artists(limit=1)[0].item
+ album = artist.get_top_albums(limit=1)[0].item
+
+ # Act
+ shouts = album.get_shouts(limit=2)
+
+ # Assert
+ self.helper_two_things_in_list(shouts, pylast.Shout)
+
+ @handle_lastfm_exceptions
+ def test_artist_shouts(self):
+ # Arrange
+ # Pick an artist with plenty of plays
+ artist = self.network.get_top_artists(limit=1)[0].item
+
+ # Act
+ shouts = artist.get_shouts(limit=2)
+
+ # Assert
+ self.helper_two_things_in_list(shouts, pylast.Shout)
+
+ @handle_lastfm_exceptions
+ def test_event_shouts(self):
+ # Arrange
+ event_id = 3478520 # Glasto 2014
+ event = pylast.Event(event_id, self.network)
+
+ # Act
+ shouts = event.get_shouts(limit=2)
+
+ # Assert
+ self.helper_two_things_in_list(shouts, pylast.Shout)
+
+ @handle_lastfm_exceptions
+ def test_track_shouts(self):
+ # Arrange
+ track = self.network.get_track("The Cinematic Orchestra", "Postlude")
+
+ # Act
+ shouts = track.get_shouts(limit=2)
+
+ # Assert
+ self.helper_two_things_in_list(shouts, pylast.Shout)
+
+ @handle_lastfm_exceptions
+ def test_user_shouts(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+
+ # Act
+ shouts = user.get_shouts(limit=2)
+
+ # Assert
+ self.helper_two_things_in_list(shouts, pylast.Shout)
+
+ @handle_lastfm_exceptions
+ def test_album_data(self):
+ # Arrange
+ thing = self.network.get_album("Test Artist", "Test Album")
+
+ # Act
+ stringed = str(thing)
+ repr = thing.__repr__()
+ title = thing.get_title()
+ name = thing.get_name()
+ playcount = thing.get_playcount()
+ url = thing.get_url()
+
+ # Assert
+ self.assertEqual(stringed, "Test Artist - Test Album")
+ self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr)
+ self.assertEqual(title, name)
+ self.assertIsInstance(playcount, int)
+ self.assertGreater(playcount, 1)
+ self.assertEqual(
+ "https://www.last.fm/music/test%2bartist/test%2balbum", url)
+
+ @handle_lastfm_exceptions
+ def test_track_data(self):
+ # Arrange
+ thing = self.network.get_track("Test Artist", "test title")
+
+ # Act
+ stringed = str(thing)
+ repr = thing.__repr__()
+ title = thing.get_title()
+ name = thing.get_name()
+ playcount = thing.get_playcount()
+ url = thing.get_url(pylast.DOMAIN_FRENCH)
+
+ # Assert
+ self.assertEqual(stringed, "Test Artist - test title")
+ self.assertIn("pylast.Track('Test Artist', 'test title',", repr)
+ self.assertEqual(title, "test title")
+ self.assertEqual(title, name)
+ self.assertIsInstance(playcount, int)
+ self.assertGreater(playcount, 1)
+ self.assertEqual(
+ "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url)
+
+ @handle_lastfm_exceptions
+ def test_tag_top_artists(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_country_top_artists(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_top_artists(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_tag_top_albums(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_top_albums(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_tagged_artists(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_tagged_albums(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_user_tagged_tracks(self):
+ # 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)
+
+ @handle_lastfm_exceptions
+ def test_caching(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+
+ # Act
+ self.network.enable_caching()
+ shouts1 = user.get_shouts(limit=1, cacheable=True)
+ shouts2 = user.get_shouts(limit=1, cacheable=True)
+
+ # Assert
+ self.assertTrue(self.network.is_caching_enabled())
+ self.assertEqual(shouts1, shouts2)
+ self.network.disable_caching()
+ self.assertFalse(self.network.is_caching_enabled())
+
+ @handle_lastfm_exceptions
+ def test_create_playlist(self):
+ # Arrange
+ title = "Test playlist"
+ description = "Testing"
+ lastfm_user = self.network.get_user(self.username)
+
+ # Act
+ playlist = self.network.create_new_playlist(title, description)
+
+ # Assert
+ self.assertIsInstance(playlist, pylast.Playlist)
+ self.assertEqual(playlist.get_title(), "Test playlist")
+ self.assertEqual(playlist.get_description(), "Testing")
+ self.assertEqual(playlist.get_user(), lastfm_user)
+
+ @handle_lastfm_exceptions
+ def test_empty_playlist_unstreamable(self):
+ # Arrange
+ title = "Empty playlist"
+ description = "Unstreamable"
+
+ # Act
+ playlist = self.network.create_new_playlist(title, description)
+
+ # Assert
+ self.assertEqual(playlist.get_size(), 0)
+ self.assertEqual(playlist.get_duration(), 0)
+ self.assertFalse(playlist.is_streamable())
+
+ @handle_lastfm_exceptions
+ def test_big_playlist_is_streamable(self):
+ # Arrange
+ # Find a big playlist on Last.fm, eg "top 100 classick rock songs"
+ user = "kaxior"
+ id = 10417943
+ playlist = pylast.Playlist(user, id, self.network)
+ self.assertEqual(
+ playlist.get_url(),
+ "https://www.last.fm/user/kaxior/library/"
+ "playlists/67ajb_top_100_classick_rock_songs")
+
+ # Act
+ # Nothing
+
+ # Assert
+ self.assertIsInstance(playlist, pylast.Playlist)
+ self.assertGreaterEqual(playlist.get_size(), 45)
+ self.assertGreater(playlist.get_duration(), 0)
+ self.assertTrue(playlist.is_streamable())
+
+ @handle_lastfm_exceptions
+ def test_add_track_to_playlist(self):
+ # Arrange
+ title = "One track playlist"
+ description = "Testing"
+ playlist = self.network.create_new_playlist(title, description)
+ track = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act
+ playlist.add_track(track)
+
+ # Assert
+ self.assertEqual(playlist.get_size(), 1)
+ self.assertEqual(len(playlist.get_tracks()), 1)
+ self.assertTrue(playlist.has_track(track))
+
+ @handle_lastfm_exceptions
+ def test_album_mbid(self):
+ # Arrange
+ mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937"
+
+ # Act
+ album = self.network.get_album_by_mbid(mbid)
+ album_mbid = album.get_mbid()
+
+ # Assert
+ self.assertIsInstance(album, pylast.Album)
+ self.assertEqual(album.title.lower(), "test")
+ self.assertEqual(album_mbid, mbid)
+
+ @handle_lastfm_exceptions
+ def test_artist_mbid(self):
+ # Arrange
+ mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55"
+
+ # Act
+ artist = self.network.get_artist_by_mbid(mbid)
+
+ # Assert
+ self.assertIsInstance(artist, pylast.Artist)
+ self.assertEqual(artist.name, "MusicBrainz Test Artist")
+
+ @handle_lastfm_exceptions
+ def test_track_mbid(self):
+ # Arrange
+ mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0"
+
+ # Act
+ track = self.network.get_track_by_mbid(mbid)
+ track_mbid = track.get_mbid()
+
+ # Assert
+ self.assertIsInstance(track, pylast.Track)
+ self.assertEqual(track.title, "first")
+ self.assertEqual(track_mbid, mbid)
+
+ @handle_lastfm_exceptions
+ def test_artist_listener_count(self):
+ # Arrange
+ artist = self.network.get_artist("Test Artist")
+
+ # Act
+ count = artist.get_listener_count()
+
+ # Assert
+ self.assertIsInstance(count, int)
+ self.assertGreater(count, 0)
+
+ @handle_lastfm_exceptions
+ def test_event_attendees(self):
+ # Arrange
+ user = self.network.get_user("RJ")
+ event = user.get_past_events(limit=1)[0]
+
+ # Act
+ users = event.get_attendees()
+
+ # Assert
+ self.assertIsInstance(users, list)
+ self.assertIsInstance(users[0], pylast.User)
+
+ @handle_lastfm_exceptions
+ def test_tag_artist(self):
+ # Arrange
+ artist = self.network.get_artist("Test Artist")
+# artist.clear_tags()
+
+ # Act
+ artist.add_tag("testing")
+
+ # Assert
+ tags = artist.get_tags()
+ self.assertGreater(len(tags), 0)
+ found = False
+ for tag in tags:
+ if tag.name == "testing":
+ found = True
+ break
+ self.assertTrue(found)
+
+ @handle_lastfm_exceptions
+ def test_remove_tag_of_type_text(self):
+ # 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 = False
+ for tag in tags:
+ if tag.name == "testing":
+ found = True
+ break
+ self.assertFalse(found)
+
+ @handle_lastfm_exceptions
+ def test_remove_tag_of_type_tag(self):
+ # 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 = False
+ for tag in tags:
+ if tag.name == "testing":
+ found = True
+ break
+ self.assertFalse(found)
+
+ @handle_lastfm_exceptions
+ def test_remove_tags(self):
+ # 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()
+ self.assertEqual(len(tags_after), len(tags_before) - 2)
+ found1, found2 = False, False
+ for tag in tags_after:
+ if tag.name == "removetag1":
+ found1 = True
+ elif tag.name == "removetag2":
+ found2 = True
+ self.assertFalse(found1)
+ self.assertFalse(found2)
+
+ @handle_lastfm_exceptions
+ def test_set_tags(self):
+ # 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()
+ self.assertNotEqual(tags_before, tags_after)
+ self.assertEqual(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
+ self.assertTrue(found1)
+ self.assertTrue(found2)
+
+ @handle_lastfm_exceptions
+ def test_tracks_notequal(self):
+ # Arrange
+ track1 = pylast.Track("Test Artist", "test title", self.network)
+ track2 = pylast.Track("Test Artist", "Test Track", self.network)
+
+ # Act
+ # Assert
+ self.assertNotEqual(track1, track2)
+
+ @handle_lastfm_exceptions
+ def test_track_id(self):
+ # Arrange
+ track = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act
+ id = track.get_id()
+
+ # Assert
+ self.skip_if_lastfm_api_broken(id)
+ self.assertEqual(id, "14053327")
+
+ @handle_lastfm_exceptions
+ def test_track_title_prop_caps(self):
+ # Arrange
+ track = pylast.Track("test artist", "test title", self.network)
+
+ # Act
+ title = track.get_title(properly_capitalized=True)
+
+ # Assert
+ self.assertEqual(title, "test title")
+
+ @handle_lastfm_exceptions
+ def test_track_listener_count(self):
+ # Arrange
+ track = pylast.Track("test artist", "test title", self.network)
+
+ # Act
+ count = track.get_listener_count()
+
+ # Assert
+ self.assertGreater(count, 21)
+
+ @handle_lastfm_exceptions
+ def test_album_rel_date(self):
+ # Arrange
+ album = pylast.Album("Test Artist", "Test Release", self.network)
+
+ # Act
+ date = album.get_release_date()
+
+ # Assert
+ self.skip_if_lastfm_api_broken(date)
+ self.assertIn("2011", date)
+
+ @handle_lastfm_exceptions
+ def test_album_tracks(self):
+ # Arrange
+ album = pylast.Album("Test Artist", "Test Release", self.network)
+
+ # Act
+ tracks = album.get_tracks()
+ url = tracks[0].get_url()
+
+ # Assert
+ self.assertIsInstance(tracks, list)
+ self.assertIsInstance(tracks[0], pylast.Track)
+ self.assertEqual(len(tracks), 4)
+ self.assertTrue(url.startswith("https://www.last.fm/music/test"))
+
+ @handle_lastfm_exceptions
+ def test_tags(self):
+ # 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
+ self.assertEqual("blues", tag_str)
+ self.assertIn("pylast.Tag", tag_repr)
+ self.assertIn("blues", tag_repr)
+ self.assertEqual("blues", name)
+ self.assertTrue(tag1 == tag1)
+ self.assertTrue(tag1 != tag2)
+ self.assertEqual(url, "https://www.last.fm/tag/blues")
+
+ @handle_lastfm_exceptions
+ def test_tags_similar(self):
+ # Arrange
+ tag = self.network.get_tag("blues")
+
+ # Act
+ similar = tag.get_similar()
+
+ # Assert
+ self.skip_if_lastfm_api_broken(similar)
+ found = False
+ for tag in similar:
+ if tag.name == "delta blues":
+ found = True
+ break
+ self.assertTrue(found)
+
+ @handle_lastfm_exceptions
+ def test_artists(self):
+ # Arrange
+ artist1 = self.network.get_artist("Radiohead")
+ artist2 = self.network.get_artist("Portishead")
+
+ # Act
+ url = artist1.get_url()
+ mbid = artist1.get_mbid()
+ image = artist1.get_cover_image()
+ playcount = artist1.get_playcount()
+ streamable = artist1.is_streamable()
+ name = artist1.get_name(properly_capitalized=False)
+ name_cap = artist1.get_name(properly_capitalized=True)
+
+ # Assert
+ self.assertIn("http", image)
+ self.assertGreater(playcount, 1)
+ self.assertTrue(artist1 != artist2)
+ self.assertEqual(name.lower(), name_cap.lower())
+ self.assertEqual(url, "https://www.last.fm/music/radiohead")
+ self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711")
+ self.assertIsInstance(streamable, bool)
+
+ @handle_lastfm_exceptions
+ def test_events(self):
+ # Arrange
+ event_id_1 = 3162700 # Glasto 2013
+ event_id_2 = 3478520 # Glasto 2014
+ event1 = pylast.Event(event_id_1, self.network)
+ event2 = pylast.Event(event_id_2, self.network)
+
+ # Act
+ text = str(event1)
+ rep = repr(event1)
+ title = event1.get_title()
+ artists = event1.get_artists()
+ start = event1.get_start_date()
+ description = event1.get_description()
+ review_count = event1.get_review_count()
+ attendance_count = event1.get_attendance_count()
+
+ # Assert
+ self.assertIn("3162700", rep)
+ self.assertIn("pylast.Event", rep)
+ self.assertEqual(text, "Event #3162700")
+ self.assertTrue(event1 != event2)
+ self.assertIn("Glastonbury", title)
+ found = False
+ for artist in artists:
+ if artist.name == "The Rolling Stones":
+ found = True
+ break
+ self.assertTrue(found)
+ self.assertIn("Wed, 26 Jun 2013", start)
+ self.assertIn("astonishing bundle", description)
+ self.assertGreater(review_count, 0)
+ self.assertGreater(attendance_count, 100)
+
+ @handle_lastfm_exceptions
+ def test_countries(self):
+ # 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
+ self.assertIn("Italy", rep)
+ self.assertIn("pylast.Country", rep)
+ self.assertEqual(text, "Italy")
+ self.assertTrue(country1 == country1)
+ self.assertTrue(country1 != country2)
+ self.assertEqual(url, "https://www.last.fm/place/italy")
+
+ @handle_lastfm_exceptions
+ def test_track_eq_none_is_false(self):
+ # Arrange
+ track1 = None
+ track2 = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act / Assert
+ self.assertFalse(track1 == track2)
+
+ @handle_lastfm_exceptions
+ def test_track_ne_none_is_true(self):
+ # Arrange
+ track1 = None
+ track2 = pylast.Track("Test Artist", "test title", self.network)
+
+ # Act / Assert
+ self.assertTrue(track1 != track2)
+
+ @handle_lastfm_exceptions
+ def test_artist_eq_none_is_false(self):
+ # Arrange
+ artist1 = None
+ artist2 = pylast.Artist("Test Artist", self.network)
+
+ # Act / Assert
+ self.assertFalse(artist1 == artist2)
+
+ @handle_lastfm_exceptions
+ def test_artist_ne_none_is_true(self):
+ # Arrange
+ artist1 = None
+ artist2 = pylast.Artist("Test Artist", self.network)
+
+ # Act / Assert
+ self.assertTrue(artist1 != artist2)
+
+ @handle_lastfm_exceptions
+ def test_album_eq_none_is_false(self):
+ # Arrange
+ album1 = None
+ album2 = pylast.Album("Test Artist", "Test Album", self.network)
+
+ # Act / Assert
+ self.assertFalse(album1 == album2)
+
+ @handle_lastfm_exceptions
+ def test_album_ne_none_is_true(self):
+ # Arrange
+ album1 = None
+ album2 = pylast.Album("Test Artist", "Test Album", self.network)
+
+ # Act / Assert
+ self.assertTrue(album1 != album2)
+
+ @handle_lastfm_exceptions
+ def test_event_eq_none_is_false(self):
+ # Arrange
+ event1 = None
+ event_id = 3478520 # Glasto 2014
+ event2 = pylast.Event(event_id, self.network)
+
+ # Act / Assert
+ self.assertFalse(event1 == event2)
+
+ @handle_lastfm_exceptions
+ def test_event_ne_none_is_true(self):
+ # Arrange
+ event1 = None
+ event_id = 3478520 # Glasto 2014
+ event2 = pylast.Event(event_id, self.network)
+
+ # Act / Assert
+ self.assertTrue(event1 != event2)
+
+ @handle_lastfm_exceptions
+ def test_band_members(self):
+ # Arrange
+ artist = pylast.Artist("The Beatles", self.network)
+
+ # Act
+ band_members = artist.get_band_members()
+
+ # Assert
+ self.skip_if_lastfm_api_broken(band_members)
+ self.assertGreaterEqual(len(band_members), 4)
+
+ @handle_lastfm_exceptions
+ def test_no_band_members(self):
+ # Arrange
+ artist = pylast.Artist("John Lennon", self.network)
+
+ # Act
+ band_members = artist.get_band_members()
+
+ # Assert
+ self.assertIsNone(band_members)
+
+ @handle_lastfm_exceptions
+ def test_get_recent_tracks_from_to(self):
+ # Arrange
+ lastfm_user = self.network.get_user("RJ")
+
+ from datetime import datetime
+ start = datetime(2011, 7, 21, 15, 10)
+ end = datetime(2011, 7, 21, 15, 15)
+ import calendar
+ 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
+ self.assertEqual(len(tracks), 1)
+ self.assertEqual(str(tracks[0].track.artist), "Johnny Cash")
+ self.assertEqual(str(tracks[0].track.title), "Ring of Fire")
+
+ @handle_lastfm_exceptions
+ def test_artist_get_correction(self):
+ # Arrange
+ artist = pylast.Artist("guns and roses", self.network)
+
+ # Act
+ corrected_artist_name = artist.get_correction()
+
+ # Assert
+ self.assertEqual(corrected_artist_name, "Guns N' Roses")
+
+ @handle_lastfm_exceptions
+ def test_track_get_correction(self):
+ # Arrange
+ track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network)
+
+ # Act
+ corrected_track_name = track.get_correction()
+
+ # Assert
+ self.assertEqual(corrected_track_name, "Mr. Brownstone")
+
+ @handle_lastfm_exceptions
+ def test_track_with_no_mbid(self):
+ # Arrange
+ track = pylast.Track("Static-X", "Set It Off", self.network)
+
+ # Act
+ mbid = track.get_mbid()
+
+ # Assert
+ self.assertEqual(mbid, None)
+
+ def test_init_with_token(self):
+ # Arrange/Act
+ 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
+ self.assertEqual(msg,
+ "Unauthorized Token - This token has not been issued")
+
+
+@flaky(max_runs=5, min_passes=1)
+class TestPyLastWithLibreFm(unittest.TestCase):
+ """Own class for Libre.fm because we don't need the Last.fm setUp"""
+
+ def test_libre_fm(self):
+ # 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
+ self.assertEqual(name, "Radiohead")
+
+
+if __name__ == '__main__':
+ unittest.main(failfast=True)
diff --git a/tests/test_tag.py b/tests/test_tag.py
deleted file mode 100755
index 7a9675c..0000000
--- a/tests/test_tag.py
+++ /dev/null
@@ -1,58 +0,0 @@
-#!/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"
diff --git a/tests/test_track.py b/tests/test_track.py
deleted file mode 100755
index db04d15..0000000
--- a/tests/test_track.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""
-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
diff --git a/tests/test_user.py b/tests/test_user.py
deleted file mode 100755
index f5069d5..0000000
--- a/tests/test_user.py
+++ /dev/null
@@ -1,496 +0,0 @@
-#!/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)
diff --git a/tests/unicode_test.py b/tests/unicode_test.py
index 67f234b..511ecd1 100644
--- a/tests/unicode_test.py
+++ b/tests/unicode_test.py
@@ -1,70 +1,29 @@
-from __future__ import annotations
-
-from unittest import mock
-
+# -*- coding: utf-8 -*-
+import mock
import pytest
+import six
import pylast
def mock_network():
- return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", "")))
+ 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})
+@pytest.mark.parametrize('artist', [
+ u'\xe9lafdasfdsafdsa', u'ééééééé',
+ pylast.Artist(u'B\xe9l', mock_network()),
+ 'fdasfdsafsaf not unicode',
+])
+def test_get_cache_key(artist):
+ 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)
+@pytest.mark.parametrize('obj', [pylast.Artist(u'B\xe9l', mock_network())])
+def test_cast_and_hash(obj):
+ assert type(six.text_type(obj)) is six.text_type
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
diff --git a/tox.ini b/tox.ini
index 3ead5fc..33c73be 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,40 +1,44 @@
[tox]
-requires =
- tox>=4.2
-env_list =
- lint
- py{py3, 313, 312, 311, 310, 39, 38}
+envlist = py27, py36, py35, py34, pypy, pypy3
+recreate = False
[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
+setenv =
+ PYLAST_USERNAME={env:PYLAST_USERNAME:}
+ PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:}
+ PYLAST_API_KEY={env:PYLAST_API_KEY:}
+ PYLAST_API_SECRET={env:PYLAST_API_SECRET:}
deps =
- pre-commit
-pass_env =
- PRE_COMMIT_COLOR
-commands =
- pre-commit run --all-files --show-diff-on-failure
+ pyyaml
+ pytest
+ mock
+ ipdb
+ pytest-cov
+ flaky
+commands = py.test -v -s -W all --cov pylast --cov-report term-missing {posargs}
[testenv:venv]
+deps = ipdb
+commands = {posargs}
+
+[testenv:py2lint]
deps =
- ipdb
+ pep8
+ pyflakes
+ clonedigger
commands =
- {posargs}
+ pyflakes pylast
+ pyflakes tests
+ pep8 pylast
+ pep8 tests
+ ./clonedigger.sh
+
+[testenv:py3lint]
+deps =
+ pep8
+ pyflakes
+commands =
+ pyflakes pylast
+ pyflakes tests
+ pep8 pylast
+ pep8 tests