1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import os 7from collections import OrderedDict, defaultdict 8from threading import RLock as Lock 9 10from calibre import filesystem_encoding 11from calibre.db.cache import Cache 12from calibre.db.legacy import LibraryDatabase, create_backend, set_global_state 13from calibre.utils.filenames import samefile as _samefile 14from calibre.utils.monotonic import monotonic 15from polyglot.builtins import iteritems, itervalues 16 17 18def gui_on_db_event(event_type, library_id, event_data): 19 from calibre.gui2.ui import get_gui 20 gui = get_gui() 21 if gui is not None: 22 gui.library_broker.on_db_event(event_type, library_id, event_data) 23 24 25def canonicalize_path(p): 26 if isinstance(p, bytes): 27 p = p.decode(filesystem_encoding) 28 p = os.path.abspath(p).replace(os.sep, '/').rstrip('/') 29 return os.path.normcase(p) 30 31 32def samefile(a, b): 33 a, b = canonicalize_path(a), canonicalize_path(b) 34 if a == b: 35 return True 36 return _samefile(a, b) 37 38 39def basename(path): 40 while path and path[-1] in ('/' + os.sep): 41 path = path[:-1] 42 ans = os.path.basename(path) 43 if not ans: 44 # Can happen for a path like D:\ on windows 45 if len(path) == 2 and path[1] == ':': 46 ans = path[0] 47 return ans or 'Library' 48 49 50def init_library(library_path, is_default_library): 51 db = Cache( 52 create_backend( 53 library_path, load_user_formatter_functions=is_default_library)) 54 db.init() 55 return db 56 57 58def make_library_id_unique(library_id, existing): 59 bname = library_id 60 c = 0 61 while library_id in existing: 62 c += 1 63 library_id = bname + ('%d' % c) 64 return library_id 65 66 67def library_id_from_path(path, existing=frozenset()): 68 library_id = basename(path).replace(' ', '_') 69 return make_library_id_unique(library_id, existing) 70 71 72def correct_case_of_last_path_component(original_path): 73 original_path = os.path.abspath(original_path) 74 prefix, basename = os.path.split(original_path) 75 q = basename.lower() 76 try: 77 equals = tuple(x for x in os.listdir(prefix) if x.lower() == q) 78 except OSError: 79 equals = () 80 if len(equals) > 1: 81 if basename not in equals: 82 basename = equals[0] 83 elif equals: 84 basename = equals[0] 85 return os.path.join(prefix, basename) 86 87 88def db_matches(db, library_id, library_path): 89 db = db.new_api 90 if getattr(db, 'server_library_id', object()) == library_id: 91 return True 92 dbpath = db.dbpath 93 return samefile(dbpath, os.path.join(library_path, os.path.basename(dbpath))) 94 95 96class LibraryBroker: 97 98 def __init__(self, libraries): 99 self.lock = Lock() 100 self.lmap = OrderedDict() 101 self.library_name_map = {} 102 self.original_path_map = {} 103 seen = set() 104 for original_path in libraries: 105 path = canonicalize_path(original_path) 106 if path in seen: 107 continue 108 is_samefile = False 109 for s in seen: 110 if samefile(s, path): 111 is_samefile = True 112 break 113 seen.add(path) 114 if is_samefile or not LibraryDatabase.exists_at(path): 115 continue 116 corrected_path = correct_case_of_last_path_component(original_path) 117 library_id = library_id_from_path(corrected_path, self.lmap) 118 self.lmap[library_id] = path 119 self.library_name_map[library_id] = basename(corrected_path) 120 self.original_path_map[path] = original_path 121 self.loaded_dbs = {} 122 self.category_caches, self.search_caches, self.tag_browser_caches = ( 123 defaultdict(OrderedDict), defaultdict(OrderedDict), 124 defaultdict(OrderedDict)) 125 126 def get(self, library_id=None): 127 with self: 128 library_id = library_id or self.default_library 129 if library_id in self.loaded_dbs: 130 return self.loaded_dbs[library_id] 131 path = self.lmap.get(library_id) 132 if path is None: 133 return 134 try: 135 self.loaded_dbs[library_id] = ans = self.init_library( 136 path, library_id == self.default_library) 137 ans.new_api.server_library_id = library_id 138 except Exception: 139 self.loaded_dbs[library_id] = None 140 raise 141 return ans 142 143 def init_library(self, library_path, is_default_library): 144 library_path = self.original_path_map.get(library_path, library_path) 145 return init_library(library_path, is_default_library) 146 147 def close(self): 148 with self: 149 for db in itervalues(self.loaded_dbs): 150 getattr(db, 'close', lambda: None)() 151 self.lmap, self.loaded_dbs = OrderedDict(), {} 152 153 @property 154 def default_library(self): 155 return next(iter(self.lmap)) 156 157 @property 158 def library_map(self): 159 with self: 160 return self.library_name_map.copy() 161 162 def allowed_libraries(self, filter_func): 163 with self: 164 allowed_names = filter_func( 165 basename(l) for l in itervalues(self.lmap)) 166 return OrderedDict(((lid, self.library_map[lid]) 167 for lid, path in iteritems(self.lmap) 168 if basename(path) in allowed_names)) 169 170 def path_for_library_id(self, library_id): 171 with self: 172 lpath = self.lmap.get(library_id) 173 if lpath is None: 174 q = library_id.lower() 175 for k, v in self.lmap.items(): 176 if k.lower() == q: 177 lpath = v 178 break 179 else: 180 return 181 return self.original_path_map.get(lpath) 182 183 def __enter__(self): 184 self.lock.acquire() 185 186 def __exit__(self, *a): 187 self.lock.release() 188 189 190EXPIRED_AGE = 300 # seconds 191 192 193def load_gui_libraries(gprefs=None): 194 if gprefs is None: 195 from calibre.utils.config import JSONConfig 196 gprefs = JSONConfig('gui') 197 stats = gprefs.get('library_usage_stats', {}) 198 return sorted(stats, key=stats.get, reverse=True) 199 200 201def path_for_db(db): 202 return db.new_api.backend.library_path 203 204 205class GuiLibraryBroker(LibraryBroker): 206 207 def __init__(self, db): 208 from calibre.gui2 import gprefs 209 self.last_used_times = defaultdict(lambda: -EXPIRED_AGE) 210 self.gui_library_id = None 211 self.listening_for_db_events = False 212 LibraryBroker.__init__(self, load_gui_libraries(gprefs)) 213 self.gui_library_changed(db) 214 215 def init_library(self, library_path, is_default_library): 216 library_path = self.original_path_map.get(library_path, library_path) 217 db = LibraryDatabase(library_path, is_second_db=True) 218 if self.listening_for_db_events: 219 db.new_api.add_listener(gui_on_db_event) 220 return db 221 222 def get(self, library_id=None): 223 try: 224 return getattr(LibraryBroker.get(self, library_id), 'new_api', None) 225 finally: 226 self.last_used_times[library_id or self.default_library] = monotonic() 227 228 def start_listening_for_db_events(self): 229 with self: 230 self.listening_for_db_events = True 231 for db in self.loaded_dbs.values(): 232 db.new_api.add_listener(gui_on_db_event) 233 234 def on_db_event(self, event_type, library_id, event_data): 235 from calibre.gui2.ui import get_gui 236 gui = get_gui() 237 if gui is not None: 238 with self: 239 db = self.loaded_dbs.get(library_id) 240 if db is not None: 241 gui.event_in_db.emit(db, event_type, event_data) 242 243 def get_library(self, original_library_path): 244 library_path = canonicalize_path(original_library_path) 245 with self: 246 for library_id, path in iteritems(self.lmap): 247 if samefile(library_path, path): 248 db = self.loaded_dbs.get(library_id) 249 if db is None: 250 db = self.loaded_dbs[library_id] = self.init_library( 251 path, False) 252 db.new_api.server_library_id = library_id 253 return db 254 # A new library 255 if library_path not in self.original_path_map: 256 self.original_path_map[library_path] = original_library_path 257 db = self.init_library(library_path, False) 258 corrected_path = correct_case_of_last_path_component(original_library_path) 259 library_id = library_id_from_path(corrected_path, self.lmap) 260 db.new_api.server_library_id = library_id 261 self.lmap[library_id] = library_path 262 self.library_name_map[library_id] = basename(corrected_path) 263 self.loaded_dbs[library_id] = db 264 return db 265 266 def prepare_for_gui_library_change(self, newloc): 267 # Must be called with lock held 268 for library_id, path in iteritems(self.lmap): 269 db = self.loaded_dbs.get(library_id) 270 if db is not None and samefile(newloc, path): 271 if library_id == self.gui_library_id: 272 # Have to reload db 273 self.loaded_dbs.pop(library_id, None) 274 return 275 set_global_state(db) 276 return db 277 278 def gui_library_changed(self, db, olddb=None): 279 # Must be called with lock held 280 original_path = path_for_db(db) 281 newloc = canonicalize_path(original_path) 282 for library_id, path in iteritems(self.lmap): 283 if samefile(newloc, path): 284 self.loaded_dbs[library_id] = db 285 self.gui_library_id = library_id 286 break 287 else: 288 # A new library 289 corrected_path = correct_case_of_last_path_component(original_path) 290 library_id = self.gui_library_id = library_id_from_path(corrected_path, self.lmap) 291 self.lmap[library_id] = newloc 292 self.library_name_map[library_id] = basename(corrected_path) 293 self.original_path_map[newloc] = original_path 294 self.loaded_dbs[library_id] = db 295 db.new_api.server_library_id = library_id 296 if self.listening_for_db_events: 297 db.new_api.add_listener(gui_on_db_event) 298 if olddb is not None and samefile(path_for_db(olddb), path_for_db(db)): 299 # This happens after a restore database, for example 300 olddb.close(), olddb.break_cycles() 301 self._prune_loaded_dbs() 302 303 def is_gui_library(self, library_path): 304 with self: 305 if self.gui_library_id and self.gui_library_id in self.lmap: 306 return samefile(library_path, self.lmap[self.gui_library_id]) 307 return False 308 309 def _prune_loaded_dbs(self): 310 now = monotonic() 311 for library_id in tuple(self.loaded_dbs): 312 if library_id != self.gui_library_id and now - self.last_used_times[ 313 library_id] > EXPIRED_AGE: 314 db = self.loaded_dbs.pop(library_id, None) 315 if db is not None: 316 db.close() 317 db.break_cycles() 318 319 def prune_loaded_dbs(self): 320 with self: 321 self._prune_loaded_dbs() 322 323 def unload_library(self, library_path): 324 with self: 325 path = canonicalize_path(library_path) 326 for library_id, q in iteritems(self.lmap): 327 if samefile(path, q): 328 break 329 else: 330 return 331 db = self.loaded_dbs.pop(library_id, None) 332 if db is not None: 333 db.close() 334 db.break_cycles() 335 336 def remove_library(self, path): 337 with self: 338 path = canonicalize_path(path) 339 for library_id, q in iteritems(self.lmap): 340 if samefile(path, q): 341 break 342 else: 343 return 344 self.lmap.pop(library_id, None), self.library_name_map.pop( 345 library_id, None), self.original_path_map.pop(path, None) 346 db = self.loaded_dbs.pop(library_id, None) 347 if db is not None: 348 db.close() 349 db.break_cycles() 350