Skip to content

Commit e30a93b

Browse files
Created a downloader.
1 parent 8075f9e commit e30a93b

File tree

6 files changed

+271
-0
lines changed

6 files changed

+271
-0
lines changed

utils/downloader/__init__.py

Whitespace-only changes.

utils/downloader/db.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.orm import sessionmaker
3+
from .models import Base, DownloadItem
4+
from datetime import datetime
5+
6+
class DownloadDB:
7+
def __init__(self, db_url="sqlite:///downloads.db"):
8+
self.engine = create_engine(db_url, echo=False)
9+
Base.metadata.create_all(self.engine)
10+
self.Session = sessionmaker(bind=self.engine)
11+
12+
def upsert(self, item_data: dict):
13+
session = self.Session()
14+
try:
15+
obj = session.query(DownloadItem).filter_by(id=item_data["id"]).first()
16+
if obj:
17+
for key, value in item_data.items():
18+
setattr(obj, key, value)
19+
else:
20+
item_data.setdefault("created_at", datetime.utcnow())
21+
obj = DownloadItem(**item_data)
22+
session.add(obj)
23+
session.commit()
24+
finally:
25+
session.close()
26+
27+
def all(self):
28+
session = self.Session()
29+
try:
30+
return session.query(DownloadItem).all()
31+
finally:
32+
session.close()
33+
34+
def delete(self, download_id: str):
35+
session = self.Session()
36+
try:
37+
session.query(DownloadItem).filter_by(id=download_id).delete()
38+
session.commit()
39+
finally:
40+
session.close()

utils/downloader/enums.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
class DownloadStatus(Enum):
4+
PAUSED = "paused"
5+
RESUMED = "resumed"
6+
CANCELLED = "cancelled"
7+
COMPLETED = "completed"
8+
ERROR = "error"
9+
PENDING = "pending"

utils/downloader/manager.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import os
2+
import uuid
3+
from PyQt6.QtCore import QObject, QThreadPool, pyqtSignal
4+
from .worker import DownloadWorker
5+
from .enums import DownloadStatus
6+
from .db import DownloadDB
7+
8+
class DownloaderManager(QObject):
9+
progress = pyqtSignal(str, str, int, int, int)
10+
finished = pyqtSignal(str, str)
11+
error = pyqtSignal(str, str)
12+
status = pyqtSignal(str, DownloadStatus)
13+
14+
def __init__(self, urls=None, default_folder="downloads", max_workers=3):
15+
super().__init__()
16+
self.default_folder = default_folder
17+
self._pause_all = False
18+
self._cancel_all = False
19+
self.db = DownloadDB()
20+
self.pool = QThreadPool.globalInstance()
21+
self.pool.setMaxThreadCount(max_workers)
22+
23+
self._downloads = {} # id → metadata
24+
self._load_state()
25+
if urls:
26+
self._add_new_downloads(urls)
27+
28+
def _load_state(self):
29+
for item in self.db.all():
30+
self._downloads[item.id] = {
31+
"id": item.id,
32+
"url": item.url,
33+
"filename": item.filename,
34+
"folder_path": item.folder_path,
35+
"status": item.status,
36+
"downloaded_bytes": item.downloaded_bytes,
37+
"total_bytes": item.total_bytes,
38+
"file_hash": item.file_hash
39+
}
40+
41+
def _add_new_downloads(self, urls):
42+
for url in urls:
43+
filename = os.path.basename(url)
44+
folder = self.default_folder
45+
download_id = str(uuid.uuid4())
46+
new_entry = {
47+
"id": download_id,
48+
"url": url,
49+
"filename": filename,
50+
"folder_path": folder,
51+
"status": DownloadStatus.PENDING.value
52+
}
53+
self.db.upsert(new_entry)
54+
self._downloads[download_id] = new_entry
55+
56+
def start(self):
57+
for download_id, info in self._downloads.items():
58+
if info["status"] in (DownloadStatus.COMPLETED.value, DownloadStatus.CANCELLED.value):
59+
continue
60+
worker = DownloadWorker(info, {
61+
"progress": self._on_progress,
62+
"finished": self._on_finished,
63+
"status": self._on_status,
64+
"error": self._on_error,
65+
}, manager=self)
66+
self._downloads[download_id]["worker"] = worker
67+
self.pool.start(worker)
68+
69+
def _on_progress(self, download_id, filename, downloaded, total, percent):
70+
self.progress.emit(download_id, filename, downloaded, total, percent)
71+
72+
def _on_status(self, download_id, status):
73+
self._downloads[download_id]["status"] = status.value
74+
self.status.emit(download_id, status)
75+
76+
def _on_error(self, download_id, message):
77+
self.error.emit(download_id, message)
78+
79+
def _on_finished(self, download_id, filename):
80+
self.finished.emit(download_id, filename)
81+
82+
def pause(self, download_id):
83+
if worker := self._downloads.get(download_id, {}).get("worker"):
84+
worker.pause()
85+
86+
def resume(self, download_id):
87+
if worker := self._downloads.get(download_id, {}).get("worker"):
88+
worker.resume()
89+
90+
def cancel(self, download_id):
91+
if worker := self._downloads.get(download_id, {}).get("worker"):
92+
worker.cancel()
93+
94+
def pause_all(self):
95+
self._pause_all = True
96+
for info in self._downloads.values():
97+
if info.get("worker"):
98+
info["worker"].pause()
99+
100+
def resume_all(self):
101+
self._pause_all = False
102+
for info in self._downloads.values():
103+
if info.get("worker"):
104+
info["worker"].resume()
105+
106+
def cancel_all(self):
107+
self._cancel_all = True
108+
for info in self._downloads.values():
109+
if info.get("worker"):
110+
info["worker"].cancel()
111+
112+
def get_download_map(self):
113+
return {
114+
download_id: {
115+
"url": data["url"],
116+
"filename": data["filename"],
117+
"folder_path": data["folder_path"],
118+
"status": data["status"]
119+
}
120+
for download_id, data in self._downloads.items()
121+
}

utils/downloader/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from sqlalchemy.ext.declarative import declarative_base
2+
from sqlalchemy import Column, String, Integer, DateTime
3+
from datetime import datetime
4+
5+
Base = declarative_base()
6+
7+
class DownloadItem(Base):
8+
__tablename__ = "downloads"
9+
10+
id = Column(String, primary_key=True)
11+
url = Column(String, nullable=False)
12+
filename = Column(String)
13+
folder_path = Column(String)
14+
status = Column(String)
15+
downloaded_bytes = Column(Integer, default=0)
16+
total_bytes = Column(Integer, default=0)
17+
file_hash = Column(String, nullable=True)
18+
created_at = Column(DateTime, default=datetime.now)

utils/downloader/worker.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
import requests
3+
from PyQt6.QtCore import QRunnable, pyqtSlot
4+
from .enums import DownloadStatus
5+
from .db import DownloadDB
6+
7+
class DownloadWorker(QRunnable):
8+
def __init__(self, item_data: dict, signals, manager):
9+
super().__init__()
10+
self.item = item_data
11+
self.signals = signals
12+
self.manager = manager
13+
self.db = DownloadDB()
14+
15+
self.filename = self.item["filename"]
16+
self.folder_path = self.item["folder_path"]
17+
self.final_path = os.path.join(self.folder_path, self.filename)
18+
self.temp_path = self.final_path + ".part"
19+
self.url = self.item["url"]
20+
self.download_id = self.item["id"]
21+
22+
self._paused = False
23+
self._cancelled = False
24+
25+
@pyqtSlot()
26+
def run(self):
27+
try:
28+
self._download_file()
29+
except Exception as e:
30+
self.signals["status"].emit(self.download_id, DownloadStatus.ERROR)
31+
self.signals["error"].emit(self.download_id, str(e))
32+
33+
def _download_file(self):
34+
os.makedirs(self.folder_path, exist_ok=True)
35+
resume_header = {}
36+
file_mode = "ab" if os.path.exists(self.temp_path) else "wb"
37+
downloaded_size = os.path.getsize(self.temp_path) if os.path.exists(self.temp_path) else 0
38+
39+
if downloaded_size > 0:
40+
resume_header["Range"] = f"bytes={downloaded_size}-"
41+
42+
with requests.get(self.url, stream=True, headers=resume_header) as r:
43+
r.raise_for_status()
44+
total_size = int(r.headers.get("content-length", 0)) + downloaded_size
45+
46+
with open(self.temp_path, file_mode) as f:
47+
for chunk in r.iter_content(chunk_size=64 * 1024):
48+
if self._cancelled or self.manager._cancel_all:
49+
self.signals["status"].emit(self.download_id, DownloadStatus.CANCELLED)
50+
return
51+
while self._paused or self.manager._pause_all:
52+
self.manager.msleep(100)
53+
54+
if chunk:
55+
f.write(chunk)
56+
downloaded_size += len(chunk)
57+
percent = int((downloaded_size / total_size) * 100) if total_size else 0
58+
self.signals["progress"].emit(
59+
self.download_id, self.filename, downloaded_size, total_size, percent
60+
)
61+
self.db.upsert({
62+
**self.item,
63+
"downloaded_bytes": downloaded_size,
64+
"total_bytes": total_size,
65+
"status": DownloadStatus.RESUMED.value
66+
})
67+
68+
if not self._cancelled and not self.manager._cancel_all:
69+
os.replace(self.temp_path, self.final_path)
70+
self.signals["status"].emit(self.download_id, DownloadStatus.COMPLETED)
71+
self.signals["finished"].emit(self.download_id, self.filename)
72+
self.db.upsert({**self.item, "downloaded_bytes": downloaded_size, "total_bytes": total_size, "status": DownloadStatus.COMPLETED.value})
73+
74+
def pause(self):
75+
self._paused = True
76+
self.signals["status"].emit(self.download_id, DownloadStatus.PAUSED)
77+
78+
def resume(self):
79+
self._paused = False
80+
self.signals["status"].emit(self.download_id, DownloadStatus.RESUMED)
81+
82+
def cancel(self):
83+
self._cancelled = True

0 commit comments

Comments
 (0)