campground 0.1.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,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: campground
3
+ Version: 0.1.0
4
+ Summary: Download albums from your Bandcamp library
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/oxfordbags/campground
7
+ Project-URL: Repository, https://github.com/oxfordbags/campground
8
+ Keywords: bandcamp,music,download
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Multimedia :: Sound/Audio
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: curl-cffi>=0.5
17
+
18
+ # campground
19
+
20
+ A lightweight command-line tool for downloading albums from your Bandcamp library.
21
+
22
+ ## Requirements
23
+
24
+ - Python 3.11+
25
+ - A Bandcamp account with purchased items
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ pip install git+https://github.com/oxfordbags/campground.git
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ Campground authenticates using your browser session cookies. You only need to do this once (or when your session expires).
36
+
37
+ 1. Log in to [bandcamp.com](https://bandcamp.com) in your browser
38
+ 2. Open Dev Tools (`Cmd+Option+I` on Mac, `F12` on Windows)
39
+ 3. Go to the **Network** tab and refresh the page
40
+ 4. Click any `bandcamp.com` request and find the **Cookie:** request header
41
+ 5. Copy the full value
42
+
43
+ Then add it to `~/.config/campground/config.toml`:
44
+
45
+ ```toml
46
+ [bandcamp]
47
+ cookies = "your_cookie_string_here"
48
+
49
+ [download]
50
+ format = "flac" # optional, default: flac
51
+ output_dir = "~/Music" # optional, default: current directory
52
+ ```
53
+
54
+ Or pass cookies directly on the command line:
55
+
56
+ ```sh
57
+ campground <url> --cookies "your_cookie_string_here"
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```sh
63
+ campground <bandcamp-album-url> [options]
64
+ ```
65
+
66
+ **Examples:**
67
+
68
+ ```sh
69
+ # Download an album in the default format (flac)
70
+ campground https://artist.bandcamp.com/album/album-title
71
+
72
+ # Choose a format
73
+ campground https://artist.bandcamp.com/album/album-title --format mp3-320
74
+
75
+ # Download to a specific directory
76
+ campground https://artist.bandcamp.com/album/album-title --output ~/Music/Bandcamp
77
+
78
+ # Re-download and replace an existing copy
79
+ campground https://artist.bandcamp.com/album/album-title --overwrite
80
+ ```
81
+
82
+ ## Options
83
+
84
+ | Flag | Description |
85
+ |---|---|
86
+ | `-f`, `--format` | Audio format (see below). Default: `flac` |
87
+ | `-o`, `--output` | Output directory. Default: current directory |
88
+ | `--cookies` | Cookie string from browser dev tools |
89
+ | `--cookies-file` | Path to a file containing the cookie string |
90
+ | `--overwrite` | Replace an existing download in the output directory |
91
+
92
+ **Supported formats:** `mp3-v0`, `mp3-320`, `flac`, `aac-hi`, `vorbis`, `alac`, `wav`, `aiff-lossless`
93
+
94
+ ## How it works
95
+
96
+ Campground uses your session cookies to access the Bandcamp collection API, locates the requested album, fetches a signed download link for your chosen format, and streams the file to a temporary directory before extracting and moving it to the output directory. If anything goes wrong the temporary files are cleaned up automatically.
@@ -0,0 +1,79 @@
1
+ # campground
2
+
3
+ A lightweight command-line tool for downloading albums from your Bandcamp library.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.11+
8
+ - A Bandcamp account with purchased items
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ pip install git+https://github.com/oxfordbags/campground.git
14
+ ```
15
+
16
+ ## Setup
17
+
18
+ Campground authenticates using your browser session cookies. You only need to do this once (or when your session expires).
19
+
20
+ 1. Log in to [bandcamp.com](https://bandcamp.com) in your browser
21
+ 2. Open Dev Tools (`Cmd+Option+I` on Mac, `F12` on Windows)
22
+ 3. Go to the **Network** tab and refresh the page
23
+ 4. Click any `bandcamp.com` request and find the **Cookie:** request header
24
+ 5. Copy the full value
25
+
26
+ Then add it to `~/.config/campground/config.toml`:
27
+
28
+ ```toml
29
+ [bandcamp]
30
+ cookies = "your_cookie_string_here"
31
+
32
+ [download]
33
+ format = "flac" # optional, default: flac
34
+ output_dir = "~/Music" # optional, default: current directory
35
+ ```
36
+
37
+ Or pass cookies directly on the command line:
38
+
39
+ ```sh
40
+ campground <url> --cookies "your_cookie_string_here"
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```sh
46
+ campground <bandcamp-album-url> [options]
47
+ ```
48
+
49
+ **Examples:**
50
+
51
+ ```sh
52
+ # Download an album in the default format (flac)
53
+ campground https://artist.bandcamp.com/album/album-title
54
+
55
+ # Choose a format
56
+ campground https://artist.bandcamp.com/album/album-title --format mp3-320
57
+
58
+ # Download to a specific directory
59
+ campground https://artist.bandcamp.com/album/album-title --output ~/Music/Bandcamp
60
+
61
+ # Re-download and replace an existing copy
62
+ campground https://artist.bandcamp.com/album/album-title --overwrite
63
+ ```
64
+
65
+ ## Options
66
+
67
+ | Flag | Description |
68
+ |---|---|
69
+ | `-f`, `--format` | Audio format (see below). Default: `flac` |
70
+ | `-o`, `--output` | Output directory. Default: current directory |
71
+ | `--cookies` | Cookie string from browser dev tools |
72
+ | `--cookies-file` | Path to a file containing the cookie string |
73
+ | `--overwrite` | Replace an existing download in the output directory |
74
+
75
+ **Supported formats:** `mp3-v0`, `mp3-320`, `flac`, `aac-hi`, `vorbis`, `alac`, `wav`, `aiff-lossless`
76
+
77
+ ## How it works
78
+
79
+ Campground uses your session cookies to access the Bandcamp collection API, locates the requested album, fetches a signed download link for your chosen format, and streams the file to a temporary directory before extracting and moving it to the output directory. If anything goes wrong the temporary files are cleaned up automatically.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "campground"
7
+ version = "0.1.0"
8
+ description = "Download albums from your Bandcamp library"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ dependencies = ["curl-cffi>=0.5"]
13
+ keywords = ["bandcamp", "music", "download"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ "Topic :: Multimedia :: Sound/Audio",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/oxfordbags/campground"
24
+ Repository = "https://github.com/oxfordbags/campground"
25
+
26
+ [project.scripts]
27
+ campground = "campground.cli:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,64 @@
1
+ import html as html_module
2
+ import json
3
+ import re
4
+ from urllib.parse import urlparse, urlunparse
5
+
6
+ from curl_cffi import requests
7
+
8
+ from .session import HEADERS
9
+
10
+ COLLECTION_SUMMARY_URL = "https://bandcamp.com/api/fan/2/collection_summary"
11
+ COLLECTION_ITEMS_URL = "https://bandcamp.com/api/fancollection/1/collection_items"
12
+
13
+
14
+ def get_fan_id(session: requests.Session) -> int:
15
+ resp = session.get(COLLECTION_SUMMARY_URL, headers=HEADERS)
16
+ resp.raise_for_status()
17
+ return resp.json()["fan_id"]
18
+
19
+
20
+ def iter_collection(session: requests.Session, fan_id: int, page_size: int = 50):
21
+ """Yield (item, redownload_url) pairs, paginating until exhausted."""
22
+ token = "9999999999::a::"
23
+ while True:
24
+ payload = {"fan_id": fan_id, "older_than_token": token, "count": page_size}
25
+ data = session.post(COLLECTION_ITEMS_URL, json=payload, headers=HEADERS).json()
26
+ redownload_urls = data.get("redownload_urls", {})
27
+ for item in data.get("items", []):
28
+ yield item, redownload_urls.get(f"p{item['sale_item_id']}")
29
+ if not data.get("more_available"):
30
+ break
31
+ token = data["last_token"]
32
+
33
+
34
+ def find_item(session: requests.Session, fan_id: int, target_url: str):
35
+ """Return (item, redownload_url) for the first collection item matching target_url."""
36
+ target = _normalise_url(target_url)
37
+ checked = 0
38
+ for item, redownload_url in iter_collection(session, fan_id):
39
+ if _normalise_url(item.get("item_url", "")) == target:
40
+ return item, redownload_url
41
+ checked += 1
42
+ if checked % 50 == 0:
43
+ print(f" ({checked} items searched...)")
44
+ return None, None
45
+
46
+
47
+ def _normalise_url(url: str) -> str:
48
+ """Normalise to https, strip query string and fragment, lowercase."""
49
+ p = urlparse(url.strip())
50
+ return urlunparse(p._replace(scheme="https", query="", fragment="")).rstrip("/").lower()
51
+
52
+
53
+ def get_download_urls(session: requests.Session, redownload_url: str) -> dict:
54
+ """Return the downloads dict from a signed redownload URL (format name → {url, size_mb, …})."""
55
+ resp = session.get(redownload_url, headers=HEADERS)
56
+ resp.raise_for_status()
57
+ blobs = re.findall(r'data-blob="([^"]{20,})"', resp.text)
58
+ if not blobs:
59
+ raise RuntimeError("Could not find data blob on download page")
60
+ blob = json.loads(html_module.unescape(blobs[0]))
61
+ items = blob.get("digital_items", [])
62
+ if not items:
63
+ raise RuntimeError("No downloadable items on this page")
64
+ return items[0].get("downloads", {})
@@ -0,0 +1,194 @@
1
+ import argparse
2
+ import os
3
+ import pathlib
4
+ import shutil
5
+ import sys
6
+
7
+ from . import api, config, download
8
+ from .session import make as make_session, parse_cookie_string
9
+
10
+ VALID_FORMATS = [
11
+ "mp3-v0", "mp3-320", "flac", "aac-hi",
12
+ "vorbis", "alac", "wav", "aiff-lossless",
13
+ ]
14
+
15
+ FORMAT_EXT = {
16
+ "mp3-v0": ".mp3",
17
+ "mp3-320": ".mp3",
18
+ "flac": ".flac",
19
+ "aac-hi": ".m4a",
20
+ "vorbis": ".ogg",
21
+ "alac": ".m4a",
22
+ "wav": ".wav",
23
+ "aiff-lossless":".aiff",
24
+ }
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ p = argparse.ArgumentParser(
29
+ prog="campground",
30
+ description="Download albums from your Bandcamp library",
31
+ )
32
+ p.add_argument("url", help="Bandcamp album or track URL")
33
+ p.add_argument(
34
+ "-f", "--format", choices=VALID_FORMATS, metavar="FORMAT",
35
+ help=f"Audio format ({', '.join(VALID_FORMATS)}). Default: flac",
36
+ )
37
+ p.add_argument("-o", "--output", metavar="DIR", help="Output directory")
38
+ p.add_argument("--cookies", metavar="STRING", help="Cookie string copied from browser dev tools")
39
+ p.add_argument(
40
+ "--cookies-file", dest="cookies_file", metavar="FILE",
41
+ help="Path to a file containing the cookie string",
42
+ )
43
+ p.add_argument(
44
+ "--overwrite", action="store_true",
45
+ help="Replace an existing download if it already exists in the output directory",
46
+ )
47
+ return p
48
+
49
+
50
+ def resolve_cookies(cfg: config.Config) -> dict:
51
+ cookie_str = cfg.cookies
52
+ if not cookie_str and cfg.cookies_file:
53
+ path = pathlib.Path(cfg.cookies_file).expanduser()
54
+ if not path.exists():
55
+ sys.exit(f"Cookies file not found: {path}")
56
+ cookie_str = path.read_text().strip()
57
+ if not cookie_str:
58
+ sys.exit(
59
+ "No cookies provided. Supply them via --cookies, --cookies-file, or config file.\n\n"
60
+ "To get your cookie string:\n"
61
+ " 1. Log in to bandcamp.com in your browser\n"
62
+ " 2. Open Dev Tools (Cmd+Option+I on Mac) → Network tab\n"
63
+ " 3. Refresh the page and click any bandcamp.com request\n"
64
+ " 4. Copy the full value of the 'Cookie:' request header"
65
+ )
66
+ return parse_cookie_string(cookie_str)
67
+
68
+
69
+ def safe_name(title: str) -> str:
70
+ return "".join(c if c.isalnum() or c in " -_." else "_" for c in title).strip()
71
+
72
+
73
+ def safe_filename(title: str, fmt: str) -> str:
74
+ return f"{safe_name(title)}{FORMAT_EXT[fmt]}"
75
+
76
+
77
+ HELP = """
78
+ campground — download albums from your Bandcamp library
79
+
80
+ Usage:
81
+ campground <url> [options]
82
+
83
+ Options:
84
+ -f, --format FORMAT Audio format (default: flac)
85
+ Choices: mp3-v0, mp3-320, flac, aac-hi,
86
+ vorbis, alac, wav, aiff-lossless
87
+ -o, --output DIR Output directory (default: current directory)
88
+ --cookies STRING Cookie string from browser dev tools
89
+ --cookies-file FILE Path to a file containing the cookie string
90
+ --overwrite Replace an existing download in the output directory
91
+
92
+ Examples:
93
+ campground https://artist.bandcamp.com/album/title
94
+ campground https://artist.bandcamp.com/album/title --format mp3-320
95
+ campground https://artist.bandcamp.com/album/title --output ~/Downloads
96
+
97
+ Setup (one-time):
98
+ campground uses your browser session cookies to authenticate.
99
+
100
+ 1. Log in to bandcamp.com in your browser
101
+ 2. Open Dev Tools (Cmd+Option+I) → Network tab
102
+ 3. Refresh the page and click any bandcamp.com request
103
+ 4. Copy the full value of the Cookie: request header
104
+
105
+ Then either pass it directly:
106
+ campground <url> --cookies "your_cookie_string"
107
+
108
+ Or save it to ~/.config/campground/config.toml:
109
+ [bandcamp]
110
+ cookies = "your_cookie_string"
111
+
112
+ [download]
113
+ format = "flac"
114
+ output_dir = "~/Music/Bandcamp" # optional
115
+ """
116
+
117
+
118
+ def main():
119
+ if len(sys.argv) == 1:
120
+ print(HELP.strip())
121
+ sys.exit(0)
122
+
123
+ try:
124
+ _run()
125
+ except KeyboardInterrupt:
126
+ print("\nCancelled.")
127
+ os._exit(130)
128
+
129
+
130
+ def _run():
131
+ args = build_parser().parse_args()
132
+ cfg = config.merge(config.load(), args)
133
+
134
+ cookies = resolve_cookies(cfg)
135
+ session = make_session(cookies)
136
+
137
+ print("Authenticating...")
138
+ try:
139
+ fan_id = api.get_fan_id(session)
140
+ except Exception as e:
141
+ sys.exit(f"Authentication failed — your cookies may have expired.\n{e}")
142
+
143
+ target_url = args.url.rstrip("/")
144
+ print(f"Searching collection for {target_url}...")
145
+
146
+ item, redownload_url = api.find_item(session, fan_id, target_url)
147
+ if not item:
148
+ sys.exit(f"Not found in your collection: {target_url}")
149
+
150
+ title = f"{item['band_name']} - {item['item_title']}"
151
+ print(f"Found: {title}")
152
+
153
+ if not item.get("download_available"):
154
+ sys.exit("This item is not available to download (may be a pre-order or stream-only).")
155
+
156
+ if not redownload_url:
157
+ sys.exit("This item has no download URL.")
158
+
159
+ fmt = cfg.format
160
+ print(f"Fetching {fmt} download link...")
161
+ try:
162
+ downloads = api.get_download_urls(session, redownload_url)
163
+ except Exception as e:
164
+ sys.exit(f"Could not fetch download page: {e}")
165
+
166
+ if fmt not in downloads:
167
+ available = ", ".join(downloads.keys())
168
+ sys.exit(f"Format '{fmt}' not available. Available formats: {available}")
169
+
170
+ entry = downloads[fmt]
171
+ print(f" Size: {entry.get('size_mb', 'unknown')}")
172
+
173
+ name = safe_name(title)
174
+ dest = cfg.output_dir
175
+
176
+ if cfg.output_dir_explicit:
177
+ dest.mkdir(parents=True, exist_ok=True)
178
+
179
+ existing = dest / name
180
+ if existing.exists():
181
+ if not args.overwrite:
182
+ print(f"Already exists, skipping: {existing}")
183
+ print("Use --overwrite to replace it.")
184
+ sys.exit(0)
185
+ shutil.rmtree(existing) if existing.is_dir() else existing.unlink()
186
+
187
+ print(f"Downloading {entry.get('size_mb', '')} to {dest}...")
188
+
189
+ try:
190
+ path = download.fetch_and_extract(session, entry["url"], dest, safe_filename(title, fmt), name)
191
+ except Exception as e:
192
+ sys.exit(f"Download failed: {e}")
193
+
194
+ print(f"Saved: {path}")
@@ -0,0 +1,53 @@
1
+ import sys
2
+ import tomllib
3
+ import pathlib
4
+ from dataclasses import dataclass, field
5
+
6
+ CONFIG_PATH = pathlib.Path.home() / ".config" / "campground" / "config.toml"
7
+ DEFAULT_FORMAT = "flac"
8
+
9
+
10
+ @dataclass
11
+ class Config:
12
+ cookies: str = ""
13
+ cookies_file: str = ""
14
+ format: str = DEFAULT_FORMAT
15
+ output_dir: pathlib.Path | None = None # None resolves to cwd at runtime
16
+ output_dir_explicit: bool = False # True when set by config or CLI flag
17
+
18
+ def __post_init__(self):
19
+ if self.output_dir is None:
20
+ self.output_dir = pathlib.Path.cwd()
21
+ elif isinstance(self.output_dir, str):
22
+ self.output_dir = pathlib.Path(self.output_dir).expanduser()
23
+
24
+
25
+ def load(path: pathlib.Path = CONFIG_PATH) -> Config:
26
+ if not path.exists():
27
+ return Config()
28
+ try:
29
+ with open(path, "rb") as f:
30
+ data = tomllib.load(f)
31
+ except tomllib.TOMLDecodeError as e:
32
+ sys.exit(f"Invalid config file ({path}):\n{e}")
33
+ bc = data.get("bandcamp", {})
34
+ dl = data.get("download", {})
35
+ raw_output = dl.get("output_dir")
36
+ return Config(
37
+ cookies=bc.get("cookies", ""),
38
+ cookies_file=bc.get("cookies_file", ""),
39
+ format=dl.get("format", DEFAULT_FORMAT),
40
+ output_dir=raw_output,
41
+ output_dir_explicit=raw_output is not None,
42
+ )
43
+
44
+
45
+ def merge(base: Config, args) -> Config:
46
+ cli_output = getattr(args, "output", None)
47
+ return Config(
48
+ cookies=getattr(args, "cookies", None) or base.cookies,
49
+ cookies_file=getattr(args, "cookies_file", None) or base.cookies_file,
50
+ format=getattr(args, "format", None) or base.format,
51
+ output_dir=cli_output or str(base.output_dir),
52
+ output_dir_explicit=bool(cli_output) or base.output_dir_explicit,
53
+ )
@@ -0,0 +1,101 @@
1
+ import pathlib
2
+ import re
3
+ import shutil
4
+ import tempfile
5
+ import zipfile
6
+
7
+ from curl_cffi import requests
8
+
9
+ from .session import HEADERS
10
+
11
+
12
+ def fetch_and_extract(
13
+ session: requests.Session,
14
+ url: str,
15
+ dest_dir: pathlib.Path,
16
+ fallback_name: str,
17
+ subdir_name: str,
18
+ ) -> pathlib.Path:
19
+ """Download to a temp directory, extract if zip, move result to dest_dir."""
20
+ with tempfile.TemporaryDirectory(prefix="campground_") as tmp:
21
+ tmp_path = pathlib.Path(tmp)
22
+ zip_path = _fetch(session, url, tmp_path, fallback_name)
23
+
24
+ if zip_path.suffix == ".zip":
25
+ print("Extracting...")
26
+ extracted = _unzip(zip_path, tmp_path, subdir_name)
27
+ final = dest_dir / extracted.name
28
+ shutil.move(str(extracted), final)
29
+ else:
30
+ final = dest_dir / zip_path.name
31
+ shutil.move(str(zip_path), final)
32
+
33
+ return final
34
+
35
+
36
+ def _fetch(session: requests.Session, url: str, dest_dir: pathlib.Path, fallback_name: str) -> pathlib.Path:
37
+ resp = session.get(url, headers=HEADERS, stream=True)
38
+ resp.raise_for_status()
39
+
40
+ filename = _filename_from_headers(resp.headers) or fallback_name
41
+ filepath = dest_dir / filename
42
+ total = int(resp.headers.get("content-length", 0))
43
+ received = 0
44
+
45
+ with open(filepath, "wb") as f:
46
+ for chunk in resp.iter_content(chunk_size=65536):
47
+ if chunk:
48
+ f.write(chunk)
49
+ received += len(chunk)
50
+ _print_progress(filename, received, total)
51
+
52
+ print()
53
+ return filepath
54
+
55
+
56
+ def _unzip(zip_path: pathlib.Path, dest_dir: pathlib.Path, subdir_name: str) -> pathlib.Path:
57
+ try:
58
+ zf_handle = zipfile.ZipFile(zip_path)
59
+ except zipfile.BadZipFile:
60
+ raise RuntimeError("Downloaded file is corrupted (not a valid zip). Try again.")
61
+
62
+ with zf_handle as zf:
63
+ names = zf.namelist()
64
+
65
+ for name in names:
66
+ parts = pathlib.Path(name).parts
67
+ if pathlib.Path(name).is_absolute() or ".." in parts:
68
+ raise RuntimeError(f"Refusing to extract unsafe path in zip: {name}")
69
+
70
+ top_level = {n.split("/")[0] for n in names}
71
+
72
+ if len(top_level) == 1 and not any(n == top_level.copy().pop() for n in names):
73
+ # All entries share a single root folder — extract directly so that
74
+ # folder becomes the subdir, avoiding double-nesting.
75
+ extract_to = dest_dir
76
+ result_dir = dest_dir / top_level.pop()
77
+ else:
78
+ # Files at root — create a named subdir.
79
+ extract_to = dest_dir / subdir_name
80
+ extract_to.mkdir(parents=True, exist_ok=True)
81
+ result_dir = extract_to
82
+
83
+ zf.extractall(extract_to)
84
+
85
+ zip_path.unlink()
86
+ return result_dir
87
+
88
+
89
+ def _filename_from_headers(headers) -> str:
90
+ cd = headers.get("content-disposition", "")
91
+ match = re.search(r'filename="([^"]+)"', cd)
92
+ return match.group(1) if match else ""
93
+
94
+
95
+ def _print_progress(filename: str, received: int, total: int):
96
+ mb = received / 1_048_576
97
+ if total:
98
+ pct = received * 100 // total
99
+ print(f"\r {filename}: {pct}% ({mb:.1f} MB)", end="", flush=True)
100
+ else:
101
+ print(f"\r {filename}: {mb:.1f} MB", end="", flush=True)
@@ -0,0 +1,25 @@
1
+ from curl_cffi import requests
2
+
3
+ UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
4
+
5
+ HEADERS = {
6
+ "User-Agent": UA,
7
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
8
+ "Accept-Language": "en-US,en;q=0.5",
9
+ }
10
+
11
+
12
+ def make(cookies: dict) -> requests.Session:
13
+ s = requests.Session(impersonate="chrome120")
14
+ s.cookies.update(cookies)
15
+ return s
16
+
17
+
18
+ def parse_cookie_string(cookie_str: str) -> dict:
19
+ cookies = {}
20
+ for part in cookie_str.split(";"):
21
+ part = part.strip()
22
+ if "=" in part:
23
+ k, _, v = part.partition("=")
24
+ cookies[k.strip()] = v.strip()
25
+ return cookies
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: campground
3
+ Version: 0.1.0
4
+ Summary: Download albums from your Bandcamp library
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/oxfordbags/campground
7
+ Project-URL: Repository, https://github.com/oxfordbags/campground
8
+ Keywords: bandcamp,music,download
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Multimedia :: Sound/Audio
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: curl-cffi>=0.5
17
+
18
+ # campground
19
+
20
+ A lightweight command-line tool for downloading albums from your Bandcamp library.
21
+
22
+ ## Requirements
23
+
24
+ - Python 3.11+
25
+ - A Bandcamp account with purchased items
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ pip install git+https://github.com/oxfordbags/campground.git
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ Campground authenticates using your browser session cookies. You only need to do this once (or when your session expires).
36
+
37
+ 1. Log in to [bandcamp.com](https://bandcamp.com) in your browser
38
+ 2. Open Dev Tools (`Cmd+Option+I` on Mac, `F12` on Windows)
39
+ 3. Go to the **Network** tab and refresh the page
40
+ 4. Click any `bandcamp.com` request and find the **Cookie:** request header
41
+ 5. Copy the full value
42
+
43
+ Then add it to `~/.config/campground/config.toml`:
44
+
45
+ ```toml
46
+ [bandcamp]
47
+ cookies = "your_cookie_string_here"
48
+
49
+ [download]
50
+ format = "flac" # optional, default: flac
51
+ output_dir = "~/Music" # optional, default: current directory
52
+ ```
53
+
54
+ Or pass cookies directly on the command line:
55
+
56
+ ```sh
57
+ campground <url> --cookies "your_cookie_string_here"
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```sh
63
+ campground <bandcamp-album-url> [options]
64
+ ```
65
+
66
+ **Examples:**
67
+
68
+ ```sh
69
+ # Download an album in the default format (flac)
70
+ campground https://artist.bandcamp.com/album/album-title
71
+
72
+ # Choose a format
73
+ campground https://artist.bandcamp.com/album/album-title --format mp3-320
74
+
75
+ # Download to a specific directory
76
+ campground https://artist.bandcamp.com/album/album-title --output ~/Music/Bandcamp
77
+
78
+ # Re-download and replace an existing copy
79
+ campground https://artist.bandcamp.com/album/album-title --overwrite
80
+ ```
81
+
82
+ ## Options
83
+
84
+ | Flag | Description |
85
+ |---|---|
86
+ | `-f`, `--format` | Audio format (see below). Default: `flac` |
87
+ | `-o`, `--output` | Output directory. Default: current directory |
88
+ | `--cookies` | Cookie string from browser dev tools |
89
+ | `--cookies-file` | Path to a file containing the cookie string |
90
+ | `--overwrite` | Replace an existing download in the output directory |
91
+
92
+ **Supported formats:** `mp3-v0`, `mp3-320`, `flac`, `aac-hi`, `vorbis`, `alac`, `wav`, `aiff-lossless`
93
+
94
+ ## How it works
95
+
96
+ Campground uses your session cookies to access the Bandcamp collection API, locates the requested album, fetches a signed download link for your chosen format, and streams the file to a temporary directory before extracting and moving it to the output directory. If anything goes wrong the temporary files are cleaned up automatically.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/campground/__init__.py
4
+ src/campground/__main__.py
5
+ src/campground/api.py
6
+ src/campground/cli.py
7
+ src/campground/config.py
8
+ src/campground/download.py
9
+ src/campground/session.py
10
+ src/campground.egg-info/PKG-INFO
11
+ src/campground.egg-info/SOURCES.txt
12
+ src/campground.egg-info/dependency_links.txt
13
+ src/campground.egg-info/entry_points.txt
14
+ src/campground.egg-info/requires.txt
15
+ src/campground.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ campground = campground.cli:main
@@ -0,0 +1 @@
1
+ curl-cffi>=0.5
@@ -0,0 +1 @@
1
+ campground