GeekFactory

int128.hatenablog.com

Google Picasaアルバムを撮影日ごとに整理してくれるバッチ

Google Picasaは写真を保存しておくのに便利です。年間$5で20GBのスペースが手に入るのも魅力的です。

Picasaでは、特定のメールアドレスに写真を添付して送ると「ドロップボックス」というアルバムに入るようになっています。この「ドロップボックス」の写真を撮影日ごとに整理するバッチを書いてみました。

Google App Engineで運用する前提でコードを書いていますが、少しの手直しで他のプラットフォームでも動くはずです。

まず、必要なライブラリを入手し、プロジェクトのクラスパスに配置します。

  • gdata-java-client
    • gdata-client-1.0.jar
    • gdata-client-meta-1.0.jar
    • gdata-core-1.0.jar
    • gdata-media-1.0.jar
    • gdata-photos-2.0.jar
    • gdata-photos-meta-2.0.jar
  • guava-libraries
    • guava-r09.jar
  • Java Mail

上記の *-meta.jar がなくてもプロジェクトは実行できますが、APIコールが失敗します。これに気づかなくて数時間を無駄にしてしまいました。

ソースコードhttps://gist.github.com/1040475 からどうぞ。

コントローラ層

バッチのコントローラを実装します。ここでは /dailyRotate というURLを叩くとバッチが走る設計にしています。

写真を整理するロジックは PhotoArrangementService クラスに書いてあります。Picasa APIで取得した写真のリストを PhotoArrangementService#arrangeByDate() に渡すと、写真が整理されます。

public class DailyRotateController extends Controller {
  private static final Logger logger = Logger.getLogger(DailyRotateController.class.getName());

  // 次のタスクを 2:00 にスケジュールする
  private static final int TASK_TIME = 2;

  @Override
  public Navigation run() throws Exception {
    TimeZoneLocator.set(TimeZone.getTimeZone("Asia/Tokyo"));

    PicasaService picasaService = PicasaServiceLocator.getInstance(Constants.CREDENTIAL);
    PhotoArrangementService photoArrangementService = new PhotoArrangementService(picasaService);

    List<PhotoEntry> photos = picasaService.getAlbum(Constants.DROPBOX_ID).getPhotoEntries();
    logger.info(photos.size() + " photos in the dropbox will be arranged.");

    photoArrangementService.arrangeByDate(photos);

    Calendar nextTime = ScheduleUtil.getNearestTimeAt(TASK_TIME);
    TaskOptions task = TaskOptions.Builder.withMethod(Method.GET)
        .url(request.getRequestURI())
        .etaMillis(nextTime.getTimeInMillis());
    QueueFactory.getDefaultQueue().add(task);
    logger.info("An next task has been scheduled at " + DateUtil.toString(nextTime));

    return null;
  }
}

サービス層

写真を日付ごとに整理するのは PhotoArrangementService の役割です。写真リストから必要なアルバムを洗い出し、存在しない場合は新しいアルバムを作成します。

AlbumEntry#setAccess() ではアルバムの公開範囲を設定できますが、 "private" を渡すと「リンクを知っている全員」になります。 "protected" を渡すと完全に非公開になります。直感と反するので注意が必要です(私だけ?)。

現状は1枚の写真ごとに更新リクエストを送っていますが、APIドキュメントを読むとまとめてbatch updateする方法がありそうです。

public class PhotoArrangementService {
  private final PicasaService picasaService;

