4 * Import games from a OUYA game data repository
6 * @link https://github.com/cweiske/ouya-game-data/
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['categorySubtitles'] = [];
23 $GLOBALS['packagelists'] = [];
24 $GLOBALS['urlRewrites'] = [];
25 $cfgFile = __DIR__ . '/../config.php';
26 if (file_exists($cfgFile)) {
30 $wwwDir = __DIR__ . '/../www/';
32 $qrDir = $wwwDir . 'gen-qr/';
33 if (!is_dir($qrDir)) {
37 $baseDir = dirname($foldersFile);
39 foreach (file($foldersFile) as $line) {
42 if (strpos($line, '..') !== false) {
43 error('Path attack in ' . $folder);
45 $folder = $baseDir . '/' . $line;
46 if (!is_dir($folder)) {
47 error('Folder does not exist: ' . $folder);
49 $gameFiles = array_merge($gameFiles, glob($folder . '/*.json'));
53 //store git repository version of last folder
56 $gitDate = `git log --max-count=1 --format="%h %cI"`;
58 file_put_contents($wwwDir . '/game-data-version', $gitDate);
64 //load game data. doing early to collect a developer's games
65 foreach ($gameFiles as $gameFile) {
66 $game = json_decode(file_get_contents($gameFile));
68 error('JSON invalid at ' . $gameFile);
70 addMissingGameProperties($game);
71 $games[$game->packageName] = $game;
73 if (!isset($developers[$game->developer->uuid])) {
74 $developers[$game->developer->uuid] = [
75 'info' => $game->developer,
80 $developers[$game->developer->uuid]['gameNames'][] = $game->packageName;
83 //write json api files
84 foreach ($games as $game) {
85 $products = $game->products ?? [];
86 foreach ($products as $product) {
88 'api/v1/developers/' . $game->developer->uuid
89 . '/products/' . $product->identifier . '.json',
90 buildDeveloperProductOnly($product, $game->developer)
92 $developers[$game->developer->uuid]['products'][] = $product;
96 'api/v1/details-data/' . $game->packageName . '.json',
99 count($developers[$game->developer->uuid]['gameNames']) > 1
104 'api/v1/games/' . $game->packageName . '/purchases',
105 buildPurchases($game)
109 'api/v1/apps/' . $game->packageName . '.json',
112 $latestRelease = $game->latestRelease;
114 'api/v1/apps/' . $latestRelease->uuid . '.json',
119 'api/v1/apps/' . $latestRelease->uuid . '-download.json',
120 buildAppDownload($game, $latestRelease)
128 calculateRank($games);
130 foreach ($developers as $developer) {
132 //index.htm does not need a rewrite rule
133 'api/v1/developers/' . $developer['info']->uuid
134 . '/products/index.htm',
135 buildDeveloperProducts($developer['products'], $developer['info'])
138 'api/v1/developers/' . $developer['info']->uuid
140 buildDeveloperCurrentGamer()
143 if (count($developer['gameNames']) > 1) {
145 'api/v1/discover-data/dev--' . $developer['info']->uuid . '.json',
146 buildSpecialCategory(
147 'Developer: ' . $developer['info']->name,
148 filterByPackageNames($games, $developer['gameNames'])
154 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
155 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
159 'api/v1/discover-data/tutorials.json',
160 buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
163 $searchLetters = 'abcdefghijklmnopqrstuvwxyz0123456789., ';
164 foreach (str_split($searchLetters) as $letter) {
165 $letterGames = filterBySearchWord($games, $letter);
167 'api/v1/search-data/' . $letter . '.json',
168 buildSearch($letterGames)
173 function buildDiscover(array $games)
175 $games = removeMakeGames($games);
177 'title' => 'DISCOVER',
184 filterLastAdded($games, 10)
187 $data, 'Best rated games',
188 filterBestRatedGames($games, 10),
192 foreach ($GLOBALS['packagelists'] as $listTitle => $listPackageNames) {
195 filterByPackageNames($games, $listPackageNames)
210 'api/v1/discover-data/' . categoryPath('Best rated') . '.json',
211 buildSpecialCategory('Best rated', filterBestRated($games, 99))
214 'api/v1/discover-data/' . categoryPath('Best rated games') . '.json',
215 buildSpecialCategory('Best rated games', filterBestRatedGames($games, 99))
218 'api/v1/discover-data/' . categoryPath('Most rated') . '.json',
219 buildSpecialCategory('Most rated', filterMostDownloaded($games, 99))
222 'api/v1/discover-data/' . categoryPath('Random') . '.json',
223 buildSpecialCategory(
224 'Random ' . date('Y-m-d H:i'),
225 filterRandom($games, 99)
229 'api/v1/discover-data/' . categoryPath('Last updated') . '.json',
230 buildSpecialCategory('Last updated', filterLastUpdated($games, 99))
239 addDiscoverRow($data, 'Multiplayer', $players);
240 foreach ($players as $num => $title) {
242 'api/v1/discover-data/' . categoryPath($title) . '.json',
243 buildDiscoverCategory(
245 //I do not want emulators here,
246 // and neither Streaming apps
249 filterByPlayers($games, $num),
258 $ages = getAllAges($games);
260 addDiscoverRow($data, 'Content rating', $ages);
261 foreach ($ages as $num => $title) {
263 'api/v1/discover-data/' . categoryPath($title) . '.json',
264 buildDiscoverCategory($title, filterByAge($games, $title))
268 $genres = removeMakeGenres(getAllGenres($games));
270 addChunkedDiscoverRows($data, $genres, 'Genres');
272 foreach ($genres as $genre) {
274 'api/v1/discover-data/' . categoryPath($genre) . '.json',
275 buildDiscoverCategory($genre, filterByGenre($games, $genre))
279 $abc = array_merge(range('A', 'Z'), ['Other']);
280 addChunkedDiscoverRows($data, $abc, 'Alphabetical');
281 foreach ($abc as $letter) {
283 'api/v1/discover-data/' . categoryPath($letter) . '.json',
284 buildDiscoverCategory($letter, filterByLetter($games, $letter))
292 * A genre category page
294 function buildDiscoverCategory($name, $games)
301 if (isset($GLOBALS['categorySubtitles'][$name])) {
302 $data['stouyapi']['subtitle'] = $GLOBALS['categorySubtitles'][$name];
305 if (count($games) >= 20) {
307 $data, 'Last Updated',
308 filterLastUpdated($games, 10)
312 filterBestRated($games, 10),
317 $games = sortByTitle($games);
318 $chunks = array_chunk($games, 4);
319 foreach ($chunks as $chunkGames) {
320 addDiscoverRow($data, '', $chunkGames);
326 function buildMakeCategory($name, $games)
334 $games = sortByTitle($games);
335 addDiscoverRow($data, '', $games);
341 * Category without the "Last updated" or "Best rated" top rows
343 * Used for "Best rated", "Most rated", "Random"
345 function buildSpecialCategory($name, $games)
353 $first3 = array_slice($games, 0, 3);
354 $chunks = array_chunk(array_slice($games, 3), 4);
355 array_unshift($chunks, $first3);
357 foreach ($chunks as $chunkGames) {
358 addDiscoverRow($data, '', $chunkGames);
364 function buildDiscoverHome(array $games)
373 if (isset($GLOBALS['home'])) {
374 reset($GLOBALS['home']);
375 $title = key($GLOBALS['home']);
378 filterByPackageNames($games, $GLOBALS['home'][$title])
382 'title' => 'FEATURED',
383 'showPrice' => false,
393 * Build api/v1/apps/$packageName
395 function buildApps($game)
397 $latestRelease = $game->latestRelease;
400 $gamePromoted = getPromotedProduct($game);
402 $product = buildProduct($gamePromoted);
405 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
408 'uuid' => $latestRelease->uuid,
409 'title' => $game->title,
410 'overview' => $game->overview,
411 'description' => $game->description,
412 'gamerNumbers' => $game->players,
413 'genres' => $game->genres,
415 'website' => $game->website,
416 'contentRating' => $game->contentRating,
417 'premium' => $game->premium,
418 'firstPublishedAt' => $game->firstPublishedAt,
420 'likeCount' => $game->rating->likeCount,
421 'ratingAverage' => $game->rating->average,
422 'ratingCount' => $game->rating->count,
424 'versionNumber' => $latestRelease->name,
425 'latestVersion' => $latestRelease->uuid,
426 'md5sum' => $latestRelease->md5sum,
427 'apkFileSize' => $latestRelease->size,
428 'publishedAt' => $latestRelease->date,
429 'publicSize' => $latestRelease->publicSize,
430 'nativeSize' => $latestRelease->nativeSize,
432 'mainImageFullUrl' => $game->discover,
433 'videoUrl' => getFirstVideoUrl($game->media),
434 'filepickerScreenshots' => getAllImageUrls($game->media),
435 'mobileAppIcon' => null,
437 'developer' => $game->developer->name,
438 'supportEmailAddress' => $game->developer->supportEmail,
439 'supportPhone' => $game->developer->supportPhone,
440 'founder' => $game->developer->founder,
442 'promotedProduct' => $product,
447 function buildAppDownload($game, $release)
451 'fileSize' => $release->size,
452 'version' => $release->uuid,
453 'contentRating' => $game->contentRating,
454 'downloadLink' => $release->url,
459 function buildProduct($product)
461 if ($product === null) {
465 'type' => $product->type ?? 'entitlement',
466 'identifier' => $product->identifier,
467 'name' => $product->name,
468 'description' => $product->description ?? '',
469 'localPrice' => $product->localPrice,
470 'originalPrice' => $product->originalPrice,
471 'priceInCents' => $product->originalPrice * 100,
473 'currency' => $product->currency,
478 * Build /app/v1/details?app=org.example.game
480 function buildDetails($game, $linkDeveloperPage = false)
482 $latestRelease = $game->latestRelease;
485 if ($game->discover) {
489 'thumbnail' => $game->discover,
490 'full' => $game->discover,
494 foreach ($game->media as $medium) {
495 if ($medium->type == 'image') {
499 'thumbnail' => $medium->thumb ?? $medium->url,
500 'full' => $medium->url,
504 if (!isUnsupportedVideoUrl($medium->url)) {
507 'url' => $medium->url,
514 if (isset($game->links->unlocked)) {
516 'text' => 'Show unlocked',
517 'url' => 'ouya://launcher/details?app=' . $game->links->unlocked,
523 $gamePromoted = getPromotedProduct($game);
525 $product = buildProduct($gamePromoted);
529 if (isset($game->latestRelease->url)
530 && substr($game->latestRelease->url, 0, 29) == 'https://archive.org/download/'
532 $iaUrl = dirname($game->latestRelease->url) . '/';
535 $description = $game->description;
536 if (isset($game->notes) && trim($game->notes)) {
537 $description = "Technical notes:\r\n" . $game->notes
542 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
545 'title' => $game->title,
546 'description' => $description,
547 'gamerNumbers' => $game->players,
548 'genres' => $game->genres,
550 'suggestedAge' => $game->contentRating,
551 'premium' => $game->premium,
552 'inAppPurchases' => $game->inAppPurchases,
553 'firstPublishedAt' => strtotime($game->firstPublishedAt),
557 'count' => $game->rating->count,
558 'average' => $game->rating->average,
562 'fileSize' => $latestRelease->size,
563 'nativeSize' => $latestRelease->nativeSize,
564 'publicSize' => $latestRelease->publicSize,
565 'md5sum' => $latestRelease->md5sum,
566 'filename' => 'FIXME',
568 'package' => $game->packageName,
569 'versionCode' => $latestRelease->versionCode,
570 'state' => 'complete',
574 'number' => $latestRelease->name,
575 'publishedAt' => strtotime($latestRelease->date),
576 'uuid' => $latestRelease->uuid,
580 'name' => $game->developer->name,
581 'founder' => $game->developer->founder,
585 'key:rating.average',
586 'key:developer.name',
588 number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
591 'tileImage' => $game->discover,
592 'mediaTiles' => $mediaTiles,
593 'mobileAppIcon' => null,
598 'promotedProduct' => $product,
599 'buttons' => $buttons,
602 'internet-archive' => $iaUrl,
603 'developer-url' => $game->developer->website ?? null,
607 if ($linkDeveloperPage) {
608 $data['developer']['url'] = 'ouya://launcher/discover/dev--'
609 . categoryPath($game->developer->uuid);
615 function buildDeveloperCurrentGamer()
619 'uuid' => '00702342-0000-1111-2222-c3e1500cafe2',
620 'username' => 'stouyapi',
626 * For /api/v1/developers/xxx/products/?only=yyy
628 function buildDeveloperProductOnly($product, $developer)
631 'developerName' => $developer->name,
632 'currency' => $product->currency,
634 buildProduct($product),
640 * For /api/v1/developers/xxx/products/
642 function buildDeveloperProducts($products, $developer)
645 $products = array_values(array_column($products, null, 'identifier'));
648 foreach ($products as $product) {
649 $jsonProducts[] = buildProduct($product);
652 'developerName' => $developer->name,
653 'currency' => $products[0]->currency ?? 'EUR',
654 'products' => $jsonProducts,
658 function buildPurchases($game)
663 $promotedProduct = getPromotedProduct($game);
664 if ($promotedProduct) {
665 $purchasesData['purchases'][] = [
666 'purchaseDate' => time() * 1000,
667 'generateDate' => time() * 1000,
668 'identifier' => $promotedProduct->identifier,
669 'gamer' => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
670 'uuid' => '00702342-0000-1111-2222-c3e1500beef3',//transaction ID
671 'priceInCents' => $promotedProduct->originalPrice * 100,
672 'localPrice' => $promotedProduct->localPrice,
673 'currency' => $promotedProduct->currency,
677 $encryptedOnce = dummyEncrypt($purchasesData);
678 $encryptedTwice = dummyEncrypt($encryptedOnce);
679 return $encryptedTwice;
682 function buildSearch($games)
684 $games = sortByTitle($games);
686 foreach ($games as $game) {
688 'title' => $game->title,
689 'url' => 'ouya://launcher/details?app=' . $game->packageName,
690 'contentRating' => $game->contentRating,
694 'count' => count($results),
695 'results' => $results,
699 function dummyEncrypt($data)
702 'key' => base64_encode('0123456789abcdef'),
703 'iv' => 't3jir1LHpICunvhlM76edQ==',//random bytes
704 'blob' => base64_encode(
705 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
710 function addChunkedDiscoverRows(&$data, $games, $title)
712 $chunks = array_chunk($games, 4);
714 foreach ($chunks as $chunk) {
716 $data, $first ? $title : '',
723 function addDiscoverRow(&$data, $title, $games, $ranked = false)
731 foreach ($games as $game) {
732 if (is_string($game)) {
734 $tilePos = count($data['tiles']);
735 $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
739 if (isset($game->links->original)) {
740 //do not link unlocked games.
741 // people an access them via the original games
744 $tilePos = findTile($data['tiles'], $game->packageName);
745 if ($tilePos === null) {
746 $tilePos = count($data['tiles']);
747 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
750 $row['tiles'][] = $tilePos;
752 $data['rows'][] = $row;
755 function findTile($tiles, $packageName)
757 foreach ($tiles as $pos => $tile) {
758 if ($tile['package'] == $packageName) {
765 function buildDiscoverCategoryTile($title)
768 'url' => 'ouya://launcher/discover/' . categoryPath($title),
775 function buildDiscoverGameTile($game)
777 $latestRelease = $game->latestRelease;
779 'gamerNumbers' => $game->players,
780 'genres' => $game->genres,
781 'url' => 'ouya://launcher/details?app=' . $game->packageName,
784 'md5sum' => $latestRelease->md5sum,
786 'versionNumber' => $latestRelease->name,
787 'uuid' => $latestRelease->uuid,
789 'inAppPurchases' => $game->inAppPurchases,
790 'promotedProduct' => null,
791 'premium' => $game->premium,
793 'package' => $game->packageName,
794 'updated_at' => strtotime($latestRelease->date),
795 'updatedAt' => $latestRelease->date,
796 'title' => $game->title,
797 'image' => $game->discover,
798 'contentRating' => $game->contentRating,
800 'count' => $game->rating->count,
801 'average' => $game->rating->average,
803 'promotedProduct' => buildProduct(getPromotedProduct($game)),
807 function getAllAges($games)
810 foreach ($games as $game) {
811 $ages[] = $game->contentRating;
813 return array_unique($ages);
816 function getAllGenres($games)
819 foreach ($games as $game) {
820 $genres = array_merge($genres, $game->genres);
822 return array_unique($genres);
825 function addMissingGameProperties($game)
827 if (!isset($game->overview)) {
828 $game->overview = null;
830 if (!isset($game->description)) {
831 $game->description = '';
833 if (!isset($game->players)) {
834 $game->players = [1];
836 if (!isset($game->genres)) {
837 $game->genres = ['Unsorted'];
839 if (!isset($game->website)) {
840 $game->website = null;
842 if (!isset($game->contentRating)) {
843 $game->contentRating = 'Everyone';
845 if (!isset($game->premium)) {
846 $game->premium = false;
848 if (!isset($game->firstPublishedAt)) {
849 $game->firstPublishedAt = gmdate('c');
852 if (!isset($game->rating)) {
853 $game->rating = new stdClass();
855 if (!isset($game->rating->likeCount)) {
856 $game->rating->likeCount = 0;
858 if (!isset($game->rating->average)) {
859 $game->rating->average = 0;
861 if (!isset($game->rating->count)) {
862 $game->rating->count = 0;
865 $game->firstRelease = null;
866 $game->latestRelease = null;
867 $firstReleaseTimestamp = null;
868 $latestReleaseTimestamp = 0;
869 foreach ($game->releases as $release) {
870 if (!isset($release->publicSize)) {
871 $release->publicSize = 0;
873 if (!isset($release->nativeSize)) {
874 $release->nativeSize = 0;
877 $releaseTimestamp = strtotime($release->date);
878 if ($releaseTimestamp > $latestReleaseTimestamp) {
879 $game->latestRelease = $release;
880 $latestReleaseTimestamp = $releaseTimestamp;
882 if ($firstReleaseTimestamp === null
883 || $releaseTimestamp < $firstReleaseTimestamp
885 $game->firstRelease = $release;
886 $firstReleaseTimestamp = $releaseTimestamp;
889 if ($game->firstRelease === null) {
890 error('No first release for ' . $game->packageName);
892 if ($game->latestRelease === null) {
893 error('No latest release for ' . $game->packageName);
896 if (!isset($game->media)) {
900 if (!isset($game->developer->uuid)) {
901 $game->developer->uuid = null;
903 if (!isset($game->developer->name)) {
904 $game->developer->name = 'unknown';
906 if (!isset($game->developer->supportEmail)) {
907 $game->developer->supportEmail = null;
909 if (!isset($game->developer->supportPhone)) {
910 $game->developer->supportPhone = null;
912 if (!isset($game->developer->founder)) {
913 $game->developer->founder = false;
916 if ($game->website) {
917 $qrfileName = preg_replace('#[^\\w\\d._-]#', '_', $game->website) . '.png';
918 $qrfilePath = $GLOBALS['qrDir'] . $qrfileName;
919 if (!file_exists($qrfilePath)) {
920 $cmd = __DIR__ . '/create-qr.sh'
921 . ' ' . escapeshellarg($game->website)
922 . ' ' . escapeshellarg($qrfilePath);
923 passthru($cmd, $retval);
928 $qrUrlPath = $GLOBALS['baseUrl'] . 'gen-qr/' . $qrfileName;
929 $game->media[] = (object) [
935 //rewrite urls from Internet Archive to our servers
936 $game->discover = rewriteUrl($game->discover);
937 foreach ($game->media as $medium) {
938 $medium->url = rewriteUrl($medium->url);
940 foreach ($game->releases as $release) {
941 $release->url = rewriteUrl($release->url);
946 * Implements a sensible ranking system described in
947 * https://stackoverflow.com/a/1411268/2826013
949 function calculateRank(array $games)
951 $averageRatings = array_map(
953 return $game->rating->average;
957 $average = array_sum($averageRatings) / count($averageRatings);
961 foreach ($games as $game) {
962 $R = $game->rating->average;
963 $v = $game->rating->count;
964 $game->rating->rank = ($R * $v + $C * $m) / ($v + $m);
968 function getFirstVideoUrl($media)
970 foreach ($media as $medium) {
971 if ($medium->type == 'video') {
978 function getAllImageUrls($media)
981 foreach ($media as $medium) {
982 if ($medium->type == 'image') {
983 $imageUrls[] = $medium->url;
989 function getPromotedProduct($game)
991 if (!isset($game->products) || !count($game->products)) {
994 foreach ($game->products as $gameProd) {
995 if ($gameProd->promoted) {
1003 * vimeo only work with HTTPS now,
1004 * and the OUYA does not support SNI.
1005 * We get SSL errors and no video for them :/
1007 function isUnsupportedVideoUrl($url)
1009 return strpos($url, '://vimeo.com/') !== false;
1012 function removeMakeGames(array $games)
1014 return filterByGenre($games, 'Tutorials', true);
1017 function removeMakeGenres($genres)
1020 foreach ($genres as $genre) {
1021 if ($genre != 'Tutorials' && $genre != 'Builds') {
1022 $filtered[] = $genre;
1028 function rewriteUrl($url)
1030 foreach ($GLOBALS['urlRewrites'] as $pattern => $replacement) {
1031 $url = preg_replace($pattern, $replacement, $url);
1036 function writeJson($path, $data)
1039 $fullPath = $wwwDir . $path;
1040 $dir = dirname($fullPath);
1041 if (!is_dir($dir)) {
1042 mkdir($dir, 0777, true);
1046 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
1050 function error($msg)
1052 fwrite(STDERR, $msg . "\n");