bandcampsync 0.6.0__py3-none-any.whl
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.
- bandcampsync/__init__.py +191 -0
- bandcampsync/bandcamp.py +364 -0
- bandcampsync/config.py +2 -0
- bandcampsync/download.py +101 -0
- bandcampsync/ignores.py +137 -0
- bandcampsync/logger.py +12 -0
- bandcampsync/media.py +99 -0
- bandcampsync/notify.py +73 -0
- bandcampsync-0.6.0.data/scripts/bandcampsync +66 -0
- bandcampsync-0.6.0.data/scripts/bandcampsync-service +97 -0
- bandcampsync-0.6.0.dist-info/METADATA +275 -0
- bandcampsync-0.6.0.dist-info/RECORD +15 -0
- bandcampsync-0.6.0.dist-info/WHEEL +5 -0
- bandcampsync-0.6.0.dist-info/licenses/LICENSE +29 -0
- bandcampsync-0.6.0.dist-info/top_level.txt +1 -0
bandcampsync/__init__.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
|
3
|
+
from .config import VERSION as version
|
|
4
|
+
from .logger import get_logger
|
|
5
|
+
from .bandcamp import Bandcamp, BandcampError
|
|
6
|
+
from .ignores import Ignores
|
|
7
|
+
from .media import LocalMedia
|
|
8
|
+
from .notify import NotifyURL
|
|
9
|
+
from .download import (
|
|
10
|
+
download_file,
|
|
11
|
+
unzip_file,
|
|
12
|
+
move_file,
|
|
13
|
+
copy_file,
|
|
14
|
+
mask_sig,
|
|
15
|
+
is_zip_file,
|
|
16
|
+
DownloadInvalidContentType,
|
|
17
|
+
DownloadBadStatusCode,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["version", "do_sync"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
log = get_logger("sync")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def do_sync(
|
|
28
|
+
cookies_path,
|
|
29
|
+
cookies,
|
|
30
|
+
dir_path,
|
|
31
|
+
media_format,
|
|
32
|
+
temp_dir_root,
|
|
33
|
+
ign_file_path,
|
|
34
|
+
ign_patterns,
|
|
35
|
+
notify_url,
|
|
36
|
+
):
|
|
37
|
+
ignores = Ignores(ign_file_path=ign_file_path, ign_patterns=ign_patterns)
|
|
38
|
+
local_media = LocalMedia(media_dir=dir_path)
|
|
39
|
+
bandcamp = Bandcamp(cookies=cookies)
|
|
40
|
+
bandcamp.verify_authentication()
|
|
41
|
+
bandcamp.load_purchases()
|
|
42
|
+
new_items_downloaded = False
|
|
43
|
+
show_id_file_warning = False
|
|
44
|
+
if notify_url:
|
|
45
|
+
notifier = NotifyURL(notify_url)
|
|
46
|
+
else:
|
|
47
|
+
notifier = None
|
|
48
|
+
|
|
49
|
+
for item in bandcamp.purchases:
|
|
50
|
+
local_path = local_media.get_path_for_purchase(item)
|
|
51
|
+
|
|
52
|
+
# Check if any ignore pattern matches the band name
|
|
53
|
+
if ignores.is_ignored(item):
|
|
54
|
+
if not show_id_file_warning and local_media.is_locally_downloaded(
|
|
55
|
+
item, local_path
|
|
56
|
+
):
|
|
57
|
+
show_id_file_warning = True
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if item.is_preorder:
|
|
61
|
+
log.info(
|
|
62
|
+
f'Item is a preorder, skipping: "{item.band_name} / {item.item_title}" '
|
|
63
|
+
f"(id:{item.item_id})"
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
elif local_media.is_locally_downloaded(item, local_path):
|
|
67
|
+
log.info(
|
|
68
|
+
f'Already locally downloaded, skipping: "{item.band_name} / {item.item_title}" '
|
|
69
|
+
f"(id:{item.item_id})"
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
72
|
+
else:
|
|
73
|
+
log.info(
|
|
74
|
+
f'New media item, will download: "{item.band_name} / {item.item_title}" '
|
|
75
|
+
f'(id:{item.item_id}) in "{media_format}"'
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
local_path.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
except OSError as e:
|
|
80
|
+
log.error(
|
|
81
|
+
f"Failed to create directory: {local_path} ({e}), skipping purchase..."
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
initial_download_url = bandcamp.get_download_file_url(
|
|
86
|
+
item, encoding=media_format
|
|
87
|
+
)
|
|
88
|
+
except BandcampError as e:
|
|
89
|
+
log.error(
|
|
90
|
+
f'Failed to locate download URL for media item "{item.band_name} / {item.item_title}" '
|
|
91
|
+
f"(id:{item.item_id}), unable to download release ({e}), skipping"
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
download_file_url = bandcamp.check_download_stat(item, initial_download_url)
|
|
95
|
+
with NamedTemporaryFile(
|
|
96
|
+
mode="w+b", delete=True, dir=temp_dir_root
|
|
97
|
+
) as temp_file:
|
|
98
|
+
log.info(
|
|
99
|
+
f'Downloading item "{item.band_name} / {item.item_title}" (id:{item.item_id}) '
|
|
100
|
+
f"from {mask_sig(download_file_url)} to {temp_file.name}"
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
download_file(download_file_url, temp_file)
|
|
104
|
+
except DownloadBadStatusCode as e:
|
|
105
|
+
log.error(
|
|
106
|
+
f"Download attempt returned an unexpected status code ({e}), skipping"
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
except DownloadInvalidContentType as e:
|
|
110
|
+
log.error(
|
|
111
|
+
f"Download attempt returned an unexpected content type ({e}), skipping"
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
temp_file.seek(0)
|
|
115
|
+
temp_file_path = Path(temp_file.name)
|
|
116
|
+
if is_zip_file(temp_file_path):
|
|
117
|
+
with TemporaryDirectory(dir=temp_dir_root) as temp_dir:
|
|
118
|
+
log.info(
|
|
119
|
+
f'Decompressing downloaded zip "{temp_file.name}" to "{temp_dir}"'
|
|
120
|
+
)
|
|
121
|
+
unzip_file(temp_file.name, temp_dir)
|
|
122
|
+
temp_path = Path(temp_dir)
|
|
123
|
+
for file_path in temp_path.iterdir():
|
|
124
|
+
file_dest = local_media.get_path_for_file(
|
|
125
|
+
local_path, file_path.name
|
|
126
|
+
)
|
|
127
|
+
log.info(
|
|
128
|
+
f'Moving extracted file: "{file_path}" to "{file_dest}"'
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
move_file(file_path, file_dest)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
log.error(
|
|
134
|
+
f"Failed to move {file_path} to {file_dest}: {e}"
|
|
135
|
+
)
|
|
136
|
+
elif item.item_type == "track":
|
|
137
|
+
slug = item.item_title
|
|
138
|
+
if item.url_hints and isinstance(item.url_hints, dict):
|
|
139
|
+
slug = item.url_hints.get("slug", item.item_title)
|
|
140
|
+
format_extension = local_media.clean_format(media_format)
|
|
141
|
+
file_dest = local_media.get_path_for_file(
|
|
142
|
+
local_path, f"{slug}.{format_extension}"
|
|
143
|
+
)
|
|
144
|
+
log.info(
|
|
145
|
+
f'Copying single track: "{temp_file_path}" to "{file_dest}"'
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
copy_file(temp_file_path, file_dest)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
log.error(f"Failed to copy {file_path} to {file_dest}: {e}")
|
|
151
|
+
else:
|
|
152
|
+
log.error(
|
|
153
|
+
f'Downloaded file for "{item.band_name} / {item.item_title}" (id:{item.item_id}) '
|
|
154
|
+
f'at "{temp_file_path}" is not a zip archive or a single track, skipping'
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if ign_file_path:
|
|
159
|
+
# We assume that if you use an ignore file once, you'll
|
|
160
|
+
# keep using it forever (e.g. Docker).
|
|
161
|
+
# In case you don't, you'll get warnings for missing id file
|
|
162
|
+
# on the items downloaded in the current session.
|
|
163
|
+
ignores.add(item)
|
|
164
|
+
else:
|
|
165
|
+
local_media.write_bandcamp_id(item, local_path)
|
|
166
|
+
new_items_downloaded = True
|
|
167
|
+
|
|
168
|
+
if new_items_downloaded:
|
|
169
|
+
log.info("New media items downloaded")
|
|
170
|
+
if notifier:
|
|
171
|
+
notifier.notify()
|
|
172
|
+
|
|
173
|
+
if show_id_file_warning:
|
|
174
|
+
log.warning(
|
|
175
|
+
f"The {ign_file_path} file is tracking already downloaded items, "
|
|
176
|
+
f"but some directories are using bandcamp_item_id.txt files. "
|
|
177
|
+
f"If you want to get migrate from ID files to using the {ign_file_path} file, "
|
|
178
|
+
f"run the following script inside the downloads directory, then append the "
|
|
179
|
+
f"content of the new ignores.txt file to the ignores file in your "
|
|
180
|
+
f"config directory:\n"
|
|
181
|
+
' find . -name "bandcamp_item_id.txt" \\\n'
|
|
182
|
+
" | while read -r id_file\n"
|
|
183
|
+
" do \\\n"
|
|
184
|
+
' comment="$(echo "$id_file" \\\n'
|
|
185
|
+
' | sed -r -e "s%./([^/]+)/([^/]+)/bandcamp_item_id.txt%\\1 / \\2%")"\n'
|
|
186
|
+
' echo "$(cat "$id_file") # $comment"\n'
|
|
187
|
+
' rm "$id_file"\n'
|
|
188
|
+
" done >> ignores.txt\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return True
|
bandcampsync/bandcamp.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from time import time
|
|
4
|
+
from http.cookies import SimpleCookie
|
|
5
|
+
from html import unescape as html_unescape
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
from bs4 import BeautifulSoup
|
|
8
|
+
from curl_cffi import requests
|
|
9
|
+
from .download import mask_sig
|
|
10
|
+
from .logger import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
log = get_logger("bandcamp")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BandcampError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Bandcamp:
|
|
21
|
+
BASE_PROTO = "https"
|
|
22
|
+
BASE_DOMAIN = "bandcamp.com"
|
|
23
|
+
URLS = {
|
|
24
|
+
"index": "/",
|
|
25
|
+
"collection_items": "/api/fancollection/1/collection_items",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def __init__(self, cookies=""):
|
|
29
|
+
self.is_authenticated = False
|
|
30
|
+
self.user_id = 0
|
|
31
|
+
self.user_verified = False
|
|
32
|
+
self.cookies = None
|
|
33
|
+
self.purchases = []
|
|
34
|
+
self.load_cookies(cookies)
|
|
35
|
+
identity = self.cookies.get("identity")
|
|
36
|
+
if not identity:
|
|
37
|
+
raise BandcampError(
|
|
38
|
+
"Cookie data does not contain an identity value, make sure your "
|
|
39
|
+
"cookies.txt file is valid and you copied it from an "
|
|
40
|
+
"authenticated browser"
|
|
41
|
+
)
|
|
42
|
+
identity_snip = identity.value[:20]
|
|
43
|
+
log.info(f"Located Bandcamp identity in cookies: {identity_snip}...")
|
|
44
|
+
# Create a requests session and map our SimpleCookie to it
|
|
45
|
+
self.session = requests.Session(impersonate="chrome")
|
|
46
|
+
for cookie_name, morsel in self.cookies.items():
|
|
47
|
+
self.session.cookies.set(cookie_name, morsel.value)
|
|
48
|
+
|
|
49
|
+
def load_cookies(self, cookies_str):
|
|
50
|
+
self.cookies = SimpleCookie()
|
|
51
|
+
try:
|
|
52
|
+
self.cookies.load(cookies_str)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise BandcampError(f"Failed to parse cookies string: {e}") from e
|
|
55
|
+
if len(self.cookies) == 0:
|
|
56
|
+
# Failed to load any cookies, attempt to parse the cookies string as a Netscape cookies export
|
|
57
|
+
lines = cookies_str.strip().split("\n")
|
|
58
|
+
for line in lines:
|
|
59
|
+
if line.startswith("#"):
|
|
60
|
+
continue
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
parts = line.split("\t")
|
|
65
|
+
if len(parts) == 7:
|
|
66
|
+
domain, tailmatch, path, secure, expires, name, value = parts
|
|
67
|
+
cookie_string = f"{name.strip()}={value.strip()}; Domain={domain.strip()}; Path={path.strip()}"
|
|
68
|
+
if secure == "TRUE":
|
|
69
|
+
cookie_string += "; Secure"
|
|
70
|
+
self.cookies.load(cookie_string)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def cookies_str(self):
|
|
75
|
+
return self.cookies.output(header="").strip().replace("\r\n", ";")
|
|
76
|
+
|
|
77
|
+
def refresh_cookes(self, file_path):
|
|
78
|
+
log.info(f"Refreshing cookies: {file_path}")
|
|
79
|
+
with open(file_path, "wt") as f:
|
|
80
|
+
f.write(self.cookies_str)
|
|
81
|
+
|
|
82
|
+
def _construct_url(self, url_name):
|
|
83
|
+
if url_name not in self.URLS:
|
|
84
|
+
raise BandcampError(f"URL name is unknown: {url_name}")
|
|
85
|
+
return urlunsplit(
|
|
86
|
+
(self.BASE_PROTO, self.BASE_DOMAIN, self.URLS[url_name], "", "")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _plain_cookies(self):
|
|
90
|
+
cookies = {}
|
|
91
|
+
for cookie_name, cookie_value in self.cookies.items():
|
|
92
|
+
cookies[cookie_value.key] = cookie_value.value
|
|
93
|
+
return cookies
|
|
94
|
+
|
|
95
|
+
def _request(
|
|
96
|
+
self, method, url, data=None, json_data=None, is_json=False, as_raw=False
|
|
97
|
+
):
|
|
98
|
+
try:
|
|
99
|
+
# The debug logs do not mask the URL which may be a security issue if you run
|
|
100
|
+
# with level=logging.DEBUG
|
|
101
|
+
log.debug(f"Making {method} request to {url}")
|
|
102
|
+
response = self.session.request(
|
|
103
|
+
method,
|
|
104
|
+
url,
|
|
105
|
+
cookies=self._plain_cookies(),
|
|
106
|
+
data=data,
|
|
107
|
+
json=json_data,
|
|
108
|
+
)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise BandcampError(
|
|
111
|
+
f"Failed to make HTTP request to {mask_sig(url)}: {e}"
|
|
112
|
+
) from e
|
|
113
|
+
if response.status_code != 200:
|
|
114
|
+
raise BandcampError(
|
|
115
|
+
f"Failed to make HTTP request to {mask_sig(url)}: "
|
|
116
|
+
f"unknown status code response: {response.status_code}"
|
|
117
|
+
)
|
|
118
|
+
if as_raw:
|
|
119
|
+
return response.text
|
|
120
|
+
elif is_json:
|
|
121
|
+
return json.loads(response.text)
|
|
122
|
+
else:
|
|
123
|
+
return BeautifulSoup(response.text, "html.parser")
|
|
124
|
+
|
|
125
|
+
def _extract_pagedata_from_soup(self, soup, id_name="pagedata"):
|
|
126
|
+
pagedata_tag = soup.find("div", id=id_name)
|
|
127
|
+
if not pagedata_tag:
|
|
128
|
+
raise BandcampError(
|
|
129
|
+
'Failed to locate <div id="HomepageApp"> in index HTML, this may '
|
|
130
|
+
"be an authentication issue or it may be that bandcamp.com has "
|
|
131
|
+
"updated their website and this tool needs to be updated."
|
|
132
|
+
)
|
|
133
|
+
encoded_pagedata = pagedata_tag.attrs.get("data-blob")
|
|
134
|
+
if not encoded_pagedata:
|
|
135
|
+
raise BandcampError(
|
|
136
|
+
"Failed to extract page data, check your cookies are from an ",
|
|
137
|
+
"authenticated session",
|
|
138
|
+
)
|
|
139
|
+
pagedata_str = html_unescape(encoded_pagedata)
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(pagedata_str)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
raise BandcampError(f"Failed to parse pagedata as JSON: {e}") from e
|
|
144
|
+
|
|
145
|
+
def _extract_pagedata_from_html(self, html):
|
|
146
|
+
"""
|
|
147
|
+
Wrapper for _extract_pagedata_from_soup() that can accept HTML rather than a bs4 soup.
|
|
148
|
+
"""
|
|
149
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
150
|
+
return self._extract_pagedata_from_soup(soup)
|
|
151
|
+
|
|
152
|
+
def _get_js_stat_url(self, body, download_url):
|
|
153
|
+
"""
|
|
154
|
+
Checks the "stat" download URL body, which is in JavaScript, for
|
|
155
|
+
either the OK response or a new updated download URL.
|
|
156
|
+
"""
|
|
157
|
+
body = body.strip()
|
|
158
|
+
if body == "var _statDL_result = { result: 'ok'};":
|
|
159
|
+
# Download is OK, original download URL will work
|
|
160
|
+
return download_url
|
|
161
|
+
# Attempt to find the updated download_url in the JavaScript with a hacky regex
|
|
162
|
+
pattern = re.compile('"([^"]+)":"([^"]+)"')
|
|
163
|
+
for k, v in pattern.findall(body):
|
|
164
|
+
if k == "download_url":
|
|
165
|
+
return v
|
|
166
|
+
# Fallback to the original download URL
|
|
167
|
+
return download_url
|
|
168
|
+
|
|
169
|
+
def verify_authentication(self):
|
|
170
|
+
"""
|
|
171
|
+
Loads the initial account and session data from a request to the index page
|
|
172
|
+
of bandcamp.com. When properly authenticated an HTML data attribute is present
|
|
173
|
+
that contains account information in an encoded form.
|
|
174
|
+
"""
|
|
175
|
+
url = self._construct_url("index")
|
|
176
|
+
soup = self._request("get", url)
|
|
177
|
+
pagedata = self._extract_pagedata_from_soup(soup, id_name="HomepageApp")
|
|
178
|
+
try:
|
|
179
|
+
pagecontext = pagedata["pageContext"]
|
|
180
|
+
except KeyError as e:
|
|
181
|
+
raise BandcampError(
|
|
182
|
+
'Failed to parse pagedata JSON, does not contain an "pageContext" key'
|
|
183
|
+
) from e
|
|
184
|
+
try:
|
|
185
|
+
identity = pagecontext["identity"]
|
|
186
|
+
except KeyError as e:
|
|
187
|
+
raise BandcampError(
|
|
188
|
+
'Failed to parse pagecontext JSON, does not contain an "identity" key'
|
|
189
|
+
) from e
|
|
190
|
+
if not isinstance(identity, dict):
|
|
191
|
+
raise BandcampError(
|
|
192
|
+
'Failed to parse pagedata JSON, "identity" is not '
|
|
193
|
+
"a dictionary. Check your cookies.txt file is valid "
|
|
194
|
+
"and up to date"
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
self.user_id = identity["fanId"]
|
|
198
|
+
self.user_verified = identity["isFanVerified"]
|
|
199
|
+
except (KeyError, TypeError) as e:
|
|
200
|
+
raise BandcampError(
|
|
201
|
+
f'Failed to parse pagedata JSON, "identity.fan" seems invalid: {identity}'
|
|
202
|
+
) from e
|
|
203
|
+
self.is_authenticated = self.user_id > 0
|
|
204
|
+
log.info(
|
|
205
|
+
f"Loaded page data, session is authenticated for user id: {self.user_id})"
|
|
206
|
+
)
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def load_purchases(self):
|
|
210
|
+
"""
|
|
211
|
+
Loads all purchases on the authenticated account and returns a list of
|
|
212
|
+
purchase data. Each purchase is a dict of data.
|
|
213
|
+
"""
|
|
214
|
+
if not self.is_authenticated:
|
|
215
|
+
raise BandcampError(
|
|
216
|
+
"Authentication not verified, call load_pagedata() first"
|
|
217
|
+
)
|
|
218
|
+
log.info(f"Loading purchases for user id: {self.user_id}")
|
|
219
|
+
self.purchases = []
|
|
220
|
+
now = int(time())
|
|
221
|
+
page_ts = 0
|
|
222
|
+
token = f"{now}:{page_ts}:a::"
|
|
223
|
+
per_page = 100
|
|
224
|
+
while True:
|
|
225
|
+
log.info(f"Requesting {per_page} purchases using token {token}")
|
|
226
|
+
data = {
|
|
227
|
+
"fan_id": self.user_id,
|
|
228
|
+
"count": per_page,
|
|
229
|
+
"older_than_token": token,
|
|
230
|
+
}
|
|
231
|
+
url = self._construct_url("collection_items")
|
|
232
|
+
data = self._request("POST", url, json_data=data, is_json=True)
|
|
233
|
+
try:
|
|
234
|
+
items = data["items"]
|
|
235
|
+
except KeyError:
|
|
236
|
+
raise BandcampError(
|
|
237
|
+
"Failed to extract items from collection results page"
|
|
238
|
+
)
|
|
239
|
+
if not items:
|
|
240
|
+
log.info("Reached end of items")
|
|
241
|
+
break
|
|
242
|
+
try:
|
|
243
|
+
redownload_urls = data["redownload_urls"]
|
|
244
|
+
except KeyError:
|
|
245
|
+
raise BandcampError(
|
|
246
|
+
"Failed to extract redownload_urls from collection results page"
|
|
247
|
+
)
|
|
248
|
+
for item_data in items:
|
|
249
|
+
try:
|
|
250
|
+
band_name = item_data["band_name"]
|
|
251
|
+
except KeyError:
|
|
252
|
+
log.error(
|
|
253
|
+
"Failed to locate band name in item metadata, skipping item..."
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
title = item_data["album_title"]
|
|
258
|
+
except KeyError:
|
|
259
|
+
log.error(
|
|
260
|
+
f'Failed to locate title in item metadata (possibly a subscription?) for "{band_name}", skipping item...'
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
sale_item_type = item_data["sale_item_type"]
|
|
264
|
+
sale_item_id = item_data["sale_item_id"]
|
|
265
|
+
download_url_key = f"{sale_item_type}{sale_item_id}"
|
|
266
|
+
try:
|
|
267
|
+
download_url = redownload_urls[download_url_key]
|
|
268
|
+
except KeyError:
|
|
269
|
+
log.error(
|
|
270
|
+
f"Failed to locate download URL for {band_name} / {title} "
|
|
271
|
+
f"(key:{download_url_key}), skipping item..."
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
item_data["download_url"] = download_url
|
|
275
|
+
item = BandcampItem(item_data)
|
|
276
|
+
token = item.token
|
|
277
|
+
log.info(f"Found item: {band_name} / {title} (id:{item.item_id})")
|
|
278
|
+
self.purchases.append(item)
|
|
279
|
+
log.info(f"Loaded {len(self.purchases)} purchases")
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def get_download_file_url(self, item, encoding="flac"):
|
|
283
|
+
soup = self._request("get", item.download_url)
|
|
284
|
+
pagedata = self._extract_pagedata_from_soup(soup)
|
|
285
|
+
download_url = None
|
|
286
|
+
if not pagedata:
|
|
287
|
+
raise ValueError('Either "url" or "pagedata" must be supplied')
|
|
288
|
+
try:
|
|
289
|
+
digital_items = pagedata["digital_items"]
|
|
290
|
+
except KeyError as e:
|
|
291
|
+
raise BandcampError(
|
|
292
|
+
"Failed to parse pagedata JSON, does not contain an "
|
|
293
|
+
'"digital_items" key'
|
|
294
|
+
) from e
|
|
295
|
+
for digital_item in digital_items:
|
|
296
|
+
try:
|
|
297
|
+
digital_item_id = digital_item["item_id"]
|
|
298
|
+
except KeyError as e:
|
|
299
|
+
raise BandcampError(
|
|
300
|
+
"Failed to parse pagedata JSON, does not contain an "
|
|
301
|
+
'"digital_items[].art_id" key'
|
|
302
|
+
) from e
|
|
303
|
+
if digital_item_id == item.item_id:
|
|
304
|
+
try:
|
|
305
|
+
downloads = digital_item["downloads"]
|
|
306
|
+
except KeyError as e:
|
|
307
|
+
raise BandcampError(
|
|
308
|
+
"Failed to parse pagedata JSON, does not contain an "
|
|
309
|
+
'"digital_items.downloads" key'
|
|
310
|
+
) from e
|
|
311
|
+
try:
|
|
312
|
+
download_format = downloads[encoding]
|
|
313
|
+
except KeyError as e:
|
|
314
|
+
encodings = downloads.keys()
|
|
315
|
+
raise BandcampError(
|
|
316
|
+
f"Download formats does not contain requested encoding: {encoding} "
|
|
317
|
+
f"(available encodings: {encodings})"
|
|
318
|
+
) from e
|
|
319
|
+
try:
|
|
320
|
+
download_url = download_format["url"]
|
|
321
|
+
except KeyError as e:
|
|
322
|
+
raise BandcampError(
|
|
323
|
+
"Failed to parse pagedata JSON, does not contain an "
|
|
324
|
+
'"digital_items.downloads.[encoding].url" key'
|
|
325
|
+
) from e
|
|
326
|
+
return download_url
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
def check_download_stat(self, item, file_download_url):
|
|
330
|
+
"""
|
|
331
|
+
Constructs the download "stat" URL and verifies the state of the download.
|
|
332
|
+
If the state is OK, return the existing URL (download is OK) otherwise wait
|
|
333
|
+
for the stat to complete and return the new download URL.
|
|
334
|
+
"""
|
|
335
|
+
download_url_parts = urlsplit(file_download_url)
|
|
336
|
+
path = download_url_parts.path
|
|
337
|
+
path_parts = path.split("/")
|
|
338
|
+
if path_parts[1] == "download":
|
|
339
|
+
path_parts[1] = "statdownload"
|
|
340
|
+
stat_url = urlunsplit(
|
|
341
|
+
(
|
|
342
|
+
download_url_parts.scheme,
|
|
343
|
+
download_url_parts.netloc,
|
|
344
|
+
"/".join(path_parts),
|
|
345
|
+
download_url_parts.query,
|
|
346
|
+
"",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
body = self._request("get", stat_url, as_raw=True)
|
|
350
|
+
return self._get_js_stat_url(body, file_download_url)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class BandcampItem:
|
|
354
|
+
def __init__(self, data):
|
|
355
|
+
self._data = data
|
|
356
|
+
|
|
357
|
+
def __repr__(self):
|
|
358
|
+
return json.dumps(self._data, indent=4, sort_keys=True)
|
|
359
|
+
|
|
360
|
+
def __getattr__(self, key):
|
|
361
|
+
try:
|
|
362
|
+
return self._data[key]
|
|
363
|
+
except KeyError as e:
|
|
364
|
+
raise KeyError(f'BandcampItem value "{key}" does not exist') from e
|
bandcampsync/config.py
ADDED
bandcampsync/download.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import shutil
|
|
3
|
+
from zipfile import ZipFile
|
|
4
|
+
from curl_cffi import requests
|
|
5
|
+
from .logger import get_logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
log = get_logger("download")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def mask_sig(url):
|
|
12
|
+
if "&sig=" not in url:
|
|
13
|
+
return url
|
|
14
|
+
url_parts = url.split("&")
|
|
15
|
+
for i, url_part in enumerate(url_parts):
|
|
16
|
+
if url_part[:4] == "sig=":
|
|
17
|
+
url_parts[i] = "sig=[masked]"
|
|
18
|
+
elif url_part[:6] == "token=":
|
|
19
|
+
url_parts[i] = "token=[masked]"
|
|
20
|
+
return "&".join(url_parts)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadBadStatusCode(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DownloadInvalidContentType(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def download_file(
|
|
32
|
+
url,
|
|
33
|
+
target,
|
|
34
|
+
mode="wb",
|
|
35
|
+
chunk_size=8192,
|
|
36
|
+
logevery=10,
|
|
37
|
+
disallow_content_type="text/html",
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Attempts to stream a download to an open target file handle in chunks. If the
|
|
41
|
+
request returns a disallowed content type then return a failed state with the
|
|
42
|
+
response content.
|
|
43
|
+
"""
|
|
44
|
+
text = True if "t" in mode else False
|
|
45
|
+
data_streamed = 0
|
|
46
|
+
last_log = 0
|
|
47
|
+
r = requests.get(url, stream=True, impersonate="chrome")
|
|
48
|
+
try:
|
|
49
|
+
# r.raise_for_status()
|
|
50
|
+
if r.status_code != 200:
|
|
51
|
+
raise DownloadBadStatusCode(f"Got non-200 status code: {r.status_code}")
|
|
52
|
+
try:
|
|
53
|
+
content_type = r.headers.get("Content-Type", "")
|
|
54
|
+
except (ValueError, KeyError):
|
|
55
|
+
content_type = ""
|
|
56
|
+
content_type_parts = content_type.split(";")
|
|
57
|
+
major_content_type = content_type_parts[0].strip()
|
|
58
|
+
if major_content_type == disallow_content_type:
|
|
59
|
+
raise DownloadInvalidContentType(
|
|
60
|
+
f"Invalid content type: {major_content_type}"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
content_length = int(r.headers.get("Content-Length", "0"))
|
|
64
|
+
except (ValueError, KeyError):
|
|
65
|
+
content_length = 0
|
|
66
|
+
for chunk in r.iter_content(chunk_size=chunk_size):
|
|
67
|
+
data_streamed += len(chunk)
|
|
68
|
+
if text:
|
|
69
|
+
chunk = chunk.decode()
|
|
70
|
+
target.write(chunk)
|
|
71
|
+
if content_length > 0 and logevery > 0:
|
|
72
|
+
percent_complete = math.floor((data_streamed / content_length) * 100)
|
|
73
|
+
if percent_complete % logevery == 0 and percent_complete > last_log:
|
|
74
|
+
log.info(f"Downloading {mask_sig(url)}: {percent_complete}%")
|
|
75
|
+
last_log = percent_complete
|
|
76
|
+
finally:
|
|
77
|
+
r.close()
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_zip_file(file_path):
|
|
82
|
+
try:
|
|
83
|
+
with ZipFile(file_path) as z:
|
|
84
|
+
z.infolist()
|
|
85
|
+
return True
|
|
86
|
+
except Exception:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def unzip_file(decompress_from, decompress_to):
|
|
91
|
+
with ZipFile(decompress_from) as z:
|
|
92
|
+
z.extractall(decompress_to)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def move_file(src, dst):
|
|
97
|
+
return shutil.move(src, dst)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def copy_file(src, dst):
|
|
101
|
+
return shutil.copyfile(src, dst)
|