mirror of
https://github.com/crosspoint-reader/calibre-plugins.git
synced 2026-08-13 14:23:41 -07:00
Implements debug_managed_device_detection() to provide verbose output about CrossPoint Reader network detection, including connection status, discovery attempts, and error reporting. Bypasses discovery throttle to force fresh scans during debugging.
831 lines
34 KiB
Python
831 lines
34 KiB
Python
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from calibre.devices.errors import ControlError
|
|
from calibre.devices.interface import DevicePlugin
|
|
from calibre.devices.usbms.deviceconfig import DeviceConfig
|
|
from calibre.devices.usbms.books import Book, BookList
|
|
from calibre.ebooks.metadata.book.base import Metadata
|
|
|
|
from . import ws_client
|
|
from .config import CrossPointConfigWidget, PREFS
|
|
from .log import add_log
|
|
|
|
|
|
class CrossPointDevice(DeviceConfig, DevicePlugin):
|
|
name = 'CrossPoint Reader'
|
|
gui_name = 'CrossPoint Reader'
|
|
description = 'CrossPoint Reader wireless device'
|
|
supported_platforms = ['windows', 'osx', 'linux']
|
|
author = 'CrossPoint Reader'
|
|
version = (0, 2, 6)
|
|
|
|
# Invalid USB vendor info to avoid USB scans matching.
|
|
VENDOR_ID = [0xFFFF]
|
|
PRODUCT_ID = [0xFFFF]
|
|
BCD = [0xFFFF]
|
|
|
|
FORMATS = ['epub']
|
|
ALL_FORMATS = ['epub']
|
|
SUPPORTS_SUB_DIRS = True
|
|
MUST_READ_METADATA = False
|
|
MANAGES_DEVICE_PRESENCE = True
|
|
DEVICE_PLUGBOARD_NAME = 'CROSSPOINT_READER'
|
|
MUST_READ_METADATA = False
|
|
SUPPORTS_DEVICE_DB = False
|
|
# Disable Calibre's device cache so we always refresh from device.
|
|
device_is_usb_mass_storage = False
|
|
|
|
def __init__(self, path):
|
|
super().__init__(path)
|
|
self.is_connected = False
|
|
self.device_host = None
|
|
self.device_port = None
|
|
self.device_model = None # 'X3' | 'X4' from /api/status
|
|
self.last_discovery = 0.0
|
|
self.report_progress = lambda x, y: x
|
|
self._debug_enabled = False
|
|
|
|
def _log(self, message):
|
|
add_log(message)
|
|
if self._debug_enabled:
|
|
try:
|
|
self.report_progress(0.0, message)
|
|
except Exception:
|
|
pass
|
|
|
|
# Device discovery / presence
|
|
def _discover(self):
|
|
now = time.time()
|
|
if now - self.last_discovery < 2.0:
|
|
return None, None
|
|
self.last_discovery = now
|
|
host, port = ws_client.discover_device(
|
|
timeout=1.0,
|
|
debug=PREFS['debug'],
|
|
logger=self._log,
|
|
extra_hosts=[PREFS['host']],
|
|
)
|
|
if host and port:
|
|
return host, port
|
|
return None, None
|
|
|
|
def detect_managed_devices(self, devices_on_system, force_refresh=False):
|
|
if self.is_connected:
|
|
return self
|
|
debug = PREFS['debug']
|
|
self._debug_enabled = debug
|
|
if debug:
|
|
self._log('[CrossPoint] detect_managed_devices')
|
|
host, port = self._discover()
|
|
if host:
|
|
if debug:
|
|
self._log(f'[CrossPoint] discovered {host} {port}')
|
|
self.device_host = host
|
|
self.device_port = port
|
|
self.is_connected = True
|
|
self._detect_device_model()
|
|
return self
|
|
if debug:
|
|
self._log('[CrossPoint] discovery failed')
|
|
return None
|
|
|
|
def debug_managed_device_detection(self, devices_on_system, output):
|
|
from functools import partial
|
|
p = partial(print, file=output)
|
|
if self.is_connected:
|
|
p(f'A CrossPoint Reader is already connected at {self.device_host}:{self.device_port}')
|
|
return True
|
|
p('Looking for a CrossPoint Reader on the network...')
|
|
try:
|
|
# Bypass the discovery throttle so a fresh scan happens.
|
|
self.last_discovery = 0.0
|
|
dev = self.detect_managed_devices(devices_on_system, force_refresh=True)
|
|
except Exception as exc:
|
|
p(f'Error while probing for a CrossPoint Reader: {exc}')
|
|
return False
|
|
if dev is not None:
|
|
p(f'Found a CrossPoint Reader at {self.device_host}:{self.device_port}')
|
|
return True
|
|
p('No CrossPoint Reader detected on the network')
|
|
return False
|
|
|
|
def _detect_device_model(self):
|
|
"""Query /api/status for the device model (X3/X4), like the web UI."""
|
|
try:
|
|
status = self._http_get_json('/api/status', timeout=4)
|
|
model = (status or {}).get('device')
|
|
if model in ('X3', 'X4'):
|
|
self.device_model = model
|
|
self._log(f'[CrossPoint] detected device model: {model}')
|
|
else:
|
|
self._log('[CrossPoint] /api/status returned no device model')
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] device model detection failed: {exc}')
|
|
return self.device_model
|
|
|
|
def open(self, connected_device, library_uuid):
|
|
if not self.is_connected:
|
|
raise ControlError(desc='Attempt to open a closed device')
|
|
return True
|
|
|
|
def get_device_information(self, end_session=True):
|
|
host = self.device_host or PREFS['host']
|
|
device_info = {
|
|
'device_store_uuid': 'crosspoint-' + host.replace('.', '-'),
|
|
'device_name': 'CrossPoint Reader',
|
|
'device_version': '1',
|
|
}
|
|
return (self.gui_name, '1', '1', '', {'main': device_info})
|
|
|
|
def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None):
|
|
self.set_progress_reporter(report_progress)
|
|
|
|
def set_progress_reporter(self, report_progress):
|
|
if report_progress is None:
|
|
self.report_progress = lambda x, y: x
|
|
else:
|
|
self.report_progress = report_progress
|
|
|
|
def _http_base(self):
|
|
host = self.device_host or PREFS['host']
|
|
return f'http://{host}'
|
|
|
|
def _device_id(self):
|
|
"""Stable id for the connected device, used to key the metadata cache."""
|
|
host = self.device_host or PREFS['host']
|
|
return 'crosspoint-' + host.replace('.', '-')
|
|
|
|
def _http_get_json(self, path, params=None, timeout=5):
|
|
url = self._http_base() + path
|
|
if params:
|
|
url += '?' + urllib.parse.urlencode(params)
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
data = resp.read().decode('utf-8', 'ignore')
|
|
except Exception as exc:
|
|
raise ControlError(desc=f'HTTP request failed: {exc}')
|
|
try:
|
|
import json
|
|
return json.loads(data)
|
|
except Exception as exc:
|
|
raise ControlError(desc=f'Invalid JSON response: {exc}')
|
|
|
|
def _http_post_form(self, path, data, timeout=5):
|
|
url = self._http_base() + path
|
|
body = urllib.parse.urlencode(data).encode('utf-8')
|
|
req = urllib.request.Request(url, data=body, method='POST',
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.status, resp.read().decode('utf-8', 'ignore')
|
|
except Exception as exc:
|
|
raise ControlError(desc=f'HTTP request failed: {exc}')
|
|
|
|
def config_widget(self):
|
|
# Runs on the GUI thread; ensure the summary bridge exists (idempotent).
|
|
try:
|
|
from . import summary as summary_ui
|
|
summary_ui.ensure_bridge()
|
|
except Exception:
|
|
pass
|
|
return CrossPointConfigWidget()
|
|
|
|
def save_settings(self, config_widget):
|
|
config_widget.save()
|
|
|
|
def _list_files_recursive(self, path='/'):
|
|
"""Return a flat list of (lpath, size) for all EPUB files on device."""
|
|
results = []
|
|
try:
|
|
entries = self._http_get_json('/api/files', params={'path': path})
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] listing {path} failed: {exc}')
|
|
return results
|
|
for entry in entries:
|
|
name = entry.get('name', '')
|
|
if not name:
|
|
continue
|
|
if path == '/':
|
|
entry_path = '/' + name
|
|
else:
|
|
entry_path = path + '/' + name
|
|
if entry.get('isDirectory'):
|
|
results.extend(self._list_files_recursive(entry_path))
|
|
elif entry.get('isEpub'):
|
|
results.append((entry_path, entry.get('size', 0)))
|
|
return results
|
|
|
|
def books(self, oncard=None, end_session=True):
|
|
if oncard is not None:
|
|
return BookList(None, None, None)
|
|
from . import metadata_cache as mc
|
|
file_list = self._list_files_recursive('/')
|
|
bl = BookList(None, None, None)
|
|
fetch_metadata = PREFS['fetch_metadata']
|
|
device_id = self._device_id()
|
|
library = self._build_library_index()
|
|
seen = set()
|
|
total = len(file_list)
|
|
for i, (lpath, size) in enumerate(file_list):
|
|
key = self._normalize_device_path(lpath)
|
|
seen.add(key)
|
|
if total:
|
|
self.report_progress(i / float(total), 'Reading books on device...')
|
|
meta = None
|
|
# Prefer the cache: a book sent from this machine is recognized
|
|
# instantly (with its library uuid) so Calibre marks it on-device
|
|
# without re-downloading. Size guards against a changed file.
|
|
entry = mc.get_entry(device_id, key)
|
|
if entry and entry.get('size') == size:
|
|
meta = Metadata(
|
|
entry.get('title') or os.path.splitext(os.path.basename(lpath))[0],
|
|
list(entry.get('authors') or []))
|
|
if entry.get('uuid'):
|
|
meta.uuid = entry['uuid']
|
|
# Match the filename against the local library by title (no download).
|
|
# A book can only be "on device" if it is in the library, so this
|
|
# marks side-loaded books instantly when their name matches.
|
|
if meta is None and library is not None:
|
|
m = self._match_from_library(library, lpath)
|
|
if m is not None:
|
|
meta = m
|
|
mc.put_many(device_id,
|
|
[(key, mc.entry_from_metadata(size, m))])
|
|
# Last resort, opt-in: download the EPUB to read its exact identity,
|
|
# for library books whose on-device filename doesn't match the title.
|
|
if meta is None and fetch_metadata:
|
|
m = self._fetch_epub_identity(lpath)
|
|
if m is not None:
|
|
meta = m
|
|
mc.put_many(device_id,
|
|
[(key, mc.entry_from_metadata(size, m))])
|
|
if meta is None:
|
|
meta = Metadata(os.path.splitext(os.path.basename(lpath))[0], [])
|
|
book = Book('', lpath, size=size, other=meta)
|
|
if getattr(meta, 'uuid', None):
|
|
book.uuid = meta.uuid
|
|
bl.add_book(book, replace_metadata=True)
|
|
if total:
|
|
self.report_progress(1.0, 'Reading books on device...')
|
|
# Forget files that are no longer on the device.
|
|
mc.prune(device_id, seen)
|
|
return bl
|
|
|
|
def sync_booklists(self, booklists, end_session=True):
|
|
# No on-device metadata sync supported.
|
|
return None
|
|
|
|
def card_prefix(self, end_session=True):
|
|
return None, None
|
|
|
|
def total_space(self, end_session=True):
|
|
return 10 * 1024 * 1024 * 1024, 0, 0
|
|
|
|
def free_space(self, end_session=True):
|
|
return 10 * 1024 * 1024 * 1024, 0, 0
|
|
|
|
def _format_upload_path(self, mi, original_name):
|
|
"""Format an upload path using the configured upload template.
|
|
|
|
Returns (subdirs, filename) where subdirs is a list of directory
|
|
components from the template (may be empty for flat templates).
|
|
"""
|
|
try:
|
|
from calibre.library.save_to_disk import config as sconfig, get_components
|
|
from calibre.utils.filenames import ascii_filename
|
|
|
|
template = PREFS['upload_template']
|
|
if not template:
|
|
template = self.save_template()
|
|
if not template:
|
|
template = sconfig().parse().send_template
|
|
|
|
# get_component_metadata sets format_args['id'] = str(book_id),
|
|
# so we must pass the actual Calibre database ID instead of -1.
|
|
book_id = mi.id if mi.id is not None else -1
|
|
|
|
components = get_components(
|
|
template, mi, book_id, '%b %Y', 250,
|
|
ascii_filename, to_lowercase=False,
|
|
replace_whitespace=False, safe_format=True,
|
|
last_has_extension=False,
|
|
)
|
|
|
|
components = [c.strip() for c in components if c and c.strip()]
|
|
if not components:
|
|
return [], original_name
|
|
|
|
ext = os.path.splitext(original_name)[1]
|
|
filename = components[-1] + ext
|
|
subdirs = components[:-1]
|
|
return subdirs, filename
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] template format failed: {exc}')
|
|
return [], original_name
|
|
|
|
def _dir_exists_on_device(self, name, path):
|
|
"""Return True if directory `name` exists inside `path` on the device."""
|
|
try:
|
|
entries = self._http_get_json('/api/files', params={'path': path})
|
|
except Exception:
|
|
return False
|
|
for entry in entries:
|
|
if entry.get('isDirectory') and entry.get('name') == name:
|
|
return True
|
|
return False
|
|
|
|
def _mkdir_on_device(self, name, path):
|
|
"""Create a directory on device via POST /mkdir.
|
|
|
|
Silently ignores 400 errors (folder already exists). Some firmware
|
|
versions fail with other codes or time out when the folder exists,
|
|
so on any error re-check the listing and only raise if the folder
|
|
really is missing.
|
|
Uses urllib directly to avoid _http_post_form which wraps all
|
|
errors as ControlError.
|
|
"""
|
|
url = self._http_base() + '/mkdir'
|
|
body = urllib.parse.urlencode({'name': name, 'path': path}).encode('utf-8')
|
|
req = urllib.request.Request(url, data=body, method='POST',
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
resp.read()
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 400 or self._dir_exists_on_device(name, path):
|
|
self._log(f'[CrossPoint] mkdir ignored (already exists): {name} in {path}')
|
|
else:
|
|
raise ControlError(desc=f'mkdir failed for {name} in {path}: {exc}')
|
|
except Exception as exc:
|
|
if self._dir_exists_on_device(name, path):
|
|
self._log(f'[CrossPoint] mkdir error ignored, folder exists: {name} in {path}: {exc}')
|
|
else:
|
|
raise ControlError(desc=f'mkdir failed for {name} in {path}: {exc}')
|
|
|
|
def _ensure_dir(self, parent_path, subdirs):
|
|
"""Ensure subdirectories exist under parent_path on device.
|
|
|
|
Creates each level individually (one mkdir per component) rather than
|
|
relying on the device to build a deep path in a single recursive call.
|
|
A single-level mkdir is the most reliable operation; nested templates
|
|
like ``Fanfiction/Fandom/Series/title.epub`` failed when the whole path
|
|
was sent at once. Each level is checked first — some firmware fails
|
|
or hangs on mkdir of an existing folder — and only created when
|
|
missing. Returns the full path.
|
|
"""
|
|
current = parent_path
|
|
for sub in subdirs:
|
|
if not self._dir_exists_on_device(sub, current):
|
|
self._mkdir_on_device(sub, current)
|
|
if current == '/':
|
|
current = '/' + sub
|
|
else:
|
|
current = current + '/' + sub
|
|
return current
|
|
|
|
def _delete_paths_on_device(self, paths):
|
|
import json as _json
|
|
norm_paths = [self._normalize_device_path(p) for p in paths]
|
|
url = self._http_base() + '/delete'
|
|
# Server expects form field 'paths' containing a JSON array string.
|
|
body = urllib.parse.urlencode({'paths': _json.dumps(norm_paths)}).encode('utf-8')
|
|
req = urllib.request.Request(url, data=body, method='POST',
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
resp.read()
|
|
return resp.status
|
|
|
|
def _delete_upload_path_on_device(self, lpath):
|
|
"""Best-effort cleanup for a possibly partial upload."""
|
|
self._delete_paths_on_device([lpath])
|
|
|
|
def upload_books(self, files, names, on_card=None, end_session=True, metadata=None):
|
|
host = self.device_host or PREFS['host']
|
|
port = self.device_port or PREFS['port']
|
|
upload_path = PREFS['path']
|
|
chunk_size = PREFS['chunk_size']
|
|
if chunk_size > 2048:
|
|
self._log(f'[CrossPoint] chunk_size capped to 2048 (was {chunk_size})')
|
|
chunk_size = 2048
|
|
debug = PREFS['debug']
|
|
upload_retries = max(0, int(PREFS['upload_retries']))
|
|
retry_delay = max(0, int(PREFS['retry_delay']))
|
|
book_cooldown = max(0, int(PREFS['book_cooldown']))
|
|
socket_timeout = max(5, int(PREFS['socket_timeout']))
|
|
max_attempts = upload_retries + 1
|
|
|
|
# Normalize base upload path
|
|
base_path = upload_path
|
|
if not base_path.startswith('/'):
|
|
base_path = '/' + base_path
|
|
if base_path != '/' and base_path.endswith('/'):
|
|
base_path = base_path[:-1]
|
|
|
|
optimize_enabled = bool(PREFS['optimize'])
|
|
opt_profile = None
|
|
summary_ui = None
|
|
if optimize_enabled:
|
|
from .optimizer import resolve_profile
|
|
_, opt_profile = resolve_profile(PREFS['device_target'], self.device_model)
|
|
# Open the live optimizer dialog up-front so the user sees steps stream.
|
|
try:
|
|
from . import summary as summary_ui
|
|
summary_ui.begin('Optimizing %d book(s) for %s…' % (
|
|
len(files), opt_profile['label']))
|
|
except Exception as exc:
|
|
summary_ui = None
|
|
self._log(f'[CrossPoint] could not open optimizer dialog: {exc}')
|
|
|
|
paths = []
|
|
summaries = []
|
|
total = len(files)
|
|
for i, (infile, name) in enumerate(zip(files, names)):
|
|
if hasattr(infile, 'read'):
|
|
filepath = getattr(infile, 'name', None)
|
|
if not filepath:
|
|
raise ControlError(desc='In-memory uploads are not supported')
|
|
else:
|
|
filepath = infile
|
|
filename = os.path.basename(name)
|
|
subdirs = []
|
|
if metadata and i < len(metadata) and not PREFS['send_to_root']:
|
|
subdirs, filename = self._format_upload_path(metadata[i], filename)
|
|
|
|
if subdirs:
|
|
target_dir = self._ensure_dir(base_path, subdirs)
|
|
else:
|
|
target_dir = base_path
|
|
|
|
if target_dir == '/':
|
|
lpath = '/' + filename
|
|
else:
|
|
lpath = target_dir + '/' + filename
|
|
|
|
# Optionally optimize the EPUB to a temp file before uploading.
|
|
send_path = filepath
|
|
opt_temp = None
|
|
if optimize_enabled and filepath.lower().endswith('.epub'):
|
|
step_cb = summary_ui.step if summary_ui is not None else None
|
|
opt_temp, summary = self._optimize_book(filepath, opt_profile,
|
|
step_cb=step_cb)
|
|
if opt_temp is not None:
|
|
send_path = opt_temp
|
|
if summary is not None:
|
|
summaries.append(summary)
|
|
if summary_ui is not None:
|
|
summary_ui.step('SEND', 'Uploading %s …' % filename)
|
|
|
|
def _progress(sent, size):
|
|
if size > 0:
|
|
self.report_progress((i + sent / float(size)) / float(total),
|
|
'Transferring books to device...')
|
|
|
|
try:
|
|
last_error = None
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
if attempt > 1:
|
|
self.report_progress(i / float(total),
|
|
'Retrying transfer to device...')
|
|
ws_client.upload_file(
|
|
host,
|
|
port,
|
|
target_dir,
|
|
filename,
|
|
send_path,
|
|
chunk_size=chunk_size,
|
|
debug=debug,
|
|
progress_cb=_progress,
|
|
logger=self._log,
|
|
timeout=socket_timeout,
|
|
)
|
|
last_error = None
|
|
break
|
|
except ws_client.UploadError as exc:
|
|
last_error = exc
|
|
self._log(f'[CrossPoint] upload failed for {filename} '
|
|
f'attempt {attempt}/{max_attempts}: {exc}')
|
|
if exc.upload_started:
|
|
try:
|
|
self._delete_upload_path_on_device(lpath)
|
|
except Exception as cleanup_exc:
|
|
self._log(f'[CrossPoint] partial upload cleanup ignored '
|
|
f'for {lpath}: {cleanup_exc}')
|
|
if attempt >= max_attempts:
|
|
break
|
|
if retry_delay > 0:
|
|
time.sleep(retry_delay)
|
|
except (ws_client.WebSocketError, OSError) as exc:
|
|
last_error = exc
|
|
self._log(f'[CrossPoint] upload failed for {filename} '
|
|
f'attempt {attempt}/{max_attempts}: {exc}')
|
|
if attempt >= max_attempts:
|
|
break
|
|
if retry_delay > 0:
|
|
time.sleep(retry_delay)
|
|
|
|
if last_error is not None:
|
|
raise ControlError(desc=f'Upload failed for {filename} after '
|
|
f'{max_attempts} attempt(s): {last_error}')
|
|
paths.append((lpath, os.path.getsize(send_path)))
|
|
if book_cooldown > 0 and i + 1 < total:
|
|
time.sleep(book_cooldown)
|
|
finally:
|
|
if opt_temp is not None:
|
|
try:
|
|
os.remove(opt_temp)
|
|
except OSError:
|
|
pass
|
|
|
|
self.report_progress(1.0, 'Transferring books to device...')
|
|
|
|
if summary_ui is not None:
|
|
try:
|
|
summary_ui.finish({
|
|
'profile': opt_profile['label'] if opt_profile else '?',
|
|
'books': summaries,
|
|
})
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] could not finalize optimizer dialog: {exc}')
|
|
|
|
return paths
|
|
|
|
def _optimize_book(self, filepath, profile, step_cb=None):
|
|
"""Optimize an EPUB to a temp file. Returns (temp_path_or_None, summary_or_None).
|
|
|
|
``step_cb(tag, message)`` (optional) streams each step to the live dialog.
|
|
On any failure the original file is used (temp_path is None) so a transfer
|
|
is never blocked by optimization.
|
|
"""
|
|
from calibre.ptempfile import PersistentTemporaryFile
|
|
from .optimizer import optimize_epub, Options
|
|
|
|
opts = Options(
|
|
quality=PREFS['optimize_quality'],
|
|
grayscale=PREFS['optimize_grayscale'],
|
|
auto_crop=PREFS['optimize_auto_crop'],
|
|
split_text=PREFS['optimize_split'],
|
|
)
|
|
|
|
def _step(tag, message):
|
|
self._log(f'[CrossPoint][opt] {tag}: {message}')
|
|
if step_cb is not None:
|
|
try:
|
|
step_cb(tag, message)
|
|
except Exception:
|
|
pass
|
|
|
|
out_path = None
|
|
try:
|
|
tf = PersistentTemporaryFile(suffix='.epub')
|
|
out_path = tf.name
|
|
tf.close()
|
|
summary = optimize_epub(filepath, out_path, profile, opts, log_fn=_step)
|
|
return out_path, summary
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] optimization failed for {os.path.basename(filepath)}: '
|
|
f'{exc} (sending original)')
|
|
if out_path:
|
|
try:
|
|
os.remove(out_path)
|
|
except OSError:
|
|
pass
|
|
return None, None
|
|
|
|
def add_books_to_metadata(self, locations, metadata, booklists):
|
|
from . import metadata_cache as mc
|
|
self._log(f'[CrossPoint] add_books_to_metadata: {len(locations)} locations, '
|
|
f'{len(booklists)} booklists')
|
|
device_id = self._device_id()
|
|
cache_entries = []
|
|
metadata = iter(metadata)
|
|
for location in locations:
|
|
info = next(metadata)
|
|
lpath = location[0]
|
|
length = location[1]
|
|
book = Book('', lpath, size=length, other=info)
|
|
if booklists:
|
|
booklists[0].add_book(book, replace_metadata=True)
|
|
self._log(f'[CrossPoint] added to booklist: {lpath}')
|
|
else:
|
|
self._log(f'[CrossPoint] WARNING: booklists empty, could not add {lpath}')
|
|
# Remember this book's identity so it is recognized (and marked
|
|
# on-device) on the next connect without re-reading the EPUB.
|
|
cache_entries.append((self._normalize_device_path(lpath),
|
|
mc.entry_from_metadata(length, info)))
|
|
mc.put_many(device_id, cache_entries)
|
|
|
|
|
|
@staticmethod
|
|
def _normalize_device_path(p):
|
|
"""Normalize a path to the device's forward-slash, leading-slash form.
|
|
|
|
Calibre stores device book paths using the local os.sep, so on Windows a
|
|
freshly-sent book's path arrives here with backslashes (e.g.
|
|
``/\\Author\\Title.epub``). The device only knows forward-slash paths, so
|
|
convert separators, collapse any resulting ``//``, and ensure a leading
|
|
slash before talking to it.
|
|
"""
|
|
if not p:
|
|
return ''
|
|
p = p.replace('\\', '/')
|
|
while '//' in p:
|
|
p = p.replace('//', '/')
|
|
if not p.startswith('/'):
|
|
p = '/' + p
|
|
return p
|
|
|
|
def delete_books(self, paths, end_session=True):
|
|
norm_paths = [self._normalize_device_path(p) for p in paths]
|
|
self._log(f'[CrossPoint] deleting {len(norm_paths)} books: {norm_paths}')
|
|
try:
|
|
status = self._delete_paths_on_device(norm_paths)
|
|
self._log(f'[CrossPoint] delete OK: {status}')
|
|
except urllib.error.HTTPError as exc:
|
|
err_body = exc.read().decode('utf-8', 'ignore') if exc.fp else ''
|
|
self._log(f'[CrossPoint] delete error {exc.code}: {err_body}')
|
|
raise ControlError(desc=f'Delete failed: {exc.code} {err_body}')
|
|
except Exception as exc:
|
|
raise ControlError(desc=f'Delete failed: {exc}')
|
|
self._prune_empty_dirs(norm_paths)
|
|
|
|
def _prune_empty_dirs(self, deleted_paths):
|
|
"""Best-effort removal of directories left empty after a delete.
|
|
|
|
Template uploads create per-author/per-series folders; without this,
|
|
deleting the books strands empty folders that later confuse users and
|
|
clutter the device. Walks every ancestor of each deleted book, deepest
|
|
first, and deletes those whose listing comes back empty. The base
|
|
upload path and the root are never removed. Failures are logged and
|
|
ignored — the books themselves are already gone.
|
|
"""
|
|
base = PREFS['path']
|
|
if not base.startswith('/'):
|
|
base = '/' + base
|
|
if base != '/' and base.endswith('/'):
|
|
base = base[:-1]
|
|
|
|
candidates = set()
|
|
for p in deleted_paths:
|
|
d = p.rsplit('/', 1)[0]
|
|
while d and d != '/' and d != base:
|
|
candidates.add(d)
|
|
d = d.rsplit('/', 1)[0]
|
|
|
|
for d in sorted(candidates, key=lambda x: x.count('/'), reverse=True):
|
|
try:
|
|
entries = self._http_get_json('/api/files', params={'path': d})
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] prune: listing {d} failed, skipping: {exc}')
|
|
continue
|
|
if entries:
|
|
continue
|
|
try:
|
|
self._delete_paths_on_device([d])
|
|
self._log(f'[CrossPoint] pruned empty folder: {d}')
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] prune: could not delete {d}: {exc}')
|
|
|
|
def remove_books_from_metadata(self, paths, booklists):
|
|
from . import metadata_cache as mc
|
|
norm = self._normalize_device_path
|
|
deleted = set(norm(p) for p in paths)
|
|
self._log(f'[CrossPoint] deleted paths: {sorted(deleted)}')
|
|
|
|
removed = 0
|
|
for bl in booklists:
|
|
for book in tuple(bl):
|
|
bpath = norm(getattr(book, 'path', ''))
|
|
blpath = norm(getattr(book, 'lpath', ''))
|
|
if bpath in deleted or blpath in deleted:
|
|
bl.remove_book(book)
|
|
removed += 1
|
|
mc.remove_many(self._device_id(), deleted)
|
|
self._log(f'[CrossPoint] removed {removed} items from device list')
|
|
|
|
def get_file(self, path, outfile, end_session=True, this_book=None, total_books=None):
|
|
url = self._http_base() + '/download'
|
|
params = urllib.parse.urlencode({'path': path})
|
|
try:
|
|
with urllib.request.urlopen(url + '?' + params, timeout=10) as resp:
|
|
while True:
|
|
chunk = resp.read(65536)
|
|
if not chunk:
|
|
break
|
|
outfile.write(chunk)
|
|
except Exception as exc:
|
|
raise ControlError(desc=f'Failed to download {path}: {exc}')
|
|
|
|
def _download_temp(self, path):
|
|
from calibre.ptempfile import PersistentTemporaryFile
|
|
tf = PersistentTemporaryFile(suffix='.epub')
|
|
self.get_file(path, tf)
|
|
tf.flush()
|
|
tf.seek(0)
|
|
return tf
|
|
|
|
@staticmethod
|
|
def _norm_title(text):
|
|
from calibre.utils.filenames import ascii_filename
|
|
return ascii_filename(text or '').strip().lower()
|
|
|
|
def _build_library_index(self):
|
|
"""Index the current library as normalized-title -> [book_id].
|
|
|
|
Lets ``books()`` match a device file to a library book by name without
|
|
downloading anything. Returns ``(api, index)`` or None if the library
|
|
isn't available (e.g. no GUI).
|
|
"""
|
|
try:
|
|
from calibre.gui2.ui import get_gui
|
|
gui = get_gui()
|
|
if gui is None:
|
|
return None
|
|
api = gui.current_db.new_api
|
|
book_ids = api.all_book_ids()
|
|
titles = api.all_field_for('title', book_ids)
|
|
index = {}
|
|
for bid, title in titles.items():
|
|
key = self._norm_title(title)
|
|
if key:
|
|
index.setdefault(key, []).append(bid)
|
|
return (api, index)
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] library index unavailable: {exc}')
|
|
return None
|
|
|
|
def _match_from_library(self, library, lpath):
|
|
"""Find the library book matching a device file name. Returns Metadata|None.
|
|
|
|
Matches the filename stem (and, for ``Title - Author`` style names, the
|
|
part before the last `` - ``) against library titles. When several books
|
|
share a title, an author appearing in the device path disambiguates;
|
|
if still ambiguous, returns None rather than risk a wrong mark.
|
|
"""
|
|
api, index = library
|
|
stem = os.path.splitext(os.path.basename(lpath))[0]
|
|
candidates = list(index.get(self._norm_title(stem), ()))
|
|
if not candidates and ' - ' in stem:
|
|
candidates = list(index.get(self._norm_title(stem.rsplit(' - ', 1)[0]), ()))
|
|
if not candidates:
|
|
return None
|
|
if len(candidates) > 1:
|
|
path_norm = self._norm_title(lpath)
|
|
|
|
def author_in_path(bid):
|
|
for a in (api.field_for('authors', bid) or ()):
|
|
# Match every word of the author name, order-independent, so
|
|
# "Emily Dickinson" matches an author-sort path "Dickinson, Emily".
|
|
tokens = self._norm_title(a).split()
|
|
if tokens and all(t in path_norm for t in tokens):
|
|
return True
|
|
return False
|
|
|
|
narrowed = [bid for bid in candidates if author_in_path(bid)]
|
|
if len(narrowed) != 1:
|
|
return None # ambiguous — don't guess
|
|
candidates = narrowed
|
|
bid = candidates[0]
|
|
mi = Metadata(api.field_for('title', bid) or stem,
|
|
list(api.field_for('authors', bid) or []))
|
|
uuid = api.field_for('uuid', bid)
|
|
if uuid:
|
|
mi.uuid = uuid
|
|
return mi
|
|
|
|
def _fetch_epub_identity(self, lpath):
|
|
"""Read a remote EPUB's identity (title/authors/uuid) as a Metadata.
|
|
|
|
Downloads the whole file then reads its metadata. A partial/early-abort
|
|
read is not possible against the device: its HTTP server streams whole
|
|
files synchronously on a single thread and does not support Range
|
|
requests, so closing the connection early stalls the next request. The
|
|
result is cached by the caller, so this is a one-time cost per file.
|
|
"""
|
|
try:
|
|
from calibre.customize.ui import quick_metadata
|
|
from calibre.ebooks.metadata.meta import get_metadata
|
|
with self._download_temp(lpath) as tf:
|
|
with quick_metadata:
|
|
return get_metadata(tf, stream_type='epub', force_read_metadata=True)
|
|
except Exception as exc:
|
|
self._log(f'[CrossPoint] metadata read failed for {lpath}: {exc}')
|
|
return None
|
|
|
|
|
|
def eject(self):
|
|
self.is_connected = False
|
|
|
|
def is_dynamically_controllable(self):
|
|
return 'crosspoint'
|
|
|
|
def start_plugin(self):
|
|
return None
|
|
|
|
def stop_plugin(self):
|
|
self.is_connected = False
|