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}