// // TagTunesItem.swift // TagTunes // // Created by Kim Wittenburg on 21.01.16. // Copyright © 2016 Kim Wittenburg. All rights reserved. // import SearchAPI /// This enum holds errors that may occur during saving. public enum SaveError: ErrorType { /// This error indicates that the artwork could not be downloaded. case ArtworkDownloadFailed /// This constant indicates that there was probably a error downloading the /// artwork. This is judged by the artwork's resolution. case LowResArtwork } /// A `TagTunesItem` (*item* for short) represents a `SearchAPIEntity` in the /// TagTunes application. There are two classes that conform to this protocol: /// `TagTunesEntityItem` (*entity*) and `TagTunesGroupItem` (*group*). The /// general structure is as follows: /// /// - A group contains zero or more entities. /// - An entity can be contained in a group (but doesn't have to). /// - An entity contains zero or more associated tracks. public protocol TagTunesItem: class { /// The `SearchAPIEntity` represented by the item. The `entity` should not /// change after initialization. var entity: SearchAPIEntity { get } /// The `TagTunesTrack`s associated with the item. For groups this is the /// union of the `associatedTracks` of its children. var associatedTracks: Set { get } /// Adds the specified `tracks` to the item's `associatedTracks`. For goups /// this sorts the `tracks` into its children. How the tracks are sorted is /// determined by the concrete group. /// /// - parameters: /// - tracks: The `TagTunesTrack`s to be associated with the item. /// /// - returns: For groups: The tracks that couldn't be sorted. /// For entities: An empty `Set`. func addAssociatedTracks(tracks: S) -> Set /// Removes all `associatedTracks` from the item. func clearAssociatedTracks() /// Returns whether the item's `associatedTracks` already contain up-to-date /// tags. This value should respect the user's `Preferences`. var saved: Bool { get } } public func ==(lhs: TagTunesItem, rhs: TagTunesItem) -> Bool { return lhs.entity == rhs.entity } /// Represents an entity item. An entity item directly corresponds to a file (for /// example a song, a movie, or a podcast episode). An entity may have a parent. /// An entity's parent does not usually correspond to a file but rather is an /// abstract grouping criterion (for example a album or a podcast). /// /// `TagTunesEntityItem` is an abstract class that can not be initialized /// directly. Instead one of the concrete subclasses (like `SongItem`) should be /// used. public class TagTunesEntityItem: TagTunesItem { public let entity: SearchAPIEntity /// The entity's parent. The parent may change if the entity is added to a /// different group. The parent is nil for entities that do not belong to a /// group (such as movies or apps). public weak var parentItem: TagTunesGroupItem? public var associatedTracks = Set() { willSet { for track in associatedTracks { track.entity = nil } } didSet { for track in associatedTracks { track.entity = self } } } /// Initializes a new `TagTunesEntityItem` with the specified `entity`. /// /// The class `TagTunesEntityItem` can not be initialized directly. Instead /// use one of the concrete subclasses like `SongItem`. public init(entity: SearchAPIEntity) { self.entity = entity assert(self.dynamicType != TagTunesEntityItem.self, "TagTunesEntityItem must not be initialized directly.") } /// Initializes a new `TagTunesEntityItem` with the specified `entity` and /// `parentItem`. The newly initialized entity is **not** added to the /// `parentItem` automatically. /// /// The class `TagTunesEntityItem` can not be initialized directly. Instead /// use one of the concrete subclasses like `SongItem`. public convenience init(entity: SearchAPIEntity, parentItem: TagTunesGroupItem?) { self.init(entity: entity) self.parentItem = parentItem } public func addAssociatedTracks(tracks: S) -> Set { associatedTracks.unionInPlace(tracks) return [] } public func clearAssociatedTracks() { associatedTracks.removeAll() } public var saved: Bool { fatalError("Must override property saved.") } /// Returns the value for the specified `tag`. This method should **not** /// act depending on the user's preferences. /// /// - returns: The value for the specified `tag` or `nil` if such a value /// does not exist. /// /// - throws: Errors that occur when reading the tag. This may for example /// happen if an artwork can not be downloaded. public func valueForTag(tag: Tag) throws -> AnyObject! { fatalError("Must override valueForTag(_:)") } } /// Represents a group item. A group item is the parent of zero or more entities. /// /// A group usually does not directly correspond to a specific file but rather is /// an abstract grouping concept. A group would for example be an album (see /// class `AlbumItem`) with its children being the songs stored on disk. /// /// Since groups can have children associated with them it may be neccessary to /// load those children sometime after the group has been initialized. The /// `TagTunesGroupItem` class offers the method `beginLoadingChildren()` to deal /// with those cases. See the documentation on that method for details. /// /// `TagTunesGroupItem` is an abstract class that can not be initialized /// directly. Instead one of the concrete subclasses (like `AlbumItem`) should be /// used. public class TagTunesGroupItem: TagTunesItem { /// This notification is posted by `TagTunesGroupItem`s when their state /// changes. Listening for this notification is the recommended way to react /// to groups starting or finishing loading their children. /// /// The `object` associated with the respective notification is the group /// item whose state changed. public static let StateChangedNotificationName = "TagTunesGroupItemStateChangedNotificationName" /// The state of a group item. public enum LoadingState { /// The group is currently not active. This state is set before and after /// a group loads its children. case Normal /// The group is currently loading its children. case Loading(NSURLSessionTask) /// This state is basically equivalent to the `Normal` state except that /// indicates that the previous attempt to load the item's children /// failed. case Error(ErrorType) } /// The URL session used to load children for groups. private static let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue()) public let entity: SearchAPIEntity /// The group's children. It is recommended that subclasses install a /// property observer on this property and sort the children appropriately. public internal(set) var children = [TagTunesEntityItem]() /// The state of the group. This state indicates whether the group is /// currently loading its children or not. See `LoadingState` for details. public private(set) var loadingState: LoadingState = .Normal { didSet { NSNotificationCenter.defaultCenter().postNotificationName(TagTunesGroupItem.StateChangedNotificationName, object: self) } } /// Creates a new group representing the specified `entity`. /// /// The class `TagTunesGroupItem` can not be initialized directly. Instead /// use one of the concrete subclasses like `AlbumItem`. public init(entity: SearchAPIEntity) { self.entity = entity assert(self.dynamicType != TagTunesGroupItem.self, "TagTunesGroupItem must not be initialized directly.") } public var associatedTracks: Set { return children.reduce(Set()) { $0.union($1.associatedTracks) } } public func addAssociatedTracks(tracks: S) -> Set { fatalError("Must override addAssociatedTracks(_:)") } /// Sorts the specified `track` into the children of the group. If there is /// no child representing the specified `childEntity` a new one is created. /// /// In contrast to the `addAssociatedTracks(_:)` method this one can be /// called before the group has loaded its children. Tracks associated by /// this method are preserved when a group loads its children. /// /// This method is especially useful if you want to associate the specified /// `track` with the `childEntity` but it is not clear whether the group did /// already load its children. /// /// This method must be implemented by subclasses. public func addAssociatedTrack(track: TagTunesTrack, forChildEntity childEntity: SearchAPIEntity) { fatalError("Must override addAssociatedTrack:forChildEntity:") } public func clearAssociatedTracks() { for child in children { child.clearAssociatedTracks() } } /// Determines whether all children have the same artist as the group itself. /// /// This must be implemented by subclasses. public var hasCommonArtist: Bool { fatalError("Must override property hasCommonArtist") } /// Starts a network request to load the group's children. This method /// returns immediately. /// /// It is possible to monitor the loading of children through the group's /// `loadingState`. Whenever that state changes a /// `TagTunesGroupItem.StateChangedNotificationName` notification is posted /// with the respective group as the notification's `object`. /// /// Normally this method should only be invoked once per group. A second /// invocation will still send the network request but the group will not /// change because its children are already loaded. The only exception to /// this rule is if the first request failed (as determined by the /// `LoadingState.Error` case). In that case you can *retry* by invoking this /// method again. public func beginLoadingChildren() { if case .Loading = loadingState { return } var request = SearchAPIRequest(lookupRequestWithID: entity.id) request.entity = self.dynamicType.childEntityType request.country = Preferences.sharedPreferences.iTunesStore if Preferences.sharedPreferences.useEnglishTags { request.language = .English } request.maximumNumberOfResults = 200 let task = TagTunesGroupItem.urlSession.dataTaskWithURL(request.URL) { (data, response, error) -> Void in if let theError = error { self.loadingState = .Error(theError) return } do { let result = try SearchAPIResult(data: data!) self.processLookupResult(result) self.loadingState = .Normal } catch let error as NSError { self.loadingState = .Error(error) } } loadingState = .Loading(task) task.resume() } /// Returns the `EntityType` of the group's children. This is used to /// construct an appropriate lookup request. /// /// This property must be overridden by subclasses. internal class var childEntityType: SearchAPIRequest.EntityType { fatalError("Must override property childEntityType") } /// Called when the group did finish loading its children. Subclasses must /// implement this method and should modify the `children` property to /// represent the `result` of the lookup. internal func processLookupResult(result: SearchAPIResult) { fatalError("Must override processLookupResult(_:)") } /// Immediately stops loading the group's children and sets the /// `loadingState` to `Error(NSUserCancelledError)`. public func cancelLoadingChildren() { if case let .Loading(task) = loadingState { task.cancel() } let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) loadingState = .Error(error) } public var saved: Bool { for child in children { if !child.saved { return false } } return true } } extension TagTunesEntityItem: Hashable { public var hashValue: Int { return Int(entity.id) } } extension TagTunesGroupItem: Hashable { public var hashValue: Int { return Int(entity.id) } } public func ==(lhs: TagTunesEntityItem, rhs: TagTunesEntityItem) -> Bool { return lhs.entity.id == rhs.entity.id } public func ==(lhs: TagTunesGroupItem, rhs: TagTunesGroupItem) -> Bool { return lhs.entity.id == rhs.entity.id }