├── LICENSE ├── README.md └── tadpoles-backup.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tyler Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tadpoles-api 2 | A PHP script which pretends to be the Tadpoles.com iPhone app and downloads/backs-up all of your kids' photos and videos from daycare. Metadata is carefully perserved so items can be searched within Apple Photos and Google Photos. 3 | 4 | ## Motivation 5 | 6 | Both of my kids attend an awesome daycare M-F. The school uses the (mostly awful) [Tadpoles](https://www.tadpoles.com) service to communicate with parents and send any photos/videos they take of our kids throughout the day. 7 | 8 | Tadpoles' iOS app is awful - I mean, it works, mostly - but its made (poorly) with [Titanium](https://www.appcelerator.com/) and decidedly not-native and very crashy. So, years ago, I stopped using the app and opted-in to receive my kids' updates via email. 9 | 10 | Anytime we'd receive a particularly good photo of our kid, we'd save it to our phone for posterity and for sharing with the grandparents. But, Tadpoles stops you from saving any photo that contains _any other child_ besides your own (for privacy reasons I assume), which means you lose all of the fun shots with friends. Also, you can't save videos at all. 11 | 12 | I'm obsessive about backing up my family's photos and home videos, so I wrote [a little script](https://gist.github.com/tylerhall/f19e78829fcd6babb301a6f3c9b90375) that automatically downloads any new ones Tadpoles sends. [More info here](https://tyler.io/fixing-a-broken-service-with-a-tiny-bit-of-automation/). 13 | 14 | It works great for new items. But I started thinking about all the old pictures/videos that I never bothered to save. So I started looking through my email archives for old Tadpoles updates and discovered that the media attachments _expire_ after three days. At first I was devastated to think I'd lost all those memories, but then my wife, who _does_ use the iOS app, told me they're still viewable in the app. Aha! 15 | 16 | So, I spent an evening digging through the app and figuring out how the Tadpoles API works. The result is this repo. It's just one script that focuses on backing up all of your kids' photos and videos no matter how long ago they were taken. If you need to do something beyond that, there are hooks in place to let you extend the script to make whichever additional API calls you need to do what you want. 17 | 18 | Also, if you have the appropriate tools installed on your system, the script will set the photo/video's EXIF creation date and filesystem modification date. This makes your items play nicely and sort properly if you archive them into Google Photos or iCloud. 19 | 20 | ## Installation 21 | 22 | **Requirements** 23 | 24 | * macOS or other Unix-y like system. 25 | * PHP 5.4 or greater with the curl extension installed. 26 | 27 | **Setup** 28 | 29 | 1. Clone this repo somewhere, or just download the [`tadpoles-backup.php`](https://github.com/tylerhall/tadpoles-api/blob/master/tadpoles-backup.php) script. The whole project is just one file - no dependencies. 30 | 2. Fill in the email address and password for your Tadpoles account at the top of `tadpoles-backup.php`. 31 | 3. Add your children's first names so the script can ignore items of other children. (Note: the Tadpoles API _does not_ return items from other children besides your own - don't worry. But, they _do_ return group shots containing your kids where another child is the focus. We use your kids' first names to ignore these types of items.) If you want to archive _all_ items no matter what, you can modify the script to suit your needs. 32 | 4. Set a path to a folder in `$absolute_destination_folder` where you want the script to save your items. This folder must exist before running the script. 33 | 5. Call the `download_all_attachments()` function for each month you want to backup. 34 | 6. Make the script executable and run it with `./tadpoles-backup.php` or `php tadpoles-backup.php`. 35 | 36 | All of the photos/videos for the month you specified will be saved into your folder with the following filename format: 37 | 38 | YYYY-mm-dd HH.mm.ss - Tadpoles - KidName.jpg 39 | 40 | or 41 | 42 | YYYY-mm-dd HH.mm.ss - Tadpoles - KidName.mp4 43 | 44 | Note: the script only handles JPGs (and PNGs pretending to be JPGs) and MP4s. Those are the only types of files that were ever returned for my children when testing. If you're seeing something else, please file a bug or pull request. 45 | 46 | **Optional Setup** 47 | 48 | The script can optionally set the EXIF date, latitude/longitude, and description of your items (so they can be searched in Apple Photos and Google Photos) and also convert PNG files returned by Tadpoles into JPGs so the date can be set on them, too. (Occasionally, the Tadpoles API will return a JPG file which is actually a PNG. It's dumb. But the script will handle that case and convert the file for you.) 49 | 50 | To use these optional features, you need to have [ExifTool](https://sno.phy.queensu.ca/~phil/exiftool/) and [ImageMagick](https://www.imagemagick.org/) installed on your system and in your `$PATH`. 51 | 52 | On Debian/Ubuntu systems, that's simple to do with `sudo apt-get install imagemagick exiftool`. On macOS, you can use [Homebrew](https://brew.sh/). Both tools are readily available for any other system you might be running. 53 | 54 | ## Notes 55 | 56 | I have no idea what sort of infrastructure Tadpoles' API is running on - it looks to maybe be in Google's Cloud. So, play nice and don't abuse their API too heavily. That said, the official iOS app itself is _terribly noisy_ and makes way more API calls than it should be doing on its own, so anyone running this script is probably going to be a nicer API citizen than their own app. 57 | -------------------------------------------------------------------------------- /tadpoles-backup.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | 'tadpoles', 'email' => $email, 'password' => $password); 49 | $response = curl('https://www.tadpoles.com/auth/login', $post_fields, true); 50 | 51 | preg_match('/^Set-Cookie:\s*(.+)="(.+)"/mi', $response, $cookies); 52 | $cookie_key = $cookies[1]; 53 | $cookie_value = $cookies[2]; 54 | $cookie = "$cookie_key=\"$cookie_value\""; 55 | } 56 | 57 | // This needs to be called immediately after login(). It takes the cookie returned by login() 58 | // and replaces it with a different (longer) cookie, which is required to make any other API requests. 59 | function admit() 60 | { 61 | global $standard_headers; 62 | global $cookie; 63 | 64 | $post_fields = array('state' => 'client', 'mac' => '00000000-0000-0000-0000-000000000000', 'os_name' => 'iphone', 'app_version' => '8.10.24', 'ostype' => '64bit', 'tz' => 'America/Chicago', 'battery_level' => '-1', 'locale' => 'en', 'logged_in' => '0', 'device_id' => '00000000-0000-0000-0000-000000000000', 'v' => '2'); 65 | $response = curl('https://www.tadpoles.com/remote/v1/athome/admit', $post_fields, true); 66 | 67 | preg_match('/^Set-Cookie:\s*(.+)="(.+)"/mi', $response, $cookies); 68 | $cookie_key = $cookies[1]; 69 | $cookie_value = $cookies[2]; 70 | $cookie = "$cookie_key=\"$cookie_value\""; 71 | } 72 | 73 | // Generic function to grab data from Tadpole's API. 74 | // Automatically spoofs the HTTP headers to appear as if we're the Tadpoles iPhone app. 75 | // Uses the auth cookie obtained earlier in login() and admit(). 76 | function curl($url, $post_fields = null, $return_headers = false) 77 | { 78 | global $standard_headers; 79 | global $cookie; 80 | 81 | $ch = curl_init($url); 82 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 83 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 84 | curl_setopt($ch, CURLOPT_HTTPHEADER, $standard_headers); 85 | curl_setopt($ch, CURLOPT_COOKIE, $cookie); 86 | 87 | if($return_headers == true) { 88 | curl_setopt($ch, CURLOPT_HEADER, 1); 89 | } 90 | 91 | if(!is_null($post_fields)) { 92 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); 93 | curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_fields)); 94 | } else { 95 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); 96 | } 97 | 98 | $response = curl_exec($ch); 99 | curl_close($ch); 100 | return $response; 101 | } 102 | 103 | // Get all the events for a given month. 104 | function events($year, $month) 105 | { 106 | $cursor = ''; 107 | $events = array(); 108 | 109 | // Tadpoles returns events spread across multiple pages of data. 110 | // Keep grabbing the next page until we've exhausted all available events. 111 | do { 112 | $month = str_pad($month, 2, '0', STR_PAD_LEFT); 113 | 114 | $last_day = date('t', strtotime("$year-$month-01")); 115 | $last_day = str_pad($last_day, 2, '0', STR_PAD_LEFT); 116 | 117 | $earliest_ts = strtotime("$year-$month-01 00:00:00"); 118 | $latest_ts = strtotime("$year-$month-$last_day 23:59:59"); 119 | 120 | $params = array('num_events' => '100', 'state' => 'client', 'direction' => 'range', 'earliest_event_time' => $earliest_ts, 'latest_event_time' => $latest_ts, 'cursor' => $cursor); 121 | $jsonStr = curl('https://www.tadpoles.com/remote/v1/events?' . http_build_query($params)); 122 | $json = json_decode($jsonStr); 123 | 124 | $events = array_merge($events, $json->events); 125 | $cursor = $json->cursor; 126 | } while(isset($json->cursor) && strlen($json->cursor) > 0); 127 | 128 | return $events; 129 | } 130 | 131 | // Downloads a full-resolution picture/video from Tadpoles. 132 | function download_attachment($key, $filename) 133 | { 134 | global $standard_headers; 135 | global $cookie; 136 | 137 | $fp = fopen($filename, 'w'); 138 | 139 | $ch = curl_init('https://www.tadpoles.com/remote/v1/attachment?key=' . $key); 140 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 141 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 142 | curl_setopt($ch, CURLOPT_HTTPHEADER, $standard_headers); 143 | curl_setopt($ch, CURLOPT_COOKIE, $cookie); 144 | curl_setopt($ch, CURLOPT_FILE, $fp); 145 | 146 | $response = curl_exec($ch); 147 | curl_close($ch); 148 | fclose($fp); 149 | } 150 | 151 | // Download all pictures/videos for a given month and rename them based on their date and which kid is pictured. 152 | // Also, if exiftool is installed, set the exif date, lat/lng, and description and convert any png files to jpg. 153 | function download_all_attachments($year, $month) 154 | { 155 | global $kids; 156 | global $absolute_destination_folder; 157 | global $lat, $lng; 158 | 159 | $exiftool_path = shell_exec('which exiftool'); 160 | if(strlen($exiftool_path) == 0) { 161 | unset($exiftool_path); 162 | } 163 | 164 | $events = events($year, $month); 165 | $count = count($events); 166 | foreach($events as $i => $e) { 167 | if($e->type == 'Activity') { 168 | foreach($e->new_attachments as $a) { 169 | // Skip photos where our kids are not the primary child in the picture. 170 | if(!in_array($e->member_display, $kids)) { 171 | continue; 172 | } 173 | 174 | $description = $e->comment; 175 | $date = date('Y-m-d H.i.s', $e->event_time); 176 | 177 | // Parse Tadpoles' bizzare date format. 178 | // NOTE: They don't seem to be sending this strange format any longer. Leaving this here just in case. 179 | // $ts = explode('E', $e->create_time)[0]; 180 | // $ts = str_replace('.', '', $ts); 181 | // $date = date('Y-m-d H.i.s', $ts); 182 | 183 | // Build the filename: "folder/YYYY-mm-dd HH.mm.ss - Tadpoles - Kid Name.[jpg|mp4]" 184 | $filename = $date . ' - Tadpoles - ' . $e->member_display; 185 | if($a->mime_type == 'image/jpeg') { 186 | $filename .= '.jpg'; 187 | } else if($a->mime_type == 'video/mp4') { 188 | $filename .= '.mp4'; 189 | } 190 | $filename = rtrim($absolute_destination_folder, '/') . '/' . $filename; 191 | 192 | echo "# Downloading $i/$count: $filename\n"; 193 | download_attachment($a->key, $filename); 194 | 195 | if(isset($exiftool_path)) { 196 | set_exif_date($filename); 197 | if(!empty($lat) && !empty($lng)) { 198 | set_exif_coords($filename, $lat, $lng); 199 | } 200 | if(!empty($description)) { 201 | set_exif_description($filename, $description); 202 | } 203 | } 204 | 205 | // Set the file's modification date to match date taken for good measure. 206 | touch($filename, strtotime($date)); 207 | } 208 | } 209 | } 210 | } 211 | 212 | // Set the exif date based on the filename and convert any png files to jpg. 213 | function set_exif_date($filename) { 214 | echo " Setting date for $filename\n"; 215 | $cmd = "/usr/bin/exiftool -overwrite_original '-datetimeoriginal&1 1> /dev/null"; 216 | $results = shell_exec($cmd); 217 | if(strpos($results, 'looks more like a PNG') !== false) { 218 | $results = shell_exec("/usr/bin/mogrify -format jpg '$filename'"); 219 | $results = shell_exec($cmd); 220 | } 221 | } 222 | 223 | // Set the exif lat/lng and convert any png files to jpg. 224 | function set_exif_coords($filename, $lat, $lng) { 225 | echo " Setting coords ($lat, $lng) for $filename\n"; 226 | $cmd = "/usr/bin/exiftool -overwrite_original -XMP:GPSLongitude='$lng' -XMP:GPSLatitude='$lat' -GPSLongitudeRef='West' -GPSLatitudeRef='North' '$filename' 2>&1 1> /dev/null"; 227 | $results = shell_exec($cmd); 228 | if(strpos($results, 'looks more like a PNG') !== false) { 229 | $results = shell_exec("/usr/bin/mogrify -format jpg '$filename'"); 230 | $results = shell_exec($cmd); 231 | } 232 | } 233 | 234 | // Set the exif caption and convert any png files to jpg. 235 | function set_exif_description($filename, $description) { 236 | echo " Setting description '$description' for $filename\n"; 237 | $description = escapeshellarg($description); 238 | $cmd = "/usr/bin/exiftool -overwrite_original -Exif:ImageDescription=$description -IPTC:Caption-Abstract=$description -xmp:description=$description '$filename' 2>&1 1> /dev/null"; 239 | $results = shell_exec($cmd); 240 | if(strpos($results, 'looks more like a PNG') !== false) { 241 | $results = shell_exec("/usr/bin/mogrify -format jpg '$filename'"); 242 | $results = shell_exec($cmd); 243 | } 244 | } 245 | --------------------------------------------------------------------------------