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['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'));
57 //load game data. doing early to collect a developer's games
58 foreach ($gameFiles as $gameFile) {
59 $game = json_decode(file_get_contents($gameFile));
61 error('JSON invalid at ' . $gameFile);
63 addMissingGameProperties($game);
64 $games[$game->packageName] = $game;
66 if (!isset($developers[$game->developer->uuid])) {
67 $developers[$game->developer->uuid] = [
68 'info' => $game->developer,
73 $developers[$game->developer->uuid]['gameNames'][] = $game->packageName;
76 //write json api files
77 foreach ($games as $game) {
78 $products = $game->products ?? [];
79 foreach ($products as $product) {
81 'api/v1/developers/' . $game->developer->uuid
82 . '/products/' . $product->identifier . '.json',
83 buildDeveloperProductOnly($product, $game->developer)
85 $developers[$game->developer->uuid]['products'][] = $product;
89 'api/v1/details-data/' . $game->packageName . '.json',
92 count($developers[$game->developer->uuid]['gameNames']) > 1
97 'api/v1/games/' . $game->packageName . '/purchases',
102 'api/v1/apps/' . $game->packageName . '.json',
105 $latestRelease = $game->latestRelease;
107 'api/v1/apps/' . $latestRelease->uuid . '.json',
112 'api/v1/apps/' . $latestRelease->uuid . '-download.json',
113 buildAppDownload($game, $latestRelease)
121 calculateRank($games);
123 foreach ($developers as $developer) {
125 //index.htm does not need a rewrite rule
126 'api/v1/developers/' . $developer['info']->uuid
127 . '/products/index.htm',
128 buildDeveloperProducts($developer['products'], $developer['info'])
131 'api/v1/developers/' . $developer['info']->uuid
133 buildDeveloperCurrentGamer()
136 if (count($developer['gameNames']) > 1) {
138 'api/v1/discover-data/dev--' . $developer['info']->uuid . '.json',
139 buildSpecialCategory(
140 'Developer: ' . $developer['info']->name,
141 filterByPackageNames($games, $developer['gameNames'])
147 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
148 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
152 'api/v1/discover-data/tutorials.json',
153 buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
156 $searchLetters = 'abcdefghijklmnopqrstuvwxyz0123456789., ';
157 foreach (str_split($searchLetters) as $letter) {
158 $letterGames = filterBySearchWord($games, $letter);
160 'api/v1/search-data/' . $letter . '.json',
161 buildSearch($letterGames)
166 function buildDiscover(array $games)
168 $games = removeMakeGames($games);
170 'title' => 'DISCOVER',
177 filterLastAdded($games, 10)
180 $data, 'Best rated games',
181 filterBestRatedGames($games, 10),
185 foreach ($GLOBALS['packagelists'] as $listTitle => $listPackageNames) {
188 filterByPackageNames($games, $listPackageNames)
203 'api/v1/discover-data/' . categoryPath('Best rated') . '.json',
204 buildSpecialCategory('Best rated', filterBestRated($games, 99))
207 'api/v1/discover-data/' . categoryPath('Best rated games') . '.json',
208 buildSpecialCategory('Best rated games', filterBestRatedGames($games, 99))
211 'api/v1/discover-data/' . categoryPath('Most rated') . '.json',
212 buildSpecialCategory('Most rated', filterMostDownloaded($games, 99))
215 'api/v1/discover-data/' . categoryPath('Random') . '.json',
216 buildSpecialCategory(
217 'Random ' . date('Y-m-d H:i'),
218 filterRandom($games, 99)
222 'api/v1/discover-data/' . categoryPath('Last updated') . '.json',
223 buildSpecialCategory('Last updated', filterLastUpdated($games, 99))
232 addDiscoverRow($data, 'Multiplayer', $players);
233 foreach ($players as $num => $title) {
235 'api/v1/discover-data/' . categoryPath($title) . '.json',
236 buildDiscoverCategory(
238 //I do not want emulators here,
239 // and neither Streaming apps
242 filterByPlayers($games, $num),
251 $ages = getAllAges($games);
253 addDiscoverRow($data, 'Content rating', $ages);
254 foreach ($ages as $num => $title) {
256 'api/v1/discover-data/' . categoryPath($title) . '.json',
257 buildDiscoverCategory($title, filterByAge($games, $title))
261 $genres = removeMakeGenres(getAllGenres($games));
263 addChunkedDiscoverRows($data, $genres, 'Genres');
265 foreach ($genres as $genre) {
267 'api/v1/discover-data/' . categoryPath($genre) . '.json',
268 buildDiscoverCategory($genre, filterByGenre($games, $genre))
272 $abc = array_merge(range('A', 'Z'), ['Other']);
273 addChunkedDiscoverRows($data, $abc, 'Alphabetical');
274 foreach ($abc as $letter) {
276 'api/v1/discover-data/' . categoryPath($letter) . '.json',
277 buildDiscoverCategory($letter, filterByLetter($games, $letter))
285 * A genre category page
287 function buildDiscoverCategory($name, $games)
294 if (isset($GLOBALS['categorySubtitles'][$name])) {
295 $data['stouyapi']['subtitle'] = $GLOBALS['categorySubtitles'][$name];
298 if (count($games) >= 20) {
300 $data, 'Last Updated',
301 filterLastUpdated($games, 10)
305 filterBestRated($games, 10),
310 $games = sortByTitle($games);
311 $chunks = array_chunk($games, 4);
312 foreach ($chunks as $chunkGames) {
313 addDiscoverRow($data, '', $chunkGames);
319 function buildMakeCategory($name, $games)
327 $games = sortByTitle($games);
328 addDiscoverRow($data, '', $games);
334 * Category without the "Last updated" or "Best rated" top rows
336 * Used for "Best rated", "Most rated", "Random"
338 function buildSpecialCategory($name, $games)
346 $first3 = array_slice($games, 0, 3);
347 $chunks = array_chunk(array_slice($games, 3), 4);
348 array_unshift($chunks, $first3);
350 foreach ($chunks as $chunkGames) {
351 addDiscoverRow($data, '', $chunkGames);
357 function buildDiscoverHome(array $games)
366 if (isset($GLOBALS['home'])) {
367 reset($GLOBALS['home']);
368 $title = key($GLOBALS['home']);
371 filterByPackageNames($games, $GLOBALS['home'][$title])
375 'title' => 'FEATURED',
376 'showPrice' => false,
386 * Build api/v1/apps/$packageName
388 function buildApps($game)
390 $latestRelease = $game->latestRelease;
393 $gamePromoted = getPromotedProduct($game);
395 $product = buildProduct($gamePromoted);
398 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
401 'uuid' => $latestRelease->uuid,
402 'title' => $game->title,
403 'overview' => $game->overview,
404 'description' => $game->description,
405 'gamerNumbers' => $game->players,
406 'genres' => $game->genres,
408 'website' => $game->website,
409 'contentRating' => $game->contentRating,
410 'premium' => $game->premium,
411 'firstPublishedAt' => $game->firstPublishedAt,
413 'likeCount' => $game->rating->likeCount,
414 'ratingAverage' => $game->rating->average,
415 'ratingCount' => $game->rating->count,
417 'versionNumber' => $latestRelease->name,
418 'latestVersion' => $latestRelease->uuid,
419 'md5sum' => $latestRelease->md5sum,
420 'apkFileSize' => $latestRelease->size,
421 'publishedAt' => $latestRelease->date,
422 'publicSize' => $latestRelease->publicSize,
423 'nativeSize' => $latestRelease->nativeSize,
425 'mainImageFullUrl' => $game->discover,
426 'videoUrl' => getFirstVideoUrl($game->media),
427 'filepickerScreenshots' => getAllImageUrls($game->media),
428 'mobileAppIcon' => null,
430 'developer' => $game->developer->name,
431 'supportEmailAddress' => $game->developer->supportEmail,
432 'supportPhone' => $game->developer->supportPhone,
433 'founder' => $game->developer->founder,
435 'promotedProduct' => $product,
440 function buildAppDownload($game, $release)
444 'fileSize' => $release->size,
445 'version' => $release->uuid,
446 'contentRating' => $game->contentRating,
447 'downloadLink' => $release->url,
452 function buildProduct($product)
454 if ($product === null) {
458 'type' => $product->type ?? 'entitlement',
459 'identifier' => $product->identifier,
460 'name' => $product->name,
461 'description' => $product->description ?? '',
462 'localPrice' => $product->localPrice,
463 'originalPrice' => $product->originalPrice,
464 'priceInCents' => $product->originalPrice * 100,
466 'currency' => $product->currency,
471 * Build /app/v1/details?app=org.example.game
473 function buildDetails($game, $linkDeveloperPage = false)
475 $latestRelease = $game->latestRelease;
478 if ($game->discover) {
482 'thumbnail' => $game->discover,
483 'full' => $game->discover,
487 foreach ($game->media as $medium) {
488 if ($medium->type == 'image') {
492 'thumbnail' => $medium->thumb ?? $medium->url,
493 'full' => $medium->url,
497 if (!isUnsupportedVideoUrl($medium->url)) {
500 'url' => $medium->url,
507 if (isset($game->links->unlocked)) {
509 'text' => 'Show unlocked',
510 'url' => 'ouya://launcher/details?app=' . $game->links->unlocked,
516 $gamePromoted = getPromotedProduct($game);
518 $product = buildProduct($gamePromoted);
522 if (isset($game->latestRelease->url)
523 && substr($game->latestRelease->url, 0, 29) == 'https://archive.org/download/'
525 $iaUrl = dirname($game->latestRelease->url) . '/';
528 $description = $game->description;
529 if (isset($game->notes) && trim($game->notes)) {
530 $description = "Technical notes:\r\n" . $game->notes
535 // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
538 'title' => $game->title,
539 'description' => $description,
540 'gamerNumbers' => $game->players,
541 'genres' => $game->genres,
543 'suggestedAge' => $game->contentRating,
544 'premium' => $game->premium,
545 'inAppPurchases' => $game->inAppPurchases,
546 'firstPublishedAt' => strtotime($game->firstPublishedAt),
550 'count' => $game->rating->count,
551 'average' => $game->rating->average,
555 'fileSize' => $latestRelease->size,
556 'nativeSize' => $latestRelease->nativeSize,
557 'publicSize' => $latestRelease->publicSize,
558 'md5sum' => $latestRelease->md5sum,
559 'filename' => 'FIXME',
561 'package' => $game->packageName,
562 'versionCode' => $latestRelease->versionCode,
563 'state' => 'complete',
567 'number' => $latestRelease->name,
568 'publishedAt' => strtotime($latestRelease->date),
569 'uuid' => $latestRelease->uuid,
573 'name' => $game->developer->name,
574 'founder' => $game->developer->founder,
578 'key:rating.average',
579 'key:developer.name',
581 number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
584 'tileImage' => $game->discover,
585 'mediaTiles' => $mediaTiles,
586 'mobileAppIcon' => null,
591 'promotedProduct' => $product,
592 'buttons' => $buttons,
595 'internet-archive' => $iaUrl,
596 'developer-url' => $game->developer->website ?? null,
600 if ($linkDeveloperPage) {
601 $data['developer']['url'] = 'ouya://launcher/discover/dev--'
602 . categoryPath($game->developer->uuid);
608 function buildDeveloperCurrentGamer()
612 'uuid' => '00702342-0000-1111-2222-c3e1500cafe2',
613 'username' => 'stouyapi',
619 * For /api/v1/developers/xxx/products/?only=yyy
621 function buildDeveloperProductOnly($product, $developer)
624 'developerName' => $developer->name,
625 'currency' => $product->currency,
627 buildProduct($product),
633 * For /api/v1/developers/xxx/products/
635 function buildDeveloperProducts($products, $developer)
638 $products = array_values(array_column($products, null, 'identifier'));
641 foreach ($products as $product) {
642 $jsonProducts[] = buildProduct($product);
645 'developerName' => $developer->name,
646 'currency' => $products[0]->currency ?? 'EUR',
647 'products' => $jsonProducts,
651 function buildPurchases($game)
656 $promotedProduct = getPromotedProduct($game);
657 if ($promotedProduct) {
658 $purchasesData['purchases'][] = [
659 'purchaseDate' => time() * 1000,
660 'generateDate' => time() * 1000,
661 'identifier' => $promotedProduct->identifier,
662 'gamer' => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
663 'uuid' => '00702342-0000-1111-2222-c3e1500beef3',//transaction ID
664 'priceInCents' => $promotedProduct->originalPrice * 100,
665 'localPrice' => $promotedProduct->localPrice,
666 'currency' => $promotedProduct->currency,
670 $encryptedOnce = dummyEncrypt($purchasesData);
671 $encryptedTwice = dummyEncrypt($encryptedOnce);
672 return $encryptedTwice;
675 function buildSearch($games)
677 $games = sortByTitle($games);
679 foreach ($games as $game) {
681 'title' => $game->title,
682 'url' => 'ouya://launcher/details?app=' . $game->packageName,
683 'contentRating' => $game->contentRating,
687 'count' => count($results),
688 'results' => $results,
692 function dummyEncrypt($data)
695 'key' => base64_encode('0123456789abcdef'),
696 'iv' => 't3jir1LHpICunvhlM76edQ==',//random bytes
697 'blob' => base64_encode(
698 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
703 function addChunkedDiscoverRows(&$data, $games, $title)
705 $chunks = array_chunk($games, 4);
707 foreach ($chunks as $chunk) {
709 $data, $first ? $title : '',
716 function addDiscoverRow(&$data, $title, $games, $ranked = false)
724 foreach ($games as $game) {
725 if (is_string($game)) {
727 $tilePos = count($data['tiles']);
728 $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
732 if (isset($game->links->original)) {
733 //do not link unlocked games.
734 // people an access them via the original games
737 $tilePos = findTile($data['tiles'], $game->packageName);
738 if ($tilePos === null) {
739 $tilePos = count($data['tiles']);
740 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
743 $row['tiles'][] = $tilePos;
745 $data['rows'][] = $row;
748 function findTile($tiles, $packageName)
750 foreach ($tiles as $pos => $tile) {
751 if ($tile['package'] == $packageName) {
758 function buildDiscoverCategoryTile($title)
761 'url' => 'ouya://launcher/discover/' . categoryPath($title),
768 function buildDiscoverGameTile($game)
770 $latestRelease = $game->latestRelease;
772 'gamerNumbers' => $game->players,
773 'genres' => $game->genres,
774 'url' => 'ouya://launcher/details?app=' . $game->packageName,
777 'md5sum' => $latestRelease->md5sum,
779 'versionNumber' => $latestRelease->name,
780 'uuid' => $latestRelease->uuid,
782 'inAppPurchases' => $game->inAppPurchases,
783 'promotedProduct' => null,
784 'premium' => $game->premium,
786 'package' => $game->packageName,
787 'updated_at' => strtotime($latestRelease->date),
788 'updatedAt' => $latestRelease->date,
789 'title' => $game->title,
790 'image' => $game->discover,
791 'contentRating' => $game->contentRating,
793 'count' => $game->rating->count,
794 'average' => $game->rating->average,
796 'promotedProduct' => buildProduct(getPromotedProduct($game)),
800 function getAllAges($games)
803 foreach ($games as $game) {
804 $ages[] = $game->contentRating;
806 return array_unique($ages);
809 function getAllGenres($games)
812 foreach ($games as $game) {
813 $genres = array_merge($genres, $game->genres);
815 return array_unique($genres);
818 function addMissingGameProperties($game)
820 if (!isset($game->overview)) {
821 $game->overview = null;
823 if (!isset($game->description)) {
824 $game->description = '';
826 if (!isset($game->players)) {
827 $game->players = [1];
829 if (!isset($game->genres)) {
830 $game->genres = ['Unsorted'];
832 if (!isset($game->website)) {
833 $game->website = null;
835 if (!isset($game->contentRating)) {
836 $game->contentRating = 'Everyone';
838 if (!isset($game->premium)) {
839 $game->premium = false;
841 if (!isset($game->firstPublishedAt)) {
842 $game->firstPublishedAt = gmdate('c');
845 if (!isset($game->rating)) {
846 $game->rating = new stdClass();
848 if (!isset($game->rating->likeCount)) {
849 $game->rating->likeCount = 0;
851 if (!isset($game->rating->average)) {
852 $game->rating->average = 0;
854 if (!isset($game->rating->count)) {
855 $game->rating->count = 0;
858 $game->firstRelease = null;
859 $game->latestRelease = null;
860 $firstReleaseTimestamp = null;
861 $latestReleaseTimestamp = 0;
862 foreach ($game->releases as $release) {
863 if (!isset($release->publicSize)) {
864 $release->publicSize = 0;
866 if (!isset($release->nativeSize)) {
867 $release->nativeSize = 0;
870 $releaseTimestamp = strtotime($release->date);
871 if ($releaseTimestamp > $latestReleaseTimestamp) {
872 $game->latestRelease = $release;
873 $latestReleaseTimestamp = $releaseTimestamp;
875 if ($firstReleaseTimestamp === null
876 || $releaseTimestamp < $firstReleaseTimestamp
878 $game->firstRelease = $release;
879 $firstReleaseTimestamp = $releaseTimestamp;
882 if ($game->firstRelease === null) {
883 error('No first release for ' . $game->packageName);
885 if ($game->latestRelease === null) {
886 error('No latest release for ' . $game->packageName);
889 if (!isset($game->media)) {
893 if (!isset($game->developer->uuid)) {
894 $game->developer->uuid = null;
896 if (!isset($game->developer->name)) {
897 $game->developer->name = 'unknown';
899 if (!isset($game->developer->supportEmail)) {
900 $game->developer->supportEmail = null;
902 if (!isset($game->developer->supportPhone)) {
903 $game->developer->supportPhone = null;
905 if (!isset($game->developer->founder)) {
906 $game->developer->founder = false;
909 if ($game->website) {
910 $qrfileName = preg_replace('#[^\\w\\d._-]#', '_', $game->website) . '.png';
911 $qrfilePath = $GLOBALS['qrDir'] . $qrfileName;
912 if (!file_exists($qrfilePath)) {
913 $cmd = __DIR__ . '/create-qr.sh'
914 . ' ' . escapeshellarg($game->website)
915 . ' ' . escapeshellarg($qrfilePath);
916 passthru($cmd, $retval);
921 $qrUrlPath = $GLOBALS['baseUrl'] . 'gen-qr/' . $qrfileName;
922 $game->media[] = (object) [
928 //rewrite urls from Internet Archive to our servers
929 $game->discover = rewriteUrl($game->discover);
930 foreach ($game->media as $medium) {
931 $medium->url = rewriteUrl($medium->url);
933 foreach ($game->releases as $release) {
934 $release->url = rewriteUrl($release->url);
939 * Implements a sensible ranking system described in
940 * https://stackoverflow.com/a/1411268/2826013
942 function calculateRank(array $games)
944 $averageRatings = array_map(
946 return $game->rating->average;
950 $average = array_sum($averageRatings) / count($averageRatings);
954 foreach ($games as $game) {
955 $R = $game->rating->average;
956 $v = $game->rating->count;
957 $game->rating->rank = ($R * $v + $C * $m) / ($v + $m);
961 function getFirstVideoUrl($media)
963 foreach ($media as $medium) {
964 if ($medium->type == 'video') {
971 function getAllImageUrls($media)
974 foreach ($media as $medium) {
975 if ($medium->type == 'image') {
976 $imageUrls[] = $medium->url;
982 function getPromotedProduct($game)
984 if (!isset($game->products) || !count($game->products)) {
987 foreach ($game->products as $gameProd) {
988 if ($gameProd->promoted) {
996 * vimeo only work with HTTPS now,
997 * and the OUYA does not support SNI.
998 * We get SSL errors and no video for them :/
1000 function isUnsupportedVideoUrl($url)
1002 return strpos($url, '://vimeo.com/') !== false;
1005 function removeMakeGames(array $games)
1007 return filterByGenre($games, 'Tutorials', true);
1010 function removeMakeGenres($genres)
1013 foreach ($genres as $genre) {
1014 if ($genre != 'Tutorials' && $genre != 'Builds') {
1015 $filtered[] = $genre;
1021 function rewriteUrl($url)
1023 foreach ($GLOBALS['urlRewrites'] as $pattern => $replacement) {
1024 $url = preg_replace($pattern, $replacement, $url);
1029 function writeJson($path, $data)
1032 $fullPath = $wwwDir . $path;
1033 $dir = dirname($fullPath);
1034 if (!is_dir($dir)) {
1035 mkdir($dir, 0777, true);
1039 json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
1043 function error($msg)
1045 fwrite(STDERR, $msg . "\n");