  /**
   * 写真を日付アルバムに移動する。
   * @param photos
   * @throws IOException
   * @throws ServiceException
   */
  public List<PhotoEntry> arrangeByDate(List<PhotoEntry> photos) throws IOException, ServiceException
  {
    // アルバムが存在しなければ作成する
    Map<String, AlbumEntry> albumTitleMap = this.picasaService.getAlbumTitleMap();
    Map<Date, List<PhotoEntry>> dailyPhotosMap = getDateMap(photos);
    Map<AlbumEntry, List<PhotoEntry>> targetAlbumMap = new HashMap<AlbumEntry, List<PhotoEntry>>(
        dailyPhotosMap.size());
    for (Map.Entry<Date, List<PhotoEntry>> entry : dailyPhotosMap.entrySet()) {
      String albumTitle = DateUtil.toString(entry.getKey(), DateUtil.ISO_DATE_PATTERN);

      Calendar albumDate = DateUtil.toCalendar(entry.getKey());
      albumDate.add(Calendar.HOUR_OF_DAY, TimeZoneLocator.get().getOffset(albumDate.getTimeInMillis()));

      AlbumEntry targetAlbum = albumTitleMap.get(albumTitle);
      if (targetAlbum == null) {
        targetAlbum = new AlbumEntry();
        targetAlbum.setTitle(new PlainTextConstruct(albumTitle));
        targetAlbum.setDate(albumDate.getTime());
        targetAlbum.setAccess("private");
        targetAlbum = this.picasaService.createAlbum(targetAlbum);
      }

      targetAlbumMap.put(targetAlbum, entry.getValue());
    }

    // 写真をアルバムIDを書き換える
    for (Map.Entry<AlbumEntry, List<PhotoEntry>> entry : targetAlbumMap.entrySet()) {
      AlbumEntry targetAlbum = entry.getKey();
      String targetAlbumId = basename(targetAlbum.getId());

      for (final PhotoEntry photo : entry.getValue()) {
        photo.setAlbumId(targetAlbumId);
        this.picasaService.execute(new PicasaCallable()
        {
          @Override
          public <V> V call() throws ServiceException, IOException
          {
            photo.update();
            return null;
          }
        });
      }
    }

    return photos;
  }

  /**
   * 日付ごとの写真リストを取得する。
   * @param album アルバム
   * @return 日付と写真リストのマップ
   * @throws ServiceException
   */
  public static Map<Date, List<PhotoEntry>> getDateMap(List<PhotoEntry> photos) throws ServiceException
  {
    Map<Date, List<PhotoEntry>> dateAndPhotoMap = new HashMap<Date, List<PhotoEntry>>();
    for (PhotoEntry photo : photos) {
      Date date = DateUtil.clearTimePart(photo.getTimestamp());
      List<PhotoEntry> photoEntries = dateAndPhotoMap.get(date);
      if (photoEntries == null) {
        photoEntries = new ArrayList<PhotoEntry>();
        dateAndPhotoMap.put(date, photoEntries);
      }

      photoEntries.add(photo);
    }

    return dateAndPhotoMap;
  }
}

APIアクセス層

Picasa APIはときどき意味不明な例外を投げるので、APIとやり取りするレイヤにリトライ機構を入れています。すべてのリクエストはリトライ機構を通して実行するようにしています。Javaでもラムダ式が使えたら便利ですね。

このレイヤでリトライしても回復しない場合は App Engine の Task Queue にリトライを任せています。

public class PicasaService {
  /**
   * APIを実行する。
   * @param <V>
   * @param picasaCallable
   * @return
   * @throws ServiceException
   * @throws IOException
   */
  public <V> V execute(PicasaCallable picasaCallable) throws ServiceException, IOException
  {
    for (int retry = 0;; retry++) {
      try {
        return picasaCallable.call();
      }
      catch (RuntimeException e) {
        if (retry > this.retryLimit) {
          throw e;
        }
        logger.warning("Retrying (" + retry + "/" + this.retryLimit + "): " + e);
      }
      catch (PreconditionFailedException e) {
        if (retry > this.retryLimit) {
          throw e;
        }
        logger.warning("Retrying (" + retry + "/" + this.retryLimit + "): " + e);
      }
      catch (ServiceException e) {
        if (retry > this.retryLimit) {
          throw e;
        }
        logger.warning("Retrying (" + retry + "/" + this.retryLimit + "): " + e);
      }
      catch (IOException e) {
        if (retry > this.retryLimit) {
          throw e;
        }
        logger.warning("Retrying (" + retry + "/" + this.retryLimit + "): " + e);
      }

      try {
        Thread.sleep(this.retrySleep);
      }
      catch (InterruptedException e) {
        // should not be thrown in single thread environment
        throw new RuntimeException(e);
      }
    }
  }
}