4 * Import games from a OUYA game data repository
6 * @link https://github.com/cweiske/ouya-game-data/
7 * @author Christian Weiske <cweiske@cweiske.de>
9 ini_set('xdebug.halt_level', E_WARNING|E_NOTICE|E_USER_WARNING|E_USER_NOTICE);
10 require_once __DIR__ . '/functions.php';
11 require_once __DIR__ . '/filters.php';
12 if (!isset($argv[1])) {
13 error('Pass the path to a "folders" file with game data json files folder names');
15 $foldersFile = $argv[1];
16 if (!is_file($foldersFile)) {
17 error('Given path is not a file: ' . $foldersFile);
20 //default configuration values
21 $GLOBALS['packagelists'] = [];
22 $GLOBALS['urlRewrites'] = [];
23 $cfgFile = __DIR__ . '/../config.php';
24 if (file_exists($cfgFile)) {
28 $wwwDir = __DIR__ . '/../www/';
30 $baseDir = dirname($foldersFile);
32 foreach (file($foldersFile) as $line) {
35 if (strpos($line, '..') !== false) {
36 error('Path attack in ' . $folder);
38 $folder = $baseDir . '/' . $line;
39 if (!is_dir($folder)) {
40 error('Folder does not exist: ' . $folder);
42 $gameFiles = array_merge($gameFiles, glob($folder . '/*.json'));
50 //load game data. doing early to collect a developer's games
51 foreach ($gameFiles as $gameFile) {
52 $game = json_decode(file_get_contents($gameFile));
54 error('JSON invalid at ' . $gameFile);
56 addMissingGameProperties($game);
57 $games[$game->packageName] = $game;
59 if (!isset($developers[$game->developer->uuid])) {
60 $developers[$game->developer->uuid] = [
61 'info' => $game->developer,
66 $developers[$game->developer->uuid]['gameNames'][] = $game->packageName;
69 //write json api files
70 foreach ($games as $game) {
71 $products = $game->products ?? [];
72 foreach ($products as $product) {
74 'api/v1/developers/' . $game->developer->uuid
75 . '/products/' . $product->identifier . '.json',
76 buildDeveloperProductOnly($product, $game->developer)
78 $developers[$game->developer->uuid]['products'][] = $product;
82 'api/v1/details-data/' . $game->packageName . '.json',
85 count($developers[$game->developer->uuid]['gameNames']) > 1
90 'api/v1/games/' . $game->packageName . '/purchases',
95 'api/v1/apps/' . $game->packageName . '.json',
98 $latestRelease = $game->latestRelease;
100 'api/v1/apps/' . $latestRelease->uuid . '.json',
105 'api/v1/apps/' . $latestRelease->uuid . '-download.json',
106 buildAppDownload($game, $latestRelease)
114 calculateRank($games);
116 foreach ($developers as $developer) {
118 //index.htm does not need a rewrite rule
119 'api/v1/developers/' . $developer['info']->uuid
120 . '/products/index.htm',
121 buildDeveloperProducts($developer['products'], $developer['info'])
124 'api/v1/developers/' . $developer['info']->uuid
126 buildDeveloperCurrentGamer()
129 if (count($developer['gameNames']) > 1) {
131 'api/v1/discover-data/dev--' . $developer['info']->uuid . '.json',
132 buildSpecialCategory(
133 'Developer: ' . $developer['info']->name,
134 filterByPackageNames($games, $developer['gameNames'])
140 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
141 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
145 'api/v1/discover-data/tutorials.json',
146 buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
149 $searchLetters = 'abcdefghijklmnopqrstuvwxyz0123456789., ';
150 foreach (str_split($searchLetters) as $letter) {
151 $letterGames = filterBySearchWord($games, $letter);
153 'api/v1/search-data/' . $letter . '.json',
154 buildSearch($letterGames)
159 function buildDiscover(array $games)
161 $games = removeMakeGames($games);
163 'title' => 'DISCOVER',
170 filterLastAdded($games, 10)
174 filterBestRated($games, 10),
178 foreach ($GLOBALS['packagelists'] as $listTitle => $listPackageNames) {
181 filterByPackageNames($games, $listPackageNames)
195 'api/v1/discover-data/' . categoryPath('Best rated') . '.json',
196 buildSpecialCategory('Best rated', filterBestRated($games, 99))
199 'api/v1/discover-data/' . categoryPath('Most rated') . '.json',
200 buildSpecialCategory('Most rated', filterMostDownloaded($games, 99))
203 'api/v1/discover-data/' . categoryPath('Random') . '.json',
204 buildSpecialCategory(
205 'Random ' . date('Y-m-d H:i'),
206 filterRandom($games, 99)
210 'api/v1/discover-data/' . categoryPath('Last updated') . '.json',
211 buildSpecialCategory('Last updated', filterLastUpdated($games, 99))
220 addDiscoverRow($data, 'Multiplayer', $players);
221 foreach ($players as $num => $title) {
223 'api/v1/discover-data/' . categoryPath($title) . '.json',
224 buildDiscoverCategory(
226 //I do not want emulators here,
227 // and neither Streaming apps
230 filterByPlayers($games, $num),
239 $ages = getAllAges($games);
241 addDiscoverRow($data, 'Content rating', $ages);
242 foreach ($ages as $num => $title) {
244 'api/v1/discover-data/' . categoryPath($title) . '.json',
245 buildDiscoverCategory($title, filterByAge($games, $title))
249 $genres = removeMakeGenres(getAllGenres($games));
251 addChunkedDiscoverRows($data, $genres, 'Genres');
253 foreach ($genres as $genre) {
255 'api/v1/discover-data/' . categoryPath($genre) . '.json',
256 buildDiscoverCategory($genre, filterByGenre($games, $genre))
260 $abc = array_merge(range('A', 'Z'), ['Other']);
261 addChunkedDiscoverRows($data, $abc, 'Alphabetical');
262 foreach ($abc as $letter) {
264 'api/v1/discover-data/' . categoryPath($letter) . '.json',
265 buildDiscoverCategory($letter, filterByLetter($games, $letter))
273 * A genre category page
275 function buildDiscoverCategory($name, $games)
283 $data, 'Last Updated',
284 filterLastUpdated($games, 10)
288 filterBestRated($games, 10),
292 $games = sortByTitle($games);
293 $chunks = array_chunk($games, 4);
294 foreach ($chunks as $chunkGames) {
295 addDiscoverRow($data, '', $chunkGames);
301 function buildMakeCategory($name, $games)
309 $games = sortByTitle($games);
310 addDiscoverRow($data, '', $games);
316 * Category without the "Last updated" or "Best rated" top rows
318 * Used for "Best rated", "Most rated", "Random"
320 function buildSpecialCategory($name, $games)
328 $first3 = array_slice($games, 0, 3);
329 $chunks = array_chunk(array_slice($games, 3), 4);
330 array_unshift($chunks, $first3);
332 foreach ($chunks as $chunkGames) {
333 addDiscoverRow($data, '', $chunkGames);
339 function buildDiscoverHome(array $games)
348 if (isset($GLOBALS['home'])) {
349 reset($GLOBALS['home']);
350 $title = key($GLOBALS['home']);
353 filterByPackageNames($games, $GLOBALS['home'][$title])
357 'title' => 'FEATURED',
358 'showPrice' => false,
368 * Build api/v1/apps/$packageName
370 function buildApps($game)
372 $latestRelease = $game->latestRelease;
375 $gamePromoted = getPromotedProduct($game);
377 $product = buildProduct($gamePromoted);
380 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
383 'uuid' => $latestRelease->uuid,
384 'title' => $game->title,
385 'overview' => $game->overview,
386 'description' => $game->description,
387 'gamerNumbers' => $game->players,
388 'genres' => $game->genres,
390 'website' => $game->website,
391 'contentRating' => $game->contentRating,
392 'premium' => $game->premium,
393 'firstPublishedAt' => $game->firstPublishedAt,
395 'likeCount' => $game->rating->likeCount,
396 'ratingAverage' => $game->rating->average,
397 'ratingCount' => $game->rating->count,
399 'versionNumber' => $latestRelease->name,
400 'latestVersion' => $latestRelease->uuid,
401 'md5sum' => $latestRelease->md5sum,
402 'apkFileSize' => $latestRelease->size,
403 'publishedAt' => $latestRelease->date,
404 'publicSize' => $latestRelease->publicSize,
405 'nativeSize' => $latestRelease->nativeSize,
407 'mainImageFullUrl' => $game->discover,
408 'videoUrl' => getFirstVideoUrl($game->media),
409 'filepickerScreenshots' => getAllImageUrls($game->media),
410 'mobileAppIcon' => null,
412 'developer' => $game->developer->name,
413 'supportEmailAddress' => $game->developer->supportEmail,
414 'supportPhone' => $game->developer->supportPhone,
415 'founder' => $game->developer->founder,
417 'promotedProduct' => $product,
422 function buildAppDownload($game, $release)
426 'fileSize' => $release->size,
427 'version' => $release->uuid,
428 'contentRating' => $game->contentRating,
429 'downloadLink' => rewriteUrl($release->url),
434 function buildProduct($product)
436 if ($product === null) {
440 'type' => $product->type ?? 'entitlement',
441 'identifier' => $product->identifier,
442 'name' => $product->name,
443 'description' => $product->description ?? '',
444 'localPrice' => $product->localPrice,
445 'originalPrice' => $product->originalPrice,
446 'priceInCents' => $product->originalPrice * 100,
448 'currency' => $product->currency,
453 * Build /app/v1/details?app=org.example.game
455 function buildDetails($game, $linkDeveloperPage = false)
457 $latestRelease = $game->latestRelease;
460 if ($game->discover) {
464 'thumbnail' => $game->discover,
465 'full' => $game->discover,
469 foreach ($game->media as $medium) {
470 if ($medium->type == 'image') {
474 'thumbnail' => $medium->thumb ?? $medium->url,
475 'full' => $medium->url,
479 if (!isUnsupportedVideoUrl($medium->url)) {
482 'url' => $medium->url,
489 if (isset($game->links->unlocked)) {
491 'text' => 'Show unlocked',
492 'url' => 'ouya://launcher/details?app=' . $game->links->unlocked,
498 $gamePromoted = getPromotedProduct($game);
500 $product = buildProduct($gamePromoted);
504 if (isset($game->latestRelease->url)
505 && substr($game->latestRelease->url, 0, 29) == 'https://archive.org/download/'
507 $iaUrl = dirname($game->latestRelease->url) . '/';
510 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
513 'title' => $game->title,
514 'description' => $game->description,
515 'gamerNumbers' => $game->players,
516 'genres' => $game->genres,
518 'suggestedAge' => $game->contentRating,
519 'premium' => $game->premium,
520 'inAppPurchases' => $game->inAppPurchases,
521 'firstPublishedAt' => strtotime($game->firstPublishedAt),
525 'count' => $game->rating->count,
526 'average' => $game->rating->average,
530 'fileSize' => $latestRelease->size,
531 'nativeSize' => $latestRelease->nativeSize,
532 'publicSize' => $latestRelease->publicSize,
533 'md5sum' => $latestRelease->md5sum,
534 'filename' => 'FIXME',
536 'package' => $game->packageName,
537 'versionCode' => $latestRelease->versionCode,
538 'state' => 'complete',
542 'number' => $latestRelease->name,
543 'publishedAt' => strtotime($latestRelease->date),
544 'uuid' => $latestRelease->uuid,
548 'name' => $game->developer->name,
549 'founder' => $game->developer->founder,
553 'key:rating.average',
554 'key:developer.name',
556 number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
559 'tileImage' => $game->discover,
560 'mediaTiles' => $mediaTiles,
561 'mobileAppIcon' => null,
566 'promotedProduct' => $product,
567 'buttons' => $buttons,
570 'internet-archive' => $iaUrl,
571 'developer-url' => $game->developer->website ?? null,
575 if ($linkDeveloperPage) {
576 $data['developer']['url'] = 'ouya://launcher/discover/dev--'
577 . categoryPath($game->developer->uuid);
583 function buildDeveloperCurrentGamer()
587 'uuid' => '00702342-0000-1111-2222-c3e1500cafe2',
588 'username' => 'stouyapi',
594 * For /api/v1/developers/xxx/products/?only=yyy
596 function buildDeveloperProductOnly($product, $developer)
599 'developerName' => $developer->name,
600 'currency' => $product->currency,
602 buildProduct($product),
608 * For /api/v1/developers/xxx/products/
610 function buildDeveloperProducts($products, $developer)
613 $products = array_values(array_column($products, null, 'identifier'));
616 foreach ($products as $product) {
617 $jsonProducts[] = buildProduct($product);
620 'developerName' => $developer->name,
621 'currency' => $products[0]->currency ?? 'EUR',
622 'products' => $jsonProducts,
626 function buildPurchases($game)
631 $promotedProduct = getPromotedProduct($game);
632 if ($promotedProduct) {
633 $purchasesData['purchases'][] = [
634 'purchaseDate' => time() * 1000,
635 'generateDate' => time() * 1000,
636 'identifier' => $promotedProduct->identifier,
637 'gamer' => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
638 'uuid' => '00702342-0000-1111-2222-c3e1500beef3',//transaction ID
639 'priceInCents' => $promotedProduct->originalPrice * 100,
640 'localPrice' => $promotedProduct->localPrice,
641 'currency' => $promotedProduct->currency,
645 $encryptedOnce = dummyEncrypt($purchasesData);
646 $encryptedTwice = dummyEncrypt($encryptedOnce);
647 return $encryptedTwice;
650 function buildSearch($games)
652 $games = sortByTitle($games);
654 foreach ($games as $game) {
656 'title' => $game->title,
657 'url' => 'ouya://launcher/details?app=' . $game->packageName,
658 'contentRating' => $game->contentRating,
662 'count' => count($results),
663 'results' => $results,
667 function dummyEncrypt($data)
670 'key' => base64_encode('0123456789abcdef'),
671 'iv' => 't3jir1LHpICunvhlM76edQ==',//random bytes
672 'blob' => base64_encode(
673 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
678 function addChunkedDiscoverRows(&$data, $games, $title)
680 $chunks = array_chunk($games, 4);
682 foreach ($chunks as $chunk) {
684 $data, $first ? $title : '',
691 function addDiscoverRow(&$data, $title, $games, $ranked = false)
699 foreach ($games as $game) {
700 if (is_string($game)) {
702 $tilePos = count($data['tiles']);
703 $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
707 if (isset($game->links->original)) {
708 //do not link unlocked games.
709 // people an access them via the original games
712 $tilePos = findTile($data['tiles'], $game->packageName);
713 if ($tilePos === null) {
714 $tilePos = count($data['tiles']);
715 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
718 $row['tiles'][] = $tilePos;
720 $data['rows'][] = $row;
723 function findTile($tiles, $packageName)
725 foreach ($tiles as $pos => $tile) {
726 if ($tile['package'] == $packageName) {
733 function buildDiscoverCategoryTile($title)
736 'url' => 'ouya://launcher/discover/' . categoryPath($title),
743 function buildDiscoverGameTile($game)
745 $latestRelease = $game->latestRelease;
747 'gamerNumbers' => $game->players,
748 'genres' => $game->genres,
749 'url' => 'ouya://launcher/details?app=' . $game->packageName,
752 'md5sum' => $latestRelease->md5sum,
754 'versionNumber' => $latestRelease->name,
755 'uuid' => $latestRelease->uuid,
757 'inAppPurchases' => $game->inAppPurchases,
758 'promotedProduct' => null,
759 'premium' => $game->premium,
761 'package' => $game->packageName,
762 'updated_at' => strtotime($latestRelease->date),
763 'updatedAt' => $latestRelease->date,
764 'title' => $game->title,
765 'image' => $game->discover,
766 'contentRating' => $game->contentRating,
768 'count' => $game->rating->count,
769 'average' => $game->rating->average,
771 'promotedProduct' => buildProduct(getPromotedProduct($game)),
775 function getAllAges($games)
778 foreach ($games as $game) {
779 $ages[] = $game->contentRating;
781 return array_unique($ages);
784 function getAllGenres($games)
787 foreach ($games as $game) {
788 $genres = array_merge($genres, $game->genres);
790 return array_unique($genres);
793 function addMissingGameProperties($game)
795 if (!isset($game->overview)) {
796 $game->overview = null;
798 if (!isset($game->description)) {
799 $game->description = '';
801 if (!isset($game->players)) {
802 $game->players = [1];
804 if (!isset($game->genres)) {
805 $game->genres = ['Unsorted'];
807 if (!isset($game->website)) {
808 $game->website = null;
810 if (!isset($game->contentRating)) {
811 $game->contentRating = 'Everyone';
813 if (!isset($game->premium)) {
814 $game->premium = false;
816 if (!isset($game->firstPublishedAt)) {
817 $game->firstPublishedAt = gmdate('c');
820 if (!isset($game->rating)) {
821 $game->rating = new stdClass();
823 if (!isset($game->rating->likeCount)) {
824 $game->rating->likeCount = 0;
826 if (!isset($game->rating->average)) {
827 $game->rating->average = 0;
829 if (!isset($game->rating->count)) {
830 $game->rating->count = 0;
833 $game->firstRelease = null;
834 $game->latestRelease = null;
835 $firstReleaseTimestamp = null;
836 $latestReleaseTimestamp = 0;
837 foreach ($game->releases as $release) {
838 if (!isset($release->publicSize)) {
839 $release->publicSize = 0;
841 if (!isset($release->nativeSize)) {
842 $release->nativeSize = 0;
845 $releaseTimestamp = strtotime($release->date);
846 if ($releaseTimestamp > $latestReleaseTimestamp) {
847 $game->latestRelease = $release;
848 $latestReleaseTimestamp = $releaseTimestamp;
850 if ($firstReleaseTimestamp === null
851 || $releaseTimestamp < $firstReleaseTimestamp
853 $game->firstRelease = $release;
854 $firstReleaseTimestamp = $releaseTimestamp;
857 if ($game->firstRelease === null) {
858 error('No first release for ' . $game->packageName);
860 if ($game->latestRelease === null) {
861 error('No latest release for ' . $game->packageName);
864 if (!isset($game->media)) {
868 if (!isset($game->developer->uuid)) {
869 $game->developer->uuid = null;
871 if (!isset($game->developer->name)) {
872 $game->developer->name = 'unknown';
874 if (!isset($game->developer->supportEmail)) {
875 $game->developer->supportEmail = null;
877 if (!isset($game->developer->supportPhone)) {
878 $game->developer->supportPhone = null;
880 if (!isset($game->developer->founder)) {
881 $game->developer->founder = false;
886 * Implements a sensible ranking system described in
887 * https://stackoverflow.com/a/1411268/2826013
889 function calculateRank(array $games)
891 $averageRatings = array_map(
893 return $game->rating->average;
897 $average = array_sum($averageRatings) / count($averageRatings);
901 foreach ($games as $game) {
902 $R = $game->rating->average;
903 $v = $game->rating->count;
904 $game->rating->rank = ($R * $v + $C * $m) / ($v + $m);
908 function getFirstVideoUrl($media)
910 foreach ($media as $medium) {
911 if ($medium->type == 'video') {
918 function getAllImageUrls($media)
921 foreach ($media as $medium) {
922 if ($medium->type == 'image') {
923 $imageUrls[] = $medium->url;
929 function getPromotedProduct($game)
931 if (!isset($game->products) || !count($game->products)) {
934 foreach ($game->products as $gameProd) {
935 if ($gameProd->promoted) {
943 * vimeo only work with HTTPS now,
944 * and the OUYA does not support SNI.
945 * We get SSL errors and no video for them :/
947 function isUnsupportedVideoUrl($url)
949 return strpos($url, '://vimeo.com/') !== false;
952 function removeMakeGames(array $games)
954 return filterByGenre($games, 'Tutorials', true);
957 function removeMakeGenres($genres)
960 foreach ($genres as $genre) {
961 if ($genre != 'Tutorials' && $genre != 'Builds') {
962 $filtered[] = $genre;
968 function rewriteUrl($url)
970 foreach ($GLOBALS['urlRewrites'] as $pattern => $replacement) {
971 $url = preg_replace($pattern, $replacement, $url);
976 function writeJson($path, $data)
979 $fullPath = $wwwDir . $path;
980 $dir = dirname($fullPath);
982 mkdir($dir, 0777, true);
986 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
992 fwrite(STDERR, $msg . "\n");