// // NYTUpdater.m // Notifications for YouTube // // Created by Kim Wittenburg on 09.07.13. // Copyright (c) 2013 Kim Wittenburg. All rights reserved. // #import "NYTUpdateManager.h" // Core Imports #import "NYTVideo.h" #import "NYTUtil.h" #import "NYTAuthentication.h" // Other Imports #import "NYTChannelRestriction.h" /* User notifications sent by this class will contain this key in their userInfo dictionary. The value will be an NSString object representing an URL that can be opened to show the complete contents of the notification. These contents are either a video page or (for coalesced notifications) the user's "my subscriptions" page. */ NSString * const NYTUserNotificationURLKey = @"NYTUserNotificationURLKey"; @interface NYTUpdateManager () // Refreshing @property (readwrite) BOOL refreshing; // Caching Refresh Data @property NSMutableSet *knownVideos; @property NSDate *lastRefreshDate; // Only for Auto Refresh // Auto Refreshing @property NSTimer *autoRefreshTimer; @property BOOL autoRefreshWasDisabled; @property NSInteger autoRefreshPauseCount; @property BOOL refreshOnResume; @end @implementation NYTUpdateManager @synthesize autoRefreshEnabled = _autoRefreshEnabled; #pragma mark *** Obtaining the Shared Instance *** static NYTUpdateManager *sharedManager = nil; + (NYTUpdateManager *)sharedManager { if (!sharedManager) { sharedManager = [[self alloc] init]; } return sharedManager; } - (id)init { // Allow exactly one instance at all (for any way of initialization) if (sharedManager) { return sharedManager; } self = [super init]; if (self) { sharedManager = self; [[NSWorkspace sharedWorkspace].notificationCenter addObserver:self selector:@selector(systemWillGoToSleep:) name:NSWorkspaceWillSleepNotification object:NULL]; [[NSWorkspace sharedWorkspace].notificationCenter addObserver:self selector:@selector(systemDidAwakeFromSleep:) name:NSWorkspaceDidWakeNotification object:NULL]; } return self; } - (void)dealloc { [self.autoRefreshTimer invalidate]; [[NSWorkspace sharedWorkspace].notificationCenter removeObserver:self]; } #pragma mark *** Refreshing Feeds *** - (BOOL)canRefresh { // If logged in refreshing is possible return [NYTAuthentication sharedAuthentication].isLoggedIn; } - (void)refreshFeedsWithErrorHandler:(void (^)(NSError *))handler notify:(BOOL)flag { self.refreshOnResume = NO; // Just for sure if (self.refreshing) { return; } // Prepare for refreshing self.refreshing = YES; [self pauseAutoRefreshing]; self.lastRefreshDate = [NSDate date]; // Since the refresh date is just for auto refresh it should be changed here to let auto refresh continue even if there was an error // Refresh on a background queue dispatch_queue_t prevQueue = dispatch_get_current_queue(); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ // Prepare for paged feeds NSError *error; NSMutableSet *feedVideos; NSUInteger processedResults = 0; NSUInteger totalResults = 0; do { // We are creating a couple of objects so setup a separate autoreleasepool @autoreleasepool { // Load the document NSString *url = [NSString stringWithFormat:@"https://gdata.youtube.com/feeds/api/users/default/newsubscriptionvideos?max-results=50&start-index=%li&v=2", processedResults+1]; NSXMLDocument *document = LoadXMLDocumentSynchronous(url, [NYTAuthentication sharedAuthentication].authentication, &error); // Store the entries as app-friendly video objects if (document) { // Set up the collection for collecting the videos if (processedResults == 0) { static NSString *totalResultsPath = @"./feed/openSearch:totalResults"; NSXMLElement *totalResultsElement = [document nodesForXPath:totalResultsPath error:nil][0]; totalResults = (NSUInteger) totalResultsElement.stringValue.integerValue; feedVideos = [NSMutableSet setWithCapacity:totalResults]; } // For each page parse its results and add them to the collection static NSString *resultsPerPagePath = @"./feed/openSearch:itemsPerPage"; NSXMLElement *resultsPerPageElement = [document nodesForXPath:resultsPerPagePath error:nil][0]; int resultsPerPage = resultsPerPageElement.stringValue.intValue; processedResults += resultsPerPage; [feedVideos unionSet:[self videosOnPage:document]]; } else { // Abort on error [self abortRefreshOnQueue:prevQueue withErrorHandler:handler usingError:error]; return; } } } while (processedResults < totalResults); // Deliver user notifications if wanted if (flag) { [self notifyForVideos:[self videosForNotifications:feedVideos]]; } // Clean up self.knownVideos = [NSMutableSet setWithCapacity:feedVideos.count]; for (NYTVideo *video in feedVideos) { // For memory efficiency just store the video IDs [self.knownVideos addObject:video.videoID]; } // Resume on the caller's queue dispatch_async(prevQueue, ^{ self.refreshing = NO; [self resumeAutoRefreshing]; }); }); } - (void)abortRefreshOnQueue:(dispatch_queue_t)queue withErrorHandler:(void(^)(NSError *))handler usingError:(NSError *)error { dispatch_async(queue, ^{ if (handler) { handler(error); } self.refreshing = NO; [self resumeAutoRefreshing]; }); } - (NSSet *)videosOnPage:(NSXMLDocument *)document { // Get the entries static NSString *entryPath = @"./feed/entry"; NSArray *entries = [document nodesForXPath:entryPath error:nil]; NSMutableSet *videos = [NSMutableSet setWithCapacity:entries.count]; // Parse the entries for (NSXMLNode *entry in entries) { static NSString *mediaGroupPath = @"./media:group"; NSXMLNode *mediaGroupNode = [entry nodesForXPath:mediaGroupPath error:nil][0]; [videos addObject:[[NYTVideo alloc] initWithMediaGroupNode:mediaGroupNode]]; } return videos; } #pragma mark - System Notifications - (void)systemWillGoToSleep:(NSNotification *)notification { [self pauseAutoRefreshing]; } - (void)systemDidAwakeFromSleep:(NSNotification *)notification { [self resumeAutoRefreshing]; [self performAutoRefresh:nil]; } #pragma mark *** Notifications *** - (NSSet *)videosForNotifications:(NSSet *)allVideos { // Prepare objects that are not dependant on videos NSMutableSet *acceptedVideos = [[NSMutableSet alloc] init]; NSDictionary *restrictions = [[NSUserDefaults standardUserDefaults] objectForKey:@"Rules"]; NSTimeInterval maximumVideoAgeOffset = self.maximumVideoAge <= 0 ? 0 : -self.maximumVideoAge; NSDate *minimumVideoUploadedDate = maximumVideoAgeOffset == 0 ? [NSDate distantPast] : [NSDate dateWithTimeIntervalSinceNow:maximumVideoAgeOffset]; // Enumerate all videos for (NYTVideo *video in allVideos) { // Get the video dependant objects NSTimeInterval timeSinceMinimumUploadedDate = [video.uploadedDate timeIntervalSinceDate:minimumVideoUploadedDate]; NSData *restrictionData = restrictions[video.uploaderID]; NYTChannelRestriction *restriction = nil; if (restrictionData) { restriction = [NSKeyedUnarchiver unarchiveObjectWithData:restrictionData]; } // Validate the video and accept it, if it passed the validation if (timeSinceMinimumUploadedDate >= 0 && ![self.knownVideos containsObject:video.videoID] && [self acceptVideo:video withRestriction:restriction]) { [acceptedVideos addObject:video]; } else { } } return acceptedVideos; } - (BOOL)acceptVideo:(NYTVideo *)video withRestriction:(NYTChannelRestriction *)restriction { // For efficiency restrictions are not stored if they only contain default values. The default values accept all videos. if (!restriction) { return YES; } if (restriction.disableAllNotifications) { return NO; } BOOL predicateResult = [restriction.predicate evaluateWithObject:video]; return restriction.positivePredicate == predicateResult; } // Convenience macros #define NYTCoalescedNotificationNumberOfIncludedChannels 3 #define NYTCoalescedNotificationShouldIncludeAll(varName) (varName.count <= NYTCoalescedNotificationNumberOfIncludedChannels + 1) - (void)notifyForVideos:(NSSet *)videos { // Should coalesce if (videos.count > 1 && self.coalescesNotifications) { // Create the notification NSUserNotification *notification = [[NSUserNotification alloc] init]; notification.title = [NSString stringWithFormat:NSLocalizedString(@"%li new videos", nil), videos.count]; // Create the textual enumeration for the uploaders NSMutableString *uploadersEnum = @"".mutableCopy; NSSet *uploaders = [videos valueForKey:@"uploaderDisplayName"]; NSInteger processed = 0; for (NSString *uploader in uploaders) { if (processed == 0) { // The first uploader is just added as is [uploadersEnum appendString:uploader]; } else if (processed < NYTCoalescedNotificationNumberOfIncludedChannels || NYTCoalescedNotificationShouldIncludeAll(uploaders)) { if (processed == uploaders.count - 1) { // This is the last one so offer a special separation text [uploadersEnum appendFormat:NSLocalizedString(@" and %@", nil), uploader]; } else { [uploadersEnum appendFormat:NSLocalizedString(@", %@", nil), uploader]; } } else { break; } processed++; } // Set the enum for the notification NSString *informativeText; if (NYTCoalescedNotificationShouldIncludeAll(uploaders)) { // There are no "more" uploaders informativeText = [NSString stringWithFormat:NSLocalizedString(@"New videos from %@", nil), uploadersEnum]; } else { // There are 2 or more "more" uploaders informativeText = [NSString stringWithFormat:NSLocalizedString(@"New videos from %@ and %li more", nil), uploadersEnum, uploaders.count - NYTCoalescedNotificationNumberOfIncludedChannels]; } // Finish the notification and deliver it notification.informativeText = informativeText; notification.userInfo = @{NYTUserNotificationURLKey: @"http://www.youtube.com/feed/subscriptions"}; notification.soundName = NSUserNotificationDefaultSoundName; dispatch_async(dispatch_get_current_queue(), ^{ [[NSUserNotificationCenter defaultUserNotificationCenter] deliverNotification:notification]; }); } else { for (NYTVideo *video in videos) { // Configure the notification for every video NSUserNotification *notification = [[NSUserNotification alloc] init]; notification.title = video.uploaderDisplayName; notification.informativeText = video.title; notification.userInfo = @{NYTUserNotificationURLKey: video.URL}; notification.deliveryDate = video.uploadedDate; notification.soundName = NSUserNotificationDefaultSoundName; // Just to make sure notifications get delivered on the main thread dispatch_async(dispatch_get_main_queue(), ^{ [[NSUserNotificationCenter defaultUserNotificationCenter] deliverNotification:notification]; }); } } } #pragma mark *** Auto Refresh *** - (void)performAutoRefresh:(id)sender { if (self.canRefresh && self.isAutoRefreshRunning) { [self refreshFeedsWithErrorHandler:NULL notify:YES]; } else { self.refreshOnResume = YES; // Will be of no matter if auto refresh is disabled } } - (void)setAutoRefreshEnabled:(BOOL)autoRefreshEnabled { if (autoRefreshEnabled) { [self enableAutoRefresh]; } else { [self disableAutoRefresh]; } } - (void)enableAutoRefresh { if (!self.autoRefreshEnabled) { _autoRefreshEnabled = YES; self.autoRefreshWasDisabled = YES; [self startAutoRefreshTimerIfAppropriate:NO]; } } - (void)disableAutoRefresh { if (self.autoRefreshEnabled) { [self stopAutoRefreshTimer]; _autoRefreshEnabled = NO; } } - (BOOL)isAutoRefreshEnabled { return _autoRefreshEnabled; } - (void)restartAutoRefresh { [self stopAutoRefreshTimer]; [self startAutoRefreshTimerIfAppropriate:YES]; } - (BOOL)isAutoRefreshRunning { return self.autoRefreshTimer != nil; } - (void)pauseAutoRefreshing { self.autoRefreshPauseCount++; if (self.autoRefreshPauseCount == 1) { [self stopAutoRefreshTimer]; } } - (void)resumeAutoRefreshing { self.autoRefreshPauseCount--; if (self.autoRefreshPauseCount == 0) { [self startAutoRefreshTimerIfAppropriate:YES]; } else if (self.autoRefreshPauseCount < 0) { [NSException raise:@"AutoRefreshUnbalancedPauseException" format:@"Auto Refresh pause-resume messages are not balanced. Each pauseAutoRefreshing must be balanced with exactly one resumeAutoRefreshin message. The last resumeAutoRefreshing message did not have a corresponding pauseAutoRefreshing message."]; } } - (void)startAutoRefreshTimerIfAppropriate:(BOOL)resume { // If auto refresh should be running right now if (self.autoRefreshEnabled && self.autoRefreshPauseCount == 0) { [self.autoRefreshTimer invalidate]; // Just to be sure // Should we really resume or restart? BOOL actualResume = resume && !self.autoRefreshWasDisabled; self.autoRefreshWasDisabled = NO; // Auto refresh was paused by sending a pauseAutoRefreshing message and should now be resumed if (actualResume) { // Get the time since the last update NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:self.lastRefreshDate]; // If the current time interval requires an update based on the elapsed time refresh immediately and schedule the timer with the complete interval if (self.refreshOnResume || elapsed >= self.autoRefreshInterval) { self.autoRefreshTimer = [NSTimer scheduledTimerWithTimeInterval:self.autoRefreshInterval target:self selector:@selector(performAutoRefresh:) userInfo:nil repeats:YES]; [self.autoRefreshTimer fire]; // Fire immediately because the elapsed time since pausing the timer exceeds the refresh interval or we are forced to do so } else { // Otherwise set the first fire date to the time point that is constitued by the current time interval and the last refresh date NSDate *firstFireDate = [NSDate dateWithTimeInterval:self.autoRefreshInterval sinceDate:self.lastRefreshDate]; self.autoRefreshTimer = [[NSTimer alloc] initWithFireDate:firstFireDate interval:self.autoRefreshInterval target:self selector:@selector(performAutoRefresh:) userInfo:nil repeats:YES]; [[NSRunLoop currentRunLoop] addTimer:self.autoRefreshTimer forMode:NSDefaultRunLoopMode]; } } else { // Auto refresh was disabled since the timer was stopped so just start the timer. Refresh on resume does not have an impact here. self.lastRefreshDate = [NSDate date]; self.autoRefreshTimer = [NSTimer scheduledTimerWithTimeInterval:self.autoRefreshInterval target:self selector:@selector(performAutoRefresh:) userInfo:nil repeats:YES]; } // Cleanup self.refreshOnResume = NO; // We already have passed the point where it's important } } - (void)stopAutoRefreshTimer { [self.autoRefreshTimer invalidate]; self.autoRefreshTimer = nil; } - (void)setAutoRefreshInterval:(NSTimeInterval)autoRefreshInterval { [self pauseAutoRefreshing]; _autoRefreshInterval = autoRefreshInterval; [self resumeAutoRefreshing]; } @end