diff --git a/.build b/.build
new file mode 100644
index 0000000..9d60796
--- /dev/null
+++ b/.build
@@ -0,0 +1 @@
+11
\ No newline at end of file
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
deleted file mode 100644
index 179fd45..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,18 +0,0 @@
-# Top-most EditorConfig file
-root = true
-
-# Unix-style newlines with a newline ending every file
-[*]
-end_of_line = lf
-insert_final_newline = true
-charset = utf-8
-
-# Four-space indentation
-[*.py]
-indent_size = 4
-indent_style = space
-trim_trailing_whitespace = true
-
-# Two-space indentation
-[*.yml]
-indent_size = 2
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
deleted file mode 100644
index 8e4ffaf..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,71 +0,0 @@
-# User Credentials
-test_pylast.yaml
-.envrc
-
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-env/
-.venv/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-*.egg-info/
-.installed.cfg
-*.egg
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-.hypothesis/
-.pytest_cache/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-# JetBrains
-.idea/
-
-# Clone Digger
-output.html
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/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/LICENSE.txt b/LICENSE.txt
deleted file mode 100644
index 9b259bd..0000000
--- a/LICENSE.txt
+++ /dev/null
@@ -1,201 +0,0 @@
- Apache License
- Version 2.0, January 2004
- https://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "{}"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright {yyyy} {name of copyright owner}
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- https://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
diff --git a/README b/README
new file mode 100644
index 0000000..64b1b7f
--- /dev/null
+++ b/README
@@ -0,0 +1,7 @@
+pylast
+------
+
+A python interface to Last.fm. Try using the pydoc utility for help
+on usage.
+For more info check out the project's home page at http://code.google.com/p/pylast/
+or the mailing list http://groups.google.com/group/pylast/
diff --git a/README.md b/README.md
deleted file mode 100644
index c22fbec..0000000
--- a/README.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# pyLast
-
-[](https://pypi.org/project/pylast/)
-[](https://pypi.org/project/pylast/)
-[](https://pypistats.org/packages/pylast)
-[](https://github.com/pylast/pylast/actions)
-[](https://codecov.io/gh/pylast/pylast)
-[](https://github.com/psf/black)
-[](https://zenodo.org/badge/latestdoi/7803088)
-
-A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites
-such as [Libre.fm](https://libre.fm/).
-
-Use the pydoc utility for help on usage or see [tests/](tests/) for examples.
-
-## Installation
-
-Install latest development version:
-
-```sh
-python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast
-```
-
-Or from requirements.txt:
-
-```txt
--e https://git.hirad.it/Hirad/pylast#egg=pylast
-```
-
-Note:
-
-- pyLast 5.3+ supports Python 3.8-3.13.
-- pyLast 5.2+ supports Python 3.8-3.12.
-- pyLast 5.1 supports Python 3.7-3.11.
-- pyLast 5.0 supports Python 3.7-3.10.
-- pyLast 4.3 - 4.5 supports Python 3.6-3.10.
-- pyLast 4.0 - 4.2 supports Python 3.6-3.9.
-- pyLast 3.2 - 3.3 supports Python 3.5-3.8.
-- pyLast 3.0 - 3.1 supports Python 3.5-3.7.
-- pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7.
-- pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6.
-- pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6.
-- pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4.
-- pyLast 0.5 supports Python 2, 3.
-- pyLast < 0.5 supports Python 2.
-
-## Features
-
-- Simple public interface.
-- Access to all the data exposed by the Last.fm web services.
-- Scrobbling support.
-- Full object-oriented design.
-- Proxy support.
-- Internal caching support for some web services calls (disabled by default).
-- Support for other API-compatible networks like Libre.fm.
-
-## Getting started
-
-Here's some simple code example to get you started. In order to create any object from
-pyLast, you need a `Network` object which represents a social music network that is
-Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm
-and use it as follows:
-
-```python
-import pylast
-
-# You have to have your own unique two values for API_KEY and API_SECRET
-# Obtain yours from https://www.last.fm/api/account/create for Last.fm
-API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key
-API_SECRET = "425b55975eed76058ac220b7b4e8a054"
-
-# In order to perform a write operation you need to authenticate yourself
-username = "your_user_name"
-password_hash = pylast.md5("your_password")
-
-network = pylast.LastFMNetwork(
- api_key=API_KEY,
- api_secret=API_SECRET,
- username=username,
- password_hash=password_hash,
-)
-```
-
-Alternatively, instead of creating `network` with a username and password, you can
-authenticate with a session key:
-
-```python
-import pylast
-
-SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key")
-network = pylast.LastFMNetwork(API_KEY, API_SECRET)
-if not os.path.exists(SESSION_KEY_FILE):
- skg = pylast.SessionKeyGenerator(network)
- url = skg.get_web_auth_url()
-
- print(f"Please authorize this script to access your account: {url}\n")
- import time
- import webbrowser
-
- webbrowser.open(url)
-
- while True:
- try:
- session_key = skg.get_web_auth_session_key(url)
- with open(SESSION_KEY_FILE, "w") as f:
- f.write(session_key)
- break
- except pylast.WSError:
- time.sleep(1)
-else:
- session_key = open(SESSION_KEY_FILE).read()
-
-network.session_key = session_key
-```
-
-And away we go:
-
-```python
-# Now you can use that object everywhere
-track = network.get_track("Iron Maiden", "The Nomad")
-track.love()
-track.add_tags(("awesome", "favorite"))
-
-# Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter
-# to get more help about anything and see examples of how it works
-```
-
-More examples in
-hugovk/lastfm-tools and
-[tests/](https://github.com/pylast/pylast/tree/main/tests).
-
-## Testing
-
-The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains
-integration and unit tests with Last.fm, and plenty of code examples.
-
-For integration tests you need a test account at Last.fm that will become cluttered with
-test data, and an API key and secret. Either copy
-[example_test_pylast.yaml](https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml)
-to test_pylast.yaml and fill out the credentials, or set them as environment variables
-like:
-
-```sh
-export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
-export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE
-export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE
-export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE
-```
-
-To run all unit and integration tests:
-
-```sh
-python3 -m pip install -e ".[tests]"
-pytest
-```
-
-Or run just one test case:
-
-```sh
-pytest -k test_scrobble
-```
-
-To run with coverage:
-
-```sh
-pytest -v --cov pylast --cov-report term-missing
-coverage report # for command-line report
-coverage html # for HTML report
-open htmlcov/index.html
-```
-
-## Logging
-
-To enable from your own code:
-
-```python
-import logging
-import pylast
-
-logging.basicConfig(level=logging.INFO)
-
-
-network = pylast.LastFMNetwork(...)
-```
-
-To enable from pytest:
-
-```sh
-pytest --log-cli-level info -k test_album_search_images
-```
-
-To also see data returned from the API, use `level=logging.DEBUG` or
-`--log-cli-level debug` instead.
diff --git a/RELEASING.md b/RELEASING.md
deleted file mode 100644
index 9b2e38a..0000000
--- a/RELEASING.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Release Checklist
-
-- [ ] Get `main` to the appropriate code release state.
- [GitHub Actions](https://github.com/pylast/pylast/actions) should be running
- cleanly for all merges to `main`.
- [](https://github.com/pylast/pylast/actions)
-
-- [ ] Edit release draft, adjust text if needed:
- https://github.com/pylast/pylast/releases
-
-- [ ] Check next tag is correct, amend if needed
-
-- [ ] Publish release
-
-- [ ] Check the tagged
- [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
- has deployed to [PyPI](https://pypi.org/project/pylast/#history)
-
-- [ ] Check installation:
-
-```bash
-pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)"
-```
diff --git a/example_test_pylast.yaml b/example_test_pylast.yaml
deleted file mode 100644
index 00b09f1..0000000
--- a/example_test_pylast.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-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.py b/pylast.py
new file mode 100644
index 0000000..aac62d9
--- /dev/null
+++ b/pylast.py
@@ -0,0 +1,3814 @@
+# -*- coding: utf-8 -*-
+#
+# pylast - A Python interface to Last.fm (and other API compatible social networks)
+#
+# Copyright 2008-2010 Amr Hassan
+#
+# 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
+#
+# http://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.
+#
+# http://code.google.com/p/pylast/
+
+__version__ = '0.5'
+__author__ = 'Amr Hassan'
+__copyright__ = "Copyright (C) 2008-2010 Amr Hassan"
+__license__ = "apache2"
+__email__ = 'amr.hassan@gmail.com'
+
+import hashlib
+from xml.dom import minidom
+import xml.dom
+import time
+import shelve
+import tempfile
+import sys
+import collections
+import warnings
+
+def _deprecation_warning(message):
+ warnings.warn(message, DeprecationWarning)
+
+if sys.version_info[0] == 3:
+ from http.client import HTTPConnection
+ import html.entities as htmlentitydefs
+ 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:
+ from httplib import HTTPConnection
+ import htmlentitydefs
+ 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_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"
+
+class _Network(object):
+ """
+ A music social network website that is Last.fm or one exposing 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):
+ """
+ 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
+
+ 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
+
+ #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 __repr__(self):
+ attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", "session_key", "submission_server",
+ "username", "password_hash", "domain_names", "urls")
+
+ text = "pylast._Network(%s)"
+ args = []
+ for attr in attributes:
+ args.append("=".join((attr, repr(getattr(self, attr)))))
+
+ return text % ", ".join(args)
+ """
+
+ def __str__(self):
+ return "The %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_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_scrobbler(self, client_id, client_version):
+ """
+ Returns a Scrobbler object used for submitting tracks to the server
+
+ Quote from http://www.last.fm/api/submissions:
+ ========
+ Client identifiers are used to provide a centrally managed database of
+ the client versions, allowing clients to be banned if they are found to
+ be behaving undesirably. The client ID is associated with a version
+ number on the server, however these are only incremented if a client is
+ banned and do not have to reflect the version of the actual client application.
+
+ During development, clients which have not been allocated an identifier should
+ use the identifier tst, with a version number of 1.0. Do not distribute code or
+ client implementations which use this test identifier. Do not use the identifiers
+ used by other clients.
+ =========
+
+ To obtain a new client identifier please contact:
+ * Last.fm: submissions@last.fm
+ * # TODO: list others
+
+ ...and provide us with the name of your client and its homepage address.
+ """
+
+ _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead")
+
+ return Scrobbler(self, client_id, client_version)
+
+ 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, type):
+ return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type])
+
+ def _get_ws_auth(self):
+ """
+ Returns a (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 a second apart
+ """
+
+ # delay time in seconds
+ DELAY_TIME = 1.0
+ now = time.time()
+
+ if (now - self.last_call_time) < DELAY_TIME:
+ time.sleep(1)
+
+ 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_tags(self, limit=None):
+ """Returns a sequence of the most used tags as a sequence of TopItem objects."""
+
+ doc = _Request(self, "tag.getTopTags").execute(True)
+ seq = []
+ for node in doc.getElementsByTagName("tag"):
+ tag = Tag(_extract(node, "name"), self)
+ weight = _number(_extract(node, "count"))
+
+ seq.append(TopItem(tag, weight))
+
+ if limit:
+ seq = seq[:limit]
+
+ 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_caching(self, file_path = None):
+ """Enables caching request-wide for all cachable calls.
+ In choosing the backend used for caching, it will try _SqliteCacheBackend first if
+ the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects.
+
+ * 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 == 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 retreive 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 retreive 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 retreive 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 retreive 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 retreive 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):
+ """Loooks 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 = {"album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID"} # so friggin lazy
+
+ 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)
+
+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:
+ http://www.last.fm/api/account
+ """
+
+ def __init__(self, api_key="", api_secret="", session_key="", username="", password_hash=""):
+ _Network.__init__(self,
+ name = "Last.fm",
+ homepage = "http://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,
+ domain_names = {
+ DOMAIN_ENGLISH: 'www.last.fm',
+ DOMAIN_GERMAN: 'www.lastfm.de',
+ DOMAIN_SPANISH: 'www.lastfm.es',
+ DOMAIN_FRENCH: 'www.lastfm.fr',
+ DOMAIN_ITALIAN: 'www.lastfm.it',
+ DOMAIN_POLISH: 'www.lastfm.pl',
+ DOMAIN_PORTUGUESE: 'www.lastfm.com.br',
+ DOMAIN_SWEDISH: 'www.lastfm.se',
+ DOMAIN_TURKISH: 'www.lastfm.com.tr',
+ DOMAIN_RUSSIAN: 'www.lastfm.ru',
+ DOMAIN_JAPANESE: 'www.lastfm.jp',
+ DOMAIN_CHINESE: 'cn.last.fm',
+ },
+ 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)))
+
+ def __str__(self):
+ return "LastFM Network"
+
+def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""):
+ """
+ Returns a preconfigured _Network object for Last.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.
+
+ 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:
+ http://www.last.fm/api/account
+ """
+
+ _deprecation_warning("Create a LastFMNetwork object instead")
+
+ return LastFMNetwork(api_key, api_secret, session_key, username, 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 = "http://alpha.dev.libre.fm",
+ ws_server = ("alpha.dev.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: "alpha.dev.libre.fm",
+ DOMAIN_GERMAN: "alpha.dev.libre.fm",
+ DOMAIN_SPANISH: "alpha.dev.libre.fm",
+ DOMAIN_FRENCH: "alpha.dev.libre.fm",
+ DOMAIN_ITALIAN: "alpha.dev.libre.fm",
+ DOMAIN_POLISH: "alpha.dev.libre.fm",
+ DOMAIN_PORTUGUESE: "alpha.dev.libre.fm",
+ DOMAIN_SWEDISH: "alpha.dev.libre.fm",
+ DOMAIN_TURKISH: "alpha.dev.libre.fm",
+ DOMAIN_RUSSIAN: "alpha.dev.libre.fm",
+ DOMAIN_JAPANESE: "alpha.dev.libre.fm",
+ DOMAIN_CHINESE: "alpha.dev.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)))
+
+ def __str__(self):
+ return "Libre.fm Network"
+
+def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""):
+ """
+ Returns 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.
+ """
+
+ _deprecation_warning("DeprecationWarning: Create a LibreFMNetwork object instead")
+
+ return LibreFMNetwork(api_key, api_secret, session_key, username, 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 get_xml(self, key):
+ return self.shelf[key]
+
+ def set_xml(self, key, xml_string):
+ self.shelf[key] = xml_string
+
+ def has_key(self, key):
+ return key in self.shelf.keys()
+
+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 not "api_sig" 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 + _string(self.params[key])
+
+ return hashlib.sha1(cache_key).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.cache.has_key(self._get_cache_key())
+
+ def _download_response(self):
+ """Returns a response body string from the server."""
+
+ # Delay the call if necessary
+ #self.network._delay_call() # enable it if you want.
+
+ 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 = HTTPConnection(host = self._get_proxy()[0], port = self._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 = HTTPConnection(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)
+
+ self._check_response_for_errors(response_text)
+ 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))
+
+ def _check_response_for_errors(self, response):
+ """Checks the response for errors and raises one if any exists."""
+
+ try:
+ doc = minidom.parseString(_string(response))
+ 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 infinie, unless the user provokes 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):
+ """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:
+ token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed.
+
+ 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", "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(funct):
+ def r(*args):
+ return _string(funct(*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):
+ self.network = network
+
+ 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):
+ return hash(self.network) + \
+ hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(self._get_params().values())).lower())
+
+class _Taggable(object):
+ """Common functions for classes with tags."""
+
+ def __init__(self, ws_prefix):
+ self.ws_prefix = ws_prefix
+
+ 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(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 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 Last.fm."""
+
+ def __init__(self, network, underlying_error):
+ self.network = network
+ self.underlying_error = underlying_error
+
+ def __str__(self):
+ return "Malformed response from Last.fm. Underlying error: %s" %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 Album(_BaseObject, _Taggable):
+ """An album."""
+
+ title = None
+ artist = None
+
+ def __init__(self, artist, title, network):
+ """
+ Create an album instance.
+ # Parameters:
+ * artist: An artist name or an Artist object.
+ * title: The album title.
+ """
+
+ _BaseObject.__init__(self, network)
+ _Taggable.__init__(self, 'album')
+
+ if isinstance(artist, Artist):
+ self.artist = artist
+ else:
+ self.artist = Artist(artist, self.network)
+
+ self.title = title
+
+ def __repr__(self):
+ return "pylast.Album(%s, %s, %s)" %(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):
+ return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower())
+
+ def __ne__(self, other):
+ return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower())
+
+ def _get_params(self):
+ return {'artist': self.get_artist().get_name(), 'album': self.get_title(), }
+
+ def get_artist(self):
+ """Returns the associated Artist object."""
+
+ return self.artist
+
+ def get_title(self):
+ """Returns the album title."""
+
+ return self.title
+
+ def get_name(self):
+ """Returns the album title (alias to Album.get_title)."""
+
+ return self.get_title()
+
+ def get_release_date(self):
+ """Retruns the release date of the album."""
+
+ return _extract(self._request("album.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("album.getInfo", cacheable = True), 'image')[size]
+
+ def get_id(self):
+ """Returns the ID"""
+
+ return _extract(self._request("album.getInfo", cacheable = True), "id")
+
+ def get_playcount(self):
+ """Returns the number of plays on the network"""
+
+ return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount"))
+
+ def get_listener_count(self):
+ """Returns the number of liteners on the network"""
+
+ return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners"))
+
+ def get_top_tags(self, limit=None):
+ """Returns a list of the most-applied tags to this album."""
+
+ doc = self._request("album.getInfo", True)
+ e = doc.getElementsByTagName("toptags")[0]
+
+ seq = []
+ for name in _extract_all(e, "name"):
+ seq.append(Tag(name, self.network))
+
+ if limit:
+ seq = seq[:limit]
+
+ return seq
+
+ def get_tracks(self):
+ """Returns the list of Tracks on this album."""
+
+ uri = 'lastfm://playlist/album/%s' %self.get_id()
+
+ return XSPF(uri, self.network).get_tracks()
+
+ def get_mbid(self):
+ """Returns the MusicBrainz id of the album."""
+
+ return _extract(self._request("album.getInfo", cacheable = True), "mbid")
+
+ def get_url(self, domain_name = DOMAIN_ENGLISH):
+ """Returns the url of the album 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())
+ album = _url_safe(self.get_title())
+
+ return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album}
+
+ def get_wiki_published_date(self):
+ """Returns the date of publishing this version of the wiki."""
+
+ doc = self._request("album.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "published")
+
+ def get_wiki_summary(self):
+ """Returns the summary of the wiki."""
+
+ doc = self._request("album.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "summary")
+
+ def get_wiki_content(self):
+ """Returns the content of the wiki."""
+
+ doc = self._request("album.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "content")
+
+class Artist(_BaseObject, _Taggable):
+ """An artist."""
+
+ name = None
+
+ def __init__(self, name, network):
+ """Create an artist object.
+ # Parameters:
+ * name str: The artist's name.
+ """
+
+ _BaseObject.__init__(self, network)
+ _Taggable.__init__(self, 'artist')
+
+ self.name = name
+
+ def __repr__(self):
+ return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_name()
+
+ def __eq__(self, other):
+ return self.get_name().lower() == other.get_name().lower()
+
+ def __ne__(self, other):
+ return self.get_name().lower() != other.get_name().lower()
+
+ def _get_params(self):
+ return {'artist': 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("artist.getInfo", True), "name")
+
+ return self.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("artist.getInfo", True), "image")[size]
+
+ def get_playcount(self):
+ """Returns the number of plays on the network."""
+
+ return _number(_extract(self._request("artist.getInfo", True), "playcount"))
+
+ def get_mbid(self):
+ """Returns the MusicBrainz ID of this artist."""
+
+ doc = self._request("artist.getInfo", True)
+
+ return _extract(doc, "mbid")
+
+ def get_listener_count(self):
+ """Returns the number of liteners on the network."""
+
+ if hasattr(self, "listener_count"):
+ return self.listener_count
+ else:
+ self.listener_count = _number(_extract(self._request("artist.getInfo", True), "listeners"))
+ return self.listener_count
+
+ def is_streamable(self):
+ """Returns True if the artist is streamable."""
+
+ return bool(_number(_extract(self._request("artist.getInfo", True), "streamable")))
+
+ def get_bio_published_date(self):
+ """Returns the date on which the artist's biography was published."""
+
+ return _extract(self._request("artist.getInfo", True), "published")
+
+ def get_bio_summary(self, language=None):
+ """Returns the summary of the artist's biography."""
+
+ if language:
+ params = self._get_params()
+ params["lang"] = language
+ else:
+ params = None
+
+ return _extract(self._request("artist.getInfo", True, params), "summary")
+
+ def get_bio_content(self, language=None):
+ """Returns the content of the artist's biography."""
+
+ if language:
+ params = self._get_params()
+ params["lang"] = language
+ else:
+ params = None
+
+ return _extract(self._request("artist.getInfo", True, params), "content")
+
+ def get_upcoming_events(self):
+ """Returns a list of the upcoming Events for this artist."""
+
+ doc = self._request('artist.getEvents', True)
+
+ ids = _extract_all(doc, 'id')
+
+ events = []
+ for e_id in ids:
+ events.append(Event(e_id, self.network))
+
+ return events
+
+ 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('artist.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):
+ """Retuns a list of the top albums."""
+
+ doc = self._request('artist.getTopAlbums', True)
+
+ 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, self.network), playcount))
+
+ return seq
+
+ def get_top_tracks(self):
+ """Returns a list of the most played Tracks by this artist."""
+
+ doc = self._request("artist.getTopTracks", True)
+
+ seq = []
+ for track in doc.getElementsByTagName('track'):
+
+ title = _extract(track, "name")
+ artist = _extract(track, "name", 1)
+ playcount = _number(_extract(track, "playcount"))
+
+ seq.append( TopItem(Track(artist, title, self.network), playcount) )
+
+ return seq
+
+ def get_top_fans(self, limit = None):
+ """Returns a list of the Users who played this artist the most.
+ # Parameters:
+ * limit int: Max elements.
+ """
+
+ doc = self._request('artist.getTopFans', True)
+
+ 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 artist (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.
+ """
+
+ #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)
+
+ nusers = []
+ for user in users:
+ if isinstance(user, User):
+ nusers.append(user.get_name())
+ else:
+ nusers.append(user)
+
+ params = self._get_params()
+ recipients = ','.join(nusers)
+ params['recipient'] = recipients
+ if message:
+ params['message'] = message
+
+ self._request('artist.share', False, params)
+
+ 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 get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None):
+ """
+ Returns a sequence of Image objects
+ if limit is None it will return all
+ order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE.
+
+ If limit==None, it will try to pull all the available data.
+ """
+
+ images = []
+
+ params = self._get_params()
+ params["order"] = order
+ nodes = _collect_nodes(limit, self, "artist.getImages", True, params)
+ for e in nodes:
+ if _extract(e, "name"):
+ user = User(_extract(e, "name"), self.network)
+ else:
+ user = None
+
+ images.append(Image(
+ _extract(e, "title"),
+ _extract(e, "url"),
+ _extract(e, "dateadded"),
+ _extract(e, "format"),
+ user,
+ ImageSizes(*_extract_all(e, "size")),
+ (_extract(e, "thumbsup"), _extract(e, "thumbsdown"))
+ )
+ )
+ return images
+
+ def get_shouts(self, limit=50):
+ """
+ Returns a sequqence of Shout objects
+ """
+
+ shouts = []
+ for node in _collect_nodes(limit, self, "artist.getShouts", False):
+ shouts.append(Shout(
+ _extract(node, "body"),
+ User(_extract(node, "author"), self.network),
+ _extract(node, "date")
+ )
+ )
+ return shouts
+
+ def shout(self, message):
+ """
+ Post a shout
+ """
+
+ params = self._get_params()
+ params["message"] = message
+
+ self._request("artist.Shout", False, params)
+
+
+class Event(_BaseObject):
+ """An event."""
+
+ id = None
+
+ def __init__(self, event_id, network):
+ _BaseObject.__init__(self, network)
+
+ 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 #" + self.get_id()
+
+ def __eq__(self, other):
+ return self.get_id() == other.get_id()
+
+ def __ne__(self, other):
+ return self.get_id() != other.get_id()
+
+ 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)
+
+ 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 share(self, users, message = None):
+ """Shares this event (sends out recommendations).
+ * users: A list that can contain usernames, emails, User objects, or all of them.
+ * message: A message to include in the recommendation message.
+ """
+
+ #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)
+
+ nusers = []
+ for user in users:
+ if isinstance(user, User):
+ nusers.append(user.get_name())
+ else:
+ nusers.append(user)
+
+ params = self._get_params()
+ recipients = ','.join(nusers)
+ params['recipient'] = recipients
+ if message:
+ params['message'] = message
+
+ self._request('event.share', False, params)
+
+ def get_shouts(self, limit=50):
+ """
+ Returns a sequqence of Shout objects
+ """
+
+ shouts = []
+ for node in _collect_nodes(limit, self, "event.getShouts", False):
+ shouts.append(Shout(
+ _extract(node, "body"),
+ User(_extract(node, "author"), self.network),
+ _extract(node, "date")
+ )
+ )
+ return shouts
+
+ 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
+
+ def __init__(self, name, network):
+ _BaseObject.__init__(self, network)
+
+ 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 self.get_name().lower() == other.get_name().lower()
+
+ def __ne__(self, other):
+ return self.get_name() != other.get_name()
+
+ def _get_params(self):
+ 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):
+ """Returns a sequence of the most played artists."""
+
+ doc = self._request('geo.getTopArtists', True)
+
+ seq = []
+ for node in doc.getElementsByTagName("artist"):
+ name = _extract(node, 'name')
+ playcount = _extract(node, "playcount")
+
+ seq.append(TopItem(Artist(name, self.network), playcount))
+
+ return seq
+
+ def get_top_tracks(self):
+ """Returns a sequence of the most played tracks"""
+
+ doc = self._request("geo.getTopTracks", True)
+
+ seq = []
+
+ for n in doc.getElementsByTagName('track'):
+
+ title = _extract(n, 'name')
+ artist = _extract(n, 'name', 1)
+ playcount = _number(_extract(n, "playcount"))
+
+ seq.append( TopItem(Track(artist, title, self.network), playcount))
+
+ return seq
+
+ 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 Library(_BaseObject):
+ """A user's Last.fm library."""
+
+ user = None
+
+ def __init__(self, user, network):
+ _BaseObject.__init__(self, network)
+
+ 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 add_artist(self, artist):
+ """Add an artist to this library."""
+
+ params = self._get_params()
+ params["artist"] = artist.get_name()
+
+ self._request("library.addArtist", False, params)
+
+ def add_track(self, track):
+ """Add a track to this library."""
+
+ params = self._get_params()
+ params["track"] = track.get_title()
+
+ self._request("library.addTrack", False, params)
+
+ def get_albums(self, artist=None, limit=50):
+ """
+ Returns a sequence of Album objects
+ If no artist is specified, it will return all, sorted by playcount descendingly.
+ 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, "library.getAlbums", True, 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):
+ """
+ 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, "library.getArtists", True):
+ 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):
+ """
+ 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, "library.getTracks", True, 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
+
+
+class Playlist(_BaseObject):
+ """A Last.fm user playlist."""
+
+ id = None
+ user = None
+
+ def __init__(self, user, id, network):
+ _BaseObject.__init__(self, network)
+
+ if isinstance(user, User):
+ self.user = user
+ else:
+ self.user = User(user, self.network)
+
+ self.id = 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):
+ """A Last.fm object tag."""
+
+ # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it)
+
+ name = None
+
+ def __init__(self, name, network):
+ _BaseObject.__init__(self, network)
+
+ 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 self.get_name().lower() == other.get_name().lower()
+
+ def __ne__(self, other):
+ return self.get_name().lower() != other.get_name().lower()
+
+ def _get_params(self):
+ return {'tag': self.get_name()}
+
+ def get_name(self, properly_capitalized=False):
+ """Returns the name of the tag. """
+
+ if properly_capitalized:
+ self.name = _extract(self._request("tag.getInfo", True), "name")
+
+ return self.name
+
+ def get_similar(self):
+ """Returns the tags similar to this one, ordered by similarity. """
+
+ doc = self._request('tag.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):
+ """Retuns a list of the top albums."""
+
+ doc = self._request('tag.getTopAlbums', True)
+
+ 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, self.network), playcount))
+
+ return seq
+
+ def get_top_tracks(self):
+ """Returns a list of the most played Tracks by this artist."""
+
+ doc = self._request("tag.getTopTracks", True)
+
+ seq = []
+ for track in doc.getElementsByTagName('track'):
+
+ title = _extract(track, "name")
+ artist = _extract(track, "name", 1)
+ playcount = _number(_extract(track, "playcount"))
+
+ seq.append( TopItem(Track(artist, title, self.network), playcount) )
+
+ return seq
+
+ def get_top_artists(self):
+ """Returns a sequence of the most played artists."""
+
+ doc = self._request('tag.getTopArtists', True)
+
+ seq = []
+ for node in doc.getElementsByTagName("artist"):
+ name = _extract(node, 'name')
+ playcount = _extract(node, "playcount")
+
+ seq.append(TopItem(Artist(name, self.network), playcount))
+
+ return seq
+
+ def get_weekly_chart_dates(self):
+ """Returns a list of From and To tuples for the available charts."""
+
+ doc = self._request("tag.getWeeklyChartList", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("chart"):
+ seq.append( (node.getAttribute("from"), node.getAttribute("to")) )
+
+ return seq
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("tag.getWeeklyArtistChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("artist"):
+ item = Artist(_extract(node, "name"), self.network)
+ weight = _number(_extract(node, "weight"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ 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(_BaseObject, _Taggable):
+ """A Last.fm track."""
+
+ artist = None
+ title = None
+
+ def __init__(self, artist, title, network):
+ _BaseObject.__init__(self, network)
+ _Taggable.__init__(self, 'track')
+
+ if isinstance(artist, Artist):
+ self.artist = artist
+ else:
+ self.artist = Artist(artist, self.network)
+
+ self.title = title
+
+ def __repr__(self):
+ return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network))
+
+ @_string_output
+ def __str__(self):
+ return self.get_artist().get_name() + ' - ' + self.get_title()
+
+ def __eq__(self, other):
+ return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower())
+
+ def __ne__(self, other):
+ return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower())
+
+ def _get_params(self):
+ return {'artist': self.get_artist().get_name(), 'track': self.get_title()}
+
+ def get_artist(self):
+ """Returns the associated Artist object."""
+
+ return self.artist
+
+ def get_title(self, properly_capitalized=False):
+ """Returns the track title."""
+
+ if properly_capitalized:
+ self.title = _extract(self._request("track.getInfo", True), "name")
+
+ return self.title
+
+ def get_name(self, properly_capitalized=False):
+ """Returns the track title (alias to Track.get_title)."""
+
+ return self.get_title(properly_capitalized)
+
+ def get_id(self):
+ """Returns the track id on the network."""
+
+ doc = self._request("track.getInfo", True)
+
+ return _extract(doc, "id")
+
+ def get_duration(self):
+ """Returns the track duration."""
+
+ doc = self._request("track.getInfo", True)
+
+ return _number(_extract(doc, "duration"))
+
+ def get_mbid(self):
+ """Returns the MusicBrainz ID of this track."""
+
+ doc = self._request("track.getInfo", True)
+
+ return _extract(doc, "mbid")
+
+ def get_listener_count(self):
+ """Returns the listener count."""
+
+ if hasattr(self, "listener_count"):
+ return self.listener_count
+ else:
+ doc = self._request("track.getInfo", True)
+ self.listener_count = _number(_extract(doc, "listeners"))
+ return self.listener_count
+
+ def get_playcount(self):
+ """Returns the play count."""
+
+ doc = self._request("track.getInfo", True)
+ return _number(_extract(doc, "playcount"))
+
+ def is_streamable(self):
+ """Returns True if the track is available at Last.fm."""
+
+ doc = self._request("track.getInfo", True)
+ return _extract(doc, "streamable") == "1"
+
+ def is_fulltrack_available(self):
+ """Returns True if the fulltrack is available for streaming."""
+
+ doc = self._request("track.getInfo", True)
+ return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1"
+
+ def get_album(self):
+ """Returns the album object of this track."""
+
+ doc = self._request("track.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 get_wiki_published_date(self):
+ """Returns the date of publishing this version of the wiki."""
+
+ doc = self._request("track.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "published")
+
+ def get_wiki_summary(self):
+ """Returns the summary of the wiki."""
+
+ doc = self._request("track.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "summary")
+
+ def get_wiki_content(self):
+ """Returns the content of the wiki."""
+
+ doc = self._request("track.getInfo", True)
+
+ if len(doc.getElementsByTagName("wiki")) == 0:
+ return
+
+ node = doc.getElementsByTagName("wiki")[0]
+
+ return _extract(node, "content")
+
+ def love(self):
+ """Adds the track to the user's loved tracks. """
+
+ self._request('track.love')
+
+ def ban(self):
+ """Ban this track from ever playing on the radio. """
+
+ self._request('track.ban')
+
+ def get_similar(self):
+ """Returns similar tracks for this track on the network, based on listening data. """
+
+ doc = self._request('track.getSimilar', True)
+
+ seq = []
+ for node in doc.getElementsByTagName("track"):
+ 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_top_fans(self, limit = None):
+ """Returns a list of the Users who played this track."""
+
+ doc = self._request('track.getTopFans', True)
+
+ 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 track (sends out recommendations).
+ * users: A list that can contain usernames, emails, User objects, or all of them.
+ * message: A message to include in the recommendation message.
+ """
+
+ #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)
+
+ nusers = []
+ for user in users:
+ if isinstance(user, User):
+ nusers.append(user.get_name())
+ else:
+ nusers.append(user)
+
+ params = self._get_params()
+ recipients = ','.join(nusers)
+ params['recipient'] = recipients
+ if message:
+ params['message'] = message
+
+ self._request('track.share', False, params)
+
+ def get_url(self, domain_name = DOMAIN_ENGLISH):
+ """Returns the url of the track 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
+ """
+
+ artist = _url_safe(self.get_artist().get_name())
+ title = _url_safe(self.get_title())
+
+ return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title}
+
+ def get_shouts(self, limit=50):
+ """
+ Returns a sequqence of Shout objects
+ """
+
+ shouts = []
+ for node in _collect_nodes(limit, self, "track.getShouts", False):
+ shouts.append(Shout(
+ _extract(node, "body"),
+ User(_extract(node, "author"), self.network),
+ _extract(node, "date")
+ )
+ )
+ return shouts
+
+class Group(_BaseObject):
+ """A Last.fm group."""
+
+ name = None
+
+ def __init__(self, group_name, network):
+ _BaseObject.__init__(self, network)
+
+ self.name = group_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 self.get_name().lower() == other.get_name().lower()
+
+ def __ne__(self, other):
+ return self.get_name() != other.get_name()
+
+ def _get_params(self):
+ return {'group': self.get_name()}
+
+ def get_name(self):
+ """Returns the group name. """
+ return self.name
+
+ def get_weekly_chart_dates(self):
+ """Returns a list of From and To tuples for the available charts."""
+
+ doc = self._request("group.getWeeklyChartList", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("chart"):
+ seq.append( (node.getAttribute("from"), node.getAttribute("to")) )
+
+ return seq
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("group.getWeeklyArtistChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("artist"):
+ item = Artist(_extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("group.getWeeklyAlbumChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("album"):
+ item = Album(_extract(node, "artist"), _extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("group.getWeeklyTrackChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("track"):
+ item = Track(_extract(node, "artist"), _extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ 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):
+ """
+ Returns a sequence of User objects
+ if limit==None it will return all
+ """
+
+ nodes = _collect_nodes(limit, self, "group.getMembers", False)
+
+ 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
+
+ def __init__(self, uri, network):
+ _BaseObject.__init__(self, network)
+
+ 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 n in doc.getElementsByTagName('track'):
+ title = _extract(n, 'title')
+ artist = _extract(n, 'creator')
+
+ seq.append(Track(artist, title, self.network))
+
+ return seq
+
+class User(_BaseObject):
+ """A Last.fm user."""
+
+ name = None
+
+ def __init__(self, user_name, network):
+ _BaseObject.__init__(self, network)
+
+ 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):
+ return self.get_name() == another.get_name()
+
+ def __ne__(self, another):
+ return self.get_name() != another.get_name()
+
+ def _get_params(self):
+ return {"user": self.get_name()}
+
+ def get_name(self, properly_capitalized=False):
+ """Returns the nuser name."""
+
+ if properly_capitalized:
+ self.name = _extract(self._request("user.getInfo", True), "name")
+
+ return self.name
+
+ def get_upcoming_events(self):
+ """Returns all the upcoming events for this user. """
+
+ doc = self._request('user.getEvents', True)
+
+ ids = _extract_all(doc, 'id')
+ events = []
+
+ for e_id in ids:
+ events.append(Event(e_id, self.network))
+
+ return events
+
+ def get_friends(self, limit = 50):
+ """Returns a list of the user's friends. """
+
+ seq = []
+ for node in _collect_nodes(limit, self, "user.getFriends", False):
+ seq.append(User(_extract(node, "name"), self.network))
+
+ return seq
+
+ def get_loved_tracks(self, limit=50):
+ """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, "user.getLovedTracks", True, 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):
+ """Returns a list of the user's friends."""
+
+ params = self._get_params()
+ if limit:
+ params['limit'] = limit
+
+ doc = self._request('user.getNeighbours', True, 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):
+ """
+ Returns a sequence of Event objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for n in _collect_nodes(limit, self, "user.getPastEvents", False):
+ seq.append(Event(_extract(n, "id"), self.network))
+
+ return seq
+
+ def get_playlists(self):
+ """Returns a list of Playlists that this user owns."""
+
+ doc = self._request("user.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('user.getRecentTracks', False, params)
+
+ e = doc.getElementsByTagName('track')[0]
+
+ if not e.hasAttribute('nowplaying'):
+ return None
+
+ artist = _extract(e, 'artist')
+ title = _extract(e, 'name')
+
+ return Track(artist, title, self.network)
+
+
+ def get_recent_tracks(self, limit = 10):
+ """Returns this user's played track as a sequence of PlayedTrack objects
+ in reverse order of their playtime, 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, "user.getRecentTracks", True, params):
+
+ if track.hasAttribute('nowplaying'):
+ continue #to prevent the now playing track from sneaking in here
+
+ title = _extract(track, "name")
+ artist = _extract(track, "artist")
+ date = _extract(track, "date")
+ timestamp = track.getElementsByTagName("date")[0].getAttribute("uts")
+
+ seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp))
+
+ return seq
+
+ def get_id(self):
+ """Returns the user id."""
+
+ doc = self._request("user.getInfo", True)
+
+ return _extract(doc, "id")
+
+ def get_language(self):
+ """Returns the language code of the language used by the user."""
+
+ doc = self._request("user.getInfo", True)
+
+ return _extract(doc, "lang")
+
+ def get_country(self):
+ """Returns the name of the country of the user."""
+
+ doc = self._request("user.getInfo", True)
+
+ return Country(_extract(doc, "country"), self.network)
+
+ def get_age(self):
+ """Returns the user's age."""
+
+ doc = self._request("user.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("user.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("user.getInfo", True)
+
+ return _extract(doc, "subscriber") == "1"
+
+ def get_playcount(self):
+ """Returns the user's playcount so far."""
+
+ doc = self._request("user.getInfo", True)
+
+ return _number(_extract(doc, "playcount"))
+
+ def get_top_albums(self, period = PERIOD_OVERALL):
+ """Returns the top albums played by a user.
+ * period: The period of time. Possible values:
+ o PERIOD_OVERALL
+ o PERIOD_7DAYS
+ o PERIOD_3MONTHS
+ o PERIOD_6MONTHS
+ o PERIOD_12MONTHS
+ """
+
+ params = self._get_params()
+ params['period'] = period
+
+ doc = self._request('user.getTopAlbums', True, params)
+
+ seq = []
+ for album in doc.getElementsByTagName('album'):
+ name = _extract(album, 'name')
+ artist = _extract(album, 'name', 1)
+ playcount = _extract(album, "playcount")
+
+ seq.append(TopItem(Album(artist, name, self.network), playcount))
+
+ return seq
+
+ def get_top_artists(self, period = PERIOD_OVERALL):
+ """Returns the top artists played by a user.
+ * period: The period of time. Possible values:
+ o PERIOD_OVERALL
+ o PERIOD_7DAYS
+ o PERIOD_3MONTHS
+ o PERIOD_6MONTHS
+ o PERIOD_12MONTHS
+ """
+
+ params = self._get_params()
+ params['period'] = period
+
+ doc = self._request('user.getTopArtists', True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName('artist'):
+ name = _extract(node, 'name')
+ playcount = _extract(node, "playcount")
+
+ seq.append(TopItem(Artist(name, self.network), playcount))
+
+ return seq
+
+ def get_top_tags(self, limit=None):
+ """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.
+ """
+
+ doc = self._request("user.getTopTags", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("tag"):
+ seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count")))
+
+ if limit:
+ seq = seq[:limit]
+
+ return seq
+
+ def get_top_tracks(self, period = PERIOD_OVERALL):
+ """Returns the top tracks played by a user.
+ * period: The period of time. Possible values:
+ o PERIOD_OVERALL
+ o PERIOD_7DAYS
+ o PERIOD_3MONTHS
+ o PERIOD_6MONTHS
+ o PERIOD_12MONTHS
+ """
+
+ params = self._get_params()
+ params['period'] = period
+
+ doc = self._request('user.getTopTracks', True, params)
+
+ seq = []
+ for track in doc.getElementsByTagName('track'):
+ name = _extract(track, 'name')
+ artist = _extract(track, 'name', 1)
+ playcount = _extract(track, "playcount")
+
+ seq.append(TopItem(Track(artist, name, self.network), playcount))
+
+ return seq
+
+ def get_weekly_chart_dates(self):
+ """Returns a list of From and To tuples for the available charts."""
+
+ doc = self._request("user.getWeeklyChartList", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("chart"):
+ seq.append( (node.getAttribute("from"), node.getAttribute("to")) )
+
+ return seq
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("user.getWeeklyArtistChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("artist"):
+ item = Artist(_extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("user.getWeeklyAlbumChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("album"):
+ item = Album(_extract(node, "artist"), _extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ 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."""
+
+ params = self._get_params()
+ if from_date and to_date:
+ params["from"] = from_date
+ params["to"] = to_date
+
+ doc = self._request("user.getWeeklyTrackChart", True, params)
+
+ seq = []
+ for node in doc.getElementsByTagName("track"):
+ item = Track(_extract(node, "artist"), _extract(node, "name"), self.network)
+ weight = _number(_extract(node, "playcount"))
+ seq.append(TopItem(item, weight))
+
+ return seq
+
+ 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("user.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 get_shouts(self, limit=50):
+ """
+ Returns a sequqence of Shout objects
+ """
+
+ shouts = []
+ for node in _collect_nodes(limit, self, "user.getShouts", False):
+ shouts.append(Shout(
+ _extract(node, "body"),
+ User(_extract(node, "author"), self.network),
+ _extract(node, "date")
+ )
+ )
+ return shouts
+
+ def shout(self, message):
+ """
+ Post a shout
+ """
+
+ params = self._get_params()
+ params["message"] = message
+
+ self._request("user.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):
+ """
+ Returns a sequence of Event objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False):
+ seq.append(Event(_extract(node, "id"), self.network))
+
+ return seq
+
+ def get_recommended_artists(self, limit=50):
+ """
+ Returns a sequence of Event objects
+ if limit==None it will return all
+ """
+
+ seq = []
+ for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False):
+ 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)
+
+ 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 _retreive_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._retreive_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 wanna 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 wanna 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.
+
+ id = None
+
+ def __init__(self, id, network):
+ _BaseObject.__init__(self, network)
+
+ self.id = _number(id)
+
+ 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 {"venue": self.get_id()}
+
+ def get_id(self):
+ """Returns the id of the venue."""
+
+ return self.id
+
+ def get_upcoming_events(self):
+ """Returns the upcoming events in this venue."""
+
+ doc = self._request("venue.getEvents", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("event"):
+ seq.append(Event(_extract(node, "id"), self.network))
+
+ return seq
+
+ def get_past_events(self):
+ """Returns the past events held in this venue."""
+
+ doc = self._request("venue.getEvents", True)
+
+ seq = []
+ for node in doc.getElementsByTagName("event"):
+ seq.append(Event(_extract(node, "id"), self.network))
+
+ 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 sys.version_info[0] == 3:
+ if type(text) in (bytes, bytearray):
+ return str(text, "utf-8")
+ elif type(text) == str:
+ return text
+ else:
+ return str(text)
+
+ elif sys.version_info[0] ==2:
+ if type(text) in (str,):
+ return unicode(text, "utf-8")
+ elif type(text) == unicode:
+ return text
+ else:
+ return unicode(text)
+
+def _string(text):
+ """For Python2 routines that can only process str type."""
+
+ if sys.version_info[0] == 3:
+ if type(text) != str:
+ return str(text)
+ else:
+ return text
+
+ elif sys.version_info[0] == 2:
+ if type(text) == str:
+ return text
+
+ if type(text) == int:
+ return str(text)
+
+ return text.encode("utf-8")
+
+def _collect_nodes(limit, sender, method_name, cacheable, params=None):
+ """
+ Returns a sequqnce 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)
+
+ main = doc.documentElement.childNodes[1]
+
+ 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 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_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 _url_safe(text):
+ """Does all kinds of tricks on a text to make it safe to use in a url."""
+
+ return url_quote_plus(url_quote_plus(_string(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
+ 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 extract_items(topitems_or_libraryitems):
+ """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects."""
+
+ seq = []
+ for i in topitems_or_libraryitems:
+ 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, type="POST"):
+
+ for key in params:
+ params[key] = str(params[key])
+
+ self.params = params
+ self.type = 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 = HTTPConnection(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 erros, 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)
+
+class Scrobbler(object):
+ """A class for scrobbling tracks to Last.fm"""
+
+ session_id = None
+ nowplaying_url = None
+ submissions_url = None
+
+ def __init__(self, network, client_id, client_version):
+ self.client_id = client_id
+ self.client_version = client_version
+ self.username = network.username
+ self.password = network.password_hash
+ self.network = network
+
+ def _do_handshake(self):
+ """Handshakes with the server"""
+
+ timestamp = str(int(time.time()))
+
+ if self.password and self.username:
+ token = md5(self.password + timestamp)
+ elif self.network.api_key and self.network.api_secret and self.network.session_key:
+ if not self.username:
+ self.username = self.network.get_authenticated_user().get_name()
+ token = md5(self.network.api_secret + timestamp)
+
+ params = {"hs": "true", "p": "1.2.1", "c": self.client_id,
+ "v": self.client_version, "u": self.username, "t": timestamp,
+ "a": token}
+
+ if self.network.session_key and self.network.api_key:
+ params["sk"] = self.network.session_key
+ params["api_key"] = self.network.api_key
+
+ server = self.network.submission_server
+ response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n")
+
+ self.session_id = response[1]
+ self.nowplaying_url = response[2]
+ self.submissions_url = response[3]
+
+ def _get_session_id(self, new = False):
+ """Returns a handshake. If new is true, then it will be requested from the server
+ even if one was cached."""
+
+ if not self.session_id or new:
+ self._do_handshake()
+
+ return self.session_id
+
+ def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""):
+
+ _deprecation_warning("DeprecationWarning: Use Netowrk.update_now_playing(...) instead")
+
+ params = {"s": self._get_session_id(), "a": artist, "t": title,
+ "b": album, "l": duration, "n": track_number, "m": mbid}
+
+ try:
+ _ScrobblerRequest(self.nowplaying_url, params, self.network).execute()
+ except BadSessionError:
+ self._do_handshake()
+ self.report_now_playing(artist, title, album, duration, track_number, mbid)
+
+ def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""):
+ """Scrobble a track. parameters:
+ artist: Artist name.
+ title: Track title.
+ time_started: UTC timestamp of when the track started playing.
+ source: The source of the track
+ SCROBBLE_SOURCE_USER: Chosen by the user (the most common value, unless you have a reason for choosing otherwise, use this).
+ SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1).
+ SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised recommendation except Last.fm (e.g. Pandora, Launchcast).
+ SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the 5-digit recommendation_key value must be set.
+ SCROBBLE_SOURCE_UNKNOWN: Source unknown.
+ mode: The submission mode
+ SCROBBLE_MODE_PLAYED: The track was played.
+ SCROBBLE_MODE_LOVED: The user manually loved the track (implies a listen)
+ SCROBBLE_MODE_SKIPPED: The track was skipped (Only if source was Last.fm)
+ SCROBBLE_MODE_BANNED: The track was banned (Only if source was Last.fm)
+ duration: Track duration in seconds.
+ album: The album name.
+ track_number: The track number on the album.
+ mbid: MusicBrainz ID.
+ """
+
+ _deprecation_warning("DeprecationWarning: Use Network.scrobble(...) instead")
+
+ params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title),
+ "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration),
+ "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid}
+
+ _ScrobblerRequest(self.submissions_url, params, self.network).execute()
+
+ def scrobble_many(self, tracks):
+ """
+ Scrobble several tracks at once.
+
+ tracks: A sequence of a sequence of parameters for each trach. The order of parameters
+ is the same as if passed to the scrobble() method.
+ """
+
+ _deprecation_warning("DeprecationWarning: Use Network.scrobble_many(...) instead")
+
+ remainder = []
+
+ if len(tracks) > 50:
+ remainder = tracks[50:]
+ tracks = tracks[:50]
+
+ params = {"s": self._get_session_id()}
+
+ i = 0
+ for t in tracks:
+ _pad_list(t, 9, "")
+ params["a[%s]" % str(i)] = _string(t[0])
+ params["t[%s]" % str(i)] = _string(t[1])
+ params["i[%s]" % str(i)] = str(t[2])
+ params["o[%s]" % str(i)] = t[3]
+ params["r[%s]" % str(i)] = t[4]
+ params["l[%s]" % str(i)] = str(t[5])
+ params["b[%s]" % str(i)] = _string(t[6])
+ params["n[%s]" % str(i)] = t[7]
+ params["m[%s]" % str(i)] = t[8]
+
+ i += 1
+
+ _ScrobblerRequest(self.submissions_url, params, self.network).execute()
+
+ if remainder:
+ self.scrobble_many(remainder)
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..4964af9
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+
+import os
+def get_build():
+ path = "./.build"
+
+ if os.path.exists(path):
+ fp = open(path, "r")
+ build = eval(fp.read())
+ if os.path.exists("./.increase_build"):
+ build += 1
+ fp.close()
+ else:
+ build = 1
+
+ fp = open(path, "w")
+ fp.write(str(build))
+ fp.close()
+
+ return str(build)
+
+setup(name = "pylast",
+ version = "0.5." + get_build(),
+ author = "Amr Hassan ",
+ description = "A Python interface to Last.fm (and other API compatible social networks)",
+ author_email = "amr.hassan@gmail.com",
+ url = "http://code.google.com/p/pylast/",
+ py_modules = ("pylast",),
+ license = "Apache2"
+ )
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/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
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
deleted file mode 100755
index c06a9c3..0000000
--- a/tests/test_pylast.py
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env python
-"""
-Integration (not unit) tests for pylast.py
-"""
-from __future__ import annotations
-
-import os
-import time
-
-import pytest
-from flaky import flaky
-
-import pylast
-
-WRITE_TEST = False
-
-
-def load_secrets(): # pragma: no cover
- secrets_file = "test_pylast.yaml"
- if os.path.isfile(secrets_file):
- import yaml # pip install pyyaml
-
- with open(secrets_file) as f: # see example_test_pylast.yaml
- doc = yaml.load(f)
- else:
- doc = {}
- try:
- doc["username"] = os.environ["PYLAST_USERNAME"].strip()
- doc["password_hash"] = os.environ["PYLAST_PASSWORD_HASH"].strip()
- doc["api_key"] = os.environ["PYLAST_API_KEY"].strip()
- doc["api_secret"] = os.environ["PYLAST_API_SECRET"].strip()
- except KeyError:
- pytest.skip("Missing environment variables: PYLAST_USERNAME etc.")
- return doc
-
-
-def _no_xfail_rerun_filter(err, name, test, plugin) -> bool:
- for _ in test.iter_markers(name="xfail"):
- return False
-
-
-@flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter)
-class TestPyLastWithLastFm:
- secrets = None
-
- @staticmethod
- def unix_timestamp() -> int:
- return int(time.time())
-
- @classmethod
- def setup_class(cls) -> None:
- if cls.secrets is None:
- cls.secrets = load_secrets()
-
- cls.username = cls.secrets["username"]
- password_hash = cls.secrets["password_hash"]
-
- api_key = cls.secrets["api_key"]
- api_secret = cls.secrets["api_secret"]
-
- cls.network = pylast.LastFMNetwork(
- api_key=api_key,
- api_secret=api_secret,
- username=cls.username,
- password_hash=password_hash,
- )
-
- @staticmethod
- def helper_is_thing_hashable(thing) -> None:
- # Arrange
- things = set()
-
- # Act
- things.add(thing)
-
- # Assert
- assert thing is not None
- assert len(things) == 1
-
- @staticmethod
- def helper_validate_results(a, b, c) -> None:
- # Assert
- assert a is not None
- assert b is not None
- assert c is not None
- assert isinstance(len(a), int)
- assert isinstance(len(b), int)
- assert isinstance(len(c), int)
- assert a == b
- assert b == c
-
- def helper_validate_cacheable(self, thing, function_name) -> None:
- # Arrange
- # get thing.function_name()
- func = getattr(thing, function_name, None)
-
- # Act
- result1 = func(limit=1, cacheable=False)
- result2 = func(limit=1, cacheable=True)
- result3 = list(func(limit=1))
-
- # Assert
- self.helper_validate_results(result1, result2, result3)
-
- @staticmethod
- def helper_at_least_one_thing_in_top_list(things, expected_type) -> None:
- # Assert
- assert len(things) > 1
- assert isinstance(things, list)
- assert isinstance(things[0], pylast.TopItem)
- assert isinstance(things[0].item, expected_type)
-
- @staticmethod
- def helper_only_one_thing_in_top_list(things, expected_type) -> None:
- # Assert
- assert len(things) == 1
- assert isinstance(things, list)
- assert isinstance(things[0], pylast.TopItem)
- assert isinstance(things[0].item, expected_type)
-
- @staticmethod
- def helper_only_one_thing_in_list(things, expected_type) -> None:
- # Assert
- assert len(things) == 1
- assert isinstance(things, list)
- assert isinstance(things[0], expected_type)
-
- @staticmethod
- def helper_two_different_things_in_top_list(things, expected_type) -> None:
- # Assert
- assert len(things) == 2
- thing1 = things[0]
- thing2 = things[1]
- assert isinstance(thing1, pylast.TopItem)
- assert isinstance(thing2, pylast.TopItem)
- assert isinstance(thing1.item, expected_type)
- assert isinstance(thing2.item, expected_type)
- assert thing1 != thing2
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
deleted file mode 100644
index 67f234b..0000000
--- a/tests/unicode_test.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from __future__ import annotations
-
-from unittest import mock
-
-import pytest
-
-import pylast
-
-
-def mock_network():
- return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", "")))
-
-
-@pytest.mark.parametrize(
- "artist",
- [
- "\xe9lafdasfdsafdsa",
- "ééééééé",
- pylast.Artist("B\xe9l", mock_network()),
- "fdasfdsafsaf not unicode",
- ],
-)
-def test_get_cache_key(artist) -> None:
- request = pylast._Request(mock_network(), "some_method", params={"artist": artist})
- request._get_cache_key()
-
-
-@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())])
-def test_cast_and_hash(obj) -> None:
- assert isinstance(str(obj), str)
- assert isinstance(hash(obj), int)
-
-
-@pytest.mark.parametrize(
- "test_input, expected",
- [
- (
- # Plain text
- 'test album name',
- 'test album name',
- ),
- (
- # Contains Unicode ENQ Enquiry control character
- 'test album \u0005name',
- 'test album name',
- ),
- ],
-)
-def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None:
- assert pylast._remove_invalid_xml_chars(test_input) == expected
-
-
-@pytest.mark.parametrize(
- "test_input, expected",
- [
- (
- # Plain text
- 'test album name',
- 'test album name',
- ),
- (
- # Contains Unicode ENQ Enquiry control character
- 'test album \u0005name',
- 'test album name',
- ),
- ],
-)
-def test__parse_response(test_input: str, expected: str) -> None:
- doc = pylast._parse_response(test_input)
- assert doc.toxml() == expected
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 3ead5fc..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,40 +0,0 @@
-[tox]
-requires =
- tox>=4.2
-env_list =
- lint
- py{py3, 313, 312, 311, 310, 39, 38}
-
-[testenv]
-extras =
- tests
-pass_env =
- FORCE_COLOR
- PYLAST_API_KEY
- PYLAST_API_SECRET
- PYLAST_PASSWORD_HASH
- PYLAST_USERNAME
-commands =
- {envpython} -m pytest -v -s -W all \
- --cov pylast \
- --cov tests \
- --cov-report html \
- --cov-report term-missing \
- --cov-report xml \
- --random-order \
- {posargs}
-
-[testenv:lint]
-skip_install = true
-deps =
- pre-commit
-pass_env =
- PRE_COMMIT_COLOR
-commands =
- pre-commit run --all-files --show-diff-on-failure
-
-[testenv:venv]
-deps =
- ipdb
-commands =
- {posargs}