1# Copyright (C) 2008-2010 Adam Olsen 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16# 17# 18# The developers of the Exaile media player hereby grant permission 19# for non-GPL compatible GStreamer and Exaile plugins to be used and 20# distributed together with GStreamer and Exaile. This permission is 21# above and beyond the permissions granted by the GPL license by which 22# Exaile is covered. If you modify this code, you may extend this 23# exception to your version of the code, but you are not obligated to 24# do so. If you do not wish to do so, delete this exception statement 25# from your version. 26 27""" 28Classes representing collections and libraries 29 30A collection is a database of tracks. It is based on :class:`TrackDB` but has 31the ability to be linked with libraries. 32 33A library finds tracks in a specified directory and adds them to an associated 34collection. 35""" 36 37from collections import deque 38import logging 39import threading 40from typing import Deque, Dict, Iterable, List, MutableSequence, Optional, Set, Tuple 41 42from gi.repository import ( 43 GLib, 44 GObject, 45 Gio, 46) 47 48from xl import common, event, settings, trax 49 50logger = logging.getLogger(__name__) 51 52COLLECTIONS: Set['Collection'] = set() 53 54 55def get_collection_by_loc(loc: str) -> Optional['Collection']: 56 """ 57 gets the collection by a location. 58 59 :param loc: Location of the collection 60 :return: collection at location or None 61 """ 62 for c in COLLECTIONS: 63 if c.loc_is_member(loc): 64 return c 65 return None 66 67 68class CollectionScanThread(common.ProgressThread): 69 """ 70 Scans the collection 71 """ 72 73 def __init__(self, collection, startup_scan=False, force_update=False): 74 """ 75 Initializes the thread 76 77 :param collection: the collection to scan 78 :param startup_scan: Only scan libraries scanned at startup 79 :param force_update: Update files regardless whether they've changed 80 """ 81 common.ProgressThread.__init__(self) 82 83 self.startup_scan = startup_scan 84 self.force_update = force_update 85 self.collection = collection 86 87 def stop(self): 88 """ 89 Stops the thread 90 """ 91 self.collection.stop_scan() 92 common.ProgressThread.stop(self) 93 94 def run(self): 95 """ 96 Runs the thread 97 """ 98 event.add_callback(self.on_scan_progress_update, 'scan_progress_update') 99 100 self.collection.rescan_libraries( 101 startup_only=self.startup_scan, force_update=self.force_update 102 ) 103 104 event.remove_callback(self.on_scan_progress_update, 'scan_progress_update') 105 106 def on_scan_progress_update(self, type, collection, progress): 107 """ 108 Notifies about progress changes 109 """ 110 if progress < 100: 111 self.emit('progress-update', progress) 112 else: 113 self.emit('done') 114 115 116class Collection(trax.TrackDB): 117 """ 118 Manages a persistent track database. 119 120 Simple usage: 121 122 >>> from xl.collection import * 123 >>> from xl.trax import search 124 >>> collection = Collection("Test Collection") 125 >>> collection.add_library(Library("./tests/data")) 126 >>> collection.rescan_libraries() 127 >>> tracks = [i.track for i in search.search_tracks_from_string( 128 ... collection, ('artist==TestArtist'))] 129 >>> print(len(tracks)) 130 5 131 """ 132 133 def __init__(self, name, location=None, pickle_attrs=[]): 134 global COLLECTIONS 135 self.libraries: Dict[str, Library] = {} 136 self._scanning = False 137 self._scan_stopped = False 138 self._running_count = 0 139 self._running_total_count = 0 140 self._frozen = False 141 self._libraries_dirty = False 142 pickle_attrs += ['_serial_libraries'] 143 trax.TrackDB.__init__(self, name, location=location, pickle_attrs=pickle_attrs) 144 COLLECTIONS.add(self) 145 146 def freeze_libraries(self) -> None: 147 """ 148 Prevents "libraries_modified" events from being sent from individual 149 add and remove library calls. 150 151 Call this before making bulk changes to the libraries. Call 152 thaw_libraries when you are done; this sends a single event if the 153 libraries were modified. 154 """ 155 self._frozen = True 156 157 def thaw_libraries(self) -> None: 158 """ 159 Re-allow "libraries_modified" events from being sent from individual 160 add and remove library calls. Also sends a "libraries_modified" 161 event if the libraries have ben modified since the last call to 162 freeze_libraries. 163 """ 164 # TODO: This method should probably be synchronized. 165 self._frozen = False 166 if self._libraries_dirty: 167 self._libraries_dirty = False 168 event.log_event('libraries_modified', self, None) 169 170 def add_library(self, library: 'Library') -> None: 171 """ 172 Add this library to the collection 173 174 :param library: the library to add 175 """ 176 loc = library.get_location() 177 if loc not in self.libraries: 178 self.libraries[loc] = library 179 library.set_collection(self) 180 self.serialize_libraries() 181 self._dirty = True 182 183 if self._frozen: 184 self._libraries_dirty = True 185 else: 186 event.log_event('libraries_modified', self, None) 187 188 def remove_library(self, library: 'Library') -> None: 189 """ 190 Remove a library from the collection 191 192 :param library: the library to remove 193 """ 194 for k, v in self.libraries.items(): 195 if v == library: 196 del self.libraries[k] 197 break 198 199 to_rem = [] 200 if "://" not in library.location: 201 location = "file://" + library.location 202 else: 203 location = library.location 204 for tr in self.tracks: 205 if tr.startswith(location): 206 to_rem.append(self.tracks[tr]._track) 207 self.remove_tracks(to_rem) 208 209 self.serialize_libraries() 210 self._dirty = True 211 212 if self._frozen: 213 self._libraries_dirty = True 214 else: 215 event.log_event('libraries_modified', self, None) 216 217 def stop_scan(self): 218 """ 219 Stops the library scan 220 """ 221 self._scan_stopped = True 222 223 def get_libraries(self) -> List['Library']: 224 """ 225 Gets a list of all the Libraries associated with this 226 Collection 227 """ 228 return list(self.libraries.values()) 229 230 def rescan_libraries(self, startup_only=False, force_update=False): 231 """ 232 Rescans all libraries associated with this Collection 233 """ 234 if self._scanning: 235 raise Exception("Collection is already being scanned") 236 if len(self.libraries) == 0: 237 event.log_event('scan_progress_update', self, 100) 238 return # no libraries, no need to scan :) 239 240 self._scanning = True 241 self._scan_stopped = False 242 243 self.file_count = -1 # negative means we dont know it yet 244 245 self.__count_files() 246 247 scan_interval = 20 248 249 for library in self.libraries.values(): 250 251 if ( 252 not force_update 253 and startup_only 254 and not (library.monitored and library.startup_scan) 255 ): 256 continue 257 258 event.add_callback(self._progress_update, 'tracks_scanned', library) 259 library.rescan(notify_interval=scan_interval, force_update=force_update) 260 event.remove_callback(self._progress_update, 'tracks_scanned', library) 261 self._running_total_count += self._running_count 262 if self._scan_stopped: 263 break 264 else: # didnt break 265 try: 266 if self.location is not None: 267 self.save_to_location() 268 except AttributeError: 269 logger.exception("Exception occurred while saving") 270 271 event.log_event('scan_progress_update', self, 100) 272 273 self._running_total_count = 0 274 self._running_count = 0 275 self._scanning = False 276 self.file_count = -1 277 278 @common.threaded 279 def __count_files(self): 280 file_count = 0 281 for library in self.libraries.values(): 282 if self._scan_stopped: 283 self._scanning = False 284 return 285 file_count += library._count_files() 286 self.file_count = file_count 287 logger.debug("File count: %s", self.file_count) 288 289 def _progress_update(self, type, library, count): 290 """ 291 Called when a progress update should be emitted while scanning 292 tracks 293 """ 294 self._running_count = count 295 count = count + self._running_total_count 296 297 if self.file_count < 0: 298 event.log_event('scan_progress_update', self, 0) 299 return 300 301 try: 302 event.log_event( 303 'scan_progress_update', 304 self, 305 count / self.file_count * 100, 306 ) 307 except ZeroDivisionError: 308 pass 309 310 def serialize_libraries(self): 311 """ 312 Save information about libraries 313 314 Called whenever the library's settings are changed 315 """ 316 _serial_libraries = [] 317 for k, v in self.libraries.items(): 318 l = {} 319 l['location'] = v.location 320 l['monitored'] = v.monitored 321 l['realtime'] = v.monitored 322 l['scan_interval'] = v.scan_interval 323 l['startup_scan'] = v.startup_scan 324 _serial_libraries.append(l) 325 return _serial_libraries 326 327 def unserialize_libraries(self, _serial_libraries): 328 """ 329 restores libraries from their serialized state. 330 331 Should only be called once, from the constructor. 332 """ 333 for l in _serial_libraries: 334 self.add_library( 335 Library( 336 l['location'], 337 l.get('monitored', l.get('realtime')), 338 l['scan_interval'], 339 l.get('startup_scan', True), 340 ) 341 ) 342 343 _serial_libraries = property(serialize_libraries, unserialize_libraries) 344 345 def close(self): 346 """ 347 close the collection. does any work like saving to disk, 348 closing network connections, etc. 349 """ 350 # TODO: make close() part of trackdb 351 COLLECTIONS.remove(self) 352 353 def delete_tracks(self, tracks: Iterable[trax.Track]) -> None: 354 for tr in tracks: 355 for prefix, lib in self.libraries.items(): 356 lib.delete(tr.get_loc_for_io()) 357 358 359class LibraryMonitor(GObject.GObject): 360 """ 361 Monitors library locations for changes 362 """ 363 364 __gproperties__ = { 365 'monitored': ( 366 GObject.TYPE_BOOLEAN, 367 'monitoring state', 368 'Whether to monitor this library', 369 False, 370 GObject.ParamFlags.READWRITE, 371 ) 372 } 373 __gsignals__ = { 374 'location-added': (GObject.SignalFlags.RUN_LAST, None, [Gio.File]), 375 'location-removed': (GObject.SignalFlags.RUN_LAST, None, [Gio.File]), 376 } 377 378 def __init__(self, library): 379 """ 380 :param library: the library to monitor 381 :type library: :class:`Library` 382 """ 383 GObject.GObject.__init__(self) 384 385 self.__library = library 386 self.__root = Gio.File.new_for_uri(library.location) 387 self.__monitored = False 388 self.__monitors = {} 389 self.__queue = {} 390 self.__lock = threading.RLock() 391 392 def do_get_property(self, property): 393 """ 394 Gets GObject properties 395 """ 396 if property.name == 'monitored': 397 return self.__monitored 398 else: 399 raise AttributeError('unknown property %s' % property.name) 400 401 def do_set_property(self, property, value): 402 """ 403 Sets GObject properties 404 """ 405 if property.name == 'monitored': 406 if value != self.__monitored: 407 self.__monitored = value 408 update_thread = threading.Thread(target=self.__update_monitors) 409 update_thread.daemon = True 410 GLib.idle_add(update_thread.start) 411 else: 412 raise AttributeError('unknown property %s' % property.name) 413 414 def __update_monitors(self): 415 """ 416 Sets up or removes library monitors 417 """ 418 with self.__lock: 419 if self.props.monitored: 420 logger.debug('Setting up library monitors') 421 422 for directory in common.walk_directories(self.__root): 423 monitor = directory.monitor_directory( 424 Gio.FileMonitorFlags.NONE, None 425 ) 426 monitor.connect('changed', self.on_location_changed) 427 self.__monitors[directory] = monitor 428 429 self.emit('location-added', directory) 430 else: 431 logger.debug('Removing library monitors') 432 433 for directory, monitor in self.__monitors.items(): 434 monitor.cancel() 435 436 self.emit('location-removed', directory) 437 438 self.__monitors = {} 439 440 def __process_change_queue(self, gfile): 441 if gfile in self.__queue: 442 added_tracks = trax.util.get_tracks_from_uri(gfile.get_uri()) 443 for tr in added_tracks: 444 tr.read_tags() 445 self.__library.collection.add_tracks(added_tracks) 446 del self.__queue[gfile] 447 448 def on_location_changed(self, monitor, gfile, other_gfile, event): 449 """ 450 Updates the library on changes of the location 451 """ 452 453 if event == Gio.FileMonitorEvent.CHANGES_DONE_HINT: 454 self.__process_change_queue(gfile) 455 elif ( 456 event == Gio.FileMonitorEvent.CREATED 457 or event == Gio.FileMonitorEvent.CHANGED 458 ): 459 460 # Enqueue tracks retrieval 461 if gfile not in self.__queue: 462 self.__queue[gfile] = True 463 464 # File monitor only emits the DONE_HINT when using inotify, 465 # and only on single files. Give it some time, but don't 466 # lose the change notification 467 GLib.timeout_add(500, self.__process_change_queue, gfile) 468 469 # Set up new monitor if directory 470 fileinfo = gfile.query_info( 471 'standard::type', Gio.FileQueryInfoFlags.NONE, None 472 ) 473 474 if ( 475 fileinfo.get_file_type() == Gio.FileType.DIRECTORY 476 and gfile not in self.__monitors 477 ): 478 479 for directory in common.walk_directories(gfile): 480 monitor = directory.monitor_directory( 481 Gio.FileMonitorFlags.NONE, None 482 ) 483 monitor.connect('changed', self.on_location_changed) 484 self.__monitors[directory] = monitor 485 486 self.emit('location-added', directory) 487 488 elif event == Gio.FileMonitorEvent.DELETED: 489 removed_tracks = [] 490 491 track = trax.Track(gfile.get_uri()) 492 493 if track in self.__library.collection: 494 # Deleted file was a regular track 495 removed_tracks += [track] 496 else: 497 # Deleted file was most likely a directory 498 for track in self.__library.collection: 499 track_gfile = Gio.File.new_for_uri(track.get_loc_for_io()) 500 501 if track_gfile.has_prefix(gfile): 502 removed_tracks += [track] 503 504 self.__library.collection.remove_tracks(removed_tracks) 505 506 # Remove obsolete monitors 507 removed_directories = [ 508 d for d in self.__monitors if d == gfile or d.has_prefix(gfile) 509 ] 510 511 for directory in removed_directories: 512 self.__monitors[directory].cancel() 513 del self.__monitors[directory] 514 515 self.emit('location-removed', directory) 516 517 518class Library: 519 """ 520 Scans and watches a folder for tracks, and adds them to 521 a Collection. 522 523 Simple usage: 524 525 >>> from xl.collection import * 526 >>> c = Collection("TestCollection") 527 >>> l = Library("./tests/data") 528 >>> c.add_library(l) 529 >>> l.rescan() 530 True 531 >>> print(c.get_libraries()[0].location) 532 ./tests/data 533 >>> print(len(list(c.search('artist="TestArtist"')))) 534 5 535 >>> 536 """ 537 538 def __init__( 539 self, 540 location: str, 541 monitored: bool = False, 542 scan_interval: int = 0, 543 startup_scan: bool = False, 544 ): 545 """ 546 Sets up the Library 547 548 :param location: the directory this library will scan 549 :param monitored: whether the library should update its 550 collection at changes within the library's path 551 :param scan_interval: the interval for automatic rescanning 552 """ 553 self.location = location 554 self.scan_interval = scan_interval 555 self.scan_id = None 556 self.scanning = False 557 self._startup_scan = startup_scan 558 self.monitor = LibraryMonitor(self) 559 self.monitor.props.monitored = monitored 560 561 self.collection: Optional[Collection] = None 562 self.set_rescan_interval(scan_interval) 563 564 def set_location(self, location: str) -> None: 565 """ 566 Changes the location of this Library 567 568 :param location: the new location to use 569 """ 570 self.location = location 571 572 def get_location(self) -> str: 573 """ 574 Gets the current location associated with this Library 575 576 :return: the current location 577 """ 578 return self.location 579 580 def set_collection(self, collection) -> None: 581 self.collection = collection 582 583 def get_monitored(self) -> bool: 584 """ 585 Whether the library should be monitored for changes 586 """ 587 return self.monitor.props.monitored 588 589 def set_monitored(self, monitored: bool) -> None: 590 """ 591 Enables or disables monitoring of the library 592 593 :param monitored: Whether to monitor the library 594 :type monitored: bool 595 """ 596 self.monitor.props.monitored = monitored 597 self.collection.serialize_libraries() 598 self.collection._dirty = True 599 600 monitored = property(get_monitored, set_monitored) 601 602 def get_rescan_interval(self) -> int: 603 """ 604 :return: the scan interval in seconds 605 """ 606 return self.scan_interval 607 608 def set_rescan_interval(self, interval: int) -> None: 609 """ 610 Sets the scan interval in seconds. If the interval is 0 seconds, 611 the scan interval is stopped 612 613 :param interval: scan interval in seconds 614 """ 615 616 if self.scan_id: 617 GLib.source_remove(self.scan_id) 618 self.scan_id = None 619 620 if interval: 621 self.scan_id = GLib.timeout_add_seconds(interval, self.rescan) 622 623 self.scan_interval = interval 624 625 def get_startup_scan(self) -> bool: 626 return self._startup_scan 627 628 def set_startup_scan(self, value: bool) -> None: 629 self._startup_scan = value 630 self.collection.serialize_libraries() 631 self.collection._dirty = True 632 633 startup_scan = property(get_startup_scan, set_startup_scan) 634 635 def _count_files(self) -> int: 636 """ 637 Counts the number of files present in this directory 638 """ 639 count = 0 640 for file in common.walk(Gio.File.new_for_uri(self.location)): 641 if self.collection: 642 if self.collection._scan_stopped: 643 break 644 count += 1 645 646 return count 647 648 def _check_compilation( 649 self, 650 ccheck: Dict[str, Dict[str, Deque[str]]], 651 compilations: MutableSequence[Tuple[str, str]], 652 tr: trax.Track, 653 ) -> None: 654 """ 655 This is the hacky way to test to see if a particular track is a 656 part of a compilation. 657 658 Basically, if there is more than one track in a directory that has 659 the same album but different artist, we assume that it's part of a 660 compilation. 661 662 :param ccheck: dictionary for internal use 663 :param compilations: if a compilation is found, it'll be appended 664 to this list 665 :param tr: the track to check 666 """ 667 # check for compilations 668 if not settings.get_option('collection/file_based_compilations', True): 669 return 670 671 def joiner(value): 672 if isinstance(value, list): 673 return "\0".join(value) 674 else: 675 return value 676 677 try: 678 basedir = joiner(tr.get_tag_raw('__basedir')) 679 album = joiner(tr.get_tag_raw('album')) 680 artist = joiner(tr.get_tag_raw('artist')) 681 except Exception: 682 logger.warning("Error while checking for compilation: %s", tr) 683 return 684 if not basedir or not album or not artist: 685 return 686 album = album.lower() 687 artist = artist.lower() 688 try: 689 if basedir not in ccheck: 690 ccheck[basedir] = {} 691 692 if album not in ccheck[basedir]: 693 ccheck[basedir][album] = deque() 694 except TypeError: 695 logger.exception("Error adding to compilation") 696 return 697 698 if ccheck[basedir][album] and artist not in ccheck[basedir][album]: 699 if not (basedir, album) in compilations: 700 compilations.append((basedir, album)) 701 logger.debug("Compilation %r detected in %r", album, basedir) 702 703 ccheck[basedir][album].append(artist) 704 705 def update_track( 706 self, gloc: Gio.File, force_update: bool = False 707 ) -> Optional[trax.Track]: 708 """ 709 Rescan the track at a given location 710 711 :param gloc: the location 712 :type gloc: :class:`Gio.File` 713 :param force_update: Force update of file (default only updates file 714 when mtime has changed) 715 716 returns: the Track object, None if it could not be updated 717 """ 718 uri = gloc.get_uri() 719 if not uri: # we get segfaults if this check is removed 720 return None 721 722 tr = self.collection.get_track_by_loc(uri) 723 if tr: 724 tr.read_tags(force=force_update) 725 else: 726 tr = trax.Track(uri) 727 if tr._scan_valid: 728 self.collection.add(tr) 729 730 # Track already existed. This fixes trax.get_tracks_from_uri 731 # on windows, unknown why fix isnt needed on linux. 732 elif not tr._init: 733 self.collection.add(tr) 734 return tr 735 736 def rescan( 737 self, notify_interval: Optional[int] = None, force_update: bool = False 738 ): # TODO: What return type? 739 """ 740 Rescan the associated folder and add the contained files 741 to the Collection 742 """ 743 # TODO: use gio's cancellable support 744 745 if self.collection is None: 746 return True 747 748 if self.scanning: 749 return 750 751 logger.info("Scanning library: %s", self.location) 752 self.scanning = True 753 libloc = Gio.File.new_for_uri(self.location) 754 755 count = 0 756 dirtracks = deque() 757 compilations = deque() 758 ccheck = {} 759 for fil in common.walk(libloc): 760 count += 1 761 type = fil.query_info( 762 "standard::type", Gio.FileQueryInfoFlags.NONE, None 763 ).get_file_type() 764 if type == Gio.FileType.DIRECTORY: 765 if dirtracks: 766 for tr in dirtracks: 767 self._check_compilation(ccheck, compilations, tr) 768 for (basedir, album) in compilations: 769 base = basedir.replace('"', '\\"') 770 alb = album.replace('"', '\\"') 771 items = [ 772 tr 773 for tr in dirtracks 774 if tr.get_tag_raw('__basedir') == base and 775 # FIXME: this is ugly 776 alb in "".join(tr.get_tag_raw('album') or []).lower() 777 ] 778 for item in items: 779 item.set_tag_raw('__compilation', (basedir, album)) 780 dirtracks = deque() 781 compilations = deque() 782 ccheck = {} 783 elif type == Gio.FileType.REGULAR: 784 tr = self.update_track(fil, force_update=force_update) 785 if not tr: 786 continue 787 788 if dirtracks is not None: 789 dirtracks.append(tr) 790 # do this so that if we have, say, a 4000-song folder 791 # we dont get bogged down trying to keep track of them 792 # for compilation detection. Most albums have far fewer 793 # than 110 tracks anyway, so it is unlikely that this 794 # restriction will affect the heuristic's accuracy. 795 # 110 was chosen to accomodate "top 100"-style 796 # compilations. 797 if len(dirtracks) > 110: 798 logger.debug( 799 "Too many files, skipping " 800 "compilation detection heuristic for %s", 801 fil.get_uri(), 802 ) 803 dirtracks = None 804 805 if self.collection and self.collection._scan_stopped: 806 self.scanning = False 807 logger.info("Scan canceled") 808 return 809 810 # progress update 811 if notify_interval is not None and count % notify_interval == 0: 812 event.log_event('tracks_scanned', self, count) 813 814 # final progress update 815 if notify_interval is not None: 816 event.log_event('tracks_scanned', self, count) 817 818 removals = deque() 819 for tr in self.collection.tracks.values(): 820 tr = tr._track 821 loc = tr.get_loc_for_io() 822 if not loc: 823 continue 824 gloc = Gio.File.new_for_uri(loc) 825 try: 826 if not gloc.has_prefix(libloc): 827 continue 828 except UnicodeDecodeError: 829 logger.exception("Error decoding file location") 830 continue 831 832 if not gloc.query_exists(None): 833 removals.append(tr) 834 835 for tr in removals: 836 logger.debug("Removing %s", tr) 837 self.collection.remove(tr) 838 839 logger.info("Scan completed: %s", self.location) 840 self.scanning = False 841 842 def add(self, loc: str, move: bool = False) -> None: 843 """ 844 Copies (or moves) a file into the library and adds it to the 845 collection 846 """ 847 oldgloc = Gio.File.new_for_uri(loc) 848 849 newgloc = Gio.File.new_for_uri(self.location).resolve_relative_path( 850 oldgloc.get_basename() 851 ) 852 853 if move: 854 oldgloc.move(newgloc) 855 else: 856 oldgloc.copy(newgloc) 857 tr = trax.Track(newgloc.get_uri()) 858 if tr._scan_valid: 859 self.collection.add(tr) 860 861 def delete(self, loc: str) -> None: 862 """ 863 Deletes a file from the disk 864 865 .. warning:: 866 This permanently deletes the file from the hard disk. 867 """ 868 tr = self.collection.get_track_by_loc(loc) 869 if tr: 870 self.collection.remove(tr) 871 loc = tr.get_loc_for_io() 872 file = Gio.File.new_for_uri(loc) 873 if not file.delete(): 874 logger.warning("Could not delete file %s.", loc) 875 876 877class TransferQueue: 878 def __init__(self, library: Library): 879 self.library = library 880 self.queue: List[trax.Track] = [] 881 self.current_pos = -1 882 self.transferring = False 883 self._stop = False 884 885 def enqueue(self, tracks: Iterable[trax.Track]) -> None: 886 self.queue.extend(tracks) 887 888 def dequeue(self, tracks: Iterable[trax.Track]) -> None: 889 if self.transferring: 890 # FIXME: use a proper exception, and make this only error on 891 # tracks that have already been transferred 892 raise Exception("Cannot remove tracks while transferring") 893 894 for t in tracks: 895 try: 896 self.queue.remove(t) 897 except ValueError: 898 pass 899 900 def transfer(self) -> None: 901 """ 902 Tranfer the queued tracks to the library. 903 904 This is NOT asynchronous 905 """ 906 self.transferring = True 907 self.current_pos += 1 908 try: 909 while self.current_pos < len(self.queue) and not self._stop: 910 track = self.queue[self.current_pos] 911 loc = track.get_loc_for_io() 912 self.library.add(loc) 913 914 # TODO: make this be based on filesize not count 915 progress = self.current_pos * 100 / len(self.queue) 916 event.log_event('track_transfer_progress', self, progress) 917 918 self.current_pos += 1 919 finally: 920 self.queue = [] 921 self.transferring = False 922 self.current_pos = -1 923 self._stop = False 924 event.log_event('track_transfer_progress', self, 100) 925 926 def cancel(self) -> None: 927 """ 928 Cancel the current transfer 929 """ 930 # TODO: make this stop mid-file as well? 931 self._stop = True 932