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.
- campground-0.1.0/PKG-INFO +96 -0
- campground-0.1.0/README.md +79 -0
- campground-0.1.0/pyproject.toml +30 -0
- campground-0.1.0/setup.cfg +4 -0
- campground-0.1.0/src/campground/__init__.py +1 -0
- campground-0.1.0/src/campground/__main__.py +3 -0
- campground-0.1.0/src/campground/api.py +64 -0
- campground-0.1.0/src/campground/cli.py +194 -0
- campground-0.1.0/src/campground/config.py +53 -0
- campground-0.1.0/src/campground/download.py +101 -0
- campground-0.1.0/src/campground/session.py +25 -0
- campground-0.1.0/src/campground.egg-info/PKG-INFO +96 -0
- campground-0.1.0/src/campground.egg-info/SOURCES.txt +15 -0
- campground-0.1.0/src/campground.egg-info/dependency_links.txt +1 -0
- campground-0.1.0/src/campground.egg-info/entry_points.txt +2 -0
- campground-0.1.0/src/campground.egg-info/requires.txt +1 -0
- campground-0.1.0/src/campground.egg-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
curl-cffi>=0.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
campground
|