概要
みてねは子供の写真や動画を無料で共有できるサービスです。夫婦でそれぞれ iPhone やデジカメで撮影した写真や動画を手軽にアップロードして共有できるので重宝しています。しかし、アップロードした写真や動画をダウンロードしようとすると、公式の方法だと 1 つずつ手動でダウンロードするしかなくて、非常に大変です(2021年9月現在)。ぐぐってみても、まとめてダウンロードする方法は見つかりませんでした。
そこで、ないなら自分で作ろう!ということで、みてねにアップロードした写真や動画をまとめてダウンロードするアプリケーションを作りました。
ビルドしたアプリケーションはこちら(Win/Mac):
https://github.com/miworky/miteneDownloader/tree/main/bin
使用方法:
https://github.com/miworky/miteneDownloader/blob/main/README.md
アプリケーションを使いたいだけの人はここでおさらばです。
コードはこちら(JavaScript, Electron):
https://github.com/miworky/miteneDownloader
コードの詳細を知りたい人は続けてお読みください。
背景
みてねにアップロードした写真や動画を使ってブルーレイを焼きたいのです。月に一度、その月の写真と動画をすべてブルーレイに焼いて、みてねを見れない両親に送ります。両親はブルーレイを大画面のテレビで見ることで、孫の1カ月の成長をまとめて確認できます。なお、みてねを見れない両親のためにまごチャンネルも導入しています。が、まごチャンネルは月々の利用料金がかかるので、それを気遣ってくれるのか、写真でいいよ、と言って受け取ってくれない親もいます。(ブルーレイを焼くよりまごチャンネルを受け取ってくれた方が楽なのですが。。。)
みてねにブルーレイを焼いてくれるサービスはまだありません(2021年9月現在)。ブルーレイではなく DVD に焼いてくれるサービスはあるようですが、焼いてくれるのは動画のみで静止画は含まれません。しかも動画の画質は DVD レベルに落とされてしまいます。みてねが提供しているサービスは、わたしが欲しいものとは違います。
ないなら自分で作ってしまおう!ということで、自分で作ることにしました。最初はすべての工程を手作業でやっていましたが、あまりに大変なので、徐々に自動化を進めています。
今回は、みてねから画像と動画をまとめてダウンロードするアプリケーションについて説明します。
なお、説明は自分で書いたコードに関する部分のみ行います。みてねに関する部分は、なんとなく書いたらまずいような気もして遠慮しておきます。
コードの説明
1. ファイルをダウンロードする方法
まずは、みてねのブラウザ版でソースを見てみました。すると、 3 行目にいかにも怪しい JSON が目につきました。この JSON の中に、表示しているページに含まれる画像や動画の URL が含まれていました。 1 ページには 25 個の画像や動画があるので、この URL が指すファイルを 1 つずつダウンロードすれば、 25 個のファイルをまとめてダウンロードできそうに見えました。
しかし、いろいろ試してみたのですが、普通の JavaScript でファイルをダウンロードする方法がわかりませんでした。
いろいろ調べてみると Node.js ならできそうなので、 Node.js を使うことにしました。 UI は HTML + JavaScript、ファイルのダウンロードは Node.js で処理することにしました。そうすると、アプリケーションのフレームワークとしては Electron を使うのがよいと思いました。
2. JSON をパース
ユーザーに、みてねのソースの 3 行目を、アプリケーションのテキストボックスにペーストしてもらいます。ペーストされた JSON をパースしたいのですが、純粋な JSON ではないので JSON.parse() は使えません。 JSON.parse() が使えるように手動で加工してペーストしたりしたくないので、仕方がなく、eval() を使うことにしました。
let element = document.getElementById('miteneSource');
eval(element.value);
3. ファイルリストを作成
ダウンロードした画像や動画のファイル名は以下のようにします:
撮影日時_みてねの最初のコメント.拡張子
- 「撮影日時」を先頭に付けることで、動画編集ソフトに画像と動画を取り込んだ際に、撮影日時順にスライドショーを作成できます。
- 「みてねの最初のコメント」は、みてねでその写真や動画に付けられた 1 つめのコメントです。このコメントをスライドショーのテロップに使いたいので、ファイル名に含めます。
「撮影日時」も「みてねの最初のコメント」も 2 で得られた JSON に含まれています。
1 ページに含まれる 25 個のファイルについて、それぞれ以下の情報をリストアップします:
- 画像や動画のURL
- ダウンロードした画像や動画のファイル名
コードは以下のようになります:
let file = {};
file["url"] = url;
file["filename"] = fileNameFixed;
gl_allFiles.push(file);
gl_allFiles に 1 ページに含まれるすべてのファイルの情報が入ります。
4. ファイルをまとめてダウンロード
3 でリストアップした情報を元にファイルをまとめてダウンロードします。ファイルのダウンロードはレンダラープロセス側ではなく、メインプロセス側で行います。
Electron のプロセス構成については公式のドキュメントに記載があります:
https://www.electronjs.org/docs/tutorial/process-model
ざっくり言うと:
- アプリケーション起動時にまずメインプロセスが起動して、そこからレンダラープロセスを起動する
- レンダラープロセスでは普通にブラウザの html や JavaScript などが動作する
- メインプロセスでは Node.js が動作する(レンダラープロセスでは Node.js は使えない)
- プロセスをまたぐときには、コンテキストブリッジを介する
で、結局どういうコードを書けば良いの?というのは公式のドキュメントを読んでわかるほど JavaScript に詳しくないので、以下の記事などを参考にして実装しました:
https://qiita.com/hibara/items/c59fb6924610fc22a9db
処理の流れ
ダウンロードボタンをクリックすると以下が呼ばれます:
function onClickDownloadAll(){
window.api.send("downloadAll", gl_allFiles );
}
この中で window.api.send を呼び出すことで、最終的にメインプロセス側の index.js の以下が呼び出されます:
ipcMain.on('downloadAll', function( event, data){
send に渡している gl_allFiles が ipcMain.on では data として受け取れます。
これでレンダラープロセスからメインプロセスへの呼び出しができるわけですが、これを実現するには以下のようなおまじないが必要です。
レンダラープロセスとメインプロセスをつなぐおまじない
window.api.send は preload.js の以下で定義されています:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
send: (channel, data) => ipcRenderer.send(channel, data), // Renderer process to Main process
on: (channel, callback) => ipcRenderer.on(channel, (event, argv)=>callback(event, argv)) // Main process to Renderer process
}
)
この定義によって、 window.api.send が呼ばれると ipcRenderer.send が呼ばれます。 ipcRenderer は Electron が提供しているモジュールで ipcRenderer.send が呼ばれると、 ipcMain.on が呼ばれます。
もう一つ on という関数が定義されていますが、これはメインプロセス側からレンダラープロセス側を呼び出す際に使用します(後述します)。
この preload.js は、 index.js で BrowserWindow を生成する際に以下のように指定することで、レンダラープロセスと紐づいています:
function createWindow () {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
5. ダウンロード先のフォルダを選択
メインプロセスの ipcMain.on が呼び出されると、ダウンロード先のフォルダを選択するダイアログを表示します。コードは以下です:
// ダウンロード先のフォルダを選択するダイアログを表示する
const downloadPath = dialog.showOpenDialogSync(null, {
properties: ['openDirectory'],
title: 'download to',
defaultPath: '.'
});
if (downloadPath === undefined) {
// キャンセルされた
return;
}
dialog を使用するには以下のように require しておく必要があります。
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
6. 1つのファイルをダウンロードする Promise を生成する
1 つのファイルのダウンロードは 1 つの Promise で非同期処理することにします。ipcMain.on の引数として受け取った data にはダウンロードするファイルの個数分のデータが含まれているので、これを 1 つずつ処理してダウンロードする個数分の Promise を生成します。この時点ではまだダウンロードは開始していません。
let downloadProgress = { finished: 0 };
let downloads = []; // ダウンロードするファイルの個数分の Promise を格納する配列
// ファイル数分ダウンロードする Promise を生成する(まだダウンロードはしない)
const fileNum = data.length;
for (let fileNo = 0;fileNo < fileNum;fileNo++) {
const url = data[fileNo]["url"];
const filename = data[fileNo]["filename"];
const downloadTo = downloadPath + "/" + filename;
const thisDownload = download(
event, url, downloadTo, downloadProgress, fileNum
);
downloads.push(thisDownload);
}
download 関数は以下のようになっており、ここでファイル1つをダウンロードする Promise を生成します。ファイルのダウンロードは、Node.js の https を使用して、ダウンロードしたファイルの保存には fs を使用します。
// uriのファイルを filename としてダウンロードする
const download = (event, uri, filename, downloadProgress, fileNum) => {
return new Promise((resolve, reject) =>
https
.request(uri, (res) => {
res
.pipe(fs.createWriteStream(filename))
.on("close", () => {
// 1つダウンロード完了した
// ダウンロード完了したファイルの個数をカウントアップ
downloadProgress.finished = downloadProgress.finished + 1;
// レンダラープロセスにその旨通知
event.reply('downloadProgress', "downloading " + downloadProgress.finished.toString() + "/" + fileNum.toString());
resolve();
})
.on("error", reject);
})
.end()
);
};
プログレスを通知するコード
:::note info event.replyを呼び出すことで、レンダラープロセスにプログレスを通知しています。プログレスの通知についてはこちら。event.replyについては後述します。 :::
7. ダウンロード開始前に、レンダラープロセスにダウンロード開始を通知する
Promise を生成し終わると、いよいよダウンロード開始ですが、その前にレンダラープロセスにダウンロード開始を通知します。
// ダウンロード開始をレンダラープロセスに通知
event.reply('startDownloading', fileNum.toString() + " files will be downloaded.");
eventは ipcMain.on の第一引数です。event.reply('startDownloading' を呼び出すことで、最終的にはレンダラープロセスの以下が呼び出されます:
window.api.on('startDownloading', (event, argv)=>{
startDownloading();
showMessage(argv);
});
これにより、GUI の部品を disable にしたり、ダウンロードするファイルの個数を表示したりします。
これでレンダラープロセスからメインプロセスへの呼び出しができるわけですが、これを実現するには以下のようなおまじないが必要です。
メインプロセスとレンダラープロセスをつなぐおまじない
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
send: (channel, data) => ipcRenderer.send(channel, data), // Renderer process to Main process
on: (channel, callback) => ipcRenderer.on(channel, (event, argv)=>callback(event, argv)) // Main process to Renderer process
}
)
8. Promiseをまとめて実行する
いよいよダウンロード開始です。以下のコードで生成したすべての Promise を実行します:
// まとめてダウンロード開始
Promise.all(downloads).then(() => {
// すべてのダウンロードが完了したら、その旨をレンダラープロセスに通知
event.reply('finishDownloading', fileNum.toString() + " files were downloaded.");
});
これですべてのファイルのダウンロードが開始されます。説明が前後してしまいますが、thenの処理にあるように、すべてのファイルのダウンロードが完了したらレンダラープロセスにダウンロード完了を通知しています。
ダウンロードが完了したときの説明はこちら
9. プログレスをレンダラープロセスに通知する
1 つのファイルのダウンロードが完了すると、レンダラープロセスの以下が呼び出されます:
window.api.on('downloadProgress', (event, argv)=>{
showMessage(argv);
});
これによって、ダウンロードのプログレスが表示されます。
ダウンロードのプログレスを送っているところは既出のこちら。
10. すべてのファイルのダウンロードが完了したらレンダラープロセスにダウンロード完了を通知する
すべてのファイルのダウンロードが完了したら、レンダラープロセスの以下が呼び出されます:
window.api.on('finishDownloading', (event, argv)=>{
showMessage(argv);
finishDownloading();
});
これにより、GUI の部品を enable にしたり、ダウンロードしたファイルの個数を表示したりします。