346 lines
13 KiB
Swift
Executable File
346 lines
13 KiB
Swift
Executable File
//
|
|
// 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<TagTunesTrack> { 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<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack>
|
|
|
|
/// 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<TagTunesTrack>() {
|
|
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<S: SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
|
|
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<TagTunesTrack> {
|
|
return children.reduce(Set()) { $0.union($1.associatedTracks) }
|
|
}
|
|
|
|
public func addAssociatedTracks<S : SequenceType where S.Generator.Element == TagTunesTrack>(tracks: S) -> Set<TagTunesTrack> {
|
|
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
|
|
}
|