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