lfss 0.9.2__py3-none-any.whl → 0.11.4__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.
Readme.md CHANGED
@@ -1,5 +1,5 @@
1
- # Lightweight File Storage Service (LFSS)
2
- [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
1
+ # Lite File Storage Service (LFSS)
2
+ [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/lfss)](https://pypi.org/project/lfss/)
3
3
 
4
4
  My experiment on a lightweight and high-performance file/object storage service...
5
5
 
@@ -32,8 +32,8 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
32
32
 
33
33
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
34
34
  The authentication can be acheived through one of the following methods:
35
- 1. `Authorization` header with the value `Bearer sha256(<username><password>)`.
36
- 2. `token` query parameter with the value `sha256(<username><password>)`.
35
+ 1. `Authorization` header with the value `Bearer sha256(<username>:<password>)`.
36
+ 2. `token` query parameter with the value `sha256(<username>:<password>)`.
37
37
  3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
38
38
 
39
39
  You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
@@ -4,9 +4,11 @@
4
4
  **Server**
5
5
  - `LFSS_DATA`: The directory to store the data. Default is `.storage_data`.
6
6
  - `LFSS_WEBDAV`: Enable WebDAV support. Default is `0`, set to `1` to enable.
7
- - `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `8m`.
7
+ - `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `1m`.
8
8
  - `LFSS_DEBUG`: Enable debug mode for more verbose logging. Default is `0`, set to `1` to enable.
9
+ - `LFSS_DISABLE_LOGGING`: Disable all file logging. Default is 0; set to `1` to disable file logging.
10
+ - `LFSS_ORIGIN`: The `Origin` header to allow CORS requests. Use `,` to separate multiple origins. Default is `*`.
9
11
 
10
12
  **Client**
11
13
  - `LFSS_ENDPOINT`: The fallback server endpoint. Default is `http://localhost:8000`.
12
- - `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username><password>)`.
14
+ - `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username>:<password>)`.
docs/Permission.md CHANGED
@@ -22,16 +22,16 @@ A file is owned by the user who created it, may not necessarily be the user unde
22
22
  ## File access with `GET` permission
23
23
 
24
24
  ### File access
25
- For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
25
+ For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the path-owner and the file.
26
26
 
27
27
  There are four types of permissions: `unset`, `public`, `protected`, `private`.
28
28
  Non-admin users can access files based on:
29
29
 
30
30
  - If the file is `public`, then all users can access it.
31
31
  - If the file is `protected`, then only the logged-in user can access it.
32
- - If the file is `private`, then only the owner can access it.
33
- - If the file is `unset`, then the file's permission is inherited from the owner's permission.
34
- - If both the owner and the file have `unset` permission, then the file is `public`.
32
+ - If the file is `private`, then only the owner/path-owner can access it.
33
+ - If the file is `unset`, then the file's permission is inherited from the path-owner's permission.
34
+ - If both the path-owner and the file have `unset` permission, then the file is `public`.
35
35
 
36
36
  ## File creation with `PUT`/`POST` permission
37
37
  `PUT`/`POST` permission is not allowed for non-peer users.
docs/Webdav.md CHANGED
@@ -15,8 +15,8 @@ Please note:
15
15
  2. LFSS not allow creating files in the root directory, however some client such as [Finder](https://sabre.io/dav/clients/finder/) will try to create files in the root directory. Thus, it is safer to mount the user directory only, e.g. `http://localhost:8000/<username>/`.
16
16
  3. LFSS not allow directory creation, instead it creates directoy implicitly when a file is uploaded to a non-exist directory.
17
17
  i.e. `PUT http://localhost:8000/<username>/dir/file.txt` will create the `dir` directory if it does not exist.
18
- However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.lfss-keep`), and hide the file from the file listing by `PROPFIND` method.
18
+ However, the WebDAV `MKCOL` method requires the directory to be created explicitly, so WebDAV `MKCOL` method instead create a decoy file on the path (`.lfss_keep`), and hide the file from the file listing by `PROPFIND` method.
19
19
  This leads to:
20
- 1) You may see a `.lfss-keep` file in the directory with native file listing (e.g. `/_api/list-files`), but it is hidden in WebDAV clients.
21
- 2) The directory may be deleted if there is no file in it and the `.lfss-keep` file is not created by WebDAV client.
20
+ 1) You may see a `.lfss_keep` file in the directory with native file listing (e.g. `/_api/list-files`), but it is hidden in WebDAV clients.
21
+ 2) The directory may be deleted if there is no file in it and the `.lfss_keep` file is not created by WebDAV client.
22
22
 
docs/changelog.md ADDED
@@ -0,0 +1,58 @@
1
+ ## 0.11
2
+
3
+ ### 0.11.2
4
+ - Improve frontend directory upload feedback.
5
+ - Set default large file threashold to 1M.
6
+ - Increase default concurrent threads.
7
+ - Use sqlite for logging.
8
+ - Add vacuum logs.
9
+ - Refactor: use dir for directory path.
10
+
11
+ ### 0.11.1
12
+ - Rename api `get_meta` function.
13
+ - Frontend support upload directory.
14
+ - Fix admin put to non-exists user path.
15
+
16
+ ### 0.11.0
17
+ - Copy file as hard link.
18
+ - Add vacuum thumb and all.
19
+ - Thumb database use file_id as index.
20
+ - improve username and url check with regular expression.
21
+
22
+ ## 0.10
23
+
24
+ ### 0.10.0
25
+ - Inherit permission from path owner for `unset` permission files.
26
+ - Add timeout and verify options for client api.
27
+ - Bundle small files in memory.
28
+
29
+ ## 0.9
30
+
31
+ ### 0.9.5
32
+ - Stream bundle path as zip file.
33
+ - Update authentication token hash format (need to reset password).
34
+
35
+ ### 0.9.4
36
+ - Decode WebDAV file name.
37
+ - Allow root-listing for WebDAV.
38
+ - Always return 207 status code for propfind.
39
+ - Refactor debounce utility.
40
+
41
+ ### 0.9.3
42
+ - Fix empty file getting.
43
+ - HTTP `PUT/POST` default to overwrite the file.
44
+ - Use shared implementations for `PUT`, `GET`, `DELETE` methods.
45
+ - Inherit permission on overwriting `unset` permission files.
46
+
47
+ ### 0.9.2
48
+ - Native copy function.
49
+ - Only enable basic authentication if WebDAV is enabled.
50
+ - `WWW-Authenticate` header is now added to the response when authentication fails.
51
+
52
+ ### 0.9.1
53
+ - Add WebDAV support.
54
+ - Code refactor, use `lfss.eng` and `lfss.svc`.
55
+
56
+ ### 0.9.0
57
+ - User peer access control, now user can share their path with other users.
58
+ - Fix high concurrency database locking on file getting.
frontend/api.js CHANGED
@@ -69,6 +69,10 @@ export default class Connector {
69
69
  /**
70
70
  * @param {string} path - the path to the file (url)
71
71
  * @param {File} file - the file to upload
72
+ * @param {Object} [options] - Optional upload configuration.
73
+ * @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
74
+ * `'abort'` to cancel and raise 409, `'overwrite'` to replace.
75
+ * @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
72
76
  * @returns {Promise<string>} - the promise of the request, the url of the file
73
77
  */
74
78
  async put(path, file, {
@@ -96,8 +100,12 @@ export default class Connector {
96
100
  }
97
101
 
98
102
  /**
99
- * @param {string} path - the path to the file (url)
103
+ * @param {string} path - the path to the file (url), should end with .json
100
104
  * @param {File} file - the file to upload
105
+ * @param {Object} [options] - Optional upload configuration.
106
+ * @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
107
+ * `'abort'` to cancel and raise 409, `'overwrite'` to replace, `'skip'` to ignore if already exists.
108
+ * @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
101
109
  * @returns {Promise<string>} - the promise of the request, the url of the file
102
110
  */
103
111
  async post(path, file, {
@@ -129,13 +137,23 @@ export default class Connector {
129
137
 
130
138
  /**
131
139
  * @param {string} path - the path to the file (url), should end with .json
132
- * @param {Objec} data - the data to upload
140
+ * @param {Object} data - the data to upload
141
+ * @param {Object} [options] - Optional upload configuration.
142
+ * @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
143
+ * `'abort'` to cancel and raise 409, `'overwrite'` to replace, `'skip'` to ignore if already exists.
144
+ * @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
133
145
  * @returns {Promise<string>} - the promise of the request, the url of the file
134
146
  */
135
- async putJson(path, data){
147
+ async putJson(path, data, {
148
+ conflict = "overwrite",
149
+ permission = 0
150
+ } = {}){
136
151
  if (!path.endsWith('.json')){ throw new Error('Upload object must end with .json'); }
137
152
  if (path.startsWith('/')){ path = path.slice(1); }
138
- const res = await fetch(this.config.endpoint + '/' + path, {
153
+ const dst = new URL(this.config.endpoint + '/' + path);
154
+ dst.searchParams.append('conflict', conflict);
155
+ dst.searchParams.append('permission', permission);
156
+ const res = await fetch(dst.toString(), {
139
157
  method: 'PUT',
140
158
  headers: {
141
159
  'Authorization': 'Bearer ' + this.config.token,
@@ -149,6 +167,50 @@ export default class Connector {
149
167
  return (await res.json()).url;
150
168
  }
151
169
 
170
+ /**
171
+ * @param {string} path - the path to the file (url), should have content type application/json
172
+ * @returns {Promise<Object>} - return the json object
173
+ */
174
+ async getJson(path){
175
+ if (path.startsWith('/')){ path = path.slice(1); }
176
+ const res = await fetch(this.config.endpoint + '/' + path, {
177
+ method: 'GET',
178
+ headers: {
179
+ "Authorization": 'Bearer ' + this.config.token
180
+ },
181
+ });
182
+ if (res.status != 200){
183
+ throw new Error(`Failed to get object, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
184
+ }
185
+ return await res.json();
186
+ }
187
+
188
+ /**
189
+ * @param {string[]} paths - the paths to the files (url), should have content type plain/text, application/json, etc.
190
+ * @param {Object} [options] - Optional configuration.
191
+ * @param {boolean} [options.skipContent=false] - If true, skips fetching content and returns a record of <path, ''>.
192
+ * @returns {Promise<Record<string, string | null>>} - return the mapping of path to text content, non-existing paths will be ignored
193
+ */
194
+ async getMultipleText(paths, {
195
+ skipContent = false
196
+ } = {}){
197
+ const url = new URL(this.config.endpoint + '/_api/get-multiple');
198
+ url.searchParams.append('skip_content', skipContent);
199
+ for (const path of paths){
200
+ url.searchParams.append('path', path);
201
+ }
202
+ const res = await fetch(url.toString(), {
203
+ method: 'GET',
204
+ headers: {
205
+ "Authorization": 'Bearer ' + this.config.token,
206
+ }
207
+ });
208
+ if (res.status != 200 && res.status != 206){
209
+ throw new Error(`Failed to get multiple files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
210
+ }
211
+ return await res.json();
212
+ }
213
+
152
214
  async delete(path){
153
215
  if (path.startsWith('/')){ path = path.slice(1); }
154
216
  const res = await fetch(this.config.endpoint + '/' + path, {
frontend/login.js CHANGED
@@ -3,7 +3,6 @@ import { createFloatingWindow, showPopup } from "./popup.js";
3
3
 
4
4
  /**
5
5
  * @import { store } from "./state.js";
6
- * @import { UserRecord } from "./api.js";
7
6
  *
8
7
  * Shows the login panel if necessary.
9
8
  * @param {store} store - The store object.
frontend/popup.js CHANGED
@@ -109,7 +109,14 @@ export function showPopup(content = '', {
109
109
  } = {}){
110
110
  const popup = document.createElement("div");
111
111
  popup.classList.add("popup-window");
112
- popup.innerHTML = showTime? `<span>[${new Date().toLocaleTimeString()}]</span> ${content}` : content;
112
+ /**
113
+ * @param {string} c
114
+ * @returns {void}
115
+ */
116
+ function setPopupContent(c){
117
+ popup.innerHTML = showTime? `<span>[${new Date().toLocaleTimeString()}]</span> ${c}` : c;
118
+ }
119
+ setPopupContent(content);
113
120
  popup.style.width = width;
114
121
  const popupHeight = '1rem';
115
122
  popup.style.height = popupHeight;
@@ -132,11 +139,19 @@ export function showPopup(content = '', {
132
139
  if (level === "success") popup.style.backgroundColor = "darkgreen";
133
140
  document.body.appendChild(popup);
134
141
  shownPopups.push(popup);
135
- window.setTimeout(() => {
142
+
143
+ function closePopup(){
136
144
  if (popup.parentNode) document.body.removeChild(popup);
137
145
  shownPopups.splice(shownPopups.indexOf(popup), 1);
138
146
  for (let i = 0; i < shownPopups.length; i++) {
139
147
  shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
140
148
  }
141
- }, timeout);
149
+ }
150
+
151
+ window.setTimeout(closePopup, timeout);
152
+ return {
153
+ elem: popup,
154
+ setContent: setPopupContent,
155
+ close: closePopup
156
+ }
142
157
  }
frontend/scripts.js CHANGED
@@ -5,6 +5,7 @@ import { showInfoPanel, showDirInfoPanel } from './info.js';
5
5
  import { makeThumbHtml } from './thumb.js';
6
6
  import { store } from './state.js';
7
7
  import { maybeShowLoginPanel } from './login.js';
8
+ import { forEachFile } from './utils.js';
8
9
 
9
10
  /** @type {import('./api.js').UserRecord}*/
10
11
  let userRecord = null;
@@ -158,55 +159,61 @@ uploadFileNameInput.addEventListener('input', debounce(onFileNameInpuChange, 500
158
159
  e.preventDefault();
159
160
  e.stopPropagation();
160
161
  });
161
- window.addEventListener('drop', (e) => {
162
+ window.addEventListener('drop', async (e) => {
162
163
  e.preventDefault();
163
164
  e.stopPropagation();
164
- const files = e.dataTransfer.files;
165
- if (files.length == 1){
166
- uploadFileSelector.files = files;
167
- uploadFileNameInput.value = files[0].name;
165
+ const items = e.dataTransfer.items;
166
+ if (items.length == 1 && items[0].kind === 'file' && items[0].webkitGetAsEntry().isFile){
167
+ uploadFileSelector.files = e.dataTransfer.files;
168
+ uploadFileNameInput.value = e.dataTransfer.files[0].name;
168
169
  uploadFileNameInput.focus();
170
+ return;
169
171
  }
170
- else if (files.length > 1){
171
- let dstPath = store.dirpath + uploadFileNameInput.value;
172
- if (!dstPath.endsWith('/')){ dstPath += '/'; }
173
- if (!confirm(`
172
+
173
+ /** @type {[string, File][]} */
174
+ const uploadInputVal = uploadFileNameInput.value? uploadFileNameInput.value : '';
175
+ let dstPath = store.dirpath + uploadInputVal;
176
+ if (!dstPath.endsWith('/')){ dstPath += '/'; }
177
+
178
+ if (!confirm(`\
174
179
  You are trying to upload multiple files at once.
175
180
  This will directly upload the files to the [${dstPath}] directory without renaming.
176
181
  Note that same name files will be overwritten.
177
- Are you sure you want to proceed?
178
- `)){ return; }
179
-
180
- let counter = 0;
181
- async function uploadFileFn(...args){
182
- const [file, path] = args;
183
- try{
184
- await uploadFile(conn, path, file, {conflict: 'overwrite'});
185
- }
186
- catch (err){
187
- showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
188
- }
189
- counter += 1;
190
- console.log("Uploading file: ", counter, "/", files.length);
182
+ Are you sure you want to proceed?\
183
+ `)){ return; }
184
+
185
+ let counter = 0;
186
+ let totalCount = 0;
187
+ const uploadPopup = showPopup('Uploading multiple files...', {level: 'info', timeout: 999999});
188
+ async function uploadFileFn(path, file){
189
+ try{
190
+ await uploadFile(conn, path, file, {conflict: 'overwrite'});
191
191
  }
192
-
193
- let promises = [];
194
- for (let i = 0; i < files.length; i++){
195
- const file = files[i];
196
- const path = dstPath + file.name;
197
- promises.push(uploadFileFn(file, path));
192
+ catch (err){
193
+ showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
198
194
  }
199
- showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
200
- Promise.all(promises).then(
201
- () => {
202
- showPopup('Upload success.', {level: 'success', timeout: 3000});
203
- refreshFileList();
204
- },
205
- (err) => {
206
- showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
207
- }
208
- );
195
+ console.log(`[${counter}/${totalCount}] Uploaded file: ${path}`);
196
+ uploadPopup.setContent(`Uploading multiple files... [${counter}/${totalCount}]`);
209
197
  }
198
+
199
+ const promises = await forEachFile(e, async (relPath, filePromiseFn) => {
200
+ counter += 1;
201
+ const file = await filePromiseFn();
202
+ await uploadFileFn(dstPath + relPath, file);
203
+ });
204
+ totalCount = promises.length;
205
+
206
+ Promise.all(promises).then(
207
+ () => {
208
+ window.setTimeout(uploadPopup.close, 3000);
209
+ showPopup('Upload success.', {level: 'success', timeout: 3000});
210
+ refreshFileList();
211
+ },
212
+ (err) => {
213
+ showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
214
+ }
215
+ );
216
+
210
217
  });
211
218
  }
212
219
 
frontend/utils.js CHANGED
@@ -93,4 +93,101 @@ export function asHtmlText(text){
93
93
  anonElem.textContent = text;
94
94
  const htmlText = anonElem.innerHTML;
95
95
  return htmlText;
96
- }
96
+ }
97
+
98
+ /**
99
+ * Iterates over all files dropped in the event,
100
+ * including files inside directories, and processes them
101
+ * using the provided callback with a concurrency limit.
102
+ *
103
+ * @param {Event} e The drop event.
104
+ * @param {(relPath: string, file: () => Promise<File>) => Promise<void>} callback A function
105
+ * that receives the relative path and a promise for the File.
106
+ * @param {number} [maxConcurrent=5] Maximum number of concurrent callback executions.
107
+ * @returns {Promise<Promise<void>[]>} A promise resolving to an array of callback promises.
108
+ */
109
+ export async function forEachFile(e, callback, maxConcurrent = 16) {
110
+ const results = []; // to collect callback promises
111
+
112
+ // Concurrency barrier variables.
113
+ let activeCount = 0;
114
+ const queue = [];
115
+
116
+ /**
117
+ * Runs the given async task when below the concurrency limit.
118
+ * If at limit, waits until a slot is free.
119
+ *
120
+ * @param {() => Promise<any>} task An async function returning a promise.
121
+ * @returns {Promise<any>}
122
+ */
123
+ async function runWithLimit(task) {
124
+ // If we reached the concurrency limit, wait for a free slot.
125
+ if (activeCount >= maxConcurrent) {
126
+ await new Promise(resolve => queue.push(resolve));
127
+ }
128
+ activeCount++;
129
+ try {
130
+ return await task();
131
+ } finally {
132
+ activeCount--;
133
+ // If there are waiting tasks, allow the next one to run.
134
+ if (queue.length) {
135
+ queue.shift()();
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Recursively traverses a file system entry.
142
+ *
143
+ * @param {FileSystemEntry} entry The entry (file or directory).
144
+ * @param {string} path The current relative path.
145
+ */
146
+ async function traverse(entry, path) {
147
+ if (entry.isFile) {
148
+ // Wrap file retrieval in a promise.
149
+ const filePromiseFn = () =>
150
+ new Promise((resolve, reject) => entry.file(resolve, reject));
151
+ // Use the concurrency barrier for the callback invocation.
152
+ results.push(runWithLimit(() => callback(path + entry.name, filePromiseFn)));
153
+ } else if (entry.isDirectory) {
154
+ const reader = entry.createReader();
155
+
156
+ async function readAllEntries(reader) {
157
+ const entries = [];
158
+ while (true) {
159
+ const chunk = await new Promise((resolve, reject) => {
160
+ reader.readEntries(resolve, reject);
161
+ });
162
+ if (chunk.length === 0) break;
163
+ entries.push(...chunk);
164
+ }
165
+ return entries;
166
+ }
167
+
168
+ const entries = await readAllEntries(reader);
169
+ await Promise.all(
170
+ entries.map(ent => traverse(ent, path + entry.name + '/'))
171
+ );
172
+ }
173
+ }
174
+
175
+ // Process using DataTransfer items if available.
176
+ if (e.dataTransfer && e.dataTransfer.items) {
177
+ await Promise.all(
178
+ Array.from(e.dataTransfer.items).map(async item => {
179
+ const entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
180
+ if (entry) {
181
+ await traverse(entry, '');
182
+ }
183
+ })
184
+ );
185
+ } else if (e.dataTransfer && e.dataTransfer.files) {
186
+ // Fallback for browsers that support only dataTransfer.files.
187
+ Array.from(e.dataTransfer.files).forEach(file => {
188
+ results.push(runWithLimit(() => callback(file.name, Promise.resolve(file))));
189
+ });
190
+ }
191
+ return results;
192
+ }
193
+
lfss/api/__init__.py CHANGED
@@ -113,7 +113,7 @@ def download_file(
113
113
  print(f"File {file_path} already exists, skipping download.")
114
114
  return True, error_msg
115
115
  try:
116
- fmeta = connector.get_metadata(src_url)
116
+ fmeta = connector.get_meta(src_url)
117
117
  if fmeta is None:
118
118
  error_msg = "File not found."
119
119
  return False, error_msg
@@ -170,14 +170,15 @@ def download_directory(
170
170
  _counter = 0
171
171
  _counter_lock = Lock()
172
172
  failed_items: list[tuple[str, str]] = []
173
+ file_count = 0
173
174
  def get_file(c, src_url):
174
- nonlocal _counter, failed_items
175
+ nonlocal _counter, failed_items, file_count, verbose
175
176
  with _counter_lock:
176
177
  _counter += 1
177
178
  this_count = _counter
178
179
  dst_path = f"{directory}{os.path.relpath(decode_uri_compnents(src_url), decode_uri_compnents(src_path))}"
179
180
  if verbose:
180
- print(f"[{this_count}] Downloading {src_url} to {dst_path}")
181
+ print(f"[{this_count}/{file_count}] Downloading {src_url} to {dst_path}")
181
182
 
182
183
  if not (res:=download_file(
183
184
  c, src_url, dst_path,
@@ -185,11 +186,13 @@ def download_directory(
185
186
  ))[0]:
186
187
  failed_items.append((src_url, res[1]))
187
188
 
188
- batch_size = 10000
189
+ batch_size = 10_000
189
190
  file_list: list[FileRecord] = []
190
191
  with connector.session(n_concurrent) as c:
191
192
  file_count = c.count_files(src_path, flat=True)
192
193
  for offset in range(0, file_count, batch_size):
194
+ if verbose:
195
+ print(f"Retrieving file list... ({offset}/{file_count})", end='\r')
193
196
  file_list.extend(c.list_files(
194
197
  src_path, offset=offset, limit=batch_size, flat=True
195
198
  ))