2d0076e13af2f70a0bfde9fb9f5412a6e60d2b1f
[stouyapi.git] / bin / import-game-data.php
1 #!/usr/bin/env php
2 <?php
3 /**
4  * Import games from a OUYA game data repository
5  *
6  * @link https://github.com/cweiske/ouya-game-data/
7  * @author Christian Weiske <cweiske@cweiske.de>
8  */
9 ini_set('xdebug.halt_level', E_WARNING|E_NOTICE|E_USER_WARNING|E_USER_NOTICE);
10 require_once __DIR__ . '/filters.php';
11 if (!isset($argv[1])) {
12     error('Pass the path to a "folders" file with game data json files folder names');
13 }
14 $foldersFile = $argv[1];
15 if (!is_file($foldersFile)) {
16     error('Given path is not a file: ' . $foldersFile);
17 }
18
19 $GLOBALS['packagelists']['cweiskepicks'] = [
20     'de.eiswuxe.blookid2',
21     'com.cosmos.babyloniantwins'
22 ];
23
24 $wwwDir = __DIR__ . '/../www/';
25
26 $baseDir   = dirname($foldersFile);
27 $gameFiles = [];
28 foreach (file($foldersFile) as $line) {
29     $line = trim($line);
30     if (strlen($line)) {
31         if (strpos($line, '..') !== false) {
32             error('Path attack in ' . $folder);
33         }
34         $folder = $baseDir . '/' . $line;
35         if (!is_dir($folder)) {
36             error('Folder does not exist: ' . $folder);
37         }
38         $gameFiles = array_merge($gameFiles, glob($folder . '/*.json'));
39     }
40 }
41
42 $games = [];
43 $count = 0;
44 $developers = [];
45 foreach ($gameFiles as $gameFile) {
46     $game = json_decode(file_get_contents($gameFile));
47     if ($game === null) {
48         error('JSON invalid at ' . $gameFile);
49     }
50     addMissingGameProperties($game);
51     $games[$game->packageName] = $game;
52
53     writeJson(
54         'api/v1/details-data/' . $game->packageName . '.json',
55         buildDetails($game)
56     );
57
58     if (!isset($developers[$game->developer->uuid])) {
59         $developers[$game->developer->uuid] = [
60             'info'     => $game->developer,
61             'products' => [],
62         ];
63     }
64
65     $products = $game->products ?? [];
66     foreach ($products as $product) {
67         writeJson(
68             'api/v1/developers/' . $game->developer->uuid
69             . '/products/' . $product->identifier . '.json',
70             buildDeveloperProductOnly($product, $game->developer)
71         );
72         $developers[$game->developer->uuid]['products'][] = $product;
73     }
74
75     /**/
76     writeJson(
77         'api/v1/games/' . $game->packageName . '/purchases',
78         buildPurchases($game)
79     );
80
81     writeJson(
82         'api/v1/apps/' . $game->packageName . '.json',
83         buildApps($game)
84     );
85     $latestRelease = $game->latestRelease;
86     writeJson(
87         'api/v1/apps/' . $latestRelease->uuid . '.json',
88         buildApps($game)
89     );
90
91     writeJson(
92         'api/v1/apps/' . $latestRelease->uuid . '-download.json',
93         buildAppDownload($game, $latestRelease)
94     );
95
96     if ($count++ > 20) {
97         //break;
98     }
99 }
100
101 foreach ($developers as $developer) {
102     writeJson(
103         //index.htm does not need a rewrite rule
104         'api/v1/developers/' . $developer['info']->uuid
105         . '/products/index.htm',
106         buildDeveloperProducts($developer['products'], $developer['info'])
107     );
108     writeJson(
109         //index.htm does not need a rewrite rule
110         'api/v1/developers/' . $developer['info']->uuid
111         . '/current_gamer',
112         buildDeveloperCurrentGamer()
113     );
114 }
115
116 writeJson('api/v1/discover-data/discover.json', buildDiscover($games));
117 writeJson('api/v1/discover-data/home.json', buildDiscoverHome($games));
118
119 //make
120 writeJson(
121     'api/v1/discover-data/tutorials.json',
122     buildMakeCategory('Tutorials', filterByGenre($games, 'Tutorials'))
123 );
124
125
126
127 function buildDiscover(array $games)
128 {
129     $games = removeMakeGames($games);
130     $data = [
131         'title' => 'DISCOVER',
132         'rows'  => [],
133         'tiles' => [],
134     ];
135
136     addDiscoverRow(
137         $data, 'Last Updated',
138         filterLastUpdated($games, 10)
139     );
140     addDiscoverRow(
141         $data, 'Best rated',
142         filterBestRated($games, 10)
143     );
144     addDiscoverRow(
145         $data, "cweiske's picks",
146         filterByPackageNames($games, $GLOBALS['packagelists']['cweiskepicks'])
147     );
148
149     $players = [
150         //1 => '1 player',
151         2 => '2 players',
152         3 => '3 players',
153         4 => '4 players',
154     ];
155     addDiscoverRow($data, '# of players', $players);
156     foreach ($players as $num => $title) {
157         writeJson(
158             'api/v1/discover-data/' . categoryPath($title) . '.json',
159             buildDiscoverCategory($title, filterByPlayers($games, $num))
160         );
161     }
162
163     $ages = getAllAges($games);
164     natsort($ages);
165     addDiscoverRow($data, 'Content rating', $ages);
166     foreach ($ages as $num => $title) {
167         writeJson(
168             'api/v1/discover-data/' . categoryPath($title) . '.json',
169             buildDiscoverCategory($title, filterByAge($games, $title))
170         );
171     }
172
173     $genres = removeMakeGenres(getAllGenres($games));
174     sort($genres);
175     addChunkedDiscoverRows($data, $genres, 'Genres');
176
177     foreach ($genres as $genre) {
178         writeJson(
179             'api/v1/discover-data/' . categoryPath($genre) . '.json',
180             buildDiscoverCategory($genre, filterByGenre($games, $genre))
181         );
182     }
183
184     $abc = array_merge(range('A', 'Z'), ['Other']);
185     addChunkedDiscoverRows($data, $abc, 'Alphabetical');
186     foreach ($abc as $letter) {
187         writeJson(
188             'api/v1/discover-data/' . categoryPath($letter) . '.json',
189             buildDiscoverCategory($letter, filterByLetter($games, $letter))
190         );
191     }
192
193     return $data;
194 }
195
196 /**
197  * A genre category page
198  */
199 function buildDiscoverCategory($name, $games)
200 {
201     $data = [
202         'title' => $name,
203         'rows'  => [],
204         'tiles' => [],
205     ];
206     addDiscoverRow(
207         $data, 'Last Updated',
208         filterLastUpdated($games, 10)
209     );
210     addDiscoverRow(
211         $data, 'Best rated',
212         filterBestRated($games, 10)
213     );
214
215     usort(
216         $games,
217         function ($gameA, $gameB) {
218             return strcasecmp($gameA->title, $gameB->title);
219         }
220     );
221     $chunks = array_chunk($games, 4);
222     foreach ($chunks as $chunkGames) {
223         addDiscoverRow($data, '', $chunkGames);
224     }
225
226     return $data;
227 }
228
229 function buildMakeCategory($name, $games)
230 {
231     $data = [
232         'title' => $name,
233         'rows'  => [],
234         'tiles' => [],
235     ];
236
237     usort(
238         $games,
239         function ($gameA, $gameB) {
240             return strcasecmp($gameA->title, $gameB->title);
241         }
242     );
243     addDiscoverRow($data, '', $games);
244
245     return $data;
246 }
247
248 function buildDiscoverHome(array $games)
249 {
250     //we do not want anything here for now
251     $data = [
252         'title' => 'home',
253         'rows'  => [
254             [
255                 'title' => 'FEATURED',
256                 'showPrice' => false,
257                 'ranked'    => false,
258                 'tiles'     => [],
259             ]
260         ],
261         'tiles' => [],
262     ];
263     return $data;
264 }
265
266 /**
267  * Build api/v1/apps/$packageName
268  */
269 function buildApps($game)
270 {
271     $latestRelease = $game->latestRelease;
272
273     $product      = null;
274     $gamePromoted = getPromotedProduct($game);
275     if ($gamePromoted) {
276         $product = buildProduct($gamePromoted);
277     }
278
279     // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-apps-xxx
280     return [
281         'app' => [
282             'uuid'          => $latestRelease->uuid,
283             'title'         => $game->title,
284             'overview'      => $game->overview,
285             'description'   => $game->description,
286             'gamerNumbers'  => $game->players,
287             'genres'        => $game->genres,
288
289             'website'       => $game->website,
290             'contentRating' => $game->contentRating,
291             'premium'       => $game->premium,
292             'firstPublishedAt' => $game->firstPublishedAt,
293
294             'likeCount'     => $game->rating->likeCount,
295             'ratingAverage' => $game->rating->average,
296             'ratingCount'   => $game->rating->count,
297
298             'versionNumber' => $latestRelease->name,
299             'latestVersion' => $latestRelease->uuid,
300             'md5sum'        => $latestRelease->md5sum,
301             'apkFileSize'   => $latestRelease->size,
302             'publishedAt'   => $latestRelease->date,
303             'publicSize'    => $latestRelease->publicSize,
304             'nativeSize'    => $latestRelease->nativeSize,
305
306             'mainImageFullUrl' => $game->discover,
307             'videoUrl'         => getFirstVideoUrl($game->media),
308             'filepickerScreenshots' => getAllImageUrls($game->media),
309             'mobileAppIcon'    => null,
310
311             'developer'           => $game->developer->name,
312             'supportEmailAddress' => $game->developer->supportEmail,
313             'supportPhone'        => $game->developer->supportPhone,
314             'founder'             => $game->developer->founder,
315
316             'promotedProduct' => $product,
317         ],
318     ];
319 }
320
321 function buildAppDownload($game, $release)
322 {
323     return [
324         'app' => [
325             'fileSize'      => $release->size,
326             'version'       => $release->uuid,
327             'contentRating' => $game->contentRating,
328             'downloadLink'  => $release->url,
329         ]
330     ];
331 }
332
333 function buildProduct($product)
334 {
335     if ($product === null) {
336         return null;
337     }
338     return [
339         'type'          => 'entitlement',
340         'identifier'    => $product->identifier,
341         'name'          => $product->name,
342         'description'   => $product->description ?? '',
343         'localPrice'    => $product->localPrice,
344         'originalPrice' => $product->originalPrice,
345         'percentOff'    => 0,
346         'currency'      => $product->currency,
347     ];
348 }
349
350 /**
351  * Build /app/v1/details?app=org.example.game
352  */
353 function buildDetails($game)
354 {
355     $latestRelease = $game->latestRelease;
356
357     $mediaTiles = [];
358     if ($game->discover) {
359         $mediaTiles[] = [
360             'type' => 'image',
361             'urls' => [
362                 'thumbnail' => $game->discover,
363                 'full'      => $game->discover,
364             ],
365         ];
366     }
367     foreach ($game->media as $medium) {
368         if ($medium->type == 'image')  {
369             $mediaTiles[] = [
370                 'type' => 'image',
371                 'urls' => [
372                     'thumbnail' => $medium->thumb,
373                     'full'      => $medium->url,
374                 ],
375             ];
376         } else {
377             $mediaTiles[] = [
378                 'type' => 'video',
379                 'url'  => $medium->url,
380             ];
381         }
382     }
383
384     $buttons = [];
385     if (isset($game->links->unlocked)) {
386         $buttons[] = [
387             'text' => 'Show unlocked',
388             'url'  => 'ouya://launcher/details?app=' . $game->links->unlocked,
389             'bold' => true,
390         ];
391     }
392
393     $product      = null;
394     $gamePromoted = getPromotedProduct($game);
395     if ($gamePromoted) {
396         $product = buildProduct($gamePromoted);
397     }
398
399     // http://cweiske.de/ouya-store-api-docs.htm#get-https-devs-ouya-tv-api-v1-details
400     return [
401         'type'             => 'Game',
402         'title'            => $game->title,
403         'description'      => $game->description,
404         'gamerNumbers'     => $game->players,
405         'genres'           => $game->genres,
406
407         'suggestedAge'     => $game->contentRating,
408         'premium'          => $game->premium,
409         'inAppPurchases'   => $game->inAppPurchases,
410         'firstPublishedAt' => strtotime($game->firstPublishedAt),
411         'ccUrl'            => null,
412
413         'rating' => [
414             'count'   => $game->rating->count,
415             'average' => $game->rating->average,
416         ],
417
418         'apk' => [
419             'fileSize'    => $latestRelease->size,
420             'nativeSize'  => $latestRelease->nativeSize,
421             'publicSize'  => $latestRelease->publicSize,
422             'md5sum'      => $latestRelease->md5sum,
423             'filename'    => 'FIXME',
424             'errors'      => '',
425             'package'     => $game->packageName,
426             'versionCode' => $latestRelease->versionCode,
427             'state'       => 'complete',
428         ],
429
430         'version' => [
431             'number'      => $latestRelease->name,
432             'publishedAt' => strtotime($latestRelease->date),
433             'uuid'        => $latestRelease->uuid,
434         ],
435
436         'developer' => [
437             'name'    => $game->developer->name,
438             'founder' => $game->developer->founder,
439         ],
440
441         'metaData' => [
442             'key:rating.average',
443             'key:developer.name',
444             'key:suggestedAge',
445             number_format($latestRelease->size / 1024 / 1024, 2, '.', '') . ' MiB',
446         ],
447
448         'tileImage'     => $game->discover,
449         'mediaTiles'    => $mediaTiles,
450         'mobileAppIcon' => null,
451         'heroImage'     => [
452             'url' => null,
453         ],
454
455         'promotedProduct' => $product,
456         'buttons'         => $buttons,
457     ];
458 }
459
460 function buildDeveloperCurrentGamer()
461 {
462     return [
463         'gamer' => [
464             'uuid'     => '00702342-0000-1111-2222-c3e1500cafe2',
465             'username' => 'stouyapi',
466         ],
467     ];
468 }
469
470 /**
471  * For /api/v1/developers/xxx/products/?only=yyy
472  */
473 function buildDeveloperProductOnly($product, $developer)
474 {
475     return [
476         'developerName' => $developer->name,
477         'currency'      => $product->currency,
478         'products'      => [
479             buildProduct($product),
480         ],
481     ];
482 }
483
484 /**
485  * For /api/v1/developers/xxx/products/
486  */
487 function buildDeveloperProducts($products, $developer)
488 {
489     $jsonProducts = [];
490     foreach ($products as $product) {
491         $jsonProducts[] = buildProduct($product);
492     }
493     return [
494         'developerName' => $developer->name,
495         'currency'      => $products[0]->currency ?? 'EUR',
496         'products'      => $jsonProducts,
497     ];
498 }
499
500 function buildPurchases($game)
501 {
502     $purchasesData = [
503         'purchases' => [],
504     ];
505     $promotedProduct = getPromotedProduct($game);
506     if ($promotedProduct) {
507         $purchasesData['purchases'][] = [
508             'purchaseDate' => time() * 1000,
509             'generateDate' => time() * 1000,
510             'identifier'   => $promotedProduct->identifier,
511             'gamer'        => 'stouyapi',
512             'uuid'         => '00702342-0000-1111-2222-c3e1500cafe2',//gamer uuid
513             'priceInCents' => $promotedProduct->originalPrice * 100,
514             'localPrice'   => $promotedProduct->localPrice,
515             'currency'     => $promotedProduct->currency,
516         ];
517     }
518
519     $encryptedOnce  = dummyEncrypt($purchasesData);
520     $encryptedTwice = dummyEncrypt($encryptedOnce);
521     return $encryptedTwice;
522 }
523
524 function dummyEncrypt($data)
525 {
526     return [
527         'key'  => base64_encode('0123456789abcdef') . "\n",
528         'iv'   => 't3jir1LHpICunvhlM76edQ==' . "\n",//random bytes
529         'blob' => base64_encode(
530             json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
531         ) . "\n",
532     ];
533 }
534
535 function addChunkedDiscoverRows(&$data, $games, $title)
536 {
537     $chunks = array_chunk($games, 4);
538     $first = true;
539     foreach ($chunks as $chunk) {
540         addDiscoverRow(
541             $data, $first ? $title : '',
542             $chunk
543         );
544         $first = false;
545     }
546 }
547
548 function addDiscoverRow(&$data, $title, $games)
549 {
550     $row = [
551         'title'     => $title,
552         'showPrice' => true,
553         'ranked'    => false,
554         'tiles'     => [],
555     ];
556     foreach ($games as $game) {
557         if (is_string($game)) {
558             //category link
559             $tilePos = count($data['tiles']);
560             $data['tiles'][$tilePos] = buildDiscoverCategoryTile($game);
561
562         } else {
563             //game
564             if (isset($game->links->original)) {
565                 //do not link unlocked games.
566                 // people an access them via the original games
567                 continue;
568             }
569             $tilePos = findTile($data['tiles'], $game->packageName);
570             if ($tilePos === null) {
571                 $tilePos = count($data['tiles']);
572                 $data['tiles'][$tilePos] = buildDiscoverGameTile($game);
573             }
574         }
575         $row['tiles'][] = $tilePos;
576     }
577     $data['rows'][] = $row;
578 }
579
580 function findTile($tiles, $packageName)
581 {
582     foreach ($tiles as $pos => $tile) {
583         if ($tile['package'] == $packageName) {
584             return $pos;
585         }
586     }
587     return null;
588 }
589
590 function buildDiscoverCategoryTile($title)
591 {
592     return [
593         'url'   => 'ouya://launcher/discover/' . categoryPath($title),
594         'image' => '',
595         'title' => $title,
596         'type'  => 'discover'
597     ];
598 }
599
600 function buildDiscoverGameTile($game)
601 {
602     $latestRelease = $game->latestRelease;
603     return [
604         'gamerNumbers' => $game->players,
605         'genres' => $game->genres,
606         'url' => 'ouya://launcher/details?app=' . $game->packageName,
607         'latestVersion' => [
608             'apk' => [
609                 'md5sum' => $latestRelease->md5sum,
610             ],
611             'versionNumber' => $latestRelease->name,
612             'uuid' => $latestRelease->uuid,
613         ],
614         'inAppPurchases' => $game->inAppPurchases,
615         'promotedProduct' => null,
616         'premium' => $game->premium,
617         'type' => 'app',
618         'package' => $game->packageName,
619         'updated_at' => strtotime($latestRelease->date),
620         'updatedAt' => $latestRelease->date,
621         'title' => $game->title,
622         'image' => $game->discover,
623         'contentRating' => $game->contentRating,
624         'rating' => [
625             'count' => $game->rating->count,
626             'average' => $game->rating->average,
627         ],
628         'promotedProduct' => buildProduct(getPromotedProduct($game)),
629     ];
630 }
631
632 function categoryPath($title)
633 {
634     return str_replace(['/', '\\', ' ', '+'], '_', $title);
635 }
636
637 function getAllAges($games)
638 {
639     $ages = [];
640     foreach ($games as $game) {
641         $ages[] = $game->contentRating;
642     }
643     return array_unique($ages);
644 }
645
646 function getAllGenres($games)
647 {
648     $genres = [];
649     foreach ($games as $game) {
650         $genres = array_merge($genres, $game->genres);
651     }
652     return array_unique($genres);
653 }
654
655 function addMissingGameProperties($game)
656 {
657     if (!isset($game->overview)) {
658         $game->overview = null;
659     }
660     if (!isset($game->description)) {
661         $game->description = '';
662     }
663     if (!isset($game->players)) {
664         $game->players = [1];
665     }
666     if (!isset($game->genres)) {
667         $game->genres = ['Unsorted'];
668     }
669     if (!isset($game->website)) {
670         $game->website = null;
671     }
672     if (!isset($game->contentRating)) {
673         $game->contentRating = 'Everyone';
674     }
675     if (!isset($game->premium)) {
676         $game->premium = false;
677     }
678     if (!isset($game->firstPublishedAt)) {
679         $game->firstPublishedAt = gmdate('c');
680     }
681
682     if (!isset($game->rating)) {
683         $game->rating = new stdClass();
684     }
685     if (!isset($game->rating->likeCount)) {
686         $game->rating->likeCount = 0;
687     }
688     if (!isset($game->rating->average)) {
689         $game->rating->average = 0;
690     }
691     if (!isset($game->rating->count)) {
692         $game->rating->count = 0;
693     }
694
695     $game->latestRelease = null;
696     $latestReleaseTimestamp = 0;
697     foreach ($game->releases as $release) {
698         if (!isset($release->publicSize)) {
699             $release->publicSize = 0;
700         }
701         if (!isset($release->nativeSize)) {
702             $release->nativeSize = 0;
703         }
704
705         $releaseTimestamp = strtotime($release->date);
706         if ($releaseTimestamp > $latestReleaseTimestamp) {
707             $game->latestRelease    = $release;
708             $latestReleaseTimestamp = $releaseTimestamp;
709         }
710     }
711     if ($game->latestRelease === null) {
712         error('No latest release for ' . $game->packageName);
713     }
714
715     if (!isset($game->media)) {
716         $game->media = [];
717     }
718
719     if (!isset($game->developer->uuid)) {
720         $game->developer->uuid = null;
721     }
722     if (!isset($game->developer->name)) {
723         $game->developer->name = 'unknown';
724     }
725     if (!isset($game->developer->supportEmail)) {
726         $game->developer->supportEmail = null;
727     }
728     if (!isset($game->developer->supportPhone)) {
729         $game->developer->supportPhone = null;
730     }
731     if (!isset($game->developer->founder)) {
732         $game->developer->founder = false;
733     }
734 }
735
736 function getFirstVideoUrl($media)
737 {
738     foreach ($media as $medium) {
739         if ($medium->type == 'video') {
740             return $medium->url;
741         }
742     }
743     return null;
744 }
745
746 function getAllImageUrls($media)
747 {
748     $imageUrls = [];
749     foreach ($media as $medium) {
750         if ($medium->type == 'image') {
751             $imageUrls[] = $medium->url;
752         }
753     }
754     return $imageUrls;
755 }
756
757 function getPromotedProduct($game)
758 {
759     if (!isset($game->products) || !count($game->products)) {
760         return null;
761     }
762     foreach ($game->products as $gameProd) {
763         if ($gameProd->promoted) {
764             return $gameProd;
765         }
766     }
767     return null;
768 }
769
770 function removeMakeGames(array $games)
771 {
772     return filterByGenre($games, 'Tutorials', true);
773 }
774
775 function removeMakeGenres($genres)
776 {
777     $filtered = [];
778     foreach ($genres as $genre) {
779         if ($genre != 'Tutorials' && $genre != 'Builds') {
780             $filtered[] = $genre;
781         }
782     }
783     return $filtered;
784 }
785
786 function writeJson($path, $data)
787 {
788     global $wwwDir;
789     $fullPath = $wwwDir . $path;
790     $dir = dirname($fullPath);
791     if (!is_dir($dir)) {
792         mkdir($dir, 0777, true);
793     }
794     file_put_contents(
795         $fullPath,
796         json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
797     );
798 }
799
800 function error($msg)
801 {
802     fwrite(STDERR, $msg . "\n");
803     exit(1);
804 }
805 ?>