pureart 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pureart-1.0.0/.gitignore +207 -0
- pureart-1.0.0/LICENSE +21 -0
- pureart-1.0.0/PKG-INFO +41 -0
- pureart-1.0.0/README.md +4 -0
- pureart-1.0.0/pyproject.toml +29 -0
- pureart-1.0.0/src/pureart/__init__.py +8 -0
- pureart-1.0.0/src/pureart/__main__.py +11 -0
- pureart-1.0.0/src/pureart/backend.py +240 -0
- pureart-1.0.0/src/pureart/frontend.py +734 -0
- pureart-1.0.0/src/pureart/styles.tcss +339 -0
pureart-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
pureart-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SrikarC6
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pureart-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pureart
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: TUI app to download high-resolution album artwork via the iTunes Search API
|
|
5
|
+
Project-URL: Homepage, https://github.com/SrikarC6/pureart
|
|
6
|
+
Project-URL: Repository, https://github.com/SrikarC6/pureart
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 SrikarC6
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Requires-Dist: pillow>=12.1.1
|
|
31
|
+
Requires-Dist: pyfiglet>=1.0.4
|
|
32
|
+
Requires-Dist: requests>=2.32.5
|
|
33
|
+
Requires-Dist: rich>=14.3.3
|
|
34
|
+
Requires-Dist: textual-image>=0.8.5
|
|
35
|
+
Requires-Dist: textual>=8.1.1
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# PureArt
|
|
39
|
+
Python TUI application to extract album artwork utilizing iTunes Search API.
|
|
40
|
+
|
|
41
|
+
WORK IN PROGRESS!!
|
pureart-1.0.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pureart"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "TUI app to download high-resolution album artwork via the iTunes Search API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"textual>=8.1.1",
|
|
14
|
+
"textual-image>=0.8.5",
|
|
15
|
+
"requests>=2.32.5",
|
|
16
|
+
"Pillow>=12.1.1",
|
|
17
|
+
"pyfiglet>=1.0.4",
|
|
18
|
+
"rich>=14.3.3",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
pureart = "pureart.__main__:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/SrikarC6/pureart"
|
|
26
|
+
Repository = "https://github.com/SrikarC6/pureart"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/pureart"]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Backend module for PureArt — handles iTunes Search API calls and artwork downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import NamedTemporaryFile
|
|
10
|
+
from typing import Literal, TypedDict
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from PIL import Image as PILImage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://itunes.apple.com/search"
|
|
17
|
+
SearchType = Literal["album", "song", "artist"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArtworkResult(TypedDict):
|
|
21
|
+
artist_name: str
|
|
22
|
+
collection_name: str
|
|
23
|
+
release_year: str
|
|
24
|
+
artwork_link: str
|
|
25
|
+
preview_url: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArtworkError(Exception):
|
|
29
|
+
"""Base class for artwork-related failures."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArtworkSearchError(ArtworkError):
|
|
33
|
+
"""Raised when the iTunes search request cannot be completed."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ArtworkDownloadError(ArtworkError):
|
|
37
|
+
"""Raised when artwork cannot be downloaded or saved."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ArtworkPreviewError(ArtworkError):
|
|
41
|
+
"""Raised when an artwork preview cannot be fetched."""
|
|
42
|
+
|
|
43
|
+
SEARCH_CONFIG: dict[str, dict[str, str]] = {
|
|
44
|
+
"album": {"entity": "album", "attribute": "albumTerm"},
|
|
45
|
+
"song": {"entity": "song", "attribute": "songTerm"},
|
|
46
|
+
"artist": {"entity": "album", "attribute": "artistTerm"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_year(date_str: str) -> str:
|
|
51
|
+
"""Extract the year from an ISO date string like '2022-01-07T00:00:00Z'."""
|
|
52
|
+
if not date_str:
|
|
53
|
+
return "Unknown"
|
|
54
|
+
try:
|
|
55
|
+
normalized = date_str.replace("Z", "+00:00")
|
|
56
|
+
return str(datetime.fromisoformat(normalized).year)
|
|
57
|
+
except ValueError:
|
|
58
|
+
match = re.match(r"^(\d{4})", date_str)
|
|
59
|
+
if match:
|
|
60
|
+
return match.group(1)
|
|
61
|
+
return "Unknown"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sanitize_filename(name: str) -> str:
|
|
65
|
+
"""Remove or replace characters that are invalid in filenames."""
|
|
66
|
+
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
|
|
67
|
+
sanitized = sanitized.strip(". ")
|
|
68
|
+
return sanitized or "artwork"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_artwork_url(preview_url: str) -> str:
|
|
72
|
+
"""Build a best-effort high-resolution artwork URL from the preview URL."""
|
|
73
|
+
if not preview_url:
|
|
74
|
+
return ""
|
|
75
|
+
return preview_url.replace("100x100bb", "10000x10000bb")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_result(item: dict[str, object]) -> ArtworkResult | None:
|
|
79
|
+
preview_url = str(item.get("artworkUrl100") or "").strip()
|
|
80
|
+
if not preview_url:
|
|
81
|
+
return None
|
|
82
|
+
artist_name = str(item.get("artistName") or "Unknown Artist")
|
|
83
|
+
collection_name = str(
|
|
84
|
+
item.get("collectionName") or item.get("trackName") or "Unknown"
|
|
85
|
+
)
|
|
86
|
+
release_year = _extract_year(str(item.get("releaseDate") or ""))
|
|
87
|
+
return ArtworkResult(
|
|
88
|
+
artist_name=artist_name,
|
|
89
|
+
collection_name=collection_name,
|
|
90
|
+
release_year=release_year,
|
|
91
|
+
artwork_link=_build_artwork_url(preview_url),
|
|
92
|
+
preview_url=preview_url,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def search_artwork(
|
|
97
|
+
search_type: SearchType | str, name: str, limit: int = 200
|
|
98
|
+
) -> list[ArtworkResult]:
|
|
99
|
+
"""Search the iTunes API for album artwork.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
search_type: One of 'album', 'song', or 'artist'.
|
|
103
|
+
name: The search query string.
|
|
104
|
+
limit: Maximum number of results (default 200, iTunes max).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
A list of dicts with keys: artist_name, collection_name,
|
|
108
|
+
release_year, artwork_link, preview_url.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If search_type is not valid.
|
|
112
|
+
ArtworkSearchError: If the API call fails or returns invalid JSON.
|
|
113
|
+
"""
|
|
114
|
+
if search_type not in SEARCH_CONFIG:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Invalid search type '{search_type}'. Expected: {list(SEARCH_CONFIG)}"
|
|
117
|
+
)
|
|
118
|
+
query = name.strip()
|
|
119
|
+
if not query:
|
|
120
|
+
return []
|
|
121
|
+
if limit < 1:
|
|
122
|
+
raise ValueError("Search limit must be at least 1")
|
|
123
|
+
limit = min(limit, 200)
|
|
124
|
+
|
|
125
|
+
config = SEARCH_CONFIG[search_type]
|
|
126
|
+
params = {
|
|
127
|
+
"term": query,
|
|
128
|
+
"entity": config["entity"],
|
|
129
|
+
"attribute": config["attribute"],
|
|
130
|
+
"limit": limit,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
response = requests.get(BASE_URL, params=params, timeout=15)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
except requests.Timeout as exc:
|
|
137
|
+
raise ArtworkSearchError(
|
|
138
|
+
"Apple Music search timed out. Please try again."
|
|
139
|
+
) from exc
|
|
140
|
+
except requests.RequestException as exc:
|
|
141
|
+
raise ArtworkSearchError(
|
|
142
|
+
"Unable to reach Apple Music right now. Please try again."
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
data = response.json()
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise ArtworkSearchError(
|
|
149
|
+
"Apple Music returned an invalid response. Please try again."
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
raw_results = data.get("results", [])
|
|
153
|
+
if not isinstance(raw_results, list):
|
|
154
|
+
raise ArtworkSearchError("Apple Music returned an unexpected response format.")
|
|
155
|
+
|
|
156
|
+
normalized_results = []
|
|
157
|
+
for item in raw_results:
|
|
158
|
+
if not isinstance(item, dict):
|
|
159
|
+
continue
|
|
160
|
+
result = _normalize_result(item)
|
|
161
|
+
if result is not None:
|
|
162
|
+
normalized_results.append(result)
|
|
163
|
+
return normalized_results
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def fetch_preview_image(url: str, timeout: float = 10) -> PILImage.Image:
|
|
167
|
+
"""Fetch and decode a preview image for terminal display."""
|
|
168
|
+
preview_url = url.strip()
|
|
169
|
+
if not preview_url:
|
|
170
|
+
raise ArtworkPreviewError("Missing preview image URL.")
|
|
171
|
+
try:
|
|
172
|
+
response = requests.get(preview_url, timeout=timeout)
|
|
173
|
+
response.raise_for_status()
|
|
174
|
+
return PILImage.open(io.BytesIO(response.content))
|
|
175
|
+
except requests.Timeout as exc:
|
|
176
|
+
raise ArtworkPreviewError("Preview image request timed out.") from exc
|
|
177
|
+
except requests.RequestException as exc:
|
|
178
|
+
raise ArtworkPreviewError("Unable to load preview image.") from exc
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise ArtworkPreviewError("Preview image data could not be decoded.") from exc
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def download_artwork(
|
|
184
|
+
url: str, save_dir: Path, artist_name: str, collection_name: str
|
|
185
|
+
) -> Path:
|
|
186
|
+
"""Download artwork from a URL and save it to disk.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
url: The full-resolution artwork URL.
|
|
190
|
+
save_dir: Directory to save the file in.
|
|
191
|
+
artist_name: Artist name for the filename.
|
|
192
|
+
collection_name: Album/collection name for the filename.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The Path to the saved file.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ArtworkDownloadError: If the download or file save fails.
|
|
199
|
+
"""
|
|
200
|
+
artwork_url = url.strip()
|
|
201
|
+
if not artwork_url:
|
|
202
|
+
raise ArtworkDownloadError("This result does not have a downloadable artwork URL.")
|
|
203
|
+
|
|
204
|
+
filename = (
|
|
205
|
+
f"{_sanitize_filename(collection_name)}_{_sanitize_filename(artist_name)}.jpg"
|
|
206
|
+
)
|
|
207
|
+
save_path = save_dir / filename
|
|
208
|
+
try:
|
|
209
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise ArtworkDownloadError(
|
|
212
|
+
f"Unable to create the save directory: {save_dir}"
|
|
213
|
+
) from exc
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
response = requests.get(artwork_url, timeout=30, stream=True)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
except requests.Timeout as exc:
|
|
219
|
+
raise ArtworkDownloadError("Artwork download timed out. Please try again.") from exc
|
|
220
|
+
except requests.RequestException as exc:
|
|
221
|
+
raise ArtworkDownloadError(
|
|
222
|
+
"Unable to download the selected artwork."
|
|
223
|
+
) from exc
|
|
224
|
+
|
|
225
|
+
temp_path: Path | None = None
|
|
226
|
+
try:
|
|
227
|
+
with NamedTemporaryFile("wb", delete=False, dir=save_dir, suffix=".tmp") as temp_file:
|
|
228
|
+
temp_path = Path(temp_file.name)
|
|
229
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
230
|
+
if chunk:
|
|
231
|
+
temp_file.write(chunk)
|
|
232
|
+
temp_path.replace(save_path)
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
if temp_path is not None and temp_path.exists():
|
|
235
|
+
temp_path.unlink(missing_ok=True)
|
|
236
|
+
raise ArtworkDownloadError(
|
|
237
|
+
f"Unable to save artwork to {save_dir}."
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
return save_path
|