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';
13 //command line option parsing
15 $opts = getopt('h', ['help', 'mini', 'noqr'], $optind);
16 $args = array_slice($argv, $optind);
18 if (isset($opts['help']) || isset($opts['h'])) {
19 echo "Import games from a OUYA game data repository\n";
21 echo "Usage: import-game-data.php [--mini] [--noqr] [--help|-h]\n";
22 echo " --mini Generate small but ugly JSON files\n";
23 echo " --noqr Do not generate and link QR code images\n";
27 if (!isset($args[0])) {
28 error('Pass the path to a "folders" file with game data json files folder names');
30 $foldersFile = $args[0];
31 if (!is_file($foldersFile)) {
32 error('Given path is not a file: ' . $foldersFile);
35 $cfgMini = isset($opts['mini']);
36 $cfgEnableQr = !isset($opts['noqr']);
39 //default configuration values
40 $GLOBALS['baseUrl'] = 'http://ouya.cweiske.de/';
41 $GLOBALS['categorySubtitles'] = [];
42 $GLOBALS['packagelists'] = [];
43 $GLOBALS['urlRewrites'] = [];
44 $cfgFile = __DIR__ . '/../config.php';
45 if (file_exists($cfgFile)) {
49 $wwwDir = __DIR__ . '/../www/';
52 $qrDir = $wwwDir . 'gen-qr/';
53 if (!is_dir($qrDir)) {
58 $baseDir = dirname($foldersFile);
60 foreach (file($foldersFile) as $line) {
63 if (strpos($line, '..') !== false) {
64 error('Path attack in ' . $folder);
66 $folder = $baseDir . '/' . $line;
67 if (!is_dir($folder)) {
68 error('Folder does not exist: ' . $folder);
70 $gameFiles = array_merge($gameFiles, glob($folder . '/*.json'));
74 //store git repository version of last folder
77 $gitDate = `git log --max-count=1 --format="%h %cI"`;
79 file_put_contents($wwwDir . '/game-data-version', $gitDate);
85 //load game data. doing early to collect a developer's games
86 foreach ($gameFiles as $gameFile) {
87 $game = json_decode(file_get_contents($gameFile));
89 error('JSON invalid at ' . $gameFile);
91 addMissingGameProperties($game);
92 $games[$game->packageName] = $game;
94 if (!isset($developers[$game->developer->uuid])) {
95 $developers[$game->developer->uuid] = [
96 'info' => $game->developer,
101 $developers[$game->developer->uuid]['gameNames'][] = $game->packageName;
104 //write json api files
105 foreach ($games as $game) {
106 $products = $game->products ?? [];
107 foreach ($products as $product) {
109 'api/v1/developers/' . $game->developer->uuid
110 . '/products/' . $product->identifier . '.json',
111 buildDeveloperProductOnly($product, $game->developer)
113 $developers[$game->developer->uuid]['products'][] = $product;
117 'api/v1/details-data/' . $game->packageName . '.json',
120 count($developers[$game->developer->uuid]['gameNames']) > 1
125 'api/v1/games/' . $game->packageName . '/purchases',
126 buildPurchases($game)
130 'api/v1/apps/' . $game->packageName . '.json',
133 $latestRelease = $game->latestRelease;
135 'api/v1/apps/' . $latestRelease->uuid . '.json',
140 'api/v1/apps/' . $latestRelease->uuid . '-download.json',
141 buildAppDownload($game, $latestRelease)
149 calculateRank($games);
151 foreach ($developers as $developer) {
153 //index.htm does not need a rewrite rule
154 'api/v1/developers/' . $developer['info']->uuid
155 . '/products/index.htm',
156 buildDeveloperProducts($developer['products'], $developer['info'])
159 'api/v1/developers/' . $developer['info']->uuid
161 buildDeveloperCurrentGamer()
164 if (count($developer['gameNames']) > 1) {
166 'api/v1/discover-data/dev--' . $developer['info']->uuid . '.json',
167 buildSpecialCategory(
168 'Developer: ' . $developer['info']->name,
169 filterByPackageNames($games, $developer['gameNames'])
175 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
176 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
180 'api/v1/discover-data/tutorials.json',
181 buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
184 $searchLetters = 'abcdefghijklmnopqrstuvwxyz0123456789., ';
185 foreach (str_split($searchLetters) as $letter) {
186 $letterGames = filterBySearchWord($games, $letter);
188 'api/v1/search-data/' . $letter . '.json',
189 buildSearch($letterGames)
194 function buildDiscover(array $games)
196 $games = removeMakeGames($games);
198 'title' => 'DISCOVER',
205 filterLastAdded($games, 10)
208 $data, 'Best rated games',
209 filterBestRatedGames($games, 10),
213 foreach ($GLOBALS['packagelists'] as $listTitle => $listPackageNames) {
216 filterByPackageNames($games, $listPackageNames)
231 'api/v1/discover-data/' . categoryPath('Best rated') . '.json',
232 buildSpecialCategory('Best rated', filterBestRated($games, 99))
235 'api/v1/discover-data/' . categoryPath('Best rated games') . '.json',
236 buildSpecialCategory('Best rated games', filterBestRatedGames($games, 99))
239 'api/v1/discover-data/' . categoryPath('Most rated') . '.json',
240 buildSpecialCategory('Most rated', filterMostDownloaded($games, 99))
243 'api/v1/discover-data/' . categoryPath('Random') . '.json',
244 buildSpecialCategory(
245 'Random ' . date('Y-m-d H:i'),
246 filterRandom($games, 99)
250 'api/v1/discover-data/' . categoryPath('Last updated') . '.json',
251 buildSpecialCategory('Last updated', filterLastUpdated($games, 99))
260 addDiscoverRow($data, 'Multiplayer', $players);
261 foreach ($players as $num => $title) {
263 'api/v1/discover-data/' . categoryPath($title) . '.json',
264 buildDiscoverCategory(
266 //I do not want emulators here,
267 // and neither Streaming apps
270 filterByPlayers($games, $num),
279 $ages = getAllAges($games);
281 addDiscoverRow($data, 'Content rating', $ages);
282 foreach ($ages as $num => $title) {
284 'api/v1/discover-data/' . categoryPath($title) . '.json',
285 buildDiscoverCategory($title, filterByAge($games, $title))
289 $genres = removeMakeGenres(getAllGenres($games));
291 addChunkedDiscoverRows($data, $genres, 'Genres');
293 foreach ($genres as $genre) {
295 'api/v1/discover-data/' . categoryPath($genre) . '.json',
296 buildDiscoverCategory($genre, filterByGenre($games, $genre))
300 $abc = array_merge(range('A', 'Z'), ['Other']);
301 addChunkedDiscoverRows($data, $abc, 'Alphabetical');
302 foreach ($abc as $letter) {
304 'api/v1/discover-data/' . categoryPath($letter) . '.json',
305 buildDiscoverCategory($letter, filterByLetter($games, $letter))
313 * A genre category page
315 function buildDiscoverCategory($name, $games)
322 if (isset($GLOBALS['categorySubtitles'][$name])) {
323 $data['stouyapi']['subtitle'] = $GLOBALS['categorySubtitles'][$name];
326 if (count($games) >= 20) {
328 $data, 'Last Updated',
329 filterLastUpdated($games, 10)
333 filterBestRated($games, 10),
338 $games = sortByTitle($games);
339 $chunks = array_chunk($games, 4);
340 foreach ($chunks as $chunkGames) {
341 addDiscoverRow($data, '', $chunkGames);
347 function buildMakeCategory($name, $games)
355 $games = sortByTitle($games);
356 addDiscoverRow($data, '', $games);
362 * Category without the "Last updated" or "Best rated" top rows
364 * Used for "Best rated", "Most rated", "Random"
366 function buildSpecialCategory($name, $games)
374 $first3 = array_slice($games, 0, 3);
375 $chunks = array_chunk(array_slice($games, 3), 4);
376 array_unshift($chunks, $first3);
378 foreach ($chunks as $chunkGames) {
379 addDiscoverRow($data, '', $chunkGames);
385 function buildDiscoverHome(array $games)
394 if (isset($GLOBALS['home'])) {
395 reset($GLOBALS['home']);
396 $title = key($GLOBALS['home']);
399 filterByPackageNames($games, $GLOBALS['home'][$title])
403 'title' => 'FEATURED',
404 'showPrice' => false,
414 * Build api/v1/apps/$packageName
416 function buildApps($game)
418 $latestRelease = $game->latestRelease;
421 $gamePromoted = getPromotedProduct($game);
423 $product = buildProduct($gamePromoted);
426 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
429 'uuid' => $latestRelease->uuid,
430 'title' => $game->title,
431 'overview' => $game->overview,
432 'description' => $game->description,
433 'gamerNumbers' => $game->players,
434 'genres' => $game->genres,
436 'website' => $game->website,
437 'contentRating' => $game->contentRating,
438 'premium' => $game->premium,
439 'firstPublishedAt' => $game->firstPublishedAt,
441 'likeCount' => $game->rating->likeCount,
442 'ratingAverage' => $game->rating->average,
443 'ratingCount' => $game->rating->count,
445 'versionNumber' => $latestRelease->name,
446 'latestVersion' => $latestRelease->uuid,
447 'md5sum' => $latestRelease->md5sum,
448 'apkFileSize' => $latestRelease->size,
449 'publishedAt' => $latestRelease->date,
450 'publicSize' => $latestRelease->publicSize,
451 'nativeSize' => $latestRelease->nativeSize,
453 'mainImageFullUrl' => $game->discover,
454 'videoUrl' => getFirstVideoUrl($game->media),
455 'filepickerScreenshots' => getAllImageUrls($game->media),
456 'mobileAppIcon' => null,
458 'developer' => $game->developer->name,
459 'supportEmailAddress' => $game->developer->supportEmail,
460 'supportPhone' => $game->developer->supportPhone,
461 'founder' => $game->developer->founder,
463 'promotedProduct' => $product,
468 function buildAppDownload($game, $release)
472 'fileSize' => $release->size,
473 'version' => $release->uuid,
474 'contentRating' => $game->contentRating,
475 'downloadLink' => $release->url,
480 function buildProduct($product)
482 if ($product === null) {
486 'type' => $product->type ?? 'entitlement',
487 'identifier' => $product->identifier,
488 'name' => $product->name,
489 'description' => $product->description ?? '',
490 'localPrice' => $product->localPrice,
491 'originalPrice' => $product->originalPrice,
492 'priceInCents' => $product->originalPrice * 100,
494 'currency' => $product->currency,
499 * Build /app/v1/details?app=org.example.game
501 function buildDetails($game, $linkDeveloperPage = false)
503 $latestRelease = $game->latestRelease;
506 if ($game->discover) {
510 'thumbnail' => $game->discover,
511 'full' => $game->discover,
515 foreach ($game->media as $medium) {
516 if ($medium->type == 'image') {
520 'thumbnail' => $medium->thumb ?? $medium->url,
521 'full' => $medium->url,
525 if (!isUnsupportedVideoUrl($medium->url)) {
528 'url' => $medium->url,
535 if (isset($game->links->unlocked)) {
537 'text' => 'Show unlocked',
538 'url' => 'ouya://launcher/details?app=' . $game->links->unlocked,
544 $gamePromoted = getPromotedProduct($game);
546 $product = buildProduct($gamePromoted);
550 if (isset($game->latestRelease->url)
551 && substr($game->latestRelease->url, 0, 29) == 'https://archive.org/download/'
553 $iaUrl = dirname($game->latestRelease->url) . '/';
556 $description = $game->description;
557 if (isset($game->notes) && trim($game->notes)) {
558 $description = "Technical notes:\r\n" . $game->notes
563 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
566 'title' => $game->title,
567 'description' => $description,
568 'gamerNumbers' => $game->players,
569 'genres' => $game->genres,
571 'suggestedAge' => $game->contentRating,
572 'premium' => $game->premium,
573 'inAppPurchases' => $game->inAppPurchases,
574 'firstPublishedAt' => strtotime($game->firstPublishedAt),
578 'count' => $game->rating->count,
579 'average' => $game->rating->average,
583 'fileSize' => $latestRelease->size,
584 'nativeSize' => $latestRelease->nativeSize,
585 'publicSize' => $latestRelease->publicSize,
586 'md5sum' => $latestRelease->md5sum,
587 'filename' => 'FIXME',
589 'package' => $game->packageName,
590 'versionCode' => $latestRelease->versionCode,
591 'state' => 'complete',
595 'number' => $latestRelease->name,
596 'publishedAt' => strtotime($latestRelease->date),
597 'uuid' => $latestRelease->uuid,
601 'name' => $game->developer->name,
602 'founder' => $game->developer->founder,
606 'key:rating.average',
607 'key:developer.name',
609 number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
612 'tileImage' => $game->discover,
613 'mediaTiles' => $mediaTiles,
614 'mobileAppIcon' => null,
619 'promotedProduct' => $product,
620 'buttons' => $buttons,
623 'internet-archive' => $iaUrl,
624 'developer-url' => $game->developer->website ?? null,
628 if ($linkDeveloperPage) {
629 $data['developer']['url'] = 'ouya://launcher/discover/dev--'
630 . categoryPath($game->developer->uuid);
636 function buildDeveloperCurrentGamer()
640 'uuid' => '00702342-0000-1111-2222-c3e1500cafe2',
641 'username' => 'stouyapi',
647 * For /api/v1/developers/xxx/products/?only=yyy
649 function buildDeveloperProductOnly($product, $developer)
652 'developerName' => $developer->name,
653 'currency' => $product->currency,
655 buildProduct($product),
661 * For /api/v1/developers/xxx/products/
663 function buildDeveloperProducts($products, $developer)
666 $products = array_values(array_column($products, null, 'identifier'));
669 foreach ($products as $product) {
670 $jsonProducts[] = buildProduct($product);
673 'developerName' => $developer->name,
674 'currency' => $products[0]->currency ?? 'EUR',
675 'products' => $jsonProducts,
679 function buildPurchases($game)
684 $promotedProduct = getPromotedProduct($game);
685 if ($promotedProduct) {
686 $purchasesData['purchases'][] = [
687 'purchaseDate' => time() * 1000,
688 'generateDate' => time() * 1000,
689 'identifier' => $promotedProduct->identifier,
690 'gamer' => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
691 'uuid' => '00702342-0000-1111-2222-c3e1500beef3',//transaction ID
692 'priceInCents' => $promotedProduct->originalPrice * 100,
693 'localPrice' => $promotedProduct->localPrice,
694 'currency' => $promotedProduct->currency,
698 $encryptedOnce = dummyEncrypt($purchasesData);
699 $encryptedTwice = dummyEncrypt($encryptedOnce);
700 return $encryptedTwice;
703 function buildSearch($games)
705 $games = sortByTitle($games);
707 foreach ($games as $game) {
709 'title' => $game->title,
710 'url' => 'ouya://launcher/details?app=' . $game->packageName,
711 'contentRating' => $game->contentRating,
715 'count' => count($results),
716 'results' => $results,
720 function dummyEncrypt($data)
723 'key' => base64_encode('0123456789abcdef'),
724 'iv' => 't3jir1LHpICunvhlM76edQ==',//random bytes
725 'blob' => base64_encode(
726 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
731 function addChunkedDiscoverRows(&$data, $games, $title)
733 $chunks = array_chunk($games, 4);
735 foreach ($chunks as $chunk) {
737 $data, $first ? $title : '',
744 function addDiscoverRow(&$data, $title, $games, $ranked = false)
752 foreach ($games as $game) {
753 if (is_string($game)) {
755 $tilePos = count($data['tiles']);
756 $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
760 if (isset($game->links->original)) {
761 //do not link unlocked games.
762 // people an access them via the original games
765 $tilePos = findTile($data['tiles'], $game->packageName);
766 if ($tilePos === null) {
767 $tilePos = count($data['tiles']);
768 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
771 $row['tiles'][] = $tilePos;
773 $data['rows'][] = $row;
776 function findTile($tiles, $packageName)
778 foreach ($tiles as $pos => $tile) {
779 if ($tile['package'] == $packageName) {
786 function buildDiscoverCategoryTile($title)
789 'url' => 'ouya://launcher/discover/' . categoryPath($title),
796 function buildDiscoverGameTile($game)
798 $latestRelease = $game->latestRelease;
800 'gamerNumbers' => $game->players,
801 'genres' => $game->genres,
802 'url' => 'ouya://launcher/details?app=' . $game->packageName,
805 'md5sum' => $latestRelease->md5sum,
807 'versionNumber' => $latestRelease->name,
808 'uuid' => $latestRelease->uuid,
810 'inAppPurchases' => $game->inAppPurchases,
811 'promotedProduct' => null,
812 'premium' => $game->premium,
814 'package' => $game->packageName,
815 'updated_at' => strtotime($latestRelease->date),
816 'updatedAt' => $latestRelease->date,
817 'title' => $game->title,
818 'image' => $game->discover,
819 'contentRating' => $game->contentRating,
821 'count' => $game->rating->count,
822 'average' => $game->rating->average,
824 'promotedProduct' => buildProduct(getPromotedProduct($game)),
828 function getAllAges($games)
831 foreach ($games as $game) {
832 $ages[] = $game->contentRating;
834 return array_unique($ages);
837 function getAllGenres($games)
840 foreach ($games as $game) {
841 $genres = array_merge($genres, $game->genres);
843 return array_unique($genres);
846 function addMissingGameProperties($game)
850 if (!isset($game->overview)) {
851 $game->overview = null;
853 if (!isset($game->description)) {
854 $game->description = '';
856 if (!isset($game->players)) {
857 $game->players = [1];
859 if (!isset($game->genres)) {
860 $game->genres = ['Unsorted'];
862 if (!isset($game->website)) {
863 $game->website = null;
865 if (!isset($game->contentRating)) {
866 $game->contentRating = 'Everyone';
868 if (!isset($game->premium)) {
869 $game->premium = false;
871 if (!isset($game->firstPublishedAt)) {
872 $game->firstPublishedAt = gmdate('c');
875 if (!isset($game->rating)) {
876 $game->rating = new stdClass();
878 if (!isset($game->rating->likeCount)) {
879 $game->rating->likeCount = 0;
881 if (!isset($game->rating->average)) {
882 $game->rating->average = 0;
884 if (!isset($game->rating->count)) {
885 $game->rating->count = 0;
888 $game->firstRelease = null;
889 $game->latestRelease = null;
890 $firstReleaseTimestamp = null;
891 $latestReleaseTimestamp = 0;
892 foreach ($game->releases as $release) {
893 if (isset($release->broken) && $release->broken) {
896 if (!isset($release->publicSize)) {
897 $release->publicSize = 0;
899 if (!isset($release->nativeSize)) {
900 $release->nativeSize = 0;
903 $releaseTimestamp = strtotime($release->date);
904 if ($releaseTimestamp > $latestReleaseTimestamp) {
905 $game->latestRelease = $release;
906 $latestReleaseTimestamp = $releaseTimestamp;
908 if ($firstReleaseTimestamp === null
909 || $releaseTimestamp < $firstReleaseTimestamp
911 $game->firstRelease = $release;
912 $firstReleaseTimestamp = $releaseTimestamp;
915 if ($game->firstRelease === null) {
916 error('No first release for ' . $game->packageName);
918 if ($game->latestRelease === null) {
919 error('No latest release for ' . $game->packageName);
922 if (!isset($game->media)) {
926 if (!isset($game->developer->uuid)) {
927 $game->developer->uuid = null;
929 if (!isset($game->developer->name)) {
930 $game->developer->name = 'unknown';
932 if (!isset($game->developer->supportEmail)) {
933 $game->developer->supportEmail = null;
935 if (!isset($game->developer->supportPhone)) {
936 $game->developer->supportPhone = null;
938 if (!isset($game->developer->founder)) {
939 $game->developer->founder = false;
942 if ($cfgEnableQr && $game->website) {
943 $qrfileName = preg_replace('#[^\\w\\d._-]#', '_', $game->website) . '.png';
944 $qrfilePath = $GLOBALS['qrDir'] . $qrfileName;
945 if (!file_exists($qrfilePath)) {
946 $cmd = __DIR__ . '/create-qr.sh'
947 . ' ' . escapeshellarg($game->website)
948 . ' ' . escapeshellarg($qrfilePath);
949 passthru($cmd, $retval);
954 $qrUrlPath = $GLOBALS['baseUrl'] . 'gen-qr/' . $qrfileName;
955 $game->media[] = (object) [
961 //rewrite urls from Internet Archive to our servers
962 $game->discover = rewriteUrl($game->discover);
963 foreach ($game->media as $medium) {
964 $medium->url = rewriteUrl($medium->url);
966 foreach ($game->releases as $release) {
967 $release->url = rewriteUrl($release->url);
972 * Implements a sensible ranking system described in
973 * https://stackoverflow.com/a/1411268/2826013
975 function calculateRank(array $games)
977 $averageRatings = array_map(
979 return $game->rating->average;
983 $average = array_sum($averageRatings) / count($averageRatings);
987 foreach ($games as $game) {
988 $R = $game->rating->average;
989 $v = $game->rating->count;
990 $game->rating->rank = ($R * $v + $C * $m) / ($v + $m);
994 function getFirstVideoUrl($media)
996 foreach ($media as $medium) {
997 if ($medium->type == 'video') {
1004 function getAllImageUrls($media)
1007 foreach ($media as $medium) {
1008 if ($medium->type == 'image') {
1009 $imageUrls[] = $medium->url;
1015 function getPromotedProduct($game)
1017 if (!isset($game->products) || !count($game->products)) {
1020 foreach ($game->products as $gameProd) {
1021 if ($gameProd->promoted) {
1029 * vimeo only work with HTTPS now,
1030 * and the OUYA does not support SNI.
1031 * We get SSL errors and no video for them :/
1033 function isUnsupportedVideoUrl($url)
1035 return strpos($url, '://vimeo.com/') !== false;
1038 function removeMakeGames(array $games)
1040 return filterByGenre($games, 'Tutorials', true);
1043 function removeMakeGenres($genres)
1046 foreach ($genres as $genre) {
1047 if ($genre != 'Tutorials' && $genre != 'Builds') {
1048 $filtered[] = $genre;
1054 function rewriteUrl($url)
1056 foreach ($GLOBALS['urlRewrites'] as $pattern => $replacement) {
1057 $url = preg_replace($pattern, $replacement, $url);
1062 function writeJson($path, $data)
1064 global $cfgMini, $wwwDir;
1065 $fullPath = $wwwDir . $path;
1066 $dir = dirname($fullPath);
1067 if (!is_dir($dir)) {
1068 mkdir($dir, 0777, true);
1070 $opts = JSON_UNESCAPED_SLASHES;
1072 $opts |= JSON_PRETTY_PRINT;
1076 json_encode($data, $opts) . "\n"
1080 function error($msg)
1082 fwrite(STDERR, $msg . "\n");