// // ViewController.swift // TagTunes // // Created by Kim Wittenburg on 28.08.15. // Copyright © 2015 Kim Wittenburg. All rights reserved. // import Cocoa import AppKitPlus internal class MainViewController: NSViewController { // MARK: Types private struct OutlineViewConstants { struct ViewIdentifiers { static let simpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier" static let centeredTableCellViewIdentifier = "CenteredTableCellViewIdentifier" static let albumTableCellViewIdentifier = "AlbumTableCellViewIdentifier" static let trackTableCellViewIdentifier = "TrackTableCellViewIdentifier" } struct Items { static let loadingItem: AnyObject = "LoadingItem" static let noResultsItem: AnyObject = "NoResultsItem" static let searchResultsHeaderItem: AnyObject = MainViewController.Section.SearchResults.rawValue static let albumsHeaderItem: AnyObject = MainViewController.Section.Albums.rawValue static let unsortedTracksHeaderItem: AnyObject = MainViewController.Section.UnsortedTracks.rawValue } static let pasteboardType = "public.item.tagtunes" } internal enum Section: String { case SearchResults = "SearchResults" case Albums = "Albums" case UnsortedTracks = "UnsortedTracks" static func isHeaderItem(item: AnyObject) -> Bool { if let itemAsString = item as? String { return Section(rawValue: itemAsString) != nil } else { return false } } } // MARK: IBOutlets @IBOutlet private weak var outlineView: NSOutlineView! // MARK: Properties /// Used for searching and loading search results private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue()) /// The URL task currently loading the search results private var searchTask: NSURLSessionTask? /// If `true` the search section is displayed at the top of the /// `outlineView`. internal var showsSearch: Bool = false /// `true` if there is currently a search in progress. internal var searching: Bool { return searchTask != nil } /// The error that occured during searching, if any. internal private(set) var searchError: NSError? // MARK: View Life Cycle private var observerObject: NSObjectProtocol? override internal func viewDidLoad() { super.viewDidLoad() NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.AlbumFinishedLoadingNotificationName, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidFinishLoadingTracks) outlineView.setDraggingSourceOperationMask(.Move, forLocal: true) outlineView.registerForDraggedTypes([OutlineViewConstants.pasteboardType]) } deinit { if let observer = observerObject { NSNotificationCenter.defaultCenter().removeObserver(observer) } } // MARK: Outline View Content internal private(set) var searchResults = [SearchResult]() internal let albumCollection = AlbumCollection() internal private(set) var unsortedTracks = [iTunesTrack]() /// Returns all `iTunesTrack` objects that are somewhere down the outline /// view. private var allITunesTracks: Set { return Set(unsortedTracks).union(albumCollection.flatMap({ $0.tracks.flatMap { $0.associatedTracks } })) } /// Returns all contents of the outline view. /// /// This property is regenerated every time it is queried. If you need to /// access it a lot of times it is recommended to chache it into a local /// variable. private var outlineViewContents: [AnyObject] { var contents = [AnyObject]() if showsSearch { contents.append(OutlineViewConstants.Items.searchResultsHeaderItem) if !searchResults.isEmpty { contents.appendContentsOf(searchResults as [AnyObject]) } else if searching { contents.append(OutlineViewConstants.Items.loadingItem) } else if let error = searchError { contents.append(error) } else { contents.append(OutlineViewConstants.Items.noResultsItem) } if !albumCollection.isEmpty { contents.append(OutlineViewConstants.Items.albumsHeaderItem) } } contents.appendContentsOf(albumCollection.albums as [AnyObject]) if !unsortedTracks.isEmpty { contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem) contents.appendContentsOf(unsortedTracks as [AnyObject]) } return contents } /// Returns the section of the specified row or `nil` if the row is not a /// valid row. internal func sectionOfRow(row: Int) -> Section? { if row < 0 { return nil } var relativeRow = row if showsSearch { let searchRelatedItemCount = 1 + (searchResults.isEmpty ? 1 : searchResults.count) if relativeRow < searchRelatedItemCount { return .SearchResults } else { relativeRow -= searchRelatedItemCount } } var maxRow = outlineView.numberOfRows if !unsortedTracks.isEmpty { maxRow -= unsortedTracks.count + 1 } if relativeRow < maxRow { return .Albums } else { relativeRow -= maxRow } if relativeRow < unsortedTracks.count + 1 { return .UnsortedTracks } return nil } // TODO: Can this algorithm be improved? /// Returns the section the specified item resides in or `nil` if the item is /// not part of the outline view's contents. internal func sectionOfItem(item: AnyObject) -> Section? { if let album = item as? Album where albumCollection.contains(album) { return .Albums } else if let track = item as? Track where albumCollection.contains(track.album) { return .Albums } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track where albumCollection.contains(parentTrack.album) { return .Albums } else { return unsortedTracks.contains(track) ? .UnsortedTracks : nil } } else if item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError { return .SearchResults } else if let string = item as? String { return Section(rawValue: string) } else { return nil } } // MARK: Searching /// Starts a search for the specified search term. Calling this method internal func beginSearchForTerm(term: String) { cancelSearch() if let url = iTunesAPI.createAlbumSearchURLForTerm(term) { showsSearch = true searchTask = urlSession.dataTaskWithURL(url, completionHandler: processSearchResults) searchTask?.resume() } else { showsSearch = false } outlineView.reloadData() } /// Cancels the current search (if there is one). This also hides the search /// results. internal func cancelSearch() { searchTask?.cancel() searchResults.removeAll() showsSearch = false outlineView.reloadData() } /// Processes the data returned from a network request into the /// `searchResults`. private func processSearchResults(data: NSData?, response: NSURLResponse?, var error: NSError?) { searchTask = nil if let theData = data where error == nil { do { let searchResults = try iTunesAPI.parseAPIData(theData).map { SearchResult(representedAlbum: $0) } self.searchResults = searchResults } catch let theError as NSError { error = theError } } if let theError = error { searchErrorOccured(theError) } showsSearch = true outlineView.reloadData() } /// Called when an error occurs during searching. private func searchErrorOccured(error: NSError) { searchError = error } /// Adds the search result at the specified `row` to the albums section and /// begins loading its tracks. internal func selectSearchResultAtRow(row: Int) { guard sectionOfRow(row) == .SearchResults else { return } let searchResult = outlineView.itemAtRow(row) as! SearchResult if !Preferences.sharedPreferences.keepSearchResults { searchResults.removeAll() showsSearch = false } var albumAlreadyPresent = false for album in albumCollection { if album == searchResult { albumAlreadyPresent = true } } if !albumAlreadyPresent { let album = Album(searchResult: searchResult) albumCollection.addAlbum(album, beginLoading: true) } outlineView.reloadData() } private func albumCollectionDidFinishLoadingTracks(notification: NSNotification) { outlineView.reloadData() } // MARK: Albums private func saveTracks(tracks: [Track: [iTunesTrack]]) { let numberOfTracks = tracks.reduce(0) { (count: Int, element: (key: Track, value: [iTunesTrack])) -> Int in return count + element.value.count } let progress = NSProgress(totalUnitCount: Int64(numberOfTracks)) NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving tracks…", comment: "Alert message indicating that the selected tracks are currently being saved") NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription for (parentTrack, targetTracks) in tracks { for track in targetTracks { if progress.cancelled { return } parentTrack.saveToTrack(track) ++progress.completedUnitCount NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription } } } private func saveArtworks(tracks: [Track: [iTunesTrack]]) { var albums = Set() for (track, _) in tracks { albums.insert(track.album) } let progress = NSProgress(totalUnitCount: Int64(albums.count)) var errorCount = 0 NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving artworks…", comment: "Alert message indicating that the artworks for the selected tracks are currently being saved") NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription for album in albums { if progress.cancelled { return } do { try album.saveArtwork() } catch _ { ++errorCount } ++progress.completedUnitCount NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription } if errorCount > 0 { dispatch_sync(dispatch_get_main_queue()) { let alert = NSAlert() if errorCount == 1 { alert.messageText = NSLocalizedString("1 artwork could not be saved.", comment: "Error message indicating that one of the artworks could not be saved.") } else { alert.messageText = String(format: NSLocalizedString("%d artworks could not be saved.", comment: "Error message indicating that n artworks could not be saved."), errorCount) } alert.informativeText = NSLocalizedString("Please check your privileges for the folder you set in the preferences and try again.", comment: "Informative text for 'artwork(s) could not be saved' errors") alert.alertStyle = .WarningAlertStyle alert.addButtonWithTitle("OK") alert.beginSheetModalForWindow(self.view.window!, completionHandler: nil) } } } // MARK: Actions /// Adds the current iTunes selection @IBAction internal func addITunesSelection(sender: AnyObject?) { if !iTunes.running { let alert = NSAlert() alert.messageText = NSLocalizedString("iTunes is not running", comment: "Error message informing the user that iTunes is not currently running.") alert.informativeText = NSLocalizedString("Please launch iTunes and try again.", comment: "Informative text for the 'iTunes is not running' error") alert.addButtonWithTitle(NSLocalizedString("OK", comment: "Button title")) alert.beginSheetModalForWindow(view.window!, completionHandler: nil) } else if let selection = iTunes.selection.get() as? [iTunesTrack] { let newTracks = Set(selection).subtract(allITunesTracks) unsortedTracks.appendContentsOf(newTracks) outlineView.reloadData() } } /// Begins to search for the `sender`'s `stringValue`. @IBAction internal func performSearch(sender: AnyObject?) { if let searchTerm = sender?.stringValue { beginSearchForTerm(searchTerm) } } /// Selects the search result associated with the `sender`'s row (as /// determined by `NSOutlineView.rowForView`) and adds it to the list of /// albums. @IBAction private func selectSearchResult(sender: AnyObject?) { if let view = sender as? NSView { let row = outlineView.rowForView(view) selectSearchResultAtRow(row) } } /// Saves the selected items to iTunes. The saving process will be reported /// to the user in a progress sheet. @IBAction internal func performSave(sender: AnyObject?) { var itemsToBeSaved = [Track: [iTunesTrack]]() for row in outlineView.selectedRowIndexes where sectionOfRow(row) == .Albums { let item = outlineView.itemAtRow(row) if let album = item as? Album { for track in album.tracks where !track.associatedTracks.isEmpty { itemsToBeSaved[track] = track.associatedTracks } } else if let track = item as? Track { itemsToBeSaved[track] = track.associatedTracks } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track { if itemsToBeSaved[parentTrack] != nil { itemsToBeSaved[parentTrack]?.append(track) } else { itemsToBeSaved[parentTrack] = [track] } } } } guard !itemsToBeSaved.isEmpty else { return } let progress = NSProgress(totalUnitCount: 100) progress.beginProgressSheetModalForWindow(self.view.window!) { reponse in self.outlineView.reloadData() } dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { if Preferences.sharedPreferences.saveArtwork { progress.becomeCurrentWithPendingUnitCount(90) } else { progress.becomeCurrentWithPendingUnitCount(100) } self.saveTracks(itemsToBeSaved) progress.resignCurrent() if Preferences.sharedPreferences.saveArtwork { progress.becomeCurrentWithPendingUnitCount(10) self.saveArtworks(itemsToBeSaved) progress.resignCurrent() } } } /// Removes the selected items from the outline view. @IBAction internal func delete(sender: AnyObject?) { let items = outlineView.selectedRowIndexes.map { ($0, outlineView.itemAtRow($0)) } for (row, item) in items { if sectionOfRow(row)! != .SearchResults { if let album = item as? Album { albumCollection.removeAlbum(album) } else if let track = item as? Track { track.associatedTracks = [] } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track { parentTrack.associatedTracks.removeElement(track) } else { unsortedTracks.removeElement(track) } } } } outlineView.reloadData() } /// Action that should be triggered from a view inside the outline view. If /// `sender` is not an `NSError` instance the item at the row associated with /// the `sender` (as determined by `NSOutlineView.rowForView`) should be a /// `NSError` or `Album` instance for this method to work correctly. @IBAction private func showErrorDetails(sender: AnyObject?) { var error: NSError if let theError = sender as? NSError { error = theError } else if let view = sender as? NSView { let row = outlineView.rowForView(view) let item = outlineView.itemAtRow(row) if let theError = item as? NSError { error = theError } else if let album = item as? Album { if let theError = albumCollection.errorForAlbum(album) { error = theError } else { return } } else { return } } else { return } presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil) } } // MARK: - Error Handling extension MainViewController { override internal func willPresentError(error: NSError) -> NSError { let recoveryOptions = [ NSLocalizedString("OK", comment: "Button title"), NSLocalizedString("Try Again", comment: "Button title for error alerts offering the user to try again.") ] return DescriptiveError(underlyingError: error, userInfo: [NSRecoveryAttempterErrorKey: self, NSLocalizedRecoveryOptionsErrorKey: recoveryOptions]) } override internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer) { let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex) // TODO: Notify the delegate } override internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool { if recoveryOptionIndex == 0 { return true } // TODO: Implementation if error == searchError { } else { for album in albumCollection { let albumError = albumCollection.errorForAlbum(album) if error == albumError { } } } return false } } // MARK: - User Interface Validations extension MainViewController: NSUserInterfaceValidations { internal func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool { if anItem.action() == "performSave:" { for row in outlineView.selectedRowIndexes { return sectionOfRow(row) == .Albums } } else if anItem.action() == "addITunesSelection:" { guard iTunes.running else { return false } return !(iTunes.selection.get() as! [AnyObject]).isEmpty } else if anItem.action() == "delete:" { for row in outlineView.selectedRowIndexes { if sectionOfRow(row) != .SearchResults { return true } } } return false } } // MARK: - Outline View extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { internal func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int { if item == nil { return outlineViewContents.count } else if let album = item as? Album { return album.tracks.count } else if let track = item as? Track { return track.associatedTracks.count } else { return 0 } } internal func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject { if item == nil { return outlineViewContents[index] } else if let album = item as? Album { return album.tracks[index] } else if let track = item as? Track { return track.associatedTracks[index] } else { return "" } } internal func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool { return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0 } internal func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool { return Section.isHeaderItem(item) } internal func outlineView(outlineView: NSOutlineView, shouldSelectItem item: AnyObject) -> Bool { return !(self.outlineView(outlineView, isGroupItem: item) || item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError) } internal func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat { if item is Album || item is SearchResult { return 39 } else if let track = item as? Track { return track.album.hasSameArtistNameAsTracks ? 24 : 31 } else if item === OutlineViewConstants.Items.loadingItem { return 39 } else if item === OutlineViewConstants.Items.noResultsItem || item is NSError { return 32 } else if Section.isHeaderItem(item) { return 24 } else { return 17 } } internal func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? { if item === OutlineViewConstants.Items.searchResultsHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Search Results", comment: "Header name for the seach results section") return view } if item === OutlineViewConstants.Items.albumsHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Albums", comment: "Header name for the albums section") return view } if item === OutlineViewConstants.Items.unsortedTracksHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Unsorted Tracks", comment: "Header name for the unsorted tracks section") return view } if item === OutlineViewConstants.Items.loadingItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.setupForLoading() return view } if item === OutlineViewConstants.Items.noResultsItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.setupForMessage(NSLocalizedString("No Results", comment: "Message informing the user that the search didn't return any results")) return view } if let error = item as? NSError { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.button?.target = self view?.button?.action = "showErrorDetails:" view?.setupForError(error, errorMessage: NSLocalizedString("Failed to load results", comment: "Error message informing the user that an error occured during searching.")) return view } if let album = item as? Album { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView if view == nil { view = AlbumTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier } view?.setupForAlbum(album, loading: albumCollection.isAlbumLoading(album), error: albumCollection.errorForAlbum(album)) view?.errorButton?.target = self view?.errorButton?.action = "showErrorDetails:" return view } if let searchResult = item as? SearchResult { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView if view == nil { view = AlbumTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier } view?.button.target = self view?.button.action = "selectSearchResult:" let selectable = albumCollection.filter { $0.id == searchResult.id }.isEmpty view?.setupForSearchResult(searchResult, selectable: selectable) return view } if let track = item as? Track { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier, owner: nil) as? TrackTableCellView if view == nil { view = TrackTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier } view?.setupForTrack(track) return view } if let track = item as? iTunesTrack { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.systemFontOfSize(0) view?.textField?.textColor = NSColor.textColor() view?.textField?.stringValue = track.name return view } return nil } internal func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool { var rows = [Int]() var containsValidItems = false for item in items { let row = outlineView.rowForItem(item) rows.append(row) if sectionOfRow(row) != .SearchResults { containsValidItems = true } } if !containsValidItems { return false } let data = NSKeyedArchiver.archivedDataWithRootObject(rows) pasteboard.declareTypes([OutlineViewConstants.pasteboardType], owner: nil) pasteboard.setData(data, forType: OutlineViewConstants.pasteboardType) return true } internal func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation { let firstUnsortedRow = outlineViewContents.count - (unsortedTracks.isEmpty ? 0 : unsortedTracks.count+1) // Drop in the 'unsorted' section if item == nil && index >= firstUnsortedRow || item === OutlineViewConstants.Items.unsortedTracksHeaderItem { outlineView.setDropItem(nil, dropChildIndex: outlineViewContents.count) return .Every } // Drop on iTunesTrack item or between items if index != NSOutlineViewDropOnItemIndex || item is iTunesTrack { return .None } // Drop on header row if item != nil && self.outlineView(outlineView, isGroupItem: item!) { return .None } // Drop in 'search results' section let row = outlineView.rowForItem(item) if sectionOfRow(row) == .SearchResults { return .None } if let album = item as? Album where albumCollection.isAlbumLoading(album) || albumCollection.errorForAlbum(album) != nil { return .None } return .Every } internal func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool { guard let data = info.draggingPasteboard().dataForType(OutlineViewConstants.pasteboardType), draggedRows = NSKeyedUnarchiver.unarchiveObjectWithData(data) as? [Int] else { return false } // Get the dragged tracks and remove them from their previous location var draggedTracks = Set() for row in draggedRows { if sectionOfRow(row) != .SearchResults { let item = outlineView.itemAtRow(row) if let album = item as? Album { for track in album.tracks { draggedTracks.unionInPlace(track.associatedTracks) track.associatedTracks.removeAll() } } else if let track = item as? Track { draggedTracks.unionInPlace(track.associatedTracks) track.associatedTracks.removeAll() } else if let track = item as? iTunesTrack { draggedTracks.insert(track) if let parentTrack = outlineView.parentForItem(track) as? Track { parentTrack.associatedTracks.removeElement(track) } else { unsortedTracks.removeElement(track) } } } } // Add the dragged tracks to the new target if let targetTrack = item as? Track { targetTrack.associatedTracks.appendContentsOf(draggedTracks) } else if let targetAlbum = item as? Album { for draggedTrack in draggedTracks { var inserted = false for track in targetAlbum.tracks { if (draggedTrack.discNumber == track.discNumber || draggedTrack.discNumber == 0) && draggedTrack.trackNumber == track.trackNumber { track.associatedTracks.append(draggedTrack) inserted = true break } } if !inserted { unsortedTracks.append(draggedTrack) } } } else { unsortedTracks.appendContentsOf(draggedTracks) } outlineView.reloadData() return true } }