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.
@@ -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
@@ -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
@@ -0,0 +1,2 @@
1
+ VERSION = "0.6.0"
2
+ INTERNAL_USER_AGENT = f"bandcampsync/{VERSION}"
@@ -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)