001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
003 * 
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * 
008 *   http://www.apache.org/licenses/LICENSE-2.0
009 * 
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package service.tut.pori.contentstorage;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Date;
021import java.util.EnumSet;
022import java.util.HashMap;
023import java.util.Iterator;
024import java.util.List;
025import java.util.Map;
026import java.util.Map.Entry;
027
028import org.apache.log4j.Logger;
029
030import service.tut.pori.contentanalysis.AccessDetails;
031import service.tut.pori.contentanalysis.AnalysisBackend.Capability;
032import service.tut.pori.contentanalysis.PhotoParameters;
033import service.tut.pori.contentanalysis.PhotoParameters.AnalysisType;
034import service.tut.pori.contentanalysis.AsyncTask.TaskType;
035import service.tut.pori.contentanalysis.CAContentCore;
036import service.tut.pori.contentanalysis.CAContentCore.ServiceType;
037import service.tut.pori.contentanalysis.PhotoFeedbackTask.FeedbackTaskBuilder;
038import service.tut.pori.contentanalysis.Photo;
039import service.tut.pori.contentanalysis.CAContentCore.Visibility;
040import service.tut.pori.contentanalysis.PhotoDAO;
041import service.tut.pori.contentanalysis.MediaObject.ConfirmationStatus;
042import service.tut.pori.contentanalysis.MediaObject.MediaObjectType;
043import service.tut.pori.contentanalysis.CAProperties;
044import service.tut.pori.contentanalysis.DeletedPhotoList;
045import service.tut.pori.contentanalysis.PhotoList;
046import service.tut.pori.contentanalysis.PhotoTaskDetails;
047import service.tut.pori.contentanalysis.MediaObject;
048import service.tut.pori.twitterjazz.TwitterExtractor;
049import service.tut.pori.twitterjazz.TwitterExtractor.ContentType;
050import service.tut.pori.twitterjazz.TwitterPhotoDescription;
051import service.tut.pori.twitterjazz.TwitterProfile;
052import service.tut.pori.twitterjazz.TwitterUserDetails;
053import core.tut.pori.context.ServiceInitializer;
054import core.tut.pori.users.UserIdentity;
055import core.tut.pori.utils.MediaUrlValidator.MediaType;
056import core.tut.pori.utils.UserIdentityLock;
057
058/**
059 * A storage handler, which can be used to retrieve data from Twitter for analysis.
060 * 
061 * This class is only for photo content, use TwitterJazz if you require summarization support.
062 */
063public final class TwitterPhotoStorage extends ContentStorage {
064  /** 
065   * media object {@link service.tut.pori.contentanalysis.MediaObject.MediaObjectType#METADATA} name for twitter id 
066   * @see service.tut.pori.twitterjazz.TwitterUserDetails#getTwitterId()
067   * @see service.tut.pori.contentanalysis.MediaObject
068   */
069  public static final String METADATA_TWITTER_ID = "twitterId";
070  /** 
071   * media object {@link service.tut.pori.contentanalysis.MediaObject.MediaObjectType#METADATA} name for twitter screen name 
072   * @see service.tut.pori.twitterjazz.TwitterUserDetails#getScreenName()
073   * @see service.tut.pori.contentanalysis.MediaObject
074   */
075  public static final String METADATA_TWITTER_SCREEN_NAME = "twitterScreenName";
076  /** Service type declaration for this storage */
077  public static final ServiceType SERVICE_TYPE = ServiceType.TWITTER_PHOTO;
078  private static final PhotoParameters ANALYSIS_PARAMETERS;
079  static{
080    ANALYSIS_PARAMETERS = new PhotoParameters();
081    ANALYSIS_PARAMETERS.setAnalysisTypes(EnumSet.of(AnalysisType.FACE_DETECTION, AnalysisType.KEYWORD_EXTRACTION, AnalysisType.VISUAL));
082  }
083  private static final EnumSet<Capability> CAPABILITIES = EnumSet.of(Capability.PHOTO_ANALYSIS);
084  private static final Logger LOGGER = Logger.getLogger(TwitterPhotoStorage.class);
085  private static final String PREFIX_MEDIA_OBJECT = "twitter_"; // prefix for created metadata objects
086  private static final UserIdentityLock USER_IDENTITY_LOCK = new UserIdentityLock();
087
088  /**
089   * Create a Twitter photo storage with default autoschedule options.
090   */
091  public TwitterPhotoStorage(){
092    super();
093  }
094
095  /**
096   * 
097   * @param autoSchedule
098   */
099  public TwitterPhotoStorage(boolean autoSchedule){
100    super(autoSchedule);
101  }
102  
103  @Override
104  public EnumSet<Capability> getBackendCapabilities() {
105    return CAPABILITIES;
106  }
107
108  /**
109   * 
110   * @param extractor
111   * @return map of entries and photos or null if none available
112   */
113  private Map<TwitterEntry, Photo> getTwitterPhotos(TwitterExtractor extractor){
114    return getTwitterPhotos(extractor.getUserId(), null, extractor.getProfile(EnumSet.of(ContentType.PHOTO_DESCRIPTIONS)));
115  }
116
117  /**
118   * A helper method for converting TwitterPhotoDescriptions to Photo objects
119   * 
120   * @param authenticatedUser
121   * @param map
122   * @param profile
123   * @return the passed map, new map if null was passed, or null if null or empty map was passed AND no new photos were extracted
124   */
125  private Map<TwitterEntry, Photo> getTwitterPhotos(UserIdentity authenticatedUser, Map<TwitterEntry, Photo> map, TwitterProfile profile){
126    if(profile == null){
127      LOGGER.warn("Null profile.");
128      return map;
129    }
130
131    List<TwitterPhotoDescription> photoDescriptions = profile.getPhotoDescriptions();
132    if(photoDescriptions == null){
133      LOGGER.debug("No photos found.");
134      return map;
135    }
136
137    TwitterUserDetails user = profile.getUser();
138    Visibility visibility = (user.isProtected() ? Visibility.PRIVATE : Visibility.PUBLIC);
139    String twitterId = user.getTwitterId();
140    String screenName = user.getScreenName();
141
142    if(map == null){
143      map = new HashMap<>(photoDescriptions.size());
144    }
145    for(Iterator<TwitterPhotoDescription> iter = photoDescriptions.iterator(); iter.hasNext();){
146      TwitterPhotoDescription d = iter.next();
147      Photo photo = new Photo();
148      photo.setVisibility(visibility);
149      photo.setOwnerUserId(authenticatedUser);
150      photo.setServiceType(SERVICE_TYPE);
151      photo.setDescription(d.getDescription());
152      Date updated = d.getCreatedTime();
153      photo.setUpdated(updated);
154      String url = d.getEntityUrl();
155      photo.setUrl(url);
156      String entityId = d.getEntityId();
157
158      MediaObject object = new MediaObject(MediaType.PHOTO, MediaObjectType.METADATA); // add the origin of the photo as metadata
159      object.setVisibility(visibility);
160      object.setOwnerUserId(authenticatedUser);
161      object.setServiceType(SERVICE_TYPE);
162      object.setUpdated(updated);
163      object.setObjectId(PREFIX_MEDIA_OBJECT+entityId+"_"+twitterId);
164      object.setValue(twitterId);
165      object.setName(METADATA_TWITTER_ID);
166      object.setConfirmationStatus(ConfirmationStatus.USER_CONFIRMED);
167      object.setConfidence(Definitions.DEFAULT_CONFIDENCE);
168      object.setRank(Definitions.DEFAULT_RANK);
169      photo.addMediaObject(object);
170
171      object = new MediaObject(MediaType.PHOTO, MediaObjectType.METADATA); // add the origin of the photo as metadata
172      object.setVisibility(visibility);
173      object.setOwnerUserId(authenticatedUser);
174      object.setServiceType(SERVICE_TYPE);
175      object.setUpdated(updated);
176      object.setObjectId(PREFIX_MEDIA_OBJECT+entityId+"_"+screenName);
177      object.setValue(screenName);
178      object.setName(METADATA_TWITTER_SCREEN_NAME);
179      object.setConfirmationStatus(ConfirmationStatus.USER_CONFIRMED);
180      object.setConfidence(Definitions.DEFAULT_CONFIDENCE);
181      object.setRank(Definitions.DEFAULT_RANK);
182      photo.addMediaObject(object);
183
184      map.put(new TwitterEntry(entityId, url, null, screenName, authenticatedUser), photo);
185    }  // for
186
187    return map;
188  }
189
190  /**
191   * 
192   * @param extractor
193   * @param screenNames list of screen names
194   * @return map of entries and photos or null if none available
195   */
196  private Map<TwitterEntry, Photo> getTwitterPhotos(TwitterExtractor extractor, Collection<String> screenNames){
197    List<TwitterProfile> profiles = extractor.getProfiles(EnumSet.of(ContentType.PHOTO_DESCRIPTIONS), screenNames.toArray(new String[screenNames.size()]));
198    if(profiles == null){
199      LOGGER.warn("No profiles found.");
200      return null;
201    }
202
203    Map<TwitterEntry, Photo> retval = null;
204    for(TwitterProfile profile : profiles){
205      retval = getTwitterPhotos(extractor.getUserId(), retval, profile);
206    }
207
208    return retval;
209  }
210
211  @Override
212  public String getTargetUrl(AccessDetails details){
213    return ServiceInitializer.getDAOHandler().getSQLDAO(TwitterDAO.class).getUrl(details.getGuid());
214  }
215
216  @Override
217  public void removeMetadata(UserIdentity userId, Collection<String> guids){
218    LOGGER.debug("Removing metadata for user, id: "+userId.getUserId());
219    PhotoDAO photoDAO = ServiceInitializer.getDAOHandler().getSolrDAO(PhotoDAO.class);
220    PhotoList photos = photoDAO.getPhotos(null, guids, null, EnumSet.of(SERVICE_TYPE), new long[]{userId.getUserId()});
221    if(PhotoList.isEmpty(photos)){
222      LOGGER.debug("User, id: "+userId.getUserId()+" has no photos.");
223      return;
224    }
225    List<String> remove = photos.getGUIDs();
226    photoDAO.remove(remove);
227    ServiceInitializer.getDAOHandler().getSQLDAO(TwitterDAO.class).removeEntries(remove);
228
229    FeedbackTaskBuilder builder = new FeedbackTaskBuilder(TaskType.FEEDBACK); // create builder for deleted photo feedback task
230    builder.setUser(userId);
231    builder.setBackends(getBackends());
232    builder.addDeletedPhotos(DeletedPhotoList.getPhotoList(photos.getPhotos(), photos.getResultInfo()));
233    PhotoTaskDetails details = builder.build();
234    if(details == null){
235      LOGGER.warn("No content.");
236    }else{
237      if(isAutoSchedule()){
238        LOGGER.debug("Scheduling feedback task.");
239        CAContentCore.scheduleTask(details);
240      }else{
241        LOGGER.debug("Auto-schedule is disabled.");
242      }
243
244      notifyFeedbackTaskCreated(details);
245    }
246  }
247
248  /**
249   * Note: the synchronization is only one-way, from Twitter to front-end, 
250   * no information will be transmitted to the other direction.
251   * Also, tags removed from Twitter will NOT be removed from front-end.
252   * 
253   * @param userId
254   * @return true on success
255   */
256  @Override
257  public boolean synchronizeAccount(UserIdentity userId){
258    return synchronizeAccount(userId, null);
259  }
260
261  /**
262   * Note: the synchronization is only one-way, from Twitter to front-end, 
263   * no information will be transmitted to the other direction.
264   * Also, tags removed from Twitter will NOT be removed from front-end.
265   * 
266   * Note that if screenNames are given, the synchronization is targeted ONLY to the given screen names. If screenNames are NOT given, the synchronization is targeted to ALL content.
267   * An example use case:
268   * <ul>
269   *  <li>User synchronized his/her account with screenName <i>name1</i>, which in this case is also the name of the user's own Twitter account</li>
270   *  <li>Then, the user re-syncs with different name, <i>name2</i>. The previously used content is left as it is, because the name <i>name1</i> is not given.</li>
271   *  <li>Now, re-syncing with the name <i>name1</i> will ignore all content previously synced with <i>name2</i>, and only synchronize <i>name1</i>. Likewise, using <i>name2</i> would only sync <i>name2</i>, and ignore all content for <i>name1</i>.</li>
272   *  <li>Re-syncing with both <i>name1</i> and <i>name2</i> would retrieve content for both accounts.</li>
273   *  <li>Re-syncing without screenNames will default the retrieval to user's own account name (<i>name1</i>), but synchronize ALL content. In practice this means, that the content for <i>name1</i> will be synchronized, and all content for <i>name2</i> will be removed.</li>
274   * </ul>
275   * 
276   * @param userId
277   * @param screenNames use the given collection of screen names instead of the authenticated user's account for synchronization
278   * @return true on success
279   */
280  public boolean synchronizeAccount(UserIdentity userId, Collection<String> screenNames){
281    USER_IDENTITY_LOCK.acquire(userId);
282    LOGGER.debug("Synchronizing account for user, id: "+userId.getUserId());
283    try{
284      TwitterExtractor extractor = TwitterExtractor.getExtractor(userId);
285      if(extractor == null){
286        LOGGER.warn("Could not get extractor.");
287        return false;
288      }
289
290      Map<TwitterEntry, Photo> twitterPhotos = (screenNames == null || screenNames.isEmpty() ? getTwitterPhotos(extractor) : getTwitterPhotos(extractor, screenNames)); // in the end this will contain all the new items
291      TwitterDAO twitterDAO = ServiceInitializer.getDAOHandler().getSQLDAO(TwitterDAO.class);
292
293      List<TwitterEntry> existing = twitterDAO.getEntriesByScreenName(screenNames, userId);  // in the end this will contain "lost" items:
294      PhotoDAO photoDao = ServiceInitializer.getDAOHandler().getSolrDAO(PhotoDAO.class);
295      if(twitterPhotos != null){
296        if(existing != null){
297          LOGGER.debug("Processing existing photos...");
298          List<Photo> updatedPhotos = new ArrayList<>();
299          for(Iterator<Entry<TwitterEntry, Photo>> entryIter = twitterPhotos.entrySet().iterator(); entryIter.hasNext();){
300            Entry<TwitterEntry, Photo> entry = entryIter.next();
301            TwitterEntry twitterEntry = entry.getKey();
302            String entityId = twitterEntry.getEntityId();
303            String screenName = twitterEntry.getScreenName();
304            for(Iterator<TwitterEntry> existingIter = existing.iterator();existingIter.hasNext();){
305              TwitterEntry exEntry = existingIter.next();
306              if(exEntry.getEntityId().equals(entityId) && exEntry.getScreenName().equals(screenName)){  // already added
307                String guid = exEntry.getGUID();
308                twitterEntry.setGUID(guid);
309                Photo p = entry.getValue();
310                p.setGUID(guid);
311                updatedPhotos.add(p); // something may have changed
312                existingIter.remove();  // remove from existing to prevent deletion
313                entryIter.remove(); // remove from entries to prevent duplicate addition
314                break;
315              }
316            }  // for siter
317          }  // for
318          if(updatedPhotos.size() > 0){
319            LOGGER.debug("Updating photo details...");
320            photoDao.updatePhotosIfNewer(userId, PhotoList.getPhotoList(updatedPhotos, null));
321          }
322        }else{
323          LOGGER.debug("No existing photos.");
324        }
325
326        if(twitterPhotos.isEmpty()){
327          LOGGER.debug("No new photos.");
328        }else{
329          LOGGER.debug("Inserting photos...");
330          if(!photoDao.insert(PhotoList.getPhotoList(twitterPhotos.values(), null))){
331            LOGGER.error("Failed to add photos to database.");
332            return false;
333          }
334
335          for(Entry<TwitterEntry, Photo> e : twitterPhotos.entrySet()){ // update entries with correct GUIDs
336            e.getKey().setGUID(e.getValue().getGUID());
337          }
338
339          LOGGER.debug("Creating photo entries...");
340          twitterDAO.createEntries(twitterPhotos.keySet());
341        }
342      }else{
343        LOGGER.debug("No photos retrieved.");
344      }
345
346      int taskLimit = ServiceInitializer.getPropertyHandler().getSystemProperties(CAProperties.class).getMaxTaskSize();
347      
348      int missing = (existing == null ? 0 : existing.size());
349      if(missing > 0){  // remove all "lost" items if any
350        LOGGER.debug("Deleting removed photos...");
351        List<String> guids = new ArrayList<>();
352        for(Iterator<TwitterEntry> iter = existing.iterator();iter.hasNext();){
353          guids.add(iter.next().getGUID());
354        }
355
356        photoDao.remove(guids); // remove photos
357        twitterDAO.removeEntries(guids); // remove entries
358
359        FeedbackTaskBuilder builder = new FeedbackTaskBuilder(TaskType.FEEDBACK); // create builder for deleted photo feedback task
360        builder.setUser(userId);
361        builder.setBackends(getBackends());
362        
363        if(taskLimit == CAProperties.MAX_TASK_SIZE_DISABLED || missing <= taskLimit){ // if task limit is disabled or there are less photos than the limit
364          builder.addDeletedPhotos(guids);
365          buildAndNotifyFeedback(builder);
366        }else{ // loop to stay below max limit
367          List<String> partial = new ArrayList<>(taskLimit);
368          int pCount = 0;
369          for(String guid : guids){
370            if(pCount == taskLimit){
371              builder.addDeletedPhotos(guids);
372              buildAndNotifyFeedback(builder);
373              pCount = 0;
374              partial.clear();
375              builder.clearDeletedPhotos();
376            }
377            ++pCount;
378            partial.add(guid);
379          } // for
380          if(!partial.isEmpty()){
381            builder.addDeletedPhotos(guids);
382            buildAndNotifyFeedback(builder);
383          }
384        } // else
385      }
386
387      int twitterPhotoCount = (twitterPhotos == null ? 0 : twitterPhotos.size());
388      LOGGER.debug("Added "+twitterPhotoCount+" photos, removed "+missing+" photos for user: "+userId.getUserId());
389
390      if(twitterPhotoCount > 0){
391        LOGGER.debug("Creating a new analysis task...");
392        PhotoTaskDetails details = new PhotoTaskDetails(TaskType.ANALYSIS);
393        details.setUserId(userId);
394        details.setBackends(getBackends(getBackendCapabilities()));
395        details.setTaskParameters(ANALYSIS_PARAMETERS);
396        
397        if(taskLimit == CAProperties.MAX_TASK_SIZE_DISABLED || twitterPhotoCount <= taskLimit){ // if task limit is disabled or there are less photos than the limit
398          details.setPhotoList(PhotoList.getPhotoList(twitterPhotos.values(), null));
399          notifyAnalysis(details);
400        }else{ // loop to stay below max limit
401          List<Photo> partial = new ArrayList<>(taskLimit);
402          PhotoList partialContainer = new PhotoList();
403          details.setPhotoList(partialContainer);
404          int pCount = 0;
405          for(Photo photo : twitterPhotos.values()){
406            if(pCount == taskLimit){
407              partialContainer.setPhotos(partial);
408              notifyAnalysis(details);
409              pCount = 0;
410              partial.clear();
411            }
412            ++pCount;
413            partial.add(photo);
414          } // for
415          if(!partial.isEmpty()){
416            partialContainer.setPhotos(partial);
417            notifyAnalysis(details);
418          }
419        } // else
420      }else{
421        LOGGER.debug("No new photos, will not create analysis task.");
422      }
423      return true;
424    } finally {
425      USER_IDENTITY_LOCK.release(userId);
426    }
427  }
428  
429  /**
430   * Helper method for calling notify
431   * 
432   * @param details
433   */
434  private void notifyAnalysis(PhotoTaskDetails details){
435    details.setTaskId(null);  //make sure the task id is not set for a new task
436    if(isAutoSchedule()){
437      LOGGER.debug("Scheduling analysis task.");
438      CAContentCore.scheduleTask(details);
439    }else{
440      LOGGER.debug("Auto-schedule is disabled.");
441    }
442
443    notifyAnalysisTaskCreated(details);
444  }
445  
446  /**
447   * helper method for building the task and calling notify
448   * 
449   * @param builder
450   */
451  private void buildAndNotifyFeedback(FeedbackTaskBuilder builder){
452    PhotoTaskDetails details = builder.build();
453    if(details == null){
454      LOGGER.warn("No content.");
455    }else{
456      if(isAutoSchedule()){
457        LOGGER.debug("Scheduling feedback task.");
458        CAContentCore.scheduleTask(details);
459      }else{
460        LOGGER.debug("Auto-schedule is disabled.");
461      }
462
463      notifyFeedbackTaskCreated(details);
464    }
465  }
466
467  @Override
468  public ServiceType getServiceType() {
469    return SERVICE_TYPE;
470  }
471
472  /**
473   * A class that represents a single Twitter content entry.
474   */
475  public static class TwitterEntry{
476    private String _entityId = null;
477    private String _entityUrl = null;
478    private String _guid = null;
479    private String _screenName = null;
480    private UserIdentity _userId = null;
481
482    /**
483     * 
484     * @param entityId
485     * @param entityUrl
486     * @param guid
487     * @param screenName 
488     * @param userId
489     */
490    public TwitterEntry(String  entityId, String entityUrl,  String guid, String screenName, UserIdentity userId){
491      _entityId = entityId;
492      _entityUrl = entityUrl;
493      _guid = guid;
494      _screenName = screenName;
495      _userId = userId;
496    }
497
498    /**
499     * 
500     */
501    protected TwitterEntry(){
502      // nothing needed
503    }
504
505    /**
506     * 
507     * @param guid
508     */
509    protected void setGUID(String guid) {
510      _guid = guid;
511    }
512
513    /**
514     * 
515     * @return entity id
516     */
517    public String getEntityId() {
518      return _entityId;
519    }
520
521    /**
522     * 
523     * @return guid
524     */
525    public String getGUID() {
526      return _guid;
527    }
528
529    /**
530     * @return the entityUrl
531     */
532    public String getEntityUrl() {
533      return _entityUrl;
534    }
535
536    /**
537     * @return the userId
538     */
539    public UserIdentity getUserId() {
540      return _userId;
541    }
542
543    /**
544     * @return the screenName
545     */
546    public String getScreenName() {
547      return _screenName;
548    }
549
550    /**
551     * @param screenName the screenName to set
552     */
553    protected void setScreenName(String screenName) {
554      _screenName = screenName;
555    }
556
557    /**
558     * 
559     * @param userId
560     */
561    protected void setUserId(UserIdentity userId) {
562      _userId = userId;
563    }
564
565    /**
566     * 
567     * @param entityId
568     */
569    protected void setEntityId(String entityId) {
570      _entityId = entityId;
571    }
572
573    /**
574     * 
575     * @param entityUrl
576     */
577    protected void setEntityUrl(String entityUrl) {
578      _entityUrl = entityUrl;
579    }
580  } // class TwitterEntry
581}