mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea2fee0bb | ||
|
|
1c24312eb0 | ||
|
|
98e3a2f114 | ||
|
|
cd16f09f2e | ||
|
|
527c5d495d | ||
|
|
f5de2ab143 | ||
|
|
4e5c9b788f | ||
|
|
199d8453eb | ||
|
|
a9854b1a5c | ||
|
|
e4d3a372c8 | ||
|
|
ff44881415 | ||
|
|
9ffedc1fc0 | ||
|
|
00370818f0 | ||
|
|
7d9a82bfea | ||
|
|
207cff96d3 | ||
|
|
c8f21b8f8d |
@@ -26,6 +26,9 @@ jobs:
|
||||
- suffix: "-tor"
|
||||
target: cwa-bd-tor
|
||||
image_name_suffix: "-tor"
|
||||
- suffix: "-extbp"
|
||||
target: cwa-bd-extbp
|
||||
image_name_suffix: "-extbp"
|
||||
steps:
|
||||
- name: Get current date
|
||||
id: date
|
||||
@@ -67,6 +70,7 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.date.outputs.date }}-${{ github.sha }}
|
||||
RELEASE_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -15,7 +15,8 @@
|
||||
"LOG_ROOT": "/tmp/cwa-book-downloader",
|
||||
"ENABLE_LOGGING": "true",
|
||||
"DOCKERMODE": "false",
|
||||
"DEBUG": "true"
|
||||
"DEBUG": "true",
|
||||
"CUSTOM_DNS": "google",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+37
-18
@@ -4,6 +4,8 @@ FROM python:3.10-slim AS base
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
ENV BUILD_VERSION=${BUILD_VERSION}
|
||||
ARG RELEASE_VERSION
|
||||
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
||||
|
||||
# Set shell to bash with pipefail option
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
@@ -38,18 +40,10 @@ RUN apt-get update && \
|
||||
curl \
|
||||
# For entrypoint
|
||||
dumb-init \
|
||||
# For dumb display
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# For debug
|
||||
zip iputils-ping \
|
||||
# For user switching
|
||||
sudo \
|
||||
# --- Chromium Browser ---
|
||||
chromium-driver \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk && \
|
||||
sudo && \
|
||||
# Cleanup APT cache *after* all installs in this layer
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
|
||||
apt-get clean && \
|
||||
@@ -67,17 +61,12 @@ WORKDIR /app
|
||||
|
||||
# Install Python dependencies using pip
|
||||
# Upgrade pip first, then copy requirements and install
|
||||
# Copying requirements.txt separately leverages build cache
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
# Copying requirements-base.txt separately leverages build cache
|
||||
COPY requirements-base.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-base.txt && \
|
||||
# Clean root's pip cache
|
||||
rm -rf /root/.cache
|
||||
|
||||
# Add this line to grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
|
||||
@@ -101,10 +90,34 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
FROM base AS cwa-bd
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# For dumb display
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# --- Chromium ---
|
||||
chromium \
|
||||
# --- ChromeDriver ---
|
||||
chromium-driver \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk
|
||||
|
||||
# install additional dependencies
|
||||
COPY requirements-cwa-bd.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-cwa-bd.txt && \
|
||||
# Clean root's pip cache
|
||||
rm -rf /root/.cache
|
||||
|
||||
# Add this line to grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
|
||||
# Default command to run the application entrypoint script
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
FROM base AS cwa-bd-tor
|
||||
FROM cwa-bd AS cwa-bd-tor
|
||||
|
||||
ENV USING_TOR=true
|
||||
|
||||
@@ -124,3 +137,9 @@ RUN apt-get update && \
|
||||
|
||||
# Override the default command to run Tor
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
FROM base AS cwa-bd-extbp
|
||||
|
||||
ENV USING_EXTERNAL_BYPASSER=true
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -12,8 +12,8 @@ from flask import url_for as flask_url_for
|
||||
import typing
|
||||
|
||||
from logger import setup_logger
|
||||
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG
|
||||
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE, SUPPORTED_FORMATS
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION
|
||||
import backend
|
||||
|
||||
from models import SearchFilters
|
||||
@@ -103,7 +103,17 @@ def index() -> str:
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
"""
|
||||
return render_template('index.html', book_languages=_SUPPORTED_BOOK_LANGUAGE, default_language=BOOK_LANGUAGE, debug=DEBUG)
|
||||
return render_template('index.html',
|
||||
book_languages=_SUPPORTED_BOOK_LANGUAGE,
|
||||
default_language=BOOK_LANGUAGE,
|
||||
supported_formats=SUPPORTED_FORMATS,
|
||||
debug=DEBUG,
|
||||
build_version=BUILD_VERSION,
|
||||
release_version=RELEASE_VERSION,
|
||||
app_env=APP_ENV
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.route('/favico<path:_>')
|
||||
@app.route('/request/favico<path:_>')
|
||||
@@ -117,7 +127,10 @@ from typing import Union, Tuple
|
||||
if DEBUG:
|
||||
import subprocess
|
||||
import time
|
||||
from cloudflare_bypasser import _reset_driver as STOP_GUI
|
||||
if USING_EXTERNAL_BYPASSER:
|
||||
STOP_GUI = lambda: None # No-op for external bypasser
|
||||
else:
|
||||
from cloudflare_bypasser import _reset_driver as STOP_GUI
|
||||
@app.route('/debug', methods=['GET'])
|
||||
@login_required
|
||||
def debug() -> Union[Response, Tuple[Response, int]]:
|
||||
|
||||
+18
-20
@@ -81,6 +81,12 @@ def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
Dict: Queue status organized by status type
|
||||
"""
|
||||
status = book_queue.get_status()
|
||||
for _, books in status.items():
|
||||
for _, book_info in books.items():
|
||||
if book_info.download_path:
|
||||
if not os.path.exists(book_info.download_path):
|
||||
book_info.download_path = None
|
||||
|
||||
# Convert Enum keys to strings and properly format the response
|
||||
return {
|
||||
status_type.value: books
|
||||
@@ -144,16 +150,9 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before book manager call: {book_id}")
|
||||
return None
|
||||
|
||||
# Update progress periodically during download
|
||||
progress_thread = threading.Thread(
|
||||
target=_update_download_progress,
|
||||
args=(book_id, cancel_flag),
|
||||
daemon=True
|
||||
)
|
||||
progress_thread.start()
|
||||
|
||||
success = book_manager.download_book(book_info, book_path)
|
||||
|
||||
progress_callback = lambda progress: update_download_progress(book_id, progress)
|
||||
success = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag)
|
||||
|
||||
# Stop progress updates
|
||||
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
|
||||
@@ -187,8 +186,12 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
try:
|
||||
shutil.move(book_path, intermediate_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error moving book: {e}, will try copying instead")
|
||||
shutil.copy(book_path, intermediate_path)
|
||||
try:
|
||||
logger.debug(f"Error moving book: {e}, will try copying instead")
|
||||
shutil.move(book_path, intermediate_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error copying book: {e}, will try copying without permissions instead")
|
||||
shutil.copyfile(book_path, intermediate_path)
|
||||
os.remove(book_path)
|
||||
|
||||
# Final cancellation check before completing
|
||||
@@ -209,14 +212,9 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
|
||||
logger.error_trace(f"Error downloading book: {e}")
|
||||
return None
|
||||
|
||||
def _update_download_progress(book_id: str, cancel_flag: Event) -> None:
|
||||
"""Update download progress periodically."""
|
||||
progress = 0.0
|
||||
while not cancel_flag.is_set() and progress < 100.0:
|
||||
# Simulate progress (in real implementation, this would get actual progress)
|
||||
progress = min(100.0, progress + 10.0)
|
||||
book_queue.update_progress(book_id, progress)
|
||||
time.sleep(DOWNLOAD_PROGRESS_UPDATE_INTERVAL)
|
||||
def update_download_progress(book_id: str, progress: float) -> None:
|
||||
"""Update download progress."""
|
||||
book_queue.update_progress(book_id, progress)
|
||||
|
||||
def cancel_download(book_id: str) -> bool:
|
||||
"""Cancel a download.
|
||||
|
||||
+50
-16
@@ -3,18 +3,19 @@
|
||||
import time, json, re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from typing import List, Optional, Dict, Union
|
||||
from typing import List, Optional, Dict, Union, Callable
|
||||
from threading import Event
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
|
||||
|
||||
import downloader
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB, ALLOW_USE_WELIB
|
||||
from models import BookInfo, SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
@@ -168,8 +169,6 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
|
||||
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
|
||||
divs = list(data.children)
|
||||
format = divs[13].text.split(" · ")[1].strip().lower()
|
||||
size = divs[13].text.split(" · ")[2].strip().lower()
|
||||
|
||||
every_url = soup.find_all("a")
|
||||
slow_urls_no_waitlist = set()
|
||||
@@ -198,7 +197,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
):
|
||||
libgen_url = url["href"]
|
||||
# TODO : Temporary fix ? Maybe get URLs from https://open-slum.org/ ?
|
||||
libgen_url = libgen_url = re.sub(r'libgen\.(\w+)', 'libgen.gs', url["href"])
|
||||
libgen_url = re.sub(r'libgen\.(lc|is|bz|st)', 'libgen.gl', url["href"])
|
||||
|
||||
external_urls_libgen.add(libgen_url)
|
||||
elif url.text.strip().lower().startswith("z-lib"):
|
||||
if ".onion/" not in url["href"]:
|
||||
@@ -222,20 +222,49 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
# Remove empty urls
|
||||
urls = [url for url in urls if url != ""]
|
||||
|
||||
# Filter out divs that are not text
|
||||
original_divs = divs
|
||||
divs = [div.text.strip() for div in divs if div.text.strip() != ""]
|
||||
|
||||
separator_index = 6
|
||||
for i, div in enumerate(divs):
|
||||
if "·" in div.strip():
|
||||
separator_index = i
|
||||
break
|
||||
|
||||
_details = divs[separator_index].lower().split(" · ")
|
||||
format = ""
|
||||
size = ""
|
||||
for f in _details:
|
||||
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
|
||||
format = f.strip().lower()
|
||||
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
|
||||
size = f.strip().lower()
|
||||
|
||||
if format == "" or size == "":
|
||||
for f in _details:
|
||||
if f == "" and not " " in f.strip().lower():
|
||||
format = f.strip().lower()
|
||||
if size == "" and "." in f.strip().lower():
|
||||
size = f.strip().lower()
|
||||
|
||||
|
||||
book_title = divs[separator_index-3].strip("🔍")
|
||||
|
||||
# Extract basic information
|
||||
book_info = BookInfo(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=divs[7].next.strip(),
|
||||
publisher=divs[11].text.strip(),
|
||||
author=divs[9].text.strip(),
|
||||
title=book_title,
|
||||
publisher=divs[separator_index-1],
|
||||
author=divs[separator_index-2],
|
||||
format=format,
|
||||
size=size,
|
||||
download_urls=urls,
|
||||
)
|
||||
|
||||
# Extract additional metadata
|
||||
info = _extract_book_metadata(divs[-6])
|
||||
info = _extract_book_metadata(original_divs[-6])
|
||||
book_info.info = info
|
||||
|
||||
# Set language and year from metadata if available
|
||||
@@ -247,6 +276,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
return book_info
|
||||
|
||||
def _get_download_urls_from_welib(book_id: str) -> set[str]:
|
||||
if ALLOW_USE_WELIB == False:
|
||||
return set()
|
||||
"""Get download urls from welib.org."""
|
||||
url = f"https://welib.org/md5/{book_id}"
|
||||
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
|
||||
@@ -300,7 +331,7 @@ def _extract_book_metadata(
|
||||
}
|
||||
|
||||
|
||||
def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> bool:
|
||||
"""Download a book from available sources.
|
||||
|
||||
Args:
|
||||
@@ -324,10 +355,11 @@ def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
|
||||
for link in download_links:
|
||||
try:
|
||||
download_url = _get_download_url(link, book_info.title)
|
||||
download_url = _get_download_url(link, book_info.title, cancel_flag)
|
||||
if download_url != "":
|
||||
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
|
||||
data = downloader.download_url(download_url, book_info.size or "")
|
||||
|
||||
data = downloader.download_url(download_url, book_info.size or "", progress_callback, cancel_flag)
|
||||
if not data:
|
||||
raise Exception("No data received")
|
||||
|
||||
@@ -344,7 +376,7 @@ def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_download_url(link: str, title: str) -> str:
|
||||
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None) -> str:
|
||||
"""Extract actual download URL from various source pages."""
|
||||
|
||||
url = ""
|
||||
@@ -371,8 +403,10 @@ def _get_download_url(link: str, title: str) -> str:
|
||||
if countdown:
|
||||
sleep_time = int(countdown[0].text)
|
||||
logger.info(f"Waiting {sleep_time}s for {title}")
|
||||
time.sleep(sleep_time)
|
||||
url = _get_download_url(link, title)
|
||||
if cancel_flag is not None and cancel_flag.wait(timeout=sleep_time):
|
||||
logger.info(f"Cancelled wait for {title}")
|
||||
return ""
|
||||
url = _get_download_url(link, title, cancel_flag)
|
||||
else:
|
||||
url = download_links[0]["href"]
|
||||
else:
|
||||
|
||||
+36
-23
@@ -8,6 +8,8 @@ from env import LOG_DIR, DEBUG
|
||||
import signal
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
# --- SeleniumBase Import ---
|
||||
from seleniumbase import Driver
|
||||
@@ -43,7 +45,7 @@ def _reset_pyautogui_display_state():
|
||||
except Exception as e:
|
||||
logger.warning(f"Error resetting pyautogui display state: {e}")
|
||||
|
||||
def _is_bypassed(sb) -> bool:
|
||||
def _is_bypassed(sb, escape_emojis : bool = True) -> bool:
|
||||
"""Enhanced bypass detection with more comprehensive checks"""
|
||||
try:
|
||||
# Get page information with error handling
|
||||
@@ -62,30 +64,25 @@ def _is_bypassed(sb) -> bool:
|
||||
except:
|
||||
current_url = ""
|
||||
|
||||
# Check if page is too long, if so we are probably bypassed
|
||||
if len(body.strip()) > 100000:
|
||||
logger.debug(f"Page content too long, we are probably bypassed len: {len(body.strip())}")
|
||||
return True
|
||||
|
||||
# Detect if there is an emoji in the page, any utf8 emoji, if so we are probably bypassed
|
||||
if escape_emojis:
|
||||
import emoji
|
||||
emoji_list = emoji.emoji_list(body)
|
||||
if len(emoji_list) >= 3:
|
||||
logger.debug(f"Detected emoji in page, we are probably bypassed len: {len(emoji_list)}")
|
||||
return True
|
||||
|
||||
# Enhanced verification texts for newer Cloudflare versions
|
||||
verification_texts = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"needs to review the security of your connection before proceeding",
|
||||
"checking your browser",
|
||||
"checking connection",
|
||||
"attention required",
|
||||
"access denied",
|
||||
"needs to review the security of your connection",
|
||||
"checking the site connection security",
|
||||
"enable javascript and cookies to continue",
|
||||
"ray id",
|
||||
"cloudflare",
|
||||
"please wait",
|
||||
"ddos protection",
|
||||
"security check",
|
||||
"browser check",
|
||||
"moment please",
|
||||
"hold on",
|
||||
"loading",
|
||||
"one more step",
|
||||
"challenge"
|
||||
"cloudflare.com/products/turnstile/?utm_source=turnstile"
|
||||
]
|
||||
|
||||
# Check for Cloudflare indicators
|
||||
@@ -277,9 +274,8 @@ def _get_chromium_args():
|
||||
except socket.gaierror:
|
||||
logger.warning(f"Could not resolve DoH hostname: {doh_hostname}")
|
||||
elif CUSTOM_DNS:
|
||||
resolver_rules = [f"MAP * {dns_server}" for dns_server in CUSTOM_DNS]
|
||||
if resolver_rules:
|
||||
arguments.append(f'--host-resolver-rules={",".join(resolver_rules)}')
|
||||
arguments.append(f'--dns-server="{",".join(CUSTOM_DNS)}"')
|
||||
arguments.append(f'--disable-features=DnsOverHttps')
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error configuring DNS settings: {e}")
|
||||
return arguments
|
||||
@@ -476,3 +472,20 @@ def wait_for_result(func, timeout : int = 10, condition : any = True):
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
_init_cleanup_thread()
|
||||
|
||||
|
||||
def get_bypassed_page(url: str) -> Optional[str]:
|
||||
"""Fetch HTML content from a URL using the internal Cloudflare Bypasser.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
"""
|
||||
|
||||
response_html = get(url)
|
||||
logger.debug(f"Cloudflare Bypasser response length: {len(response_html)}")
|
||||
if response_html.strip() != "":
|
||||
return response_html
|
||||
else:
|
||||
raise requests.exceptions.RequestException("Failed to bypass Cloudflare")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from logger import setup_logger
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
try:
|
||||
from env import EXT_BYPASSER_PATH, EXT_BYPASSER_TIMEOUT, EXT_BYPASSER_URL
|
||||
except ImportError:
|
||||
raise RuntimeError("Failed to import environment variables. Are you using an `extbp` image?")
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def get_bypassed_page(url: str) -> Optional[str]:
|
||||
"""Fetch HTML content from a URL using an External Cloudflare Resolver.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
"""
|
||||
if not EXT_BYPASSER_URL or not EXT_BYPASSER_PATH:
|
||||
logger.error("Wrong External Bypass configuration. Please check your environment configuration.")
|
||||
return None
|
||||
ext_url = f"{EXT_BYPASSER_URL}{EXT_BYPASSER_PATH}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
data = {
|
||||
"cmd": "request.get",
|
||||
"url": url,
|
||||
"maxTimeout": EXT_BYPASSER_TIMEOUT
|
||||
}
|
||||
response = requests.post(ext_url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
logger.debug(f"External Bypass response for '{url}': {response.json()['status']} - {response.json()['message']}")
|
||||
return response.json()['solution']['response']
|
||||
@@ -36,16 +36,16 @@ logger.info(f"CROSS_FILE_SYSTEM: {CROSS_FILE_SYSTEM}")
|
||||
_custom_dns = env._CUSTOM_DNS.lower().strip()
|
||||
_doh_server = ""
|
||||
if _custom_dns == "google":
|
||||
CUSTOM_DNS = ["8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844"]
|
||||
CUSTOM_DNS = ["8.8.8.8", "8.8.4.4", "2001:4860:4860:0000:0000:0000:0000:8888", "2001:4860:4860:0000:0000:0000:0000:8844"]
|
||||
_doh_server = "https://dns.google/dns-query"
|
||||
elif _custom_dns == "quad9":
|
||||
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:fe::fe", "26620:fe::9"]
|
||||
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:00fe:0000:0000:0000:0000:0000:00fe", "2620:00fe:0000:0000:0000:0000:0000:0009"]
|
||||
_doh_server = "https://dns.quad9.net/dns-query"
|
||||
elif _custom_dns == "cloudflare":
|
||||
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"]
|
||||
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700:0000:0000:0000:0000:1111", "2606:4700:4700:0000:0000:0000:0000:1001"]
|
||||
_doh_server = "https://cloudflare-dns.com/dns-query"
|
||||
elif _custom_dns == "opendns":
|
||||
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:119:35::35", "2620:119:53::53"]
|
||||
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:0119:0035:0000:0000:0000:0000:0035", "2620:0119:0053:0000:0000:0000:0000:0053"]
|
||||
_doh_server = "https://doh.opendns.com/dns-query"
|
||||
else:
|
||||
_custom_dns_ip = _custom_dns.split(",")
|
||||
@@ -93,7 +93,9 @@ if CUSTOM_SCRIPT:
|
||||
CUSTOM_SCRIPT = ""
|
||||
|
||||
# Debugging settings
|
||||
VIRTUAL_SCREEN_SIZE = (1024, 768)
|
||||
RECORDING_DIR = env.LOG_DIR / "recording"
|
||||
if env.DEBUG:
|
||||
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not env.USING_EXTERNAL_BYPASSER:
|
||||
# Virtual display settings for debugging internal cloudflare bypasser
|
||||
VIRTUAL_SCREEN_SIZE = (1024, 768)
|
||||
RECORDING_DIR = env.LOG_DIR / "recording"
|
||||
if env.DEBUG:
|
||||
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
calibre-web-automated-book-downloader-extbp-dev:
|
||||
container_name: cwa-bd-extbp-dev
|
||||
extends:
|
||||
file: ./docker-compose.extbp.yml
|
||||
service: calibre-web-automated-book-downloader-extbp
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: cwa-bd-extbp
|
||||
environment:
|
||||
DEBUG: true
|
||||
APP_ENV: dev
|
||||
USE_DOH: true
|
||||
CUSTOM_DNS: cloudflare
|
||||
USE_CF_BYPASS: true # Enable Cloudflare bypass (default: true)
|
||||
# External Cloudflare Bypass environment variables
|
||||
EXT_BYPASSER_URL: "http://flaresolverr:8191" # URL of the external Cloudflare resolver service (used FlareSolverr)
|
||||
EXT_BYPASSER_PATH: "/v1" # Path for external Cloudflare resolver API (default: /v1)
|
||||
EXT_BYPASSER_TIMEOUT: 60000 # Timeout for external Cloudflare resolver requests (default: 60000)
|
||||
volumes:
|
||||
#- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
|
||||
#- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
|
||||
- ./deploy/ingest:/cwa-book-ingest
|
||||
- ./deploy/log:/var/log/cwa-book-downloader
|
||||
- ./deploy/tmp:/tmp/cwa-book-downloader
|
||||
|
||||
flaresolverr: # External Cloudflare resolver service
|
||||
image: ghcr.io/flaresolverr/flaresolverr:v3.3.22
|
||||
container_name: flaresolverr
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
LOG_HTML: false
|
||||
CAPTCHA_SOLVER: none
|
||||
TZ: Europe/Rome
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
calibre-web-automated-book-downloader-extbp:
|
||||
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-extbp:latest
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
LOG_LEVEL: info
|
||||
BOOK_LANGUAGE: en
|
||||
USE_BOOK_TITLE: true
|
||||
TZ: America/New_York
|
||||
APP_ENV: prod
|
||||
UID: 1000
|
||||
GID: 100
|
||||
EXT_BYPASSER_URL: http://flaresolverr:8191
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# This is where the books will be downloaded to, usually it would be
|
||||
# the same as whatever you gave in "calibre-web-automated"
|
||||
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
|
||||
# This is the location of CWA's app.db, which contains authentication
|
||||
# details
|
||||
#- /cwa/config/path/app.db:/auth/app.db:ro
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
+14
-10
@@ -8,12 +8,16 @@ from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from tqdm import tqdm
|
||||
|
||||
from typing import Callable
|
||||
from threading import Event
|
||||
from logger import setup_logger
|
||||
from config import PROXIES
|
||||
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS
|
||||
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS, USING_EXTERNAL_BYPASSER
|
||||
if USE_CF_BYPASS:
|
||||
import cloudflare_bypasser
|
||||
if USING_EXTERNAL_BYPASSER:
|
||||
from cloudflare_bypasser_external import get_bypassed_page
|
||||
else:
|
||||
from cloudflare_bypasser import get_bypassed_page
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -34,12 +38,7 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
|
||||
if use_bypasser and USE_CF_BYPASS:
|
||||
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
|
||||
response_html = cloudflare_bypasser.get(url)
|
||||
logger.debug(f"Cloudflare Bypasser response length: {len(response_html)}")
|
||||
if response_html.strip() != "":
|
||||
return response_html
|
||||
else:
|
||||
raise requests.exceptions.RequestException("Failed to bypass Cloudflare")
|
||||
return get_bypassed_page(url)
|
||||
else:
|
||||
logger.info(f"GET: {url}")
|
||||
response = requests.get(url, proxies=PROXIES)
|
||||
@@ -71,7 +70,7 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1, use_bypasser)
|
||||
|
||||
def download_url(link: str, size: str = "") -> Optional[BytesIO]:
|
||||
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> Optional[BytesIO]:
|
||||
"""Download content from URL into a BytesIO buffer.
|
||||
|
||||
Args:
|
||||
@@ -99,6 +98,11 @@ def download_url(link: str, size: str = "") -> Optional[BytesIO]:
|
||||
for chunk in response.iter_content(chunk_size=1000):
|
||||
buffer.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
if progress_callback is not None:
|
||||
progress_callback(pbar.n * 100.0 / total_size)
|
||||
if cancel_flag is not None and cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled: {link}")
|
||||
return None
|
||||
|
||||
pbar.close()
|
||||
if buffer.tell() * 0.1 < total_size * 0.9:
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@ set -e
|
||||
|
||||
# Print build version
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
|
||||
# Configure timezone
|
||||
if [ "$TZ" ]; then
|
||||
@@ -112,8 +113,8 @@ else
|
||||
command="python3 app.py"
|
||||
fi
|
||||
|
||||
# IF DEBUG
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
# If DEBUG and not using an external bypass
|
||||
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
set +e
|
||||
set -x
|
||||
echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv"
|
||||
|
||||
@@ -26,7 +26,13 @@ _CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip()
|
||||
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
|
||||
APP_ENV = os.getenv("APP_ENV", "N/A").lower()
|
||||
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
|
||||
ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
|
||||
|
||||
# Version information from Docker build
|
||||
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
|
||||
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
|
||||
|
||||
# If debug is true, we want to log everything
|
||||
if DEBUG:
|
||||
@@ -41,10 +47,16 @@ DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
|
||||
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "").strip()
|
||||
USE_DOH = string_to_bool(os.getenv("USE_DOH", "false"))
|
||||
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
|
||||
APP_ENV = os.getenv("APP_ENV", "prod").lower()
|
||||
|
||||
# Logging settings
|
||||
LOG_FILE = LOG_DIR / "cwa-book-downloader.log"
|
||||
|
||||
USING_EXTERNAL_BYPASSER = string_to_bool(os.getenv("USING_EXTERNAL_BYPASSER", "false"))
|
||||
if USING_EXTERNAL_BYPASSER:
|
||||
EXT_BYPASSER_URL = os.getenv("EXT_BYPASSER_URL", "http://flaresolverr:8191").strip()
|
||||
EXT_BYPASSER_PATH = os.getenv("EXT_BYPASSER_PATH", "/v1").strip()
|
||||
EXT_BYPASSER_TIMEOUT = int(os.getenv("EXT_BYPASSER_TIMEOUT", "60000"))
|
||||
|
||||
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
|
||||
# If using Tor, we don't need to set custom DNS, use DOH, or proxy
|
||||
if USING_TOR:
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
# Set up log paths
|
||||
LOG_ROOT=${LOG_ROOT:-"/var/log"}
|
||||
LOG_DIR="$LOG_ROOT/cwa-book-downloader"
|
||||
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
|
||||
|
||||
# Create LOG_DIR if it doesn't exist
|
||||
@@ -125,16 +125,16 @@ fi
|
||||
env | grep -v -E "(AA_DONATOR_KEY)" | sort > "$LOG_DIR/environment.txt"
|
||||
|
||||
echo "--- HTTPBin ---" > $LOG_DIR/network_info.txt
|
||||
pyrequests https://httpbin.org/get >> $LOG_DIR/network_info.txt
|
||||
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- HowsMySSL ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
|
||||
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- IPInfo ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://ipinfo.io >> $LOG_DIR/network_info.txt
|
||||
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- Cloudflare Trace ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
|
||||
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
|
||||
|
||||
# Create the zip file directly from LOG_DIR
|
||||
ln -s "$LOG_DIR" /tmp/$OUTPUT_FILE_NAME
|
||||
|
||||
@@ -83,6 +83,7 @@ Note that if using TOR, the TZ will be calculated automatically based on IP.
|
||||
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
|
||||
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
|
||||
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
|
||||
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
|
||||
|
||||
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
|
||||
|
||||
@@ -175,7 +176,9 @@ volumes:
|
||||
|
||||
Mount should align with your Calibre-Web-Automated ingest folder.
|
||||
|
||||
## 🧅 Tor Variant
|
||||
## Variants:
|
||||
|
||||
### 🧅 Tor Variant
|
||||
|
||||
This application also offers a variant that routes all its traffic through the Tor network. This can be useful for enhanced privacy or bypassing network restrictions.
|
||||
|
||||
@@ -196,6 +199,48 @@ To use the Tor variant:
|
||||
* **Timezone:** When running in Tor mode, the container will attempt to determine the timezone based on the Tor exit node's IP address and set it automatically. This will override the `TZ` environment variable if it is set.
|
||||
* **Network Settings:** Custom DNS, DoH, and HTTP(S) proxy settings (`CUSTOM_DNS`, `USE_DOH`, `HTTP_PROXY`, `HTTPS_PROXY`) are ignored when using the Tor variant, as all traffic goes through Tor.
|
||||
|
||||
### External Cloudflare resolver variant
|
||||
|
||||
This variant allows the application to use an external service to bypass Cloudflare protection, instead of relying on the built-in bypasser. This is useful if you already have a dedicated Cloudflare resolver (such as [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) or compatible services like [ByParr](https://github.com/ThePhaseless/Byparr)) running elsewhere.
|
||||
|
||||
#### How it works:
|
||||
|
||||
- When enabled, all requests that require Cloudflare bypass are sent to your external resolver service.
|
||||
- The application communicates with the resolver using its API.
|
||||
- This approach can improve reliability and performance, especially if your external resolver is optimized or shared across multiple applications.
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------------- | ----------------------------------------------------------- | ----------------------- |
|
||||
| `EXT_BYPASSER_URL` | The full URL of your external resolver (required) | |
|
||||
| `EXT_BYPASSER_PATH` | API path for the resolver (usually `/v1`) | `/v1` |
|
||||
| `EXT_BYPASSER_TIMEOUT` | Timeout for page loading (in milliseconds) | `60000` |
|
||||
|
||||
#### Important
|
||||
|
||||
This feature follows the same configuration of the built-in Cloudflare bypasser, so you should turn on the `USE_CF_BYPASS` configuration to enable it.
|
||||
|
||||
#### To use the External Cloudflare resolver variant:
|
||||
|
||||
1. Get the extbp-specific docker-compose file:
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.extbp.yml
|
||||
```
|
||||
2. Start the service using this file:
|
||||
```bash
|
||||
docker compose -f docker-compose.extbp.yml up -d
|
||||
```
|
||||
|
||||
#### Compatibility:
|
||||
This feature is designed to work with any resolver that implements the `FlareSolverr` API schema, including `ByParr` and similar projects.
|
||||
|
||||
#### Benefits:
|
||||
|
||||
- Centralizes Cloudflare bypass logic for easier maintenance.
|
||||
- Can leverage more powerful or distributed resolver infrastructure.
|
||||
- Reduces load on the main application container.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The application consists of a single service:
|
||||
|
||||
@@ -2,10 +2,7 @@ flask
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
pyvirtualdisplay
|
||||
dnspython
|
||||
pyautogui
|
||||
seleniumbase>=4.41.1
|
||||
gunicorn
|
||||
python-xlib
|
||||
psutil
|
||||
emoji
|
||||
@@ -0,0 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase>=4.41.1
|
||||
python-xlib
|
||||
@@ -15,6 +15,13 @@
|
||||
--table-border-color: #e5e5e5;
|
||||
--input-background: #fff;
|
||||
--heading-color: #333;
|
||||
|
||||
/* Modern UI alias tokens */
|
||||
--bg: var(--background-color);
|
||||
--text: var(--text-color);
|
||||
--border-muted: var(--border-color);
|
||||
--bg-soft: var(--card-background);
|
||||
--footer-bg: var(--header-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
|
||||
+381
-1073
File diff suppressed because it is too large
Load Diff
+208
-220
@@ -3,251 +3,239 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Calibre Web Book Downloader Application">
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI">
|
||||
<meta name="theme-color" content="#333333">
|
||||
<title>Calibre Web Book Downloader</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<!-- Add favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<!-- UIkit CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/css/uikit.min.css" />
|
||||
<title>Book Downloader • Modern</title>
|
||||
|
||||
<!-- UIkit JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/js/uikit-icons.min.js"></script>
|
||||
<!-- Base styles and theme variables -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
|
||||
|
||||
<!-- Tailwind (no-build) for rapid iteration) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="uk-flex uk-flex-between uk-flex-middle">
|
||||
<h1 class="uk-heading-medium" style="color: white;">Book Search & Download</h1>
|
||||
<div class="uk-margin-right">
|
||||
<body class="min-h-screen" style="background: var(--bg); color: var(--text);">
|
||||
<!-- Header -->
|
||||
<header class="w-full border-b border-[color:var(--border-muted)]" style="background: var(--header-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ url_for('static', filename='media/logo.png') }}" alt="Logo" class="h-8 w-8">
|
||||
<h1 class="text-lg font-semibold">Book Search & Download</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if debug %}
|
||||
<form action="/api/restart" method="get" style="display: inline;" id="restart-form">
|
||||
<button class="uk-button uk-button-danger uk-margin-small-right" id="restart-button" type="submit">
|
||||
RESTART <span uk-spinner="ratio: 0.8" class="uk-hidden" id="restart-spinner"></span>
|
||||
<form action="/request/api/restart" method="get" id="restart-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600 text-white text-sm" id="restart-button" type="submit">
|
||||
RESTART
|
||||
</button>
|
||||
</form>
|
||||
<form action="/debug" method="get" style="display: inline;" id="debug-form">
|
||||
<button class="uk-button uk-button-danger uk-margin-small-right" id="debug-button" type="submit">
|
||||
DEBUG <span uk-spinner="ratio: 0.8" class="uk-hidden" id="debug-spinner"></span>
|
||||
<form action="/request/debug" method="get" id="debug-form">
|
||||
<button class="px-3 py-1 rounded bg-red-600/80 text-white text-sm" id="debug-button" type="submit">
|
||||
DEBUG
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button class="uk-button uk-button-default" type="button" id="theme-toggle">
|
||||
<span uk-icon="icon: paint-bucket"></span>
|
||||
<span id="theme-text">Theme</span>
|
||||
</button>
|
||||
<div uk-dropdown="mode: click">
|
||||
<ul class="uk-nav uk-dropdown-nav">
|
||||
<li><a href="#" data-theme="light">Light</a></li>
|
||||
<li><a href="#" data-theme="dark">Dark</a></li>
|
||||
<li><a href="#" data-theme="auto">Auto (System)</a></li>
|
||||
</ul>
|
||||
|
||||
<!-- Theme Dropdown -->
|
||||
<div class="relative">
|
||||
<button id="theme-toggle" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">
|
||||
<span id="theme-text">Theme</span>
|
||||
</button>
|
||||
<div id="theme-menu" class="absolute right-0 mt-2 w-36 rounded-md shadow-lg ring-1 ring-black/5 hidden" style="background: var(--bg-soft);">
|
||||
<ul class="py-1 text-sm">
|
||||
<li><a href="#" data-theme="light" class="block px-3 py-1 hover:bg-black/10">Light</a></li>
|
||||
<li><a href="#" data-theme="dark" class="block px-3 py-1 hover:bg-black/10">Dark</a></li>
|
||||
<li><a href="#" data-theme="auto" class="block px-3 py-1 hover:bg-black/10">Auto (System)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="uk-container">
|
||||
<!-- Search Section -->
|
||||
<section class="uk-section-xxsmall uk-padding">
|
||||
<div class="uk-container uk-width-expand">
|
||||
<form class="uk-search uk-search-default uk-width-expand">
|
||||
<input class="uk-search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books" id="search-input">
|
||||
<button class="uk-search-icon-flip" uk-search-icon id="search-button" type="button"></button>
|
||||
</form>
|
||||
<button class="uk-button uk-button-default uk-float-right" uk-toggle="target: #search-filters" type="button">Advanced Search</button>
|
||||
<form hidden id="search-filters" class="uk-search uk-search-default uk-inline uk-width-expand uk-flex uk-flex-wrap">
|
||||
<div class="search-filter">
|
||||
<label for="isbn-input">ISBN</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="ISBN..." aria-label="Search by ISBN" id="isbn-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="author-input">Author</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Author..." aria-label="Search by author" id="author-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="title-input">Title</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Title" aria-label="Search by title" id="title-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="lang-input">Language</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="lang-input">
|
||||
<option value="all">All</option>
|
||||
{% for lang in book_languages %}
|
||||
<option value="{{ lang.code }}"
|
||||
{% if lang.code == default_language[0] %}selected{% endif %}>
|
||||
{{ lang.language }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="sort-input">Sort</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="sort-input">
|
||||
<option value="">Most relevant</option>
|
||||
<option value="newest">Newest (publication year)</option>
|
||||
<option value="oldest">Oldest (publication year)</option>
|
||||
<option value="largest">Largest (filesize)</option>
|
||||
<option value="smallest">Smallest (filesize)</option>
|
||||
<option value="newest_added">Newest (open sourced)</option>
|
||||
<option value="oldest_added">Oldest (open sourced)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label for="content-input">Content</label>
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<select class="uk-select uk-width-4-5" id="content-input">
|
||||
<option value="">All</option>
|
||||
<option value="book_nonfiction">Book (non-fiction)</option>
|
||||
<option value="book_fiction">Book (fiction)</option>
|
||||
<option value="book_unknown">Book (unkown)</option>
|
||||
<option value="magazine">Magazine</option>
|
||||
<option value="book_comic">Comic Book</option>
|
||||
<option value="standards_document">Standards document</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="musical_score">Musical score</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-filter">
|
||||
<label>Formats</label>
|
||||
<div class="uk-margin uk-grid-small uk-child-width-auto uk-grid">
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-pdf" value="pdf"> PDF</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-epub" value="epub" checked> EPUB</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-mobi" value="mobi" checked> MOBI</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-azw3" value="azw3" checked> AZW3</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-fb2" value="fb2" checked> FB2</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-djvu" value="djvu" checked> DJVU</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-cbz" value="cbz" checked> CBZ</label>
|
||||
<label><input class="uk-checkbox" type="checkbox" id="format-cbr" value="cbr" checked> CBR</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-flex uk-margin-auto-top uk-margin-auto-left">
|
||||
<button class="uk-button uk-button-default uk-margin-small-top" id="adv-search-button" type="button">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Hero / Search -->
|
||||
<section class="mb-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input id="search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books"
|
||||
class="flex-1 px-4 py-3 rounded-md border outline-none"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<button id="search-button" class="px-4 py-3 rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Results Section -->
|
||||
<ul uk-accordion="animation: false" id="results-section-accordion">
|
||||
<li id="search-accordion">
|
||||
<a class="uk-accordion-title" href><h1 class="uk-heading-xsmall">Search Results</h1></a>
|
||||
<div class="uk-accordion-content">
|
||||
<div id="search-loading" class="uk-flex uk-flex-center" role="status" hidden>
|
||||
<div uk-spinner="ratio: 2"></div>
|
||||
<span>Loading...</span>
|
||||
<div>
|
||||
<button id="toggle-advanced" class="text-sm underline opacity-80 hover:opacity-100">Advanced Search</button>
|
||||
</div>
|
||||
<!-- Advanced Filters -->
|
||||
<form id="search-filters" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 hidden">
|
||||
<div>
|
||||
<label for="isbn-input" class="block text-sm mb-1 opacity-80">ISBN</label>
|
||||
<input id="isbn-input" type="search" placeholder="ISBN"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div class="results-content">
|
||||
<button class="uk-button uk-button-primary uk-margin-small" id="download-selected-button" disabled>Download Selected</button>
|
||||
<div class="uk-overflow-auto">
|
||||
<table id="results-table" class="uk-table uk-table-hover uk-table-divider" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" id="select-all-checkbox" class="uk-checkbox" />
|
||||
</th>
|
||||
<th scope="col" data-sort="index"># <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col" data-sort="title">Title <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="author">Author <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="publisher">Publisher <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="year">Year <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="language">Language <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="format">Format <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col" data-sort="size">Size <span class="sort-icon" uk-icon></span></th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Search results will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<label for="author-input" class="block text-sm mb-1 opacity-80">Author</label>
|
||||
<input id="author-input" type="search" placeholder="Author"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="title-input" class="block text-sm mb-1 opacity-80">Title</label>
|
||||
<input id="title-input" type="search" placeholder="Title"
|
||||
class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lang-input" class="block text-sm mb-1 opacity-80">Language</label>
|
||||
<select id="lang-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="all">All</option>
|
||||
{% for lang in book_languages %}
|
||||
<option value="{{ lang.code }}" {% if lang.code == default_language[0] %}selected{% endif %}>
|
||||
{{ lang.language }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sort-input" class="block text-sm mb-1 opacity-80">Sort</label>
|
||||
<select id="sort-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="">Most relevant</option>
|
||||
<option value="newest">Newest (publication year)</option>
|
||||
<option value="oldest">Oldest (publication year)</option>
|
||||
<option value="largest">Largest (filesize)</option>
|
||||
<option value="smallest">Smallest (filesize)</option>
|
||||
<option value="newest_added">Newest (open sourced)</option>
|
||||
<option value="oldest_added">Oldest (open sourced)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="content-input" class="block text sm mb-1 opacity-80">Content</label>
|
||||
<select id="content-input" class="w-full px-3 py-2 rounded-md border"
|
||||
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
|
||||
<option value="">All</option>
|
||||
<option value="book_nonfiction">Book (non-fiction)</option>
|
||||
<option value="book_fiction">Book (fiction)</option>
|
||||
<option value="book_unknown">Book (unknown)</option>
|
||||
<option value="magazine">Magazine</option>
|
||||
<option value="book_comic">Comic Book</option>
|
||||
<option value="standards_document">Standards document</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="musical_score">Musical score</option>
|
||||
<option value="audiobook">Audiobook</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<label class="block text-sm mb-1 opacity-80">Formats</label>
|
||||
<div class="flex flex-wrap gap-3 text-sm">
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'pdf' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-pdf" value="pdf" {% if 'pdf' not in supported_formats %}disabled{% endif %}>
|
||||
PDF
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'epub' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-epub" value="epub" {% if 'epub' in supported_formats %}checked{% endif %} {% if 'epub' not in supported_formats %}disabled{% endif %}>
|
||||
EPUB
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'mobi' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-mobi" value="mobi" {% if 'mobi' in supported_formats %}checked{% endif %} {% if 'mobi' not in supported_formats %}disabled{% endif %}>
|
||||
MOBI
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'azw3' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-azw3" value="azw3" {% if 'azw3' in supported_formats %}checked{% endif %} {% if 'azw3' not in supported_formats %}disabled{% endif %}>
|
||||
AZW3
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'fb2' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-fb2" value="fb2" {% if 'fb2' in supported_formats %}checked{% endif %} {% if 'fb2' not in supported_formats %}disabled{% endif %}>
|
||||
FB2
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'djvu' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-djvu" value="djvu" {% if 'djvu' in supported_formats %}checked{% endif %} {% if 'djvu' not in supported_formats %}disabled{% endif %}>
|
||||
DJVU
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbz' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbz" value="cbz" {% if 'cbz' in supported_formats %}checked{% endif %} {% if 'cbz' not in supported_formats %}disabled{% endif %}>
|
||||
CBZ
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbr' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbr" value="cbr" {% if 'cbr' in supported_formats %}checked{% endif %} {% if 'cbr' not in supported_formats %}disabled{% endif %}>
|
||||
CBR
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="md:col-span-2 lg:col-span-3 flex justify-end">
|
||||
<button id="adv-search-button" type="button" class="px-4 py-2 rounded-md border"
|
||||
style="border-color: var(--border-muted);">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div class="details-container" id="details-container">
|
||||
<!-- Details will be dynamically injected here -->
|
||||
<!-- Active Downloads (Top) -->
|
||||
<section id="active-downloads-top" class="mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Active Downloads</h2>
|
||||
<button id="active-refresh-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
</div>
|
||||
<div id="active-downloads-list" class="space-y-2"></div>
|
||||
</section>
|
||||
|
||||
<!-- Results -->
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-xl font-semibold">Search Results</h2>
|
||||
<div id="search-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
</div>
|
||||
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<!-- Cards will be injected here -->
|
||||
</div>
|
||||
<div id="no-results" class="mt-4 text-sm opacity-80 hidden">No results found.</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div class="details-container" id="details-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<section>
|
||||
<div class="flex items-center flex-wrap mb-3">
|
||||
<h2 class="text-xl font-semibold mr-4 sm:mr-6">Download Queue & Status</h2>
|
||||
<div class="flex items-center gap-3 ml-4 sm:ml-auto">
|
||||
<button id="refresh-status-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
|
||||
<button id="clear-completed-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Clear Completed</button>
|
||||
<span id="active-downloads-count" class="text-sm opacity-80">Active: 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Section -->
|
||||
<ul uk-accordion="animation: false" id="status-section">
|
||||
<li id="search-accordion">
|
||||
<a class="uk-accordion-title" href><h1 class="uk-heading-xsmall">Download Queue & Status</h1></a>
|
||||
<div class="uk-accordion-content">
|
||||
<div id="status-loading" class="uk-flex uk-flex-center" role="status" hidden>
|
||||
<div uk-spinner="ratio: 2"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div class="status-content">
|
||||
<div class="uk-overflow-auto">
|
||||
<!-- Queue Management Controls -->
|
||||
<div class="uk-margin-small-bottom">
|
||||
<div class="uk-flex uk-flex-between uk-flex-wrap uk-flex-middle">
|
||||
<div class="uk-flex uk-flex-wrap uk-flex-middle">
|
||||
<button class="uk-button uk-button-default uk-button-small uk-margin-small-right" id="refresh-status-button">
|
||||
<span uk-icon="icon: refresh"></span> Refresh
|
||||
</button>
|
||||
<button class="uk-button uk-button-secondary uk-button-small uk-margin-small-right" id="clear-completed-button">
|
||||
<span uk-icon="icon: trash"></span> Clear Completed
|
||||
</button>
|
||||
<span class="uk-text-small uk-text-muted uk-margin-small-right" id="active-downloads-count">Active: 0</span>
|
||||
</div>
|
||||
<div class="uk-flex uk-flex-middle">
|
||||
<label class="uk-text-small uk-margin-small-right">Max Concurrent:</label>
|
||||
<span class="uk-text-small uk-text-bold" id="max-concurrent-display">3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="status-table" class="uk-table uk-table-hover uk-table-divider uk-table-small" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Priority</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Progress</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Search results will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="status-loading" class="text-sm opacity-80 hidden">Loading…</div>
|
||||
<div id="status-list" class="space-y-2"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<h1 class="uk-heading-xxsmall" style="color: white;">Calibre Web Book Downloader</h1>
|
||||
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="uk-icon-link" uk-icon="icon:github; ratio: 2"></a>
|
||||
<footer class="mt-10 border-t pt-6 pb-10" style="border-color: var(--border-muted); background: var(--footer-bg);">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p class="text-xs opacity-60 mt-1">
|
||||
Build: {{ build_version }} • Release: {{ release_version }} • Env: {{ app_env }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="opacity-80 hover:opacity-100" aria-label="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38
|
||||
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52
|
||||
-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95
|
||||
0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.54 7.54 0 012 0c1.53-1.03 2.2-.82 2.2-.82
|
||||
.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2
|
||||
0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user