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['baseUrl'] = 'http://ouya.cweiske.de/';
22 $GLOBALS['packagelists'] = [];
23 $GLOBALS['urlRewrites'] = [];
24 $cfgFile = __DIR__ . '/../config.php';
25 if (file_exists($cfgFile)) {
29 $wwwDir = __DIR__ . '/../www/';
31 $qrDir = $wwwDir . 'gen-qr/';
32 if (!is_dir($qrDir)) {
36 $baseDir = dirname($foldersFile);
38 foreach (file($foldersFile) as $line) {
41 if (strpos($line, '..') !== false) {
42 error('Path attack in ' . $folder);
44 $folder = $baseDir . '/' . $line;
45 if (!is_dir($folder)) {
46 error('Folder does not exist: ' . $folder);
48 $gameFiles = array_merge($gameFiles, glob($folder . '/*.json'));
56 //load game data. doing early to collect a developer's games
57 foreach ($gameFiles as $gameFile) {
58 $game = json_decode(file_get_contents($gameFile));
60 error('JSON invalid at ' . $gameFile);
62 addMissingGameProperties($game);
63 $games[$game->packageName] = $game;
65 if (!isset($developers[$game->developer->uuid])) {
66 $developers[$game->developer->uuid] = [
67 'info' => $game->developer,
72 $developers[$game->developer->uuid]['gameNames'][] = $game->packageName;
75 //write json api files
76 foreach ($games as $game) {
77 $products = $game->products ?? [];
78 foreach ($products as $product) {
80 'api/v1/developers/' . $game->developer->uuid
81 . '/products/' . $product->identifier . '.json',
82 buildDeveloperProductOnly($product, $game->developer)
84 $developers[$game->developer->uuid]['products'][] = $product;
88 'api/v1/details-data/' . $game->packageName . '.json',
91 count($developers[$game->developer->uuid]['gameNames']) > 1
96 'api/v1/games/' . $game->packageName . '/purchases',
101 'api/v1/apps/' . $game->packageName . '.json',
104 $latestRelease = $game->latestRelease;
106 'api/v1/apps/' . $latestRelease->uuid . '.json',
111 'api/v1/apps/' . $latestRelease->uuid . '-download.json',
112 buildAppDownload($game, $latestRelease)
120 calculateRank($games);
122 foreach ($developers as $developer) {
124 //index.htm does not need a rewrite rule
125 'api/v1/developers/' . $developer['info']->uuid
126 . '/products/index.htm',
127 buildDeveloperProducts($developer['products'], $developer['info'])
130 'api/v1/developers/' . $developer['info']->uuid
132 buildDeveloperCurrentGamer()
135 if (count($developer['gameNames']) > 1) {
137 'api/v1/discover-data/dev--' . $developer['info']->uuid . '.json',
138 buildSpecialCategory(
139 'Developer: ' . $developer['info']->name,
140 filterByPackageNames($games, $developer['gameNames'])
146 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
147 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
151 'api/v1/discover-data/tutorials.json',
152 buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
155 $searchLetters = 'abcdefghijklmnopqrstuvwxyz0123456789., ';
156 foreach (str_split($searchLetters) as $letter) {
157 $letterGames = filterBySearchWord($games, $letter);
159 'api/v1/search-data/' . $letter . '.json',
160 buildSearch($letterGames)
165 function buildDiscover(array $games)
167 $games = removeMakeGames($games);
169 'title' => 'DISCOVER',
176 filterLastAdded($games, 10)
179 $data, 'Best rated games',
180 filterBestRatedGames($games, 10),
184 foreach ($GLOBALS['packagelists'] as $listTitle => $listPackageNames) {
187 filterByPackageNames($games, $listPackageNames)
202 'api/v1/discover-data/' . categoryPath('Best rated') . '.json',
203 buildSpecialCategory('Best rated', filterBestRated($games, 99))
206 'api/v1/discover-data/' . categoryPath('Best rated games') . '.json',
207 buildSpecialCategory('Best rated games', filterBestRatedGames($games, 99))
210 'api/v1/discover-data/' . categoryPath('Most rated') . '.json',
211 buildSpecialCategory('Most rated', filterMostDownloaded($games, 99))
214 'api/v1/discover-data/' . categoryPath('Random') . '.json',
215 buildSpecialCategory(
216 'Random ' . date('Y-m-d H:i'),
217 filterRandom($games, 99)
221 'api/v1/discover-data/' . categoryPath('Last updated') . '.json',
222 buildSpecialCategory('Last updated', filterLastUpdated($games, 99))
231 addDiscoverRow($data, 'Multiplayer', $players);
232 foreach ($players as $num => $title) {
234 'api/v1/discover-data/' . categoryPath($title) . '.json',
235 buildDiscoverCategory(
237 //I do not want emulators here,
238 // and neither Streaming apps
241 filterByPlayers($games, $num),
250 $ages = getAllAges($games);
252 addDiscoverRow($data, 'Content rating', $ages);
253 foreach ($ages as $num => $title) {
255 'api/v1/discover-data/' . categoryPath($title) . '.json',
256 buildDiscoverCategory($title, filterByAge($games, $title))
260 $genres = removeMakeGenres(getAllGenres($games));
262 addChunkedDiscoverRows($data, $genres, 'Genres');
264 foreach ($genres as $genre) {
266 'api/v1/discover-data/' . categoryPath($genre) . '.json',
267 buildDiscoverCategory($genre, filterByGenre($games, $genre))
271 $abc = array_merge(range('A', 'Z'), ['Other']);
272 addChunkedDiscoverRows($data, $abc, 'Alphabetical');
273 foreach ($abc as $letter) {
275 'api/v1/discover-data/' . categoryPath($letter) . '.json',
276 buildDiscoverCategory($letter, filterByLetter($games, $letter))
284 * A genre category page
286 function buildDiscoverCategory($name, $games)
294 $data, 'Last Updated',
295 filterLastUpdated($games, 10)
299 filterBestRated($games, 10),
303 $games = sortByTitle($games);
304 $chunks = array_chunk($games, 4);
305 foreach ($chunks as $chunkGames) {
306 addDiscoverRow($data, '', $chunkGames);
312 function buildMakeCategory($name, $games)
320 $games = sortByTitle($games);
321 addDiscoverRow($data, '', $games);
327 * Category without the "Last updated" or "Best rated" top rows
329 * Used for "Best rated", "Most rated", "Random"
331 function buildSpecialCategory($name, $games)
339 $first3 = array_slice($games, 0, 3);
340 $chunks = array_chunk(array_slice($games, 3), 4);
341 array_unshift($chunks, $first3);
343 foreach ($chunks as $chunkGames) {
344 addDiscoverRow($data, '', $chunkGames);
350 function buildDiscoverHome(array $games)
359 if (isset($GLOBALS['home'])) {
360 reset($GLOBALS['home']);
361 $title = key($GLOBALS['home']);
364 filterByPackageNames($games, $GLOBALS['home'][$title])
368 'title' => 'FEATURED',
369 'showPrice' => false,
379 * Build api/v1/apps/$packageName
381 function buildApps($game)
383 $latestRelease = $game->latestRelease;
386 $gamePromoted = getPromotedProduct($game);
388 $product = buildProduct($gamePromoted);
391 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
394 'uuid' => $latestRelease->uuid,
395 'title' => $game->title,
396 'overview' => $game->overview,
397 'description' => $game->description,
398 'gamerNumbers' => $game->players,
399 'genres' => $game->genres,
401 'website' => $game->website,
402 'contentRating' => $game->contentRating,
403 'premium' => $game->premium,
404 'firstPublishedAt' => $game->firstPublishedAt,
406 'likeCount' => $game->rating->likeCount,
407 'ratingAverage' => $game->rating->average,
408 'ratingCount' => $game->rating->count,
410 'versionNumber' => $latestRelease->name,
411 'latestVersion' => $latestRelease->uuid,
412 'md5sum' => $latestRelease->md5sum,
413 'apkFileSize' => $latestRelease->size,
414 'publishedAt' => $latestRelease->date,
415 'publicSize' => $latestRelease->publicSize,
416 'nativeSize' => $latestRelease->nativeSize,
418 'mainImageFullUrl' => $game->discover,
419 'videoUrl' => getFirstVideoUrl($game->media),
420 'filepickerScreenshots' => getAllImageUrls($game->media),
421 'mobileAppIcon' => null,
423 'developer' => $game->developer->name,
424 'supportEmailAddress' => $game->developer->supportEmail,
425 'supportPhone' => $game->developer->supportPhone,
426 'founder' => $game->developer->founder,
428 'promotedProduct' => $product,
433 function buildAppDownload($game, $release)
437 'fileSize' => $release->size,
438 'version' => $release->uuid,
439 'contentRating' => $game->contentRating,
440 'downloadLink' => $release->url,
445 function buildProduct($product)
447 if ($product === null) {
451 'type' => $product->type ?? 'entitlement',
452 'identifier' => $product->identifier,
453 'name' => $product->name,
454 'description' => $product->description ?? '',
455 'localPrice' => $product->localPrice,
456 'originalPrice' => $product->originalPrice,
457 'priceInCents' => $product->originalPrice * 100,
459 'currency' => $product->currency,
464 * Build /app/v1/details?app=org.example.game
466 function buildDetails($game, $linkDeveloperPage = false)
468 $latestRelease = $game->latestRelease;
471 if ($game->discover) {
475 'thumbnail' => $game->discover,
476 'full' => $game->discover,
480 foreach ($game->media as $medium) {
481 if ($medium->type == 'image') {
485 'thumbnail' => $medium->thumb ?? $medium->url,
486 'full' => $medium->url,
490 if (!isUnsupportedVideoUrl($medium->url)) {
493 'url' => $medium->url,
500 if (isset($game->links->unlocked)) {
502 'text' => 'Show unlocked',
503 'url' => 'ouya://launcher/details?app=' . $game->links->unlocked,
509 $gamePromoted = getPromotedProduct($game);
511 $product = buildProduct($gamePromoted);
515 if (isset($game->latestRelease->url)
516 && substr($game->latestRelease->url, 0, 29) == 'https://archive.org/download/'
518 $iaUrl = dirname($game->latestRelease->url) . '/';
521 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
524 'title' => $game->title,
525 'description' => $game->description,
526 'gamerNumbers' => $game->players,
527 'genres' => $game->genres,
529 'suggestedAge' => $game->contentRating,
530 'premium' => $game->premium,
531 'inAppPurchases' => $game->inAppPurchases,
532 'firstPublishedAt' => strtotime($game->firstPublishedAt),
536 'count' => $game->rating->count,
537 'average' => $game->rating->average,
541 'fileSize' => $latestRelease->size,
542 'nativeSize' => $latestRelease->nativeSize,
543 'publicSize' => $latestRelease->publicSize,
544 'md5sum' => $latestRelease->md5sum,
545 'filename' => 'FIXME',
547 'package' => $game->packageName,
548 'versionCode' => $latestRelease->versionCode,
549 'state' => 'complete',
553 'number' => $latestRelease->name,
554 'publishedAt' => strtotime($latestRelease->date),
555 'uuid' => $latestRelease->uuid,
559 'name' => $game->developer->name,
560 'founder' => $game->developer->founder,
564 'key:rating.average',
565 'key:developer.name',
567 number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
570 'tileImage' => $game->discover,
571 'mediaTiles' => $mediaTiles,
572 'mobileAppIcon' => null,
577 'promotedProduct' => $product,
578 'buttons' => $buttons,
581 'internet-archive' => $iaUrl,
582 'developer-url' => $game->developer->website ?? null,
586 if ($linkDeveloperPage) {
587 $data['developer']['url'] = 'ouya://launcher/discover/dev--'
588 . categoryPath($game->developer->uuid);
594 function buildDeveloperCurrentGamer()
598 'uuid' => '00702342-0000-1111-2222-c3e1500cafe2',
599 'username' => 'stouyapi',
605 * For /api/v1/developers/xxx/products/?only=yyy
607 function buildDeveloperProductOnly($product, $developer)
610 'developerName' => $developer->name,
611 'currency' => $product->currency,
613 buildProduct($product),
619 * For /api/v1/developers/xxx/products/
621 function buildDeveloperProducts($products, $developer)
624 $products = array_values(array_column($products, null, 'identifier'));
627 foreach ($products as $product) {
628 $jsonProducts[] = buildProduct($product);
631 'developerName' => $developer->name,
632 'currency' => $products[0]->currency ?? 'EUR',
633 'products' => $jsonProducts,
637 function buildPurchases($game)
642 $promotedProduct = getPromotedProduct($game);
643 if ($promotedProduct) {
644 $purchasesData['purchases'][] = [
645 'purchaseDate' => time() * 1000,
646 'generateDate' => time() * 1000,
647 'identifier' => $promotedProduct->identifier,
648 'gamer' => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
649 'uuid' => '00702342-0000-1111-2222-c3e1500beef3',//transaction ID
650 'priceInCents' => $promotedProduct->originalPrice * 100,
651 'localPrice' => $promotedProduct->localPrice,
652 'currency' => $promotedProduct->currency,
656 $encryptedOnce = dummyEncrypt($purchasesData);
657 $encryptedTwice = dummyEncrypt($encryptedOnce);
658 return $encryptedTwice;
661 function buildSearch($games)
663 $games = sortByTitle($games);
665 foreach ($games as $game) {
667 'title' => $game->title,
668 'url' => 'ouya://launcher/details?app=' . $game->packageName,
669 'contentRating' => $game->contentRating,
673 'count' => count($results),
674 'results' => $results,
678 function dummyEncrypt($data)
681 'key' => base64_encode('0123456789abcdef'),
682 'iv' => 't3jir1LHpICunvhlM76edQ==',//random bytes
683 'blob' => base64_encode(
684 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
689 function addChunkedDiscoverRows(&$data, $games, $title)
691 $chunks = array_chunk($games, 4);
693 foreach ($chunks as $chunk) {
695 $data, $first ? $title : '',
702 function addDiscoverRow(&$data, $title, $games, $ranked = false)
710 foreach ($games as $game) {
711 if (is_string($game)) {
713 $tilePos = count($data['tiles']);
714 $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
718 if (isset($game->links->original)) {
719 //do not link unlocked games.
720 // people an access them via the original games
723 $tilePos = findTile($data['tiles'], $game->packageName);
724 if ($tilePos === null) {
725 $tilePos = count($data['tiles']);
726 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
729 $row['tiles'][] = $tilePos;
731 $data['rows'][] = $row;
734 function findTile($tiles, $packageName)
736 foreach ($tiles as $pos => $tile) {
737 if ($tile['package'] == $packageName) {
744 function buildDiscoverCategoryTile($title)
747 'url' => 'ouya://launcher/discover/' . categoryPath($title),
754 function buildDiscoverGameTile($game)
756 $latestRelease = $game->latestRelease;
758 'gamerNumbers' => $game->players,
759 'genres' => $game->genres,
760 'url' => 'ouya://launcher/details?app=' . $game->packageName,
763 'md5sum' => $latestRelease->md5sum,
765 'versionNumber' => $latestRelease->name,
766 'uuid' => $latestRelease->uuid,
768 'inAppPurchases' => $game->inAppPurchases,
769 'promotedProduct' => null,
770 'premium' => $game->premium,
772 'package' => $game->packageName,
773 'updated_at' => strtotime($latestRelease->date),
774 'updatedAt' => $latestRelease->date,
775 'title' => $game->title,
776 'image' => $game->discover,
777 'contentRating' => $game->contentRating,
779 'count' => $game->rating->count,
780 'average' => $game->rating->average,
782 'promotedProduct' => buildProduct(getPromotedProduct($game)),
786 function getAllAges($games)
789 foreach ($games as $game) {
790 $ages[] = $game->contentRating;
792 return array_unique($ages);
795 function getAllGenres($games)
798 foreach ($games as $game) {
799 $genres = array_merge($genres, $game->genres);
801 return array_unique($genres);
804 function addMissingGameProperties($game)
806 if (!isset($game->overview)) {
807 $game->overview = null;
809 if (!isset($game->description)) {
810 $game->description = '';
812 if (!isset($game->players)) {
813 $game->players = [1];
815 if (!isset($game->genres)) {
816 $game->genres = ['Unsorted'];
818 if (!isset($game->website)) {
819 $game->website = null;
821 if (!isset($game->contentRating)) {
822 $game->contentRating = 'Everyone';
824 if (!isset($game->premium)) {
825 $game->premium = false;
827 if (!isset($game->firstPublishedAt)) {
828 $game->firstPublishedAt = gmdate('c');
831 if (!isset($game->rating)) {
832 $game->rating = new stdClass();
834 if (!isset($game->rating->likeCount)) {
835 $game->rating->likeCount = 0;
837 if (!isset($game->rating->average)) {
838 $game->rating->average = 0;
840 if (!isset($game->rating->count)) {
841 $game->rating->count = 0;
844 $game->firstRelease = null;
845 $game->latestRelease = null;
846 $firstReleaseTimestamp = null;
847 $latestReleaseTimestamp = 0;
848 foreach ($game->releases as $release) {
849 if (!isset($release->publicSize)) {
850 $release->publicSize = 0;
852 if (!isset($release->nativeSize)) {
853 $release->nativeSize = 0;
856 $releaseTimestamp = strtotime($release->date);
857 if ($releaseTimestamp > $latestReleaseTimestamp) {
858 $game->latestRelease = $release;
859 $latestReleaseTimestamp = $releaseTimestamp;
861 if ($firstReleaseTimestamp === null
862 || $releaseTimestamp < $firstReleaseTimestamp
864 $game->firstRelease = $release;
865 $firstReleaseTimestamp = $releaseTimestamp;
868 if ($game->firstRelease === null) {
869 error('No first release for ' . $game->packageName);
871 if ($game->latestRelease === null) {
872 error('No latest release for ' . $game->packageName);
875 if (!isset($game->media)) {
879 if (!isset($game->developer->uuid)) {
880 $game->developer->uuid = null;
882 if (!isset($game->developer->name)) {
883 $game->developer->name = 'unknown';
885 if (!isset($game->developer->supportEmail)) {
886 $game->developer->supportEmail = null;
888 if (!isset($game->developer->supportPhone)) {
889 $game->developer->supportPhone = null;
891 if (!isset($game->developer->founder)) {
892 $game->developer->founder = false;
895 if ($game->website) {
896 $qrfileName = preg_replace('#[^\\w\\d._-]#', '_', $game->website) . '.png';
897 $qrfilePath = $GLOBALS['qrDir'] . $qrfileName;
898 if (!file_exists($qrfilePath)) {
899 $cmd = __DIR__ . '/create-qr.sh'
900 . ' ' . escapeshellarg($game->website)
901 . ' ' . escapeshellarg($qrfilePath);
902 passthru($cmd, $retval);
907 $qrUrlPath = $GLOBALS['baseUrl'] . 'gen-qr/' . $qrfileName;
908 $game->media[] = (object) [
914 //rewrite urls from Internet Archive to our servers
915 $game->discover = rewriteUrl($game->discover);
916 foreach ($game->media as $medium) {
917 $medium->url = rewriteUrl($medium->url);
919 foreach ($game->releases as $release) {
920 $release->url = rewriteUrl($release->url);
925 * Implements a sensible ranking system described in
926 * https://stackoverflow.com/a/1411268/2826013
928 function calculateRank(array $games)
930 $averageRatings = array_map(
932 return $game->rating->average;
936 $average = array_sum($averageRatings) / count($averageRatings);
940 foreach ($games as $game) {
941 $R = $game->rating->average;
942 $v = $game->rating->count;
943 $game->rating->rank = ($R * $v + $C * $m) / ($v + $m);
947 function getFirstVideoUrl($media)
949 foreach ($media as $medium) {
950 if ($medium->type == 'video') {
957 function getAllImageUrls($media)
960 foreach ($media as $medium) {
961 if ($medium->type == 'image') {
962 $imageUrls[] = $medium->url;
968 function getPromotedProduct($game)
970 if (!isset($game->products) || !count($game->products)) {
973 foreach ($game->products as $gameProd) {
974 if ($gameProd->promoted) {
982 * vimeo only work with HTTPS now,
983 * and the OUYA does not support SNI.
984 * We get SSL errors and no video for them :/
986 function isUnsupportedVideoUrl($url)
988 return strpos($url, '://vimeo.com/') !== false;
991 function removeMakeGames(array $games)
993 return filterByGenre($games, 'Tutorials', true);
996 function removeMakeGenres($genres)
999 foreach ($genres as $genre) {
1000 if ($genre != 'Tutorials' && $genre != 'Builds') {
1001 $filtered[] = $genre;
1007 function rewriteUrl($url)
1009 foreach ($GLOBALS['urlRewrites'] as $pattern => $replacement) {
1010 $url = preg_replace($pattern, $replacement, $url);
1015 function writeJson($path, $data)
1018 $fullPath = $wwwDir . $path;
1019 $dir = dirname($fullPath);
1020 if (!is_dir($dir)) {
1021 mkdir($dir, 0777, true);
1025 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
1029 function error($msg)
1031 fwrite(STDERR, $msg . "\n");