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.io.IOException;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Date;
022import java.util.EnumSet;
023import java.util.HashMap;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Map;
027import java.util.Map.Entry;
028
029import org.apache.commons.lang3.StringUtils;
030import org.apache.log4j.Logger;
031
032import service.tut.pori.contentanalysis.AccessDetails;
033import service.tut.pori.contentanalysis.AnalysisBackend.Capability;
034import service.tut.pori.contentanalysis.CAProperties;
035import service.tut.pori.contentanalysis.PhotoParameters;
036import service.tut.pori.contentanalysis.PhotoParameters.AnalysisType;
037import service.tut.pori.contentanalysis.CAContentCore;
038import service.tut.pori.contentanalysis.AsyncTask.TaskType;
039import service.tut.pori.contentanalysis.CAContentCore.ServiceType;
040import service.tut.pori.contentanalysis.PhotoFeedbackTask.FeedbackTaskBuilder;
041import service.tut.pori.contentanalysis.PhotoDAO;
042import service.tut.pori.contentanalysis.Photo;
043import service.tut.pori.contentanalysis.CAContentCore.Visibility;
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.contentanalysis.MediaObject.ConfirmationStatus;
049import service.tut.pori.contentanalysis.MediaObject.MediaObjectType;
050import service.tut.pori.contentanalysis.MediaObjectList;
051import service.tut.pori.contentstorage.PicasawebClient.PhotoEntry;
052import service.tut.pori.users.google.GoogleCredential;
053import service.tut.pori.users.google.GoogleUserCore;
054import core.tut.pori.context.ServiceInitializer;
055import core.tut.pori.users.UserIdentity;
056import core.tut.pori.utils.MediaUrlValidator.MediaType;
057import core.tut.pori.utils.UserIdentityLock;
058
059
060/**
061 * A storage handler for retrieving content from Picasa and creating photo analysis and feedback tasks based on the retrieved content.
062 *
063 */
064public final class PicasaCloudStorage extends ContentStorage {
065  /** Service type declaration for this storage */
066  public static final ServiceType SERVICE_TYPE = ServiceType.PICASA_STORAGE_SERVICE;
067  private static final String ALBUM_ACCESS_PUBLIC = "public";
068  private static final PhotoParameters ANALYSIS_PARAMETERS;
069  static{
070    ANALYSIS_PARAMETERS = new PhotoParameters();
071    ANALYSIS_PARAMETERS.setAnalysisTypes(EnumSet.of(AnalysisType.FACE_DETECTION, AnalysisType.KEYWORD_EXTRACTION, AnalysisType.VISUAL));
072  }
073  private static final EnumSet<Capability> CAPABILITIES = EnumSet.of(Capability.PHOTO_ANALYSIS);
074  private static final Logger LOGGER = Logger.getLogger(PicasaCloudStorage.class);
075  private static final String PREFIX_VISUAL_OBJECT = "picasa_";
076  private static final UserIdentityLock USER_IDENTITY_LOCK = new UserIdentityLock();
077
078  /**
079   * 
080   */
081  public PicasaCloudStorage(){
082    super();
083  }
084
085  /**
086   * 
087   * @param autoSchedule
088   */
089  public PicasaCloudStorage(boolean autoSchedule){
090    super(autoSchedule);
091  }
092
093  @Override
094  public EnumSet<Capability> getBackendCapabilities() {
095    return CAPABILITIES;
096  }
097
098  @Override
099  public String getTargetUrl(AccessDetails details){
100    String guid = details.getGuid();
101    PicasaDAO picasaDAO = ServiceInitializer.getDAOHandler().getSQLDAO(PicasaDAO.class);
102    PicasaEntry entry = picasaDAO.getEntry(guid);
103    if(entry == null){
104      LOGGER.debug("No entry for GUID: "+guid);
105      return null;
106    }
107    String url = entry.getStaticUrl();
108    if(url != null){ // return static URL if it is known
109      return url;
110    }
111    LOGGER.debug("No static URL known, trying to resolve...");
112
113    GoogleCredential gc = GoogleUserCore.getCredential(details.getOwner());
114    if(gc == null){
115      LOGGER.warn("Failed to resolve Google credentials.");
116      return null;
117    }
118    try(PicasawebClient client = new PicasawebClient(gc)){
119      url = client.generateStaticUrl(entry.getAlbumId(), entry.getPhotoId());
120    } catch (IllegalArgumentException | IOException ex) {
121      LOGGER.error(ex, ex);
122    }
123    if(url == null){
124      LOGGER.warn("Could not resolve url.");
125    }else{ // store the URL 
126      LOGGER.debug("URL resolved, updating entries...");
127      entry.setStaticUrl(url);
128      picasaDAO.updateEntry(entry);
129    }
130    return url;
131  }
132
133  /**
134   * return map of user's photos, the user is taken from the passed client object
135   *  
136   * @param client
137   * @return list of photos or null if the user has none
138   */
139  private Map<PicasaEntry, Photo> getPicasaPhotos(PicasawebClient client){  
140    List<PhotoEntry> photos = client.getPhotos();
141    if(photos == null){
142      LOGGER.debug("No photos found.");
143      return null;
144    }
145
146    Map<PicasaEntry, Photo> retval = new HashMap<>(photos.size());
147    UserIdentity userId = client.getUserIdentity();
148    String googleUserId = client.getGoogleUserId();
149
150    for(Iterator<PhotoEntry> iter = photos.iterator(); iter.hasNext();){
151      PhotoEntry e = iter.next();
152      Photo photo = new Photo();
153      Visibility visibility = null;
154      if(ALBUM_ACCESS_PUBLIC.equalsIgnoreCase(e.getAlbumAccess())){
155        visibility = Visibility.PUBLIC;
156      }else{
157        visibility = Visibility.PRIVATE;
158      }
159      photo.setVisibility(visibility);
160      photo.setOwnerUserId(userId);
161      photo.setServiceType(SERVICE_TYPE);
162
163      String temp = e.getSummary();
164      if(!StringUtils.isBlank(temp)){
165        photo.setDescription(temp);
166      }
167      temp = e.getTitle();
168      if(!StringUtils.isBlank(temp)){
169        photo.setName(temp);
170      }
171
172      Date updated = e.getUpdated();
173      photo.setUpdated(updated);
174
175      String photoId = e.getGphotoId();
176      List<String> mk = e.getKeywords();
177      if(mk != null){
178        List<MediaObject> objects = new ArrayList<>();
179        for(Iterator<String> kiter = mk.iterator(); kiter.hasNext(); ){
180          MediaObject o = new MediaObject(MediaType.PHOTO, MediaObjectType.KEYWORD);
181          o.setConfirmationStatus(ConfirmationStatus.USER_CONFIRMED);
182          String value = kiter.next();
183          o.setValue(value);
184          o.setOwnerUserId(userId);
185          o.setServiceType(SERVICE_TYPE);
186          o.setUpdated(updated);
187          o.setVisibility(visibility);
188          o.setConfidence(Definitions.DEFAULT_CONFIDENCE);
189          o.setObjectId(PREFIX_VISUAL_OBJECT+photoId+"_"+value);
190          o.setRank(Definitions.DEFAULT_RANK);
191          objects.add(o);
192        } // for
193        photo.setMediaObjects(MediaObjectList.getMediaObjectList(objects, null));
194      }
195
196      String url = e.getUrl();
197      photo.setUrl(url);
198
199      retval.put(new PicasaEntry(null, e.getAlbumId(), photoId, googleUserId, url), photo);
200    }  // for
201
202    return (retval.isEmpty() ? null : retval);
203  }
204
205  @Override
206  public void removeMetadata(UserIdentity userId, Collection<String> guids){
207    LOGGER.debug("Removing metadata for user, id: "+userId.getUserId());
208    PhotoDAO photoDAO = ServiceInitializer.getDAOHandler().getSolrDAO(PhotoDAO.class);
209    PhotoList photos = photoDAO.getPhotos(null, guids, null, EnumSet.of(SERVICE_TYPE), new long[]{userId.getUserId()});
210    if(PhotoList.isEmpty(photos)){
211      LOGGER.debug("User, id: "+userId.getUserId()+" has no photos.");
212      return;
213    }
214    List<String> remove = photos.getGUIDs();
215    photoDAO.remove(remove);
216    ServiceInitializer.getDAOHandler().getSQLDAO(PicasaDAO.class).removeEntries(remove);
217
218    FeedbackTaskBuilder builder = new FeedbackTaskBuilder(TaskType.FEEDBACK); // create builder for deleted photo feedback task
219    builder.setUser(userId);
220    builder.setBackends(getBackends());
221    builder.addDeletedPhotos(DeletedPhotoList.getPhotoList(photos.getPhotos(), photos.getResultInfo()));
222    PhotoTaskDetails details = builder.build();
223    if(details == null){
224      LOGGER.warn("No content.");
225    }else{
226      if(isAutoSchedule()){
227        LOGGER.debug("Scheduling feedback task.");
228        CAContentCore.scheduleTask(details);
229      }else{
230        LOGGER.debug("Auto-schedule is disabled.");
231      }
232
233      notifyFeedbackTaskCreated(details);
234    }
235  }
236
237  /**
238   * Note: the synchronization is only one-way, from Picasa to front-end, 
239   * no information will be transmitted to the other direction.
240   * Also, tags removed from Picasa will NOT be removed from front-end.
241   * 
242   * @param userId
243   * @return true on success
244   */
245  @Override
246  public boolean synchronizeAccount(UserIdentity userId){
247    USER_IDENTITY_LOCK.acquire(userId);
248    LOGGER.debug("Synchronizing account for user, id: "+userId.getUserId());
249    try{
250      GoogleCredential gc = GoogleUserCore.getCredential(userId);
251      if(gc == null){
252        LOGGER.warn("Could not resolve credentials.");
253        return false;
254      }
255      Map<PicasaEntry, Photo> picasaPhotos = null;
256      try(PicasawebClient client = new PicasawebClient(gc)){
257        picasaPhotos = getPicasaPhotos(client); // in the end this will contain all the new items
258      } catch (IllegalArgumentException | IOException ex) {
259        LOGGER.error(ex, ex);
260        return false;
261      }
262      PicasaDAO picasaDAO = ServiceInitializer.getDAOHandler().getSQLDAO(PicasaDAO.class);
263
264      List<PicasaEntry> existing = picasaDAO.getEntries(gc.getId());  // in the end this will contain "lost" items:
265      PhotoDAO photoDao = ServiceInitializer.getDAOHandler().getSolrDAO(PhotoDAO.class);
266      if(picasaPhotos != null){
267        if(existing != null){
268          LOGGER.debug("Processing existing photos...");
269          List<Photo> updatedPhotos = new ArrayList<>();
270          List<PicasaEntry> updatedEntries = new ArrayList<>();
271          for(Iterator<Entry<PicasaEntry, Photo>> entryIter = picasaPhotos.entrySet().iterator(); entryIter.hasNext();){
272            Entry<PicasaEntry, Photo> entry = entryIter.next();
273            PicasaEntry picasaEntry = entry.getKey();
274            String photoId = picasaEntry.getPhotoId();
275            for(Iterator<PicasaEntry> existingIter = existing.iterator();existingIter.hasNext();){
276              PicasaEntry exEntry = existingIter.next();
277              if(exEntry.getPhotoId().equals(photoId)){  // already added
278                String guid = exEntry.getGUID();
279                picasaEntry.setGUID(guid);
280                if(!exEntry.getAlbumId().equals(picasaEntry.getAlbumId())){ // album changed
281                  updatedEntries.add(picasaEntry);  // update the entry
282                }
283                Photo p = entry.getValue();
284                p.setGUID(guid);
285                updatedPhotos.add(p); // something in addition to albumId may have changed
286                existingIter.remove();  // remove from existing to prevent deletion
287                entryIter.remove(); // remove from entries to prevent duplicate addition
288                break;
289              }
290            }  // for siter
291          }  // for
292          if(updatedPhotos.size() > 0){
293            LOGGER.debug("Updating photo details...");
294            photoDao.updatePhotosIfNewer(userId, PhotoList.getPhotoList(updatedPhotos, null));
295          }
296
297          if(updatedEntries.size() > 0){
298            LOGGER.debug("Updating photo entries...");
299            picasaDAO.updateEntries(updatedEntries);
300          }
301        }else{
302          LOGGER.debug("No existing photos.");
303        }
304
305        if(picasaPhotos.isEmpty()){
306          LOGGER.debug("No new photos.");
307        }else{
308          LOGGER.debug("Inserting photos...");
309          if(!photoDao.insert(PhotoList.getPhotoList(picasaPhotos.values(), null))){
310            LOGGER.error("Failed to add photos to database.");
311            return false;
312          }
313
314          for(Entry<PicasaEntry, Photo> e : picasaPhotos.entrySet()){ // update entries with correct guids
315            e.getKey().setGUID(e.getValue().getGUID());
316          }
317
318          LOGGER.debug("Creating photo entries...");
319          picasaDAO.createEntries(picasaPhotos.keySet());
320        }
321      }else{
322        LOGGER.debug("No photos retrieved.");
323      }
324      
325      int taskLimit = ServiceInitializer.getPropertyHandler().getSystemProperties(CAProperties.class).getMaxTaskSize();
326
327      int missing = (existing == null ? 0 : existing.size());
328      if(missing > 0){  // remove all "lost" items if any
329        LOGGER.debug("Deleting removed photos...");
330        List<String> guids = new ArrayList<>();
331        for(Iterator<PicasaEntry> iter = existing.iterator();iter.hasNext();){
332          guids.add(iter.next().getGUID());
333        }
334
335        photoDao.remove(guids); // remove photos
336        picasaDAO.removeEntries(guids); // remove entries
337
338        FeedbackTaskBuilder builder = new FeedbackTaskBuilder(TaskType.FEEDBACK);
339        builder.setUser(userId); // create builder for deleted photo feedback task
340        builder.setBackends(getBackends());
341
342        if(taskLimit == CAProperties.MAX_TASK_SIZE_DISABLED || missing <= taskLimit){ // if task limit is disabled or there are less photos than the limit
343          builder.addDeletedPhotos(guids);
344          buildAndNotifyFeedback(builder);
345        }else{ // loop to stay below max limit
346          List<String> partial = new ArrayList<>(taskLimit);
347          int pCount = 0;
348          for(String guid : guids){
349            if(pCount == taskLimit){
350              builder.addDeletedPhotos(guids);
351              buildAndNotifyFeedback(builder);
352              pCount = 0;
353              partial.clear();
354              builder.clearDeletedPhotos();
355            }
356            ++pCount;
357            partial.add(guid);
358          } // for
359          if(!partial.isEmpty()){
360            builder.addDeletedPhotos(guids);
361            buildAndNotifyFeedback(builder);
362          }
363        } // else
364      }
365
366      int picasaPhotoCount = (picasaPhotos == null ? 0 : picasaPhotos.size());
367      LOGGER.debug("Added "+picasaPhotoCount+" photos, removed "+missing+" photos for user: "+userId.getUserId());
368
369      if(picasaPhotoCount > 0){
370        LOGGER.debug("Creating a new analysis task...");
371        PhotoTaskDetails details = new PhotoTaskDetails(TaskType.ANALYSIS);
372        details.setUserId(userId);
373        details.setBackends(getBackends(getBackendCapabilities()));
374        details.setTaskParameters(ANALYSIS_PARAMETERS);
375        
376        if(taskLimit == CAProperties.MAX_TASK_SIZE_DISABLED || picasaPhotoCount <= taskLimit){ // if task limit is disabled or there are less photos than the limit
377          details.setPhotoList(PhotoList.getPhotoList(picasaPhotos.values(), null));
378          notifyAnalysis(details);
379        }else{ // loop to stay below max limit
380          List<Photo> partial = new ArrayList<>(taskLimit);
381          PhotoList partialContainer = new PhotoList();
382          details.setPhotoList(partialContainer);
383          int pCount = 0;
384          for(Photo photo : picasaPhotos.values()){
385            if(pCount == taskLimit){
386              partialContainer.setPhotos(partial);
387              notifyAnalysis(details);
388              pCount = 0;
389              partial.clear();
390            }
391            ++pCount;
392            partial.add(photo);
393          } // for
394          if(!partial.isEmpty()){
395            partialContainer.setPhotos(partial);
396            notifyAnalysis(details);
397          }
398        } // else
399      }else{
400        LOGGER.debug("No new photos, will not create analysis task.");
401      }
402      return true;
403    } finally {
404      USER_IDENTITY_LOCK.release(userId);
405    }
406  }
407  
408  /**
409   * Helper method for calling notify
410   * 
411   * @param details
412   */
413  private void notifyAnalysis(PhotoTaskDetails details){
414    details.setTaskId(null);  //make sure the task id is not set for a new task
415    if(isAutoSchedule()){
416      LOGGER.debug("Scheduling analysis task.");
417      CAContentCore.scheduleTask(details);
418    }else{
419      LOGGER.debug("Auto-schedule is disabled.");
420    }
421
422    notifyAnalysisTaskCreated(details);
423  }
424  
425  /**
426   * helper method for building the task and calling notify
427   * 
428   * @param builder
429   */
430  private void buildAndNotifyFeedback(FeedbackTaskBuilder builder){
431    PhotoTaskDetails details = builder.build();
432    if(details == null){
433      LOGGER.warn("No content.");
434    }else{
435      if(isAutoSchedule()){
436        LOGGER.debug("Scheduling feedback task.");
437        CAContentCore.scheduleTask(details);
438      }else{
439        LOGGER.debug("Auto-schedule is disabled.");
440      }
441
442      notifyFeedbackTaskCreated(details);
443    }
444  }
445
446  @Override
447  public ServiceType getServiceType() {
448    return SERVICE_TYPE;
449  }
450
451  /**
452   * Represents a single picasa content entry
453   */
454  public static class PicasaEntry{
455    private String _guid = null;
456    private String _albumId = null;
457    private String _photoId = null;
458    private String _googleUserId = null;
459    private String _staticUrl = null;
460
461    /**
462     * @param guid
463     * @param albumId
464     * @param photoId
465     * @param googleUserId
466     * @param staticUrl
467     */
468    public PicasaEntry(String guid, String albumId, String photoId, String googleUserId, String staticUrl) {
469      _guid = guid;
470      _albumId = albumId;
471      _photoId = photoId;
472      _googleUserId = googleUserId;
473      _staticUrl = staticUrl;
474    }
475
476    /**
477     * 
478     */
479    protected PicasaEntry(){
480      // nothing needed
481    }
482
483    /**
484     * @return the guid
485     */
486    public String getGUID() {
487      return _guid;
488    }
489
490    /**
491     * @return the albumId
492     */
493    public String getAlbumId() {
494      return _albumId;
495    }
496
497    /**
498     * @return the photoId
499     */
500    public String getPhotoId() {
501      return _photoId;
502    }
503
504    /**
505     * @return the googleUserId
506     */
507    public String getGoogleUserId() {
508      return _googleUserId;
509    }
510
511    /**
512     * @param guid the guid to set
513     */
514    public void setGUID(String guid) {
515      _guid = guid;
516    }
517
518    /**
519     * @param albumId the albumId to set
520     */
521    public void setAlbumId(String albumId) {
522      _albumId = albumId;
523    }
524
525    /**
526     * @param photoId the photoId to set
527     */
528    public void setPhotoId(String photoId) {
529      _photoId = photoId;
530    }
531
532    /**
533     * @param googleUserId the googleUserId to set
534     */
535    public void setGoogleUserId(String googleUserId) {
536      _googleUserId = googleUserId;
537    }
538
539    /**
540     * @return the staticUrl
541     */
542    public String getStaticUrl() {
543      return _staticUrl;
544    }
545
546    /**
547     * @param staticUrl the staticUrl to set
548     */
549    public void setStaticUrl(String staticUrl) {
550      _staticUrl = staticUrl;
551    }
552  } // class PicasaEntry
553}