diff --git a/app/Contracts/AnimeRepository.php b/app/Contracts/AnimeRepository.php index c0d734a7..ee5b484c 100644 --- a/app/Contracts/AnimeRepository.php +++ b/app/Contracts/AnimeRepository.php @@ -39,11 +39,12 @@ public function getCurrentlyAiring( ?AnimeScheduleFilterEnum $filter = null ): EloquentBuilder; - public function getAiredBetween( + public function getItemsBySeason( Carbon $from, Carbon $to, ?AnimeTypeEnum $type = null, - ?string $premiered = null + ?string $premiered = null, + bool $includeContinuingItems = false ): EloquentBuilder; public function getUpcomingSeasonItems(?AnimeTypeEnum $type = null): EloquentBuilder; diff --git a/app/Dto/Concerns/HasContinuingParameter.php b/app/Dto/Concerns/HasContinuingParameter.php new file mode 100644 index 00000000..f6244ad7 --- /dev/null +++ b/app/Dto/Concerns/HasContinuingParameter.php @@ -0,0 +1,26 @@ +Example usage: `?continuing`", + * @OA\Schema(type="boolean") + * ), + */ +trait HasContinuingParameter +{ + use PreparesData; + + #[BooleanType, WithCast(ContextualBooleanCast::class)] + public bool|Optional $continuing = false; +} diff --git a/app/Dto/Concerns/PreparesData.php b/app/Dto/Concerns/PreparesData.php index 9adc76b5..10ef1d1a 100644 --- a/app/Dto/Concerns/PreparesData.php +++ b/app/Dto/Concerns/PreparesData.php @@ -17,7 +17,6 @@ public static function prepareForPipeline(Collection $properties): Collection { // let's always set the limit parameter to the globally configured default value if (property_exists(static::class, "limit") && !$properties->has("limit")) { - /** @noinspection PhpUndefinedFieldInspection */ $properties->put("limit", max_results_per_page( property_exists(static::class, "defaultLimit") ? static::$defaultLimit : null)); } @@ -44,7 +43,7 @@ public static function prepareForPipeline(Collection $properties): Collection } } // if the property is optional and the value is an empty string, we want to ignore it. - if ($property->type->isOptional && $propertyVal === "") { + if ($property->type->isOptional && $propertyVal === "" && !$property->type->acceptsType("bool")) { $propertyVal = null; } @@ -53,7 +52,7 @@ public static function prepareForPipeline(Collection $properties): Collection } else { $properties->forget($propertyRawName); } - } + } } return $properties; diff --git a/app/Dto/QueryAnimeSeasonCommand.php b/app/Dto/QueryAnimeSeasonCommand.php index 3b7c875f..f53c1369 100644 --- a/app/Dto/QueryAnimeSeasonCommand.php +++ b/app/Dto/QueryAnimeSeasonCommand.php @@ -11,6 +11,7 @@ use App\Dto\Concerns\HasPageParameter; use App\Dto\Concerns\HasSfwParameter; use App\Dto\Concerns\HasUnapprovedParameter; +use App\Dto\Concerns\HasContinuingParameter; use App\Enums\AnimeTypeEnum; use App\Rules\Attributes\EnumValidation; use Spatie\LaravelData\Attributes\WithCast; @@ -20,7 +21,13 @@ abstract class QueryAnimeSeasonCommand extends Data implements DataRequest { - use HasSfwParameter, HasKidsParameter, HasUnapprovedParameter, HasLimitParameter, HasRequestFingerprint, HasPageParameter; + use HasSfwParameter, + HasKidsParameter, + HasUnapprovedParameter, + HasLimitParameter, + HasRequestFingerprint, + HasPageParameter, + HasContinuingParameter; #[WithCast(EnumCast::class, AnimeTypeEnum::class), EnumValidation(AnimeTypeEnum::class)] public AnimeTypeEnum|Optional $filter; diff --git a/app/Features/QueryAnimeSeasonHandlerBase.php b/app/Features/QueryAnimeSeasonHandlerBase.php index 778cd07e..67a8cedf 100644 --- a/app/Features/QueryAnimeSeasonHandlerBase.php +++ b/app/Features/QueryAnimeSeasonHandlerBase.php @@ -32,8 +32,6 @@ public function handle($request): JsonResponse { $requestParams = collect($request->all()); $type = $requestParams->has("filter") ? $request->filter : null; - $season = $requestParams->has("season") ? $request->season : null; - $year = $requestParams->has("year") ? $request->year : null; $results = $this->getSeasonItems($request, $type); // apply sfw, kids and unapproved filters /** @noinspection PhpUndefinedMethodInspection */ diff --git a/app/Features/QueryCurrentAnimeSeasonHandler.php b/app/Features/QueryCurrentAnimeSeasonHandler.php index da7d427e..972d36b6 100644 --- a/app/Features/QueryCurrentAnimeSeasonHandler.php +++ b/app/Features/QueryCurrentAnimeSeasonHandler.php @@ -53,7 +53,8 @@ protected function getSeasonItems($request, ?AnimeTypeEnum $type): Builder */ [$from, $to] = $this->getSeasonRange($year, $season); $premiered = ucfirst($season)." {$year}"; + $includeContinuingItems = $request->continuing; - return $this->repository->getAiredBetween($from, $to, $type, $premiered); + return $this->repository->getItemsBySeason($from, $to, $type, $premiered, $includeContinuingItems); } } diff --git a/app/Features/QuerySpecificAnimeSeasonHandler.php b/app/Features/QuerySpecificAnimeSeasonHandler.php index f133c301..1ec68210 100644 --- a/app/Features/QuerySpecificAnimeSeasonHandler.php +++ b/app/Features/QuerySpecificAnimeSeasonHandler.php @@ -28,8 +28,8 @@ protected function getSeasonItems($request, ?AnimeTypeEnum $type): Builder [$from, $to] = $this->getSeasonRange($request->year, $request->season); $premiered = ucfirst($request->season)." {$request->year}"; + $includeContinuingItems = $request->continuing; - return $this->repository->getAiredBetween($from, $to, $type, $premiered); -// ->where("status", "!=", AnimeStatusEnum::upcoming()->label); + return $this->repository->getItemsBySeason($from, $to, $type, $premiered, $includeContinuingItems); } } diff --git a/app/Http/Controllers/V4DB/SeasonController.php b/app/Http/Controllers/V4DB/SeasonController.php index a70a0cde..aa2c0914 100644 --- a/app/Http/Controllers/V4DB/SeasonController.php +++ b/app/Http/Controllers/V4DB/SeasonController.php @@ -2,21 +2,12 @@ namespace App\Http\Controllers\V4DB; -use App\Anime; use App\Dto\QueryAnimeSeasonListCommand; use App\Dto\QueryCurrentAnimeSeasonCommand; use App\Dto\QuerySpecificAnimeSeasonCommand; use App\Dto\QueryUpcomingAnimeSeasonCommand; -use App\Http\HttpResponse; -use App\Http\QueryBuilder\AnimeSearchQueryBuilder; -use App\Http\Resources\V4\AnimeCollection; -use App\Http\Resources\V4\ResultsResource; use Exception; -use Illuminate\Http\Request; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\DB; -use Jikan\Request\SeasonList\SeasonListRequest; -use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use OpenApi\Annotations as OA; /** * @@ -38,6 +29,7 @@ class SeasonController extends Controller * * @OA\Parameter(ref="#/components/parameters/sfw"), * @OA\Parameter(ref="#/components/parameters/unapproved"), + * @OA\Parameter(ref="#/components/parameters/continuing"), * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/limit"), * @@ -89,6 +81,7 @@ public function now(QueryCurrentAnimeSeasonCommand $command) * * @OA\Parameter(ref="#/components/parameters/sfw"), * @OA\Parameter(ref="#/components/parameters/unapproved"), + * @OA\Parameter(ref="#/components/parameters/continuing"), * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/limit"), * @@ -177,6 +170,7 @@ public function archive(QueryAnimeSeasonListCommand $command) * * @OA\Parameter(ref="#/components/parameters/sfw"), * @OA\Parameter(ref="#/components/parameters/unapproved"), + * @OA\Parameter(ref="#/components/parameters/continuing"), * @OA\Parameter(ref="#/components/parameters/page"), * @OA\Parameter(ref="#/components/parameters/limit"), * diff --git a/app/Repositories/DefaultAnimeRepository.php b/app/Repositories/DefaultAnimeRepository.php index 4a29a484..ffbf103d 100644 --- a/app/Repositories/DefaultAnimeRepository.php +++ b/app/Repositories/DefaultAnimeRepository.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; use Laravel\Scout\Builder as ScoutBuilder; +use MongoDB\BSON\UTCDateTime; /** * @implements Repository @@ -110,11 +111,12 @@ public function getCurrentlyAiring( return $queryable; } - public function getAiredBetween( + public function getItemsBySeason( Carbon $from, Carbon $to, ?AnimeTypeEnum $type = null, - ?string $premiered = null + ?string $premiered = null, + bool $includeContinuingItems = false ): EloquentBuilder { $queryable = $this->queryable(true); @@ -127,9 +129,10 @@ public function getAiredBetween( $finalFilter = []; // if the premiered parameter for the filter is not null, look for those items which have a premiered attribute set, - // and equals to the parameter value, OR look for those items which doesn't have premired attribute set, + // and equals to the parameter value, OR look for those items which doesn't have premiered attribute set, // they don't have a garbled aired string and their aired.from date is within the from-to parameters range. - // Additionally, we want to include all those items which are carry overs from previous seasons. + // Additionally, we want to include all those items which are carry overs from previous seasons, + // if the includeContinuingItems argument is set to true. if ($premiered !== null) { $finalFilter['$or'] = [ ['premiered' => $premiered], @@ -140,12 +143,50 @@ public function getAiredBetween( ], ...$airedFilter ], - // this condition will include "continuing" items from previous seasons - [ + ]; + if ($includeContinuingItems) { + // these conditions will include "continuing" items from previous seasons + // long running shows + $finalFilter['$or'][] = [ 'aired.from' => ['$lte' => $from->toAtomString()], + 'aired.to' => null, + 'episodes' => null, 'airing' => true - ] - ]; + ]; + // We want to include those which are currently airing, and their aired.to is past the date of the + // current season start. + $finalFilter['$or'][] = [ + 'aired.from' => ['$lte' => $from->toAtomString()], + 'aired.to' => ['$gte' => $from->toAtomString()], + 'airing' => true + ]; + // In many cases MAL doesn't show the date until an airing show is going to be aired. So we need to get + // clever here. + // We want to include those shows which have started in previous season only (not before) and it's going + // to continue in the current season. + $finalFilter['$or'][] = [ + // note: this expression only works with mongodb version 5.0.0 or higher + '$expr' => [ + '$lte' => [ + [ + '$dateDiff' => [ + 'startDate' => [ + '$dateFromString' => [ + 'dateString' => '$aired.from' + ] + ], + 'endDate' => new UTCDateTime($from), + 'unit' => 'month' + ] + ], + 3 // there are 3 months in a season, so anything that started in 3 months or less will be included + ] + ], + 'aired.to' => null, + 'episodes' => ['$gte' => 14], + 'airing' => true + ]; + } } else { $finalFilter = array_merge($finalFilter, $airedFilter); $finalFilter['aired.string'] = [ diff --git a/app/Testing/SyntheticMongoDbTransaction.php b/app/Testing/SyntheticMongoDbTransaction.php index acb357e6..224369ed 100644 --- a/app/Testing/SyntheticMongoDbTransaction.php +++ b/app/Testing/SyntheticMongoDbTransaction.php @@ -32,6 +32,7 @@ public function beginDatabaseTransaction(): void $tablesWithoutModels = [ "anime_characters_staff", "anime_episodes", + "anime_episode", "anime_forum", "anime_moreinfo", "anime_news", @@ -42,6 +43,7 @@ public function beginDatabaseTransaction(): void "anime_userupdates", "anime_videos", "character_pictures", + "characters_pictures", "clubs_members", "demographics_manga", "demographics_anime", diff --git a/database/factories/JikanMediaModelFactory.php b/database/factories/JikanMediaModelFactory.php index 78aa7483..289eabda 100644 --- a/database/factories/JikanMediaModelFactory.php +++ b/database/factories/JikanMediaModelFactory.php @@ -244,7 +244,7 @@ protected function getOverridesFromQueryStringParameters(Collection $additionalP if ($additionalParams->has("start_date") && !empty($additionalParams["start_date"]) && !$additionalParams->has("end_date")) { $startDate = $this->adaptDateString($additionalParams["start_date"]); - $dt = Carbon::parse($startDate)->addDays($this->faker->numberBetween(0, 25)); + $dt = Carbon::parse($startDate)->addDays($this->faker->numberBetween(1, 25)); $overrides[$activityMarkerKeyName] = new CarbonDateRange($dt, null); } @@ -253,7 +253,7 @@ protected function getOverridesFromQueryStringParameters(Collection $additionalP $endDate = $this->adaptDateString($additionalParams["end_date"]); $to = Carbon::parse($endDate); $from = $to->copy()->subDays($this->faker->randomElement([30, 60, 90, 120, 180])); - $overrides[$activityMarkerKeyName] = new CarbonDateRange($from, $to->subDays($this->faker->numberBetween(0, 25))); + $overrides[$activityMarkerKeyName] = new CarbonDateRange($from, $to->subDays($this->faker->numberBetween(1, 25))); } if ($additionalParams->has(["start_date", "end_date"]) @@ -312,14 +312,14 @@ protected function getOppositeOverridesFromQueryStringParameters(Collection $add if ($additionalParams->has("min_score") && !$additionalParams->has("max_score")) { $min_score = floatval($additionalParams["min_score"]); if ($this->isScoreValueValid($min_score)) { - $overrides["score"] = $this->faker->randomFloat(2, 1.00, floatval($additionalParams["min_score"])); + $overrides["score"] = $this->faker->randomFloat(2, 1.00, floatval($additionalParams["min_score"]) - 0.01); } } if (!$additionalParams->has("min_score") && $additionalParams->has("max_score")) { $max_score = $additionalParams["max_score"]; if ($this->isScoreValueValid($max_score)) { - $overrides["score"] = $this->faker->randomFloat(2, floatval($additionalParams["max_score"]), 9.99); + $overrides["score"] = $this->faker->randomFloat(2, floatval($additionalParams["max_score"]) + 0.01, 9.99); } } @@ -330,8 +330,8 @@ protected function getOppositeOverridesFromQueryStringParameters(Collection $add if ($this->isScoreValueValid($min_score) && $this->isScoreValueValid($max_score)) { $overrides["score"] = $this->faker->randomElement([ - $this->faker->randomFloat(2, 1.00, floatval($additionalParams["min_score"])), - $this->faker->randomFloat(2, floatval($additionalParams["max_score"]), 9.99) + $this->faker->randomFloat(2, 1.00, floatval($additionalParams["min_score"]) - 0.01), + $this->faker->randomFloat(2, floatval($additionalParams["max_score"]) + 0.01, 9.99) ]); } } diff --git a/database/factories/JikanModelFactory.php b/database/factories/JikanModelFactory.php index 2abd2619..b599003e 100644 --- a/database/factories/JikanModelFactory.php +++ b/database/factories/JikanModelFactory.php @@ -7,12 +7,18 @@ use JMS\Serializer\Serializer; use \Illuminate\Database\Eloquent\Factories\Factory; use Spatie\Enum\Laravel\Faker\FakerEnumProvider; +use Illuminate\Support\Str; abstract class JikanModelFactory extends Factory { public function configure(): JikanModelFactory|static { $this->faker->addProvider(new FakerEnumProvider($this->faker)); + if (array_key_exists("GITHUB_JOB", $_ENV) && $_ENV["GITHUB_JOB"] !== "") { + $this->faker->seed($_ENV["GITHUB_JOB"]); + } else { + $this->faker->seed(Str::random()); + } return $this; } diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index c9108d31..8ab520ac 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -3046,6 +3046,9 @@ { "$ref": "#/components/parameters/unapproved" }, + { + "$ref": "#/components/parameters/continuing" + }, { "$ref": "#/components/parameters/page" }, @@ -3115,6 +3118,9 @@ { "$ref": "#/components/parameters/unapproved" }, + { + "$ref": "#/components/parameters/continuing" + }, { "$ref": "#/components/parameters/page" }, @@ -3191,6 +3197,9 @@ { "$ref": "#/components/parameters/unapproved" }, + { + "$ref": "#/components/parameters/continuing" + }, { "$ref": "#/components/parameters/page" }, @@ -9027,6 +9036,15 @@ } }, "parameters": { + "continuing": { + "name": "continuing", + "in": "query", + "description": "This is a flag. When supplied it will include entries which are continuing from previous seasons. MAL includes these items on the seasons view in the ″TV (continuing)″ section. (Example: https://myanimelist.net/anime/season/2024/winter)
Example usage: `?continuing`", + "required": false, + "schema": { + "type": "boolean" + } + }, "kids": { "name": "kids", "in": "query", @@ -9092,4 +9110,4 @@ "description": "About", "url": "https://jikan.moe" } -} +} \ No newline at end of file diff --git a/tests/Integration/SeasonControllerTest.php b/tests/Integration/SeasonControllerTest.php index 44a71db4..6d6a752e 100644 --- a/tests/Integration/SeasonControllerTest.php +++ b/tests/Integration/SeasonControllerTest.php @@ -15,6 +15,14 @@ class SeasonControllerTest extends TestCase use SyntheticMongoDbTransaction; use ScoutFlush; + private function continuingUrlProvider(): array + { + return [ + "?continuing=true" => ["/v4/seasons/2024/winter?continuing=true"], + "?continuing" => ["/v4/seasons/2024/winter?continuing"], + ]; + } + public function testShouldFilterOutAnimeWithGarbledAiredString() { Carbon::setTestNow(Carbon::parse("2024-01-11")); @@ -95,7 +103,11 @@ public function testShouldNotFilterOutFutureAiringDates() $this->assertCount(2, $content["data"]); } - public function testShouldNotFilterOutContinuingItemsFromPreviousSeasons() + /** + * @return void + * @dataProvider continuingUrlProvider + */ + public function testShouldNotFilterOutContinuingItemsFromPreviousSeasons($requestUrl) { Carbon::setTestNow(Carbon::parse("2024-01-11")); // an item in the future airing @@ -131,9 +143,51 @@ public function testShouldNotFilterOutContinuingItemsFromPreviousSeasons() $state["airing"] = true; $f->create($state); - $content = $this->getJsonResponse([], "/v4/seasons/2024/winter"); + $content = $this->getJsonResponse([], $requestUrl); $this->seeStatusCode(200); $this->assertIsArray($content["data"]); $this->assertCount(3, $content["data"]); } + + public function testShouldNotIncludeContinuingItemsByDefault() + { + Carbon::setTestNow(Carbon::parse("2024-01-11")); + // an item in the future airing + $f = Anime::factory(1); + $startDate = "2024-02-24"; + $carbonStartDate = Carbon::parse($startDate); + $state = $f->serializeStateDefinition([ + "aired" => new CarbonDateRange($carbonStartDate, null) + ]); + $state["aired"]["string"] = "Feb 24, 2024 to ?"; + $state["premiered"] = null; + $state["status"] = "Not yet aired"; + $state["airing"] = false; + $f->create($state); + + // the absolutely correct item + $f = Anime::factory(1); + $state = $f->serializeStateDefinition([ + "aired" => new CarbonDateRange(Carbon::parse("2024-01-10"), Carbon::parse("2024-02-15")) + ]); + $state["premiered"] = "Winter 2024"; + $state["status"] = "Currently Airing"; + $state["airing"] = true; + $f->create($state); + + // the continuing item + $f = Anime::factory(1); + $state = $f->serializeStateDefinition([ + "aired" => new CarbonDateRange(Carbon::parse("2023-10-10"), null) + ]); + $state["premiered"] = "Fall 2023"; + $state["status"] = "Currently Airing"; + $state["airing"] = true; + $f->create($state); + + $content = $this->getJsonResponse([], "/v4/seasons/2024/winter"); + $this->seeStatusCode(200); + $this->assertIsArray($content["data"]); + $this->assertCount(2, $content["data"]); + } }