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.
@@ -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!!
@@ -0,0 +1,4 @@
1
+ # PureArt
2
+ Python TUI application to extract album artwork utilizing iTunes Search API.
3
+
4
+ WORK IN PROGRESS!!
@@ -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,8 @@
1
+ """PureArt — TUI app to download high-resolution album artwork."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("pureart")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
@@ -0,0 +1,11 @@
1
+ """Entry point for PureArt — invoked by `pureart` command or `python -m pureart`."""
2
+
3
+ from pureart.frontend import PureArt
4
+
5
+
6
+ def main() -> None:
7
+ PureArt().run()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -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