Skip to content

Commit

Permalink
feat: Update web ui and more
Browse files Browse the repository at this point in the history
- Use bootstrap as component framework
  and replace all templates with styled
  elements
- Add htmx response-targets for error handling
- Add custom js to open bootstrap toast
- Add cancel and resume feature
  • Loading branch information
holomekc committed Jul 20, 2024
1 parent e9b8514 commit bfbe452
Show file tree
Hide file tree
Showing 30 changed files with 474 additions and 33 deletions.
65 changes: 63 additions & 2 deletions src/icloudpd/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from icloudpd.server import serve_app
from icloudpd.status import Status, StatusExchange
from icloudpd.string_helpers import truncate_middle
from icloudpd.config import Config


def build_filename_cleaner(
Expand Down Expand Up @@ -671,6 +672,47 @@ def main(
sys.exit(2)

status_exchange = StatusExchange()
config = Config(
directory=directory,
username=username,
auth_only=auth_only,
cookie_directory=cookie_directory,
size=size,
live_photo_size=live_photo_size,
recent=recent,
until_found=until_found,
album=album,
list_albums=list_albums,
library=library,
list_libraries=list_libraries,
skip_videos=skip_videos,
skip_live_photos=skip_live_photos,
force_size=force_size,
auto_delete=auto_delete,
only_print_filenames=only_print_filenames,
folder_structure=folder_structure,
set_exif_datetime=set_exif_datetime,
smtp_username=smtp_username,
smtp_host=smtp_host,
smtp_port=smtp_port,
smtp_no_tls=smtp_no_tls,
notification_email=notification_email,
notification_email_from=notification_email_from,
log_level=log_level,
no_progress_bar=no_progress_bar,
notification_script=notification_script,
threads_num=threads_num,
delete_after_download=delete_after_download,
domain=domain,
watch_with_interval=watch_with_interval,
dry_run=dry_run,
raw_policy=raw_policy,
password_providers=password_providers,
file_match_policy=file_match_policy,
mfa_provider=mfa_provider,
use_os_locale=use_os_locale,
)
status_exchange.set_config(config)

# hacky way to use one param in another
if "webui" in password_providers:
Expand Down Expand Up @@ -1273,6 +1315,9 @@ def should_break(counter: Counter) -> bool:
"""Exit if until_found condition is reached"""
return until_found is not None and counter.value() >= until_found

status_exchange.get_progress().photos_count = photos_count
photos_counter = 0

photos_iterator = iter(photos_enumerator)
while True:
try:
Expand All @@ -1294,13 +1339,25 @@ def should_break(counter: Counter) -> bool:

retrier(delete_local, error_handler)

photos_counter += 1
status_exchange.get_progress().photos_counter = photos_counter

if status_exchange.get_progress().cancel:
break

except StopIteration:
break

if only_print_filenames:
return 0

logger.info("All photos have been downloaded")
if status_exchange.get_progress().cancel:
logger.info("Iteration was cancelled")
status_exchange.get_progress().photos_last_message = "Iteration was cancelled"
else:
logger.info("All photos have been downloaded")
status_exchange.get_progress().photos_last_message = "All photos have been downloaded"
status_exchange.get_progress().reset()

if auto_delete:
autodelete_photos(
Expand All @@ -1324,7 +1381,11 @@ def should_break(counter: Counter) -> bool:
),
)
)
for _ in iterable:
for counter in iterable:
status_exchange.get_progress().waiting = watch_interval - counter
if status_exchange.get_progress().resume:
status_exchange.get_progress().reset()
break
time.sleep(1)
else:
break # pragma: no cover
Expand Down
91 changes: 91 additions & 0 deletions src/icloudpd/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import json
from typing import Sequence, Optional, Callable, Dict, Tuple

from icloudpd.mfa_provider import MFAProvider
from pyicloud_ipd.file_match import FileMatchPolicy
from pyicloud_ipd.raw_policy import RawTreatmentPolicy
from pyicloud_ipd.version_size import AssetVersionSize, LivePhotoVersionSize


class Config:
def __init__(
self,
directory: str,
username: str,
auth_only: bool,
cookie_directory: str,
size: Sequence[AssetVersionSize],
live_photo_size: LivePhotoVersionSize,
recent: Optional[int],
until_found: Optional[int],
album: str,
list_albums: bool,
library: str,
list_libraries: bool,
skip_videos: bool,
skip_live_photos: bool,
force_size: bool,
auto_delete: bool,
only_print_filenames: bool,
folder_structure: str,
set_exif_datetime: bool,
smtp_username: Optional[str],
smtp_host: str,
smtp_port: int,
smtp_no_tls: bool,
notification_email: Optional[str],
notification_email_from: Optional[str],
log_level: str,
no_progress_bar: bool,
notification_script: Optional[str],
threads_num: int,
delete_after_download: bool,
domain: str,
watch_with_interval: Optional[int],
dry_run: bool,
raw_policy: RawTreatmentPolicy,
password_providers: Dict[
str, Tuple[Callable[[str], Optional[str]], Callable[[str, str], None]]
],
file_match_policy: FileMatchPolicy,
mfa_provider: MFAProvider,
use_os_locale: bool,
):
self.directory = directory
self.username = username
self.auth_only = auth_only
self.cookie_directory = cookie_directory
self.size = " ".join(str(e) for e in size)
self.live_photo_size = live_photo_size
self.recent = recent
self.until_found = until_found
self.album = album
self.list_albums = list_albums
self.library = library
self.list_libraries = list_libraries
self.skip_videos = skip_videos
self.skip_live_photos = skip_live_photos
self.force_size = force_size
self.auto_delete = auto_delete
self.only_print_filenames = only_print_filenames
self.folder_structure = folder_structure
self.set_exif_datetime = set_exif_datetime
self.smtp_username = smtp_username
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.smtp_no_tls = smtp_no_tls
self.notification_email = notification_email
self.notification_email_from = notification_email_from
self.log_level = log_level
self.no_progress_bar = no_progress_bar
self.notification_script = notification_script
self.threads_num = threads_num
self.delete_after_download = delete_after_download
self.domain = domain
self.watch_with_interval = watch_with_interval
self.dry_run = dry_run
self.raw_policy = raw_policy
self.password_providers = " ".join(str(e) for e in password_providers.keys())
self.file_match_policy = file_match_policy
self.mfa_provider = mfa_provider
self.use_os_locale = use_os_locale
3 changes: 3 additions & 0 deletions src/icloudpd/mfa_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
class MFAProvider(Enum):
CONSOLE = "console"
WEBUI = "webui"

def __str__(self):
return self.name
53 changes: 53 additions & 0 deletions src/icloudpd/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import datetime


class Progress:
def __init__(self):
self._photos_count = 0
self._photos_counter = 0
self.photos_percent = 0
self.waiting_readable = ""
self.resume = False
self.cancel = False
self._waiting = 0

@property
def waiting(self):
return self._waiting

@waiting.setter
def waiting(self, waiting):
self._waiting = str(datetime.timedelta(seconds=waiting))

@property
def photos_count(self):
return self._photos_count

@photos_count.setter
def photos_count(self, photos_count):
self._photos_count = photos_count
if self.photos_count != 0:
self.photos_percent = round(100 / self.photos_count * self.photos_counter)
else:
self.photos_percent = 0

@property
def photos_counter(self):
return self._photos_counter

@photos_counter.setter
def photos_counter(self, photos_counter):
self._photos_counter = photos_counter
if self.photos_count != 0:
self.photos_percent = round(100 / self.photos_count * self.photos_counter)
else:
self.photos_percent = 0

def reset(self):
self._photos_count = 0
self._photos_counter = 0
self.photos_percent = 0
self.waiting_readable = ""
self.resume = False
self.cancel = False
self._waiting = 0
23 changes: 19 additions & 4 deletions src/icloudpd/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

def serve_app(logger: Logger, _status_exchange: StatusExchange) -> None:
app = Flask(__name__)
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.logger = logger
# for running in pyinstaller
bundle_dir = getattr(sys, "_MEIPASS", None)
Expand All @@ -25,33 +26,47 @@ def index() -> Union[Response, str]:
@app.route("/status", methods=["GET"])
def get_status() -> Union[Response, str]:
_status = _status_exchange.get_status()
_config = _status_exchange.get_config()
_progress = _status_exchange.get_progress()
if _status == Status.NO_INPUT_NEEDED:
return render_template("no_input.html")
return render_template("no_input.html", status=_status, progress=_progress, config=vars(_config))
if _status == Status.NEED_MFA:
return render_template("code.html")
if _status == Status.NEED_PASSWORD:
return render_template("password.html")
return render_template("password.html", config=_config)
return render_template("status.html", status=_status)

@app.route("/code", methods=["POST"])
def set_code() -> Union[Response, str]:
_config = _status_exchange.get_config()
code = request.form.get("code")
if code is not None:
if _status_exchange.set_payload(code):
return render_template("code_submitted.html")
else:
logger.error(f"cannot find code in request {request.form}")
return make_response(render_template("error.html"), 400) # incorrect code
return make_response(render_template("auth_error.html", config=_config, type="Two-Factor Code"), 400) # incorrect code

@app.route("/password", methods=["POST"])
def set_password() -> Union[Response, str]:
_config = _status_exchange.get_config()
password = request.form.get("password")
if password is not None:
if _status_exchange.set_payload(password):
return render_template("password_submitted.html")
else:
logger.error(f"cannot find password in request {request.form}")
return make_response(render_template("error.html"), 400) # incorrect code
return make_response(render_template("auth_error.html", config=_config, type="password"), 400) # incorrect code

@app.route("/resume", methods=["POST"])
def resume() -> Union[Response, str]:
_status_exchange.get_progress().resume = True
return make_response("Ok", 200)

@app.route("/cancel", methods=["POST"])
def cancel() -> Union[Response, str]:
_status_exchange.get_progress().cancel = True
return make_response("Ok", 200)

logger.debug("Starting web server...")
return waitress.serve(app)

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading

0 comments on commit bfbe452

Please sign in to comment.