// // LookupController.swift // TagTunes // // Created by Kim Wittenburg on 07.03.16. // Copyright © 2016 Kim Wittenburg. All rights reserved. // import SearchAPI import AppKitPlus /// The state of a lookup process. public enum LookupState { /// The track has not been processed for lookup. This is the initial state. case Unprocessed /// The track is being prepared for lookup. During preparation the track's id /// is read. case Preparing /// The lookup is currently searching for the track's metadata. case Searching /// The track was found. The associated `Track` represents the found metadata /// of the track. case Found(Track) /// The track has been looked up but was not found. case NotFound /// The track is not qualified for lookup because its id could not be read. The /// associated value contains an error description identifying the reason why /// the track's id could not be read. case Unqualified(ErrorType) /// During the lookup an error occured. This state is also used if the lookup /// was cancelled by the user. case Error(ErrorType?) } // TODO: Documentation // DOCUMENTATION: All delegate methods are executed on main thread. protocol LookupQueueDelegate: class { /// Invoked before the `lookupController` will process the specified /// `tracks`. This method may be invoked multiple times before /// `lookupControllerDiDFinishLookup(_:)` is invoked, but it is guaranteed /// that it will be invoked at least once. For more information see the /// documentation for `LookupController`. func lookupQueue(lookupQueue: LookupQueue, willBeginLookupForTracks tracks: [TagTunesTrack]) /// Invoked when the `lookupController` completes the lookup process for the /// specified `tracks`. This method is called for tracks that could be /// successfully looked up or that could not be looked up. There can also be /// both in one invocation of this method. Use the `lookupState` of the /// `TagTunesTrack` instances to get information about the lookup result for /// individual tracks. /// /// This method may be called multiple times in a row. Every track that has /// been part of the `tracks` array of a /// `lookupController(_:willBeginLookupForTracks:)` message will be part of /// a invocation of this method exactly once. /// /// - parameters: /// - lookupController: The controller that did the lookup. /// - tracks: The tracks that have been processed by the /// `lookupController`. The order of the tracks has no meaning. func lookupQueue(lookupQueue: LookupQueue, completedLookupForTracks tracks: [TagTunesTrack]) /// Invoked after the `lookupController` finished looking up all enqueued /// tracks. func lookupQueueDidFinishLookup(lookupQueue: LookupQueue) } /// The lookup controller looks up tracks' metadata online based on the iTunes /// store ID embedded into the a file. To be notified about the lookup progress /// set the controller's `delegate` property. There are some valid assumptions /// you can make about the order in which the delgate methods are invoked: /// /// 1. `lookupController(_:willBeginLookupForTracks:)` is the first of the /// methods to be invoked. /// 2. Every `TagTunesTrack` that is part of the `tracks` in /// `lookupController(_:willBeginLookupForTracks:)` will be part of the /// `tracks` of `lookupController(_:completedLookupForTracks:)` or. /// 4. `lookupControllerDidFinishLookup(_:)` may only be called after (1) and (2) /// are satisfied. /// 5. After `lookupControllerDidFinishLookup(_:)` the next delegate message will /// be `lookupController(_:willBeginLookupForTracks:)`. /// /// The calls to `lookupController(_:willBeginLookupForTracks:)` and /// `lookupControllerDidFinishLookup(_:)` are **not** balanced. There may be /// multiple invokations of the former with only a single invokation of the /// latter. class LookupQueue: OperationQueue { static let globalQueue = LookupQueue() // TODO: Is there an alternative to two delegates /// The lookup controller's delegate. weak var lookupDelegate: LookupQueueDelegate? var lookupOperation: LookupOperation? private var aggregatedTracks = [TagTunesTrack]() /// Enqueues the specified `tracks` for lookup. The point in time where the /// `tracks` will actually be looked up is not defined. It may be when this /// method returns or at some later point. To get notified when the lookup /// actually starts implement the delgate method /// `lookupController(_:willBeginLookupForTracks:)`. /// /// - note: There is no correspondance between the specified `tracks` an the /// tracks passed to the delegate methods. If there is currently a /// lookup in progress the lookup controller might collect the /// enqueued tracks and batch-process them. func enqueueTracksForLookup(tracks: S) { aggregatedTracks.appendContentsOf(tracks) for track in tracks { let preparationOperation = LookupPreparationOperation(track: track) addOperation(preparationOperation) } beginLookupIfPossible() } // DOCUMENTATION: Must execute on main thread // FIXME: ? Can there be two lookup operations? private func beginLookupIfPossible() { // TODO: "Finished Lookup" delegate call if lookupOperation == nil && !aggregatedTracks.isEmpty { lookupOperation = LookupOperation(tracks: aggregatedTracks) lookupOperation?.addObserver(BlockObserver(startHandler: { operation in dispatch_sync(dispatch_get_main_queue()) { // TODO: Is dispatch_sync ok? let lookupOperation = operation as! LookupOperation self.lookupDelegate?.lookupQueue(self, willBeginLookupForTracks: lookupOperation.tracks) } }, produceHandler: nil, finishHandler: { (operation, errors) in dispatch_sync(dispatch_get_main_queue()) { // TODO: Is this a retain cycle? // TODO: Is dispatch_sync ok? self.lookupOperation(operation, finishedWithErrors: errors) } })) aggregatedTracks = [] for operation in operations where operation is LookupPreparationOperation { lookupOperation?.addDependency(operation) } addOperation(lookupOperation!) } } // DOCUMENTATION: Must execute on main thread. private func lookupOperation(operation: Operation, finishedWithErrors errors: [ErrorType]) { let lookupOperation = operation as! LookupOperation self.lookupDelegate?.lookupQueue(self, completedLookupForTracks: lookupOperation.tracks) self.lookupOperation = nil if aggregatedTracks.isEmpty { lookupDelegate?.lookupQueueDidFinishLookup(self) } else { beginLookupIfPossible() } } /// Cancels the lookup. This will do three things: /// /// 1. Stop any running or pending network request. Set the `lookupState` of /// all tracks to `NSCocoaError.UserCancelledError`. /// 2. Send the delegate a `lookupController(_:completedLookupForTracks:)` /// message. /// 3. Send the delegate a `lookupControllerDidFinishLookup(_:)` message. func cancelLookup() { // TODO: Implementation } }