From a63515cfc6be75e8cfba8e1bdb4f87c34408c108 Mon Sep 17 00:00:00 2001 From: ayrisdev Date: Tue, 14 Jul 2026 00:01:48 +0300 Subject: [PATCH] Initial commit: Animexe Laravel platform Co-Authored-By: Claude Opus 4.8 --- .editorconfig | 18 + .env.example | 65 + .gitattributes | 11 + .gitignore | 24 + .htaccess | 4 + README.md | 59 + app/Console/Commands/CloseTribunals.php | 55 + .../Commands/CreateCrossImportJobs.php | 132 + app/Console/Commands/FetchAniListImages.php | 67 + app/Console/Commands/FetchMalIds.php | 62 + app/Console/Commands/FillAnimeMeta.php | 174 + app/Console/Commands/FixSubtitleMismatch.php | 112 + app/Console/Commands/GenerateBlogPosts.php | 135 + .../Commands/GenerateDiscoveryHooks.php | 59 + .../Admin/ActivationCodeController.php | 142 + app/Http/Controllers/Admin/AdController.php | 187 + app/Http/Controllers/Admin/AiController.php | 267 + .../Controllers/Admin/AnalyticsController.php | 358 + .../Controllers/Admin/AnimeController.php | 412 + .../Admin/AnimeRequestController.php | 45 + app/Http/Controllers/Admin/AuthController.php | 43 + .../Controllers/Admin/BannerController.php | 65 + app/Http/Controllers/Admin/BlogController.php | 156 + .../Controllers/Admin/CommentController.php | 82 + .../Admin/ContentStatsController.php | 136 + .../Controllers/Admin/DashboardController.php | 32 + .../Controllers/Admin/EpisodeController.php | 337 + .../Controllers/Admin/GenreController.php | 46 + .../Controllers/Admin/HealthController.php | 227 + .../Controllers/Admin/ImportController.php | 375 + .../Controllers/Admin/MobileAppController.php | 69 + .../Controllers/Admin/ModeratorController.php | 111 + .../Admin/NotificationController.php | 89 + .../Admin/PermissionController.php | 29 + app/Http/Controllers/Admin/PlanController.php | 115 + .../Controllers/Admin/SeasonController.php | 59 + app/Http/Controllers/Admin/SeoController.php | 971 ++ .../Controllers/Admin/SettingController.php | 160 + .../Admin/SubscriptionController.php | 71 + .../Controllers/Admin/TrendingController.php | 240 + .../Admin/UserAnalyticsController.php | 140 + app/Http/Controllers/Admin/UserController.php | 153 + app/Http/Controllers/Api/AdApiController.php | 23 + app/Http/Controllers/Api/AiApiController.php | 37 + .../Controllers/Api/AnimeApiController.php | 534 + .../Controllers/Api/AuthApiController.php | 150 + .../Controllers/Api/CommentApiController.php | 108 + .../Controllers/Api/ImportApiController.php | 1100 ++ .../Controllers/Api/MessageApiController.php | 184 + .../Controllers/Api/PlanApiController.php | 66 + .../Controllers/Api/SocialApiController.php | 462 + .../Controllers/Api/TribunalApiController.php | 205 + .../Controllers/Api/UserApiController.php | 300 + app/Http/Controllers/Controller.php | 8 + .../Frontend/ActivationController.php | 80 + .../Controllers/Frontend/AiController.php | 270 + .../Controllers/Frontend/AnimeController.php | 64 + .../Controllers/Frontend/AuthController.php | 174 + .../Controllers/Frontend/BlogController.php | 53 + .../Frontend/CheckoutController.php | 183 + .../Frontend/CommentController.php | 287 + .../Frontend/DiscoverController.php | 253 + .../Frontend/EmailVerificationController.php | 65 + .../Controllers/Frontend/HomeController.php | 447 + .../Frontend/MessageController.php | 263 + .../Frontend/PasswordResetController.php | 78 + .../Controllers/Frontend/PlayerController.php | 372 + .../Frontend/PremiumController.php | 97 + .../Frontend/ProfileController.php | 235 + .../Controllers/Frontend/SocialController.php | 569 + .../Frontend/TrackingController.php | 194 + .../Frontend/TribunalController.php | 219 + .../Frontend/UserFeatureController.php | 465 + .../Frontend/VoiceCallController.php | 134 + app/Http/Controllers/MediaController.php | 24 + app/Http/Controllers/SitemapController.php | 97 + app/Http/Middleware/AdminAccessMiddleware.php | 22 + app/Http/Middleware/AdminMiddleware.php | 43 + app/Http/Middleware/BotDetector.php | 218 + app/Http/Middleware/ImportApiMiddleware.php | 21 + app/Http/Middleware/SecurePlayer.php | 24 + app/Http/Middleware/SeoRedirectMiddleware.php | 33 + app/Mail/ResetPasswordMail.php | 25 + app/Mail/TestMail.php | 20 + app/Mail/VerifyEmailMail.php | 25 + app/Models/Achievement.php | 18 + app/Models/ActivationCode.php | 59 + app/Models/Ad.php | 78 + app/Models/Analytics/AiQuery.php | 23 + app/Models/Analytics/BotLog.php | 18 + app/Models/Analytics/PageView.php | 28 + app/Models/Analytics/VisitorSession.php | 28 + app/Models/Analytics/WatchEvent.php | 30 + app/Models/Anime.php | 103 + app/Models/AnimeFollow.php | 15 + app/Models/AnimeRating.php | 13 + app/Models/AnimeRequest.php | 31 + app/Models/AnimeRequestVote.php | 14 + app/Models/AnimeSwipe.php | 15 + app/Models/Banner.php | 17 + app/Models/BlogPost.php | 56 + app/Models/Comment.php | 46 + app/Models/CommentLike.php | 20 + app/Models/ContentPermission.php | 10 + app/Models/ContinueWatching.php | 27 + app/Models/Conversation.php | 45 + app/Models/Episode.php | 78 + app/Models/EpisodeNote.php | 21 + app/Models/EpisodePrediction.php | 16 + app/Models/EpisodeTimestampComment.php | 22 + app/Models/EpisodeVote.php | 17 + app/Models/FirstWatchSession.php | 17 + app/Models/Genre.php | 27 + app/Models/ImportJob.php | 86 + app/Models/MembershipPlan.php | 35 + app/Models/Message.php | 15 + app/Models/ModeratorPermission.php | 73 + app/Models/Payment.php | 28 + app/Models/PermissionSetting.php | 10 + app/Models/PredictionVote.php | 15 + app/Models/Season.php | 31 + app/Models/SeoKeyword.php | 14 + app/Models/SeoRedirect.php | 16 + app/Models/Setting.php | 20 + app/Models/SpoilerBox.php | 18 + app/Models/SpoilerBoxLike.php | 14 + app/Models/Subscription.php | 28 + app/Models/Subtitle.php | 15 + app/Models/TimeCapsule.php | 28 + app/Models/Tribunal.php | 37 + app/Models/TribunalArgument.php | 14 + app/Models/TribunalArgumentVote.php | 14 + app/Models/TribunalVote.php | 14 + app/Models/User.php | 264 + app/Models/UserAchievement.php | 17 + app/Models/UserActivityLog.php | 45 + app/Models/UserFollow.php | 14 + app/Models/UserNotification.php | 25 + app/Models/VideoSource.php | 25 + app/Models/VoiceCall.php | 33 + app/Models/WatchParty.php | 40 + app/Models/WatchPartyMember.php | 17 + app/Models/Watchlist.php | 22 + app/Providers/AppServiceProvider.php | 49 + app/Services/AchievementService.php | 63 + app/Services/AgoraTokenService.php | 75 + app/Services/AniListService.php | 152 + app/Services/AniSkipService.php | 58 + app/Services/BunnyCdnSigner.php | 62 + app/Services/BunnyCdnStorage.php | 69 + app/Services/DeepSeekService.php | 636 ++ app/Services/FcmService.php | 85 + app/Services/JikanService.php | 162 + app/Services/PremiumFeatures.php | 235 + app/Support/ActivityLogger.php | 77 + app/Support/ImageOptimizer.php | 90 + app/Support/MediaUrl.php | 59 + artisan | 18 + bootstrap/app.php | 32 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 91 + composer.lock | 9293 +++++++++++++++++ config/app.php | 128 + config/auth.php | 117 + config/cache.php | 117 + config/database.php | 184 + config/filesystems.php | 80 + config/iyzico.php | 7 + config/logging.php | 132 + config/mail.php | 118 + config/queue.php | 129 + config/sanctum.php | 84 + config/services.php | 55 + config/session.php | 217 + database/.gitignore | 1 + database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 50 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...1_000010_create_membership_plans_table.php | 28 + .../2024_01_01_000020_create_genres_table.php | 24 + .../2024_01_01_000030_create_animes_table.php | 44 + ...2024_01_01_000040_create_seasons_table.php | 29 + ...024_01_01_000050_create_episodes_table.php | 41 + ...00060_create_content_permissions_table.php | 39 + ...024_01_01_000070_create_comments_table.php | 30 + ...1_01_000080_create_subscriptions_table.php | 28 + ...2024_01_01_000090_create_banners_table.php | 25 + ...024_01_01_000100_create_settings_table.php | 23 + ..._01_01_000110_create_import_jobs_table.php | 33 + ...120_add_animecix_fields_to_import_jobs.php | 23 + ...24_01_01_000120_create_subtitles_table.php | 26 + ...1_01_000121_create_video_sources_table.php | 31 + ...2_add_animecix_to_episodes_source_enum.php | 17 + ...f_to_comments_and_create_comment_likes.php | 30 + ..._000140_add_available_dubs_to_episodes.php | 22 + ...24_01_01_000150_add_trending_to_animes.php | 22 + ...4_01_01_000160_create_analytics_tables.php | 75 + ..._01_000170_create_user_features_tables.php | 126 + ..._01_000180_create_follow_notify_tables.php | 53 + ..._01_000200_add_intro_times_to_episodes.php | 18 + ..._01_000200_add_profile_fields_to_users.php | 34 + ...24_01_01_000210_add_fcm_token_to_users.php | 22 + ...02_000001_add_updated_at_to_watchlists.php | 20 + ..._01_02_000002_create_skip_events_table.php | 23 + ...024_01_02_000003_add_mal_id_to_seasons.php | 19 + ...17_create_personal_access_tokens_table.php | 33 + ...6_04_17_000001_create_blog_posts_table.php | 37 + ...04_17_100001_create_user_follows_table.php | 26 + ...4_17_100002_create_conversations_table.php | 45 + ...7_200001_create_social_features_tables.php | 104 + ...00000_create_community_features_tables.php | 102 + ...17_310000_add_extra_sides_to_tribunals.php | 31 + ...001_create_moderator_permissions_table.php | 49 + ...add_bot_columns_to_analytics_pageviews.php | 29 + ...58_add_anizium_to_episodes_source_enum.php | 19 + ...0_100000_add_perks_to_membership_plans.php | 24 + ..._100001_add_premium_cosmetics_to_users.php | 31 + ...01_add_plan_visibility_and_admin_badge.php | 44 + ...00002_add_trial_days_and_profile_music.php | 38 + ...05_000001_add_trending_score_to_animes.php | 23 + ..._05_000002_add_priority_to_import_jobs.php | 25 + ...06_09_000001_create_anime_swipes_table.php | 33 + ...dd_purchase_fields_to_membership_plans.php | 24 + ...0_000002_create_activation_codes_table.php | 29 + ...03_add_premium_visual_effects_to_users.php | 23 + ...6_06_12_000001_add_is_dubbed_to_animes.php | 21 + ..._06_12_100001_create_voice_calls_table.php | 30 + ..._06_16_000001_add_social_auth_to_users.php | 24 + ...28_000001_add_is_hevc_to_video_sources.php | 22 + .../2026_07_07_000001_create_ads_table.php | 36 + database/seeders/AchievementSeeder.php | 32 + database/seeders/DatabaseSeeder.php | 56 + env | 73 + package-lock.json | 2424 +++++ package.json | 17 + phpunit.xml | 36 + public/.htaccess | 32 + public/app/animexe.apk | Bin 0 -> 21670638 bytes public/cf-worker/hls-proxy-worker.js | 113 + public/clearcache.php | 10 + public/css/premium-cosmetics.css | 1511 +++ public/error_log | 13 + public/favicon.png | Bin 0 -> 349121 bytes public/hls-proxy.php | 154 + public/index.php | 20 + public/logo.jpg | Bin 0 -> 24609 bytes public/manifest.json | 44 + public/migrate_run.php | 724 ++ public/opensearch.xml | 13 + public/robots.txt | 58 + public/setup.php | 30 + resources/css/app.css | 11 + resources/js/app.js | 1 + resources/js/bootstrap.js | 4 + .../admin/activation-codes/index.blade.php | 356 + resources/views/admin/ads/edit.blade.php | 117 + resources/views/admin/ads/index.blade.php | 291 + resources/views/admin/ai/anime-meta.blade.php | 207 + .../views/admin/ai/descriptions.blade.php | 141 + .../views/admin/analytics/index.blade.php | 802 ++ .../admin/analytics/user-detail.blade.php | 126 + .../views/admin/analytics/users.blade.php | 375 + .../admin/anime-requests/index.blade.php | 117 + resources/views/admin/animes/create.blade.php | 334 + resources/views/admin/animes/edit.blade.php | 369 + resources/views/admin/animes/index.blade.php | 488 + resources/views/admin/animes/show.blade.php | 247 + resources/views/admin/auth/login.blade.php | 291 + resources/views/admin/banners/index.blade.php | 123 + resources/views/admin/blog/edit.blade.php | 248 + resources/views/admin/blog/index.blade.php | 165 + .../views/admin/comments/index.blade.php | 249 + resources/views/admin/dashboard.blade.php | 192 + .../views/admin/episodes/create.blade.php | 230 + resources/views/admin/episodes/edit.blade.php | 371 + .../views/admin/episodes/index.blade.php | 319 + resources/views/admin/genres/index.blade.php | 173 + resources/views/admin/health/index.blade.php | 373 + resources/views/admin/import/index.blade.php | 528 + resources/views/admin/import/show.blade.php | 104 + resources/views/admin/layouts/app.blade.php | 1330 +++ resources/views/admin/mobile/index.blade.php | 178 + .../views/admin/moderators/edit.blade.php | 133 + .../views/admin/moderators/index.blade.php | 189 + .../views/admin/notifications/index.blade.php | 243 + .../views/admin/partials/pagination.blade.php | 53 + .../views/admin/permissions/index.blade.php | 74 + resources/views/admin/plans/create.blade.php | 202 + resources/views/admin/plans/edit.blade.php | 198 + resources/views/admin/plans/index.blade.php | 93 + resources/views/admin/seo/index.blade.php | 2429 +++++ .../views/admin/settings/index.blade.php | 417 + resources/views/admin/stats/index.blade.php | 471 + .../views/admin/subscriptions/index.blade.php | 109 + .../views/admin/trending/index.blade.php | 274 + resources/views/admin/users/edit.blade.php | 77 + resources/views/admin/users/index.blade.php | 134 + resources/views/admin/users/show.blade.php | 267 + resources/views/emails/layout.blade.php | 35 + .../views/emails/reset-password.blade.php | 12 + resources/views/emails/test.blade.php | 7 + resources/views/emails/verify-email.blade.php | 12 + resources/views/frontend/ai/index.blade.php | 462 + .../views/frontend/anime-request.blade.php | 213 + resources/views/frontend/anime.blade.php | 1337 +++ .../frontend/auth/forgot-password.blade.php | 57 + resources/views/frontend/auth/login.blade.php | 159 + .../views/frontend/auth/register.blade.php | 110 + .../frontend/auth/reset-password.blade.php | 53 + .../frontend/auth/verify-email.blade.php | 48 + resources/views/frontend/blog/index.blade.php | 144 + resources/views/frontend/blog/show.blade.php | 234 + resources/views/frontend/capsules.blade.php | 114 + .../views/frontend/checkout/failed.blade.php | 35 + .../views/frontend/checkout/form.blade.php | 31 + .../views/frontend/checkout/show.blade.php | 104 + .../views/frontend/checkout/success.blade.php | 29 + resources/views/frontend/discover.blade.php | 2018 ++++ resources/views/frontend/genre.blade.php | 96 + resources/views/frontend/home.blade.php | 2982 ++++++ .../views/frontend/layouts/app.blade.php | 3431 ++++++ resources/views/frontend/legal/dmca.blade.php | 118 + .../views/frontend/legal/privacy.blade.php | 152 + .../views/frontend/legal/terms.blade.php | 141 + .../views/frontend/messages/index.blade.php | 103 + .../views/frontend/messages/show.blade.php | 655 ++ .../views/frontend/notifications.blade.php | 82 + resources/views/frontend/pagination.blade.php | 70 + .../frontend/partials/ad-banner.blade.php | 49 + resources/views/frontend/player.blade.php | 5581 ++++++++++ .../views/frontend/premium/activate.blade.php | 129 + .../views/frontend/premium/plans.blade.php | 662 ++ .../views/frontend/profile-settings.blade.php | 904 ++ resources/views/frontend/profile.blade.php | 825 ++ .../views/frontend/public-profile.blade.php | 529 + resources/views/frontend/search.blade.php | 229 + .../views/frontend/tribunal/index.blade.php | 222 + .../views/frontend/tribunal/show.blade.php | 253 + .../views/frontend/watch-party.blade.php | 274 + resources/views/sitemap.blade.php | 45 + resources/views/sitemap_index.blade.php | 19 + resources/views/sitemaps/animes.blade.php | 21 + resources/views/sitemaps/blog.blade.php | 32 + resources/views/sitemaps/main.blade.php | 22 + resources/views/sitemaps/videos.blade.php | 27 + resources/views/welcome.blade.php | 277 + routes/api.php | 223 + routes/console.php | 33 + routes/web.php | 742 ++ storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/ExampleTest.php | 19 + tests/Feature/MediaControllerTest.php | 33 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 18 + 366 files changed, 74773 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 README.md create mode 100644 app/Console/Commands/CloseTribunals.php create mode 100644 app/Console/Commands/CreateCrossImportJobs.php create mode 100644 app/Console/Commands/FetchAniListImages.php create mode 100644 app/Console/Commands/FetchMalIds.php create mode 100644 app/Console/Commands/FillAnimeMeta.php create mode 100644 app/Console/Commands/FixSubtitleMismatch.php create mode 100644 app/Console/Commands/GenerateBlogPosts.php create mode 100644 app/Console/Commands/GenerateDiscoveryHooks.php create mode 100644 app/Http/Controllers/Admin/ActivationCodeController.php create mode 100644 app/Http/Controllers/Admin/AdController.php create mode 100644 app/Http/Controllers/Admin/AiController.php create mode 100644 app/Http/Controllers/Admin/AnalyticsController.php create mode 100644 app/Http/Controllers/Admin/AnimeController.php create mode 100644 app/Http/Controllers/Admin/AnimeRequestController.php create mode 100644 app/Http/Controllers/Admin/AuthController.php create mode 100644 app/Http/Controllers/Admin/BannerController.php create mode 100644 app/Http/Controllers/Admin/BlogController.php create mode 100644 app/Http/Controllers/Admin/CommentController.php create mode 100644 app/Http/Controllers/Admin/ContentStatsController.php create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Http/Controllers/Admin/EpisodeController.php create mode 100644 app/Http/Controllers/Admin/GenreController.php create mode 100644 app/Http/Controllers/Admin/HealthController.php create mode 100644 app/Http/Controllers/Admin/ImportController.php create mode 100644 app/Http/Controllers/Admin/MobileAppController.php create mode 100644 app/Http/Controllers/Admin/ModeratorController.php create mode 100644 app/Http/Controllers/Admin/NotificationController.php create mode 100644 app/Http/Controllers/Admin/PermissionController.php create mode 100644 app/Http/Controllers/Admin/PlanController.php create mode 100644 app/Http/Controllers/Admin/SeasonController.php create mode 100644 app/Http/Controllers/Admin/SeoController.php create mode 100644 app/Http/Controllers/Admin/SettingController.php create mode 100644 app/Http/Controllers/Admin/SubscriptionController.php create mode 100644 app/Http/Controllers/Admin/TrendingController.php create mode 100644 app/Http/Controllers/Admin/UserAnalyticsController.php create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 app/Http/Controllers/Api/AdApiController.php create mode 100644 app/Http/Controllers/Api/AiApiController.php create mode 100644 app/Http/Controllers/Api/AnimeApiController.php create mode 100644 app/Http/Controllers/Api/AuthApiController.php create mode 100644 app/Http/Controllers/Api/CommentApiController.php create mode 100644 app/Http/Controllers/Api/ImportApiController.php create mode 100644 app/Http/Controllers/Api/MessageApiController.php create mode 100644 app/Http/Controllers/Api/PlanApiController.php create mode 100644 app/Http/Controllers/Api/SocialApiController.php create mode 100644 app/Http/Controllers/Api/TribunalApiController.php create mode 100644 app/Http/Controllers/Api/UserApiController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/Frontend/ActivationController.php create mode 100644 app/Http/Controllers/Frontend/AiController.php create mode 100644 app/Http/Controllers/Frontend/AnimeController.php create mode 100644 app/Http/Controllers/Frontend/AuthController.php create mode 100644 app/Http/Controllers/Frontend/BlogController.php create mode 100644 app/Http/Controllers/Frontend/CheckoutController.php create mode 100644 app/Http/Controllers/Frontend/CommentController.php create mode 100644 app/Http/Controllers/Frontend/DiscoverController.php create mode 100644 app/Http/Controllers/Frontend/EmailVerificationController.php create mode 100644 app/Http/Controllers/Frontend/HomeController.php create mode 100644 app/Http/Controllers/Frontend/MessageController.php create mode 100644 app/Http/Controllers/Frontend/PasswordResetController.php create mode 100644 app/Http/Controllers/Frontend/PlayerController.php create mode 100644 app/Http/Controllers/Frontend/PremiumController.php create mode 100644 app/Http/Controllers/Frontend/ProfileController.php create mode 100644 app/Http/Controllers/Frontend/SocialController.php create mode 100644 app/Http/Controllers/Frontend/TrackingController.php create mode 100644 app/Http/Controllers/Frontend/TribunalController.php create mode 100644 app/Http/Controllers/Frontend/UserFeatureController.php create mode 100644 app/Http/Controllers/Frontend/VoiceCallController.php create mode 100644 app/Http/Controllers/MediaController.php create mode 100644 app/Http/Controllers/SitemapController.php create mode 100644 app/Http/Middleware/AdminAccessMiddleware.php create mode 100644 app/Http/Middleware/AdminMiddleware.php create mode 100644 app/Http/Middleware/BotDetector.php create mode 100644 app/Http/Middleware/ImportApiMiddleware.php create mode 100644 app/Http/Middleware/SecurePlayer.php create mode 100644 app/Http/Middleware/SeoRedirectMiddleware.php create mode 100644 app/Mail/ResetPasswordMail.php create mode 100644 app/Mail/TestMail.php create mode 100644 app/Mail/VerifyEmailMail.php create mode 100644 app/Models/Achievement.php create mode 100644 app/Models/ActivationCode.php create mode 100644 app/Models/Ad.php create mode 100644 app/Models/Analytics/AiQuery.php create mode 100644 app/Models/Analytics/BotLog.php create mode 100644 app/Models/Analytics/PageView.php create mode 100644 app/Models/Analytics/VisitorSession.php create mode 100644 app/Models/Analytics/WatchEvent.php create mode 100644 app/Models/Anime.php create mode 100644 app/Models/AnimeFollow.php create mode 100644 app/Models/AnimeRating.php create mode 100644 app/Models/AnimeRequest.php create mode 100644 app/Models/AnimeRequestVote.php create mode 100644 app/Models/AnimeSwipe.php create mode 100644 app/Models/Banner.php create mode 100644 app/Models/BlogPost.php create mode 100644 app/Models/Comment.php create mode 100644 app/Models/CommentLike.php create mode 100644 app/Models/ContentPermission.php create mode 100644 app/Models/ContinueWatching.php create mode 100644 app/Models/Conversation.php create mode 100644 app/Models/Episode.php create mode 100644 app/Models/EpisodeNote.php create mode 100644 app/Models/EpisodePrediction.php create mode 100644 app/Models/EpisodeTimestampComment.php create mode 100644 app/Models/EpisodeVote.php create mode 100644 app/Models/FirstWatchSession.php create mode 100644 app/Models/Genre.php create mode 100644 app/Models/ImportJob.php create mode 100644 app/Models/MembershipPlan.php create mode 100644 app/Models/Message.php create mode 100644 app/Models/ModeratorPermission.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/PermissionSetting.php create mode 100644 app/Models/PredictionVote.php create mode 100644 app/Models/Season.php create mode 100644 app/Models/SeoKeyword.php create mode 100644 app/Models/SeoRedirect.php create mode 100644 app/Models/Setting.php create mode 100644 app/Models/SpoilerBox.php create mode 100644 app/Models/SpoilerBoxLike.php create mode 100644 app/Models/Subscription.php create mode 100644 app/Models/Subtitle.php create mode 100644 app/Models/TimeCapsule.php create mode 100644 app/Models/Tribunal.php create mode 100644 app/Models/TribunalArgument.php create mode 100644 app/Models/TribunalArgumentVote.php create mode 100644 app/Models/TribunalVote.php create mode 100644 app/Models/User.php create mode 100644 app/Models/UserAchievement.php create mode 100644 app/Models/UserActivityLog.php create mode 100644 app/Models/UserFollow.php create mode 100644 app/Models/UserNotification.php create mode 100644 app/Models/VideoSource.php create mode 100644 app/Models/VoiceCall.php create mode 100644 app/Models/WatchParty.php create mode 100644 app/Models/WatchPartyMember.php create mode 100644 app/Models/Watchlist.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/AchievementService.php create mode 100644 app/Services/AgoraTokenService.php create mode 100644 app/Services/AniListService.php create mode 100644 app/Services/AniSkipService.php create mode 100644 app/Services/BunnyCdnSigner.php create mode 100644 app/Services/BunnyCdnStorage.php create mode 100644 app/Services/DeepSeekService.php create mode 100644 app/Services/FcmService.php create mode 100644 app/Services/JikanService.php create mode 100644 app/Services/PremiumFeatures.php create mode 100644 app/Support/ActivityLogger.php create mode 100644 app/Support/ImageOptimizer.php create mode 100644 app/Support/MediaUrl.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/iyzico.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/sanctum.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2024_01_01_000010_create_membership_plans_table.php create mode 100644 database/migrations/2024_01_01_000020_create_genres_table.php create mode 100644 database/migrations/2024_01_01_000030_create_animes_table.php create mode 100644 database/migrations/2024_01_01_000040_create_seasons_table.php create mode 100644 database/migrations/2024_01_01_000050_create_episodes_table.php create mode 100644 database/migrations/2024_01_01_000060_create_content_permissions_table.php create mode 100644 database/migrations/2024_01_01_000070_create_comments_table.php create mode 100644 database/migrations/2024_01_01_000080_create_subscriptions_table.php create mode 100644 database/migrations/2024_01_01_000090_create_banners_table.php create mode 100644 database/migrations/2024_01_01_000100_create_settings_table.php create mode 100644 database/migrations/2024_01_01_000110_create_import_jobs_table.php create mode 100644 database/migrations/2024_01_01_000120_add_animecix_fields_to_import_jobs.php create mode 100644 database/migrations/2024_01_01_000120_create_subtitles_table.php create mode 100644 database/migrations/2024_01_01_000121_create_video_sources_table.php create mode 100644 database/migrations/2024_01_01_000122_add_animecix_to_episodes_source_enum.php create mode 100644 database/migrations/2024_01_01_000130_add_gif_to_comments_and_create_comment_likes.php create mode 100644 database/migrations/2024_01_01_000140_add_available_dubs_to_episodes.php create mode 100644 database/migrations/2024_01_01_000150_add_trending_to_animes.php create mode 100644 database/migrations/2024_01_01_000160_create_analytics_tables.php create mode 100644 database/migrations/2024_01_01_000170_create_user_features_tables.php create mode 100644 database/migrations/2024_01_01_000180_create_follow_notify_tables.php create mode 100644 database/migrations/2024_01_01_000200_add_intro_times_to_episodes.php create mode 100644 database/migrations/2024_01_01_000200_add_profile_fields_to_users.php create mode 100644 database/migrations/2024_01_01_000210_add_fcm_token_to_users.php create mode 100644 database/migrations/2024_01_02_000001_add_updated_at_to_watchlists.php create mode 100644 database/migrations/2024_01_02_000002_create_skip_events_table.php create mode 100644 database/migrations/2024_01_02_000003_add_mal_id_to_seasons.php create mode 100644 database/migrations/2026_04_10_150117_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_04_17_000001_create_blog_posts_table.php create mode 100644 database/migrations/2026_04_17_100001_create_user_follows_table.php create mode 100644 database/migrations/2026_04_17_100002_create_conversations_table.php create mode 100644 database/migrations/2026_04_17_200001_create_social_features_tables.php create mode 100644 database/migrations/2026_04_17_300000_create_community_features_tables.php create mode 100644 database/migrations/2026_04_17_310000_add_extra_sides_to_tribunals.php create mode 100644 database/migrations/2026_04_19_000001_create_moderator_permissions_table.php create mode 100644 database/migrations/2026_04_19_000002_add_bot_columns_to_analytics_pageviews.php create mode 100644 database/migrations/2026_05_09_040058_add_anizium_to_episodes_source_enum.php create mode 100644 database/migrations/2026_05_10_100000_add_perks_to_membership_plans.php create mode 100644 database/migrations/2026_05_10_100001_add_premium_cosmetics_to_users.php create mode 100644 database/migrations/2026_05_14_000001_add_plan_visibility_and_admin_badge.php create mode 100644 database/migrations/2026_05_14_000002_add_trial_days_and_profile_music.php create mode 100644 database/migrations/2026_06_05_000001_add_trending_score_to_animes.php create mode 100644 database/migrations/2026_06_05_000002_add_priority_to_import_jobs.php create mode 100644 database/migrations/2026_06_09_000001_create_anime_swipes_table.php create mode 100644 database/migrations/2026_06_10_000001_add_purchase_fields_to_membership_plans.php create mode 100644 database/migrations/2026_06_10_000002_create_activation_codes_table.php create mode 100644 database/migrations/2026_06_10_000003_add_premium_visual_effects_to_users.php create mode 100644 database/migrations/2026_06_12_000001_add_is_dubbed_to_animes.php create mode 100644 database/migrations/2026_06_12_100001_create_voice_calls_table.php create mode 100644 database/migrations/2026_06_16_000001_add_social_auth_to_users.php create mode 100644 database/migrations/2026_06_28_000001_add_is_hevc_to_video_sources.php create mode 100644 database/migrations/2026_07_07_000001_create_ads_table.php create mode 100644 database/seeders/AchievementSeeder.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 env create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/app/animexe.apk create mode 100644 public/cf-worker/hls-proxy-worker.js create mode 100644 public/clearcache.php create mode 100644 public/css/premium-cosmetics.css create mode 100644 public/error_log create mode 100644 public/favicon.png create mode 100644 public/hls-proxy.php create mode 100644 public/index.php create mode 100644 public/logo.jpg create mode 100644 public/manifest.json create mode 100644 public/migrate_run.php create mode 100644 public/opensearch.xml create mode 100644 public/robots.txt create mode 100644 public/setup.php create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/views/admin/activation-codes/index.blade.php create mode 100644 resources/views/admin/ads/edit.blade.php create mode 100644 resources/views/admin/ads/index.blade.php create mode 100644 resources/views/admin/ai/anime-meta.blade.php create mode 100644 resources/views/admin/ai/descriptions.blade.php create mode 100644 resources/views/admin/analytics/index.blade.php create mode 100644 resources/views/admin/analytics/user-detail.blade.php create mode 100644 resources/views/admin/analytics/users.blade.php create mode 100644 resources/views/admin/anime-requests/index.blade.php create mode 100644 resources/views/admin/animes/create.blade.php create mode 100644 resources/views/admin/animes/edit.blade.php create mode 100644 resources/views/admin/animes/index.blade.php create mode 100644 resources/views/admin/animes/show.blade.php create mode 100644 resources/views/admin/auth/login.blade.php create mode 100644 resources/views/admin/banners/index.blade.php create mode 100644 resources/views/admin/blog/edit.blade.php create mode 100644 resources/views/admin/blog/index.blade.php create mode 100644 resources/views/admin/comments/index.blade.php create mode 100644 resources/views/admin/dashboard.blade.php create mode 100644 resources/views/admin/episodes/create.blade.php create mode 100644 resources/views/admin/episodes/edit.blade.php create mode 100644 resources/views/admin/episodes/index.blade.php create mode 100644 resources/views/admin/genres/index.blade.php create mode 100644 resources/views/admin/health/index.blade.php create mode 100644 resources/views/admin/import/index.blade.php create mode 100644 resources/views/admin/import/show.blade.php create mode 100644 resources/views/admin/layouts/app.blade.php create mode 100644 resources/views/admin/mobile/index.blade.php create mode 100644 resources/views/admin/moderators/edit.blade.php create mode 100644 resources/views/admin/moderators/index.blade.php create mode 100644 resources/views/admin/notifications/index.blade.php create mode 100644 resources/views/admin/partials/pagination.blade.php create mode 100644 resources/views/admin/permissions/index.blade.php create mode 100644 resources/views/admin/plans/create.blade.php create mode 100644 resources/views/admin/plans/edit.blade.php create mode 100644 resources/views/admin/plans/index.blade.php create mode 100644 resources/views/admin/seo/index.blade.php create mode 100644 resources/views/admin/settings/index.blade.php create mode 100644 resources/views/admin/stats/index.blade.php create mode 100644 resources/views/admin/subscriptions/index.blade.php create mode 100644 resources/views/admin/trending/index.blade.php create mode 100644 resources/views/admin/users/edit.blade.php create mode 100644 resources/views/admin/users/index.blade.php create mode 100644 resources/views/admin/users/show.blade.php create mode 100644 resources/views/emails/layout.blade.php create mode 100644 resources/views/emails/reset-password.blade.php create mode 100644 resources/views/emails/test.blade.php create mode 100644 resources/views/emails/verify-email.blade.php create mode 100644 resources/views/frontend/ai/index.blade.php create mode 100644 resources/views/frontend/anime-request.blade.php create mode 100644 resources/views/frontend/anime.blade.php create mode 100644 resources/views/frontend/auth/forgot-password.blade.php create mode 100644 resources/views/frontend/auth/login.blade.php create mode 100644 resources/views/frontend/auth/register.blade.php create mode 100644 resources/views/frontend/auth/reset-password.blade.php create mode 100644 resources/views/frontend/auth/verify-email.blade.php create mode 100644 resources/views/frontend/blog/index.blade.php create mode 100644 resources/views/frontend/blog/show.blade.php create mode 100644 resources/views/frontend/capsules.blade.php create mode 100644 resources/views/frontend/checkout/failed.blade.php create mode 100644 resources/views/frontend/checkout/form.blade.php create mode 100644 resources/views/frontend/checkout/show.blade.php create mode 100644 resources/views/frontend/checkout/success.blade.php create mode 100644 resources/views/frontend/discover.blade.php create mode 100644 resources/views/frontend/genre.blade.php create mode 100644 resources/views/frontend/home.blade.php create mode 100644 resources/views/frontend/layouts/app.blade.php create mode 100644 resources/views/frontend/legal/dmca.blade.php create mode 100644 resources/views/frontend/legal/privacy.blade.php create mode 100644 resources/views/frontend/legal/terms.blade.php create mode 100644 resources/views/frontend/messages/index.blade.php create mode 100644 resources/views/frontend/messages/show.blade.php create mode 100644 resources/views/frontend/notifications.blade.php create mode 100644 resources/views/frontend/pagination.blade.php create mode 100644 resources/views/frontend/partials/ad-banner.blade.php create mode 100644 resources/views/frontend/player.blade.php create mode 100644 resources/views/frontend/premium/activate.blade.php create mode 100644 resources/views/frontend/premium/plans.blade.php create mode 100644 resources/views/frontend/profile-settings.blade.php create mode 100644 resources/views/frontend/profile.blade.php create mode 100644 resources/views/frontend/public-profile.blade.php create mode 100644 resources/views/frontend/search.blade.php create mode 100644 resources/views/frontend/tribunal/index.blade.php create mode 100644 resources/views/frontend/tribunal/show.blade.php create mode 100644 resources/views/frontend/watch-party.blade.php create mode 100644 resources/views/sitemap.blade.php create mode 100644 resources/views/sitemap_index.blade.php create mode 100644 resources/views/sitemaps/animes.blade.php create mode 100644 resources/views/sitemaps/blog.blade.php create mode 100644 resources/views/sitemaps/main.blade.php create mode 100644 resources/views/sitemaps/videos.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/MediaControllerTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..6ff9005 --- /dev/null +++ b/.htaccess @@ -0,0 +1,4 @@ + + RewriteEngine On + RewriteRule ^(.*)$ public/$1 [L] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/CloseTribunals.php b/app/Console/Commands/CloseTribunals.php new file mode 100644 index 0000000..df8c441 --- /dev/null +++ b/app/Console/Commands/CloseTribunals.php @@ -0,0 +1,55 @@ +where('closes_at', '<=', now()) + ->get(); + + if ($expired->isEmpty()) { + $this->info('Kapatılacak mahkeme yok.'); + return 0; + } + + foreach ($expired as $tribunal) { + $sides = $tribunal->allSides(); + $counts = []; + foreach (array_keys($sides) as $side) { + $counts[$side] = TribunalVote::where('tribunal_id', $tribunal->id) + ->where('side', $side) + ->count(); + } + + arsort($counts); + $topSide = array_key_first($counts); + $topCount = $counts[$topSide]; + $allEqual = count(array_unique(array_values($counts))) === 1 && array_sum($counts) > 0; + + $verdict = null; + if (!$allEqual && $topCount > 0) { + $verdict = $sides[$topSide] ?? $topSide; + } + + $tribunal->update([ + 'status' => 'closed', + 'verdict' => $verdict, + ]); + + $this->line("Kapatıldı: #{$tribunal->id} — Karar: " . ($verdict ?? 'Beraberlik')); + } + + $this->info("{$expired->count()} mahkeme kapatıldı."); + return 0; + } +} diff --git a/app/Console/Commands/CreateCrossImportJobs.php b/app/Console/Commands/CreateCrossImportJobs.php new file mode 100644 index 0000000..c4b46f5 --- /dev/null +++ b/app/Console/Commands/CreateCrossImportJobs.php @@ -0,0 +1,132 @@ +option('force'); + $only = $this->option('only'); + $dryRun = $this->option('dry-run'); + + $this->info("Animexe Çapraz Import Job Oluşturucu"); + $this->info("===================================="); + + // Tüm yayınlanan animeleri job durumlarıyla al + $animes = Anime::where('is_published', true) + ->with('importJobs') + ->get(); + + $this->info("Toplam yayınlanan anime: {$animes->count()}"); + + $aniziumCreated = 0; + $animecixCreated = 0; + $skipped = 0; + $requeued = 0; + + foreach ($animes as $anime) { + $jobs = $anime->importJobs; + $aniziumJobs = $jobs->where('source', 'anizium'); + $animecixJobs = $jobs->where('source', 'animecix'); + + $hasAniziumDone = $aniziumJobs->where('status', 'done')->isNotEmpty(); + $hasAnimecixDone = $animecixJobs->where('status', 'done')->isNotEmpty(); + $hasAniziumAny = $aniziumJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty(); + $hasAnimecixAny = $animecixJobs->whereIn('status', ['pending','fetching','downloading','uploading','done'])->isNotEmpty(); + + // ── Re-queue: ikisi de done olan animeleri yeniden işlet ────────── + if ($force && $hasAniziumDone && $hasAnimecixDone) { + if (!$dryRun) { + // En son done Anizium job'unu yeniden pending yap + $aj = $aniziumJobs->where('status', 'done')->sortByDesc('id')->first(); + if ($aj) { + $aj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']); + } + // En son done AnimeCix job'unu yeniden pending yap + $cj = $animecixJobs->where('status', 'done')->sortByDesc('id')->first(); + if ($cj) { + $cj->update(['status' => 'pending', 'done_episodes' => 0, 'error_log' => null, 'current_step' => 'Çapraz re-import']); + } + $requeued++; + } else { + $this->line("[DRY] Re-queue: {$anime->title} (her iki kaynak mevcut)"); + } + continue; + } + + // ── Anizium job oluştur ─────────────────────────────────────────── + if ((!$only || $only === 'anizium') && !$hasAniziumAny) { + // Anizium watch_id'yi bilmiyoruz — Python reimport_all.py bu görevi üstlenir. + // Buradan sadece anime_id'yi atayarak "ihtiyaç listesi" oluşturabiliriz; + // watch_id olmadan bot job'ı işleyemez. Bu yüzden sadece logluyoruz. + $this->warn("[SKIP-ANİZİUM] {$anime->title} (watch_id bilinmiyor — reimport_all.py kullan)"); + $skipped++; + } + + // ── AnimeCix job oluştur ────────────────────────────────────────── + if ((!$only || $only === 'animecix') && !$hasAnimecixAny) { + // animecix_title_id'yi bilmiyoruz — daemon.py bunu otomatik keşfeder. + $this->warn("[SKIP-ANİMECİX] {$anime->title} (title_id bilinmiyor — daemon.py kullan)"); + $skipped++; + } + + // ── Anizium'u olan ama AnimeCix'i olmayan: Anizium job'larından watch_id var ── + // ─ (bu zaten mevcut) ─ + + // ── AnimeCix'i olan ama Anizium'u olmayan: watch_id bilinmiyorsa skip ── + if ((!$only || $only === 'anizium') && $hasAnimecixDone && !$hasAniziumAny) { + // AnimeCix'te var ama Anizium'da yok — reimport_all.py Anizium katalogunda arayacak + $this->line("[ANİZİUM-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — reimport_all.py işleyecek"); + } + + // ── Anizium'u olan ama AnimeCix'i olmayan: daemon.py katalog taramasında otomatik bulur ── + if ((!$only || $only === 'animecix') && $hasAniziumDone && !$hasAnimecixAny) { + $this->line("[ANİMECİX-GEREKLİ] {$anime->title} (MAL: {$anime->mal_id}) — daemon.py bulacak"); + } + } + + // ── Var olan Anizium done job'larını yeniden pending yap (--force) ─── + if ($force && !$dryRun) { + $this->info("Re-queue tamamlandı: {$requeued} anime"); + } + + $this->newLine(); + $this->info("Özet:"); + $this->info(" Anizium job oluşturuldu : {$aniziumCreated}"); + $this->info(" AnimeCix job oluşturuldu: {$animecixCreated}"); + $this->info(" Yeniden kuyruğa alındı : {$requeued}"); + $this->info(" Atlandı (watch_id yok) : {$skipped}"); + $this->newLine(); + $this->info("Tüm anime + kaynak eşleştirmesi için: python reimport_all.py"); + + return 0; + } +} diff --git a/app/Console/Commands/FetchAniListImages.php b/app/Console/Commands/FetchAniListImages.php new file mode 100644 index 0000000..51dc0c1 --- /dev/null +++ b/app/Console/Commands/FetchAniListImages.php @@ -0,0 +1,67 @@ +option('id')) { + $query->where('id', $id); + } elseif (!$this->option('all')) { + // Varsayılan: eksik resmi olanlar + $query->where(function ($q) { + $q->whereNull('cover_image')->orWhere('cover_image', '') + ->orWhereNull('banner_image')->orWhere('banner_image', ''); + }); + } + + $limit = (int) $this->option('limit'); + $animes = $query->limit($limit)->get(); + + if ($animes->isEmpty()) { + $this->info('Eksik resim bulunamadı.'); + return; + } + + $this->info("İşlenecek: {$animes->count()} anime"); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $ok = $skip = $fail = 0; + + foreach ($animes as $anime) { + try { + $updated = $service->fillImages($anime); + $updated ? $ok++ : $skip++; + } catch (\Throwable $e) { + $fail++; + $this->newLine(); + $this->warn("#{$anime->id} {$anime->title}: {$e->getMessage()}"); + } + + $bar->advance(); + usleep(500_000); // AniList rate limit: 90 req/dakika + } + + $bar->finish(); + $this->newLine(2); + $this->info("Bitti — güncellendi: {$ok}, değişmedi: {$skip}, hata: {$fail}"); + } +} diff --git a/app/Console/Commands/FetchMalIds.php b/app/Console/Commands/FetchMalIds.php new file mode 100644 index 0000000..cf0ae3f --- /dev/null +++ b/app/Console/Commands/FetchMalIds.php @@ -0,0 +1,62 @@ +option('force')) { + $query->whereNull('mal_id'); + } + + $animes = $query->get(); + $this->info("Processing {$animes->count()} anime(s)…"); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $found = 0; + foreach ($animes as $anime) { + try { + if (!$anime->mal_id || $this->option('force')) { + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $found++; + } + usleep(400_000); // rate limit + } + + // Fill season chain + if ($anime->mal_id) { + $chain = $jikan->fetchSeasonMalIds($anime->mal_id); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + } + } catch (\Throwable $e) { + $this->newLine(); + $this->warn(" ⚠ [{$anime->title}]: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Done. {$found} new MAL ID(s) fetched."); + return 0; + } +} diff --git a/app/Console/Commands/FillAnimeMeta.php b/app/Console/Commands/FillAnimeMeta.php new file mode 100644 index 0000000..f2a9a1a --- /dev/null +++ b/app/Console/Commands/FillAnimeMeta.php @@ -0,0 +1,174 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'); + return self::FAILURE; + } + + $limit = (int) $this->option('limit'); + $animeId = $this->option('anime'); + $force = $this->option('force'); + $dry = $this->option('dry-run'); + + // İşlenecek animeleri belirle + if ($animeId) { + $animes = Anime::where('id', $animeId)->with('genres')->get(); + } else { + $query = Anime::with('genres'); + + if (!$force) { + $query->where(function ($q) { + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->orDoesntHave('genres'); + } + + $query->orderBy('id'); + if ($limit > 0) $query->limit($limit); + $animes = $query->get(); + } + + if ($animes->isEmpty()) { + $this->info('İşlenecek anime bulunamadı (tüm alanlar dolu).'); + Log::channel('daily')->info('[FillAnimeMeta] İşlenecek anime yok.'); + return self::SUCCESS; + } + + $this->info("Toplam {$animes->count()} anime işlenecek" . ($dry ? ' (dry-run)' : '') . '...'); + Log::channel('daily')->info("[FillAnimeMeta] Başladı. {$animes->count()} anime, limit={$limit}, force=" . ($force ? 'evet' : 'hayır')); + + $done = 0; + $skipped = 0; + $failed = 0; + + foreach ($animes as $anime) { + $missing = $this->missingFields($anime); + + if (!$force && empty($missing)) { + $this->line(" ATLA {$anime->title} — tüm alanlar dolu"); + $skipped++; + continue; + } + + $label = $force ? 'tüm alanlar' : implode(', ', $missing); + $this->line(" İŞLE [{$anime->id}] {$anime->title} — eksik: {$label}"); + + if ($dry) { + $done++; + continue; + } + + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + + if (!$meta) { + $this->warn(" HATA {$anime->title} — AI boş yanıt döndürdü"); + Log::channel('daily')->warning("[FillAnimeMeta] HATA [{$anime->id}] {$anime->title}: AI boş yanıt"); + $failed++; + sleep(3); + continue; + } + + // Sadece boş alanları doldur (force modunda hepsini güncelle) + $updates = []; + + $fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) { + $updates[$field] = $value; + } + }; + + $fillIfEmpty('description', $meta['description'] ?? null); + $fillIfEmpty('release_year', $meta['release_year'] ?? null); + $fillIfEmpty('studio', $meta['studio'] ?? null); + $fillIfEmpty('type', $meta['type'] ?? null); + $fillIfEmpty('status', $meta['status'] ?? null); + $fillIfEmpty('title_en', $meta['title_en'] ?? null); + $fillIfEmpty('title_jp', $meta['title_jp'] ?? null); + + // Rating: sadece boşsa veya 0 ise doldur + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) { + $anime->update($updates); + } + + // Genres: boşsa ekle + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $genreIds = []; + foreach ($meta['genres'] as $genreName) { + $genre = Genre::firstOrCreate( + ['name' => $genreName], + ['slug' => \Illuminate\Support\Str::slug($genreName)] + ); + $genreIds[] = $genre->id; + } + if ($genreIds) { + $force ? $anime->genres()->sync($genreIds) : $anime->genres()->syncWithoutDetaching($genreIds); + } + } + + $updatedFields = array_keys($updates); + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $updatedFields[] = 'genres(' . implode(',', $meta['genres'] ?? []) . ')'; + } + + $summary = empty($updatedFields) ? 'Yeni alan yok' : implode(', ', $updatedFields); + $this->info(" ✓ OK [{$anime->id}] {$anime->title} → {$summary}"); + Log::channel('daily')->info("[FillAnimeMeta] OK [{$anime->id}] {$anime->title} → {$summary}"); + + $done++; + + // API rate limit — DeepSeek'i boğma + sleep(2); + } + + $summary = "Tamamlandı: {$done} işlendi, {$skipped} atlandı, {$failed} hata."; + $this->info($summary); + Log::channel('daily')->info("[FillAnimeMeta] {$summary}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + private function missingFields(Anime $anime): array + { + $missing = []; + if (empty($anime->description)) $missing[] = 'description'; + if (empty($anime->release_year)) $missing[] = 'release_year'; + if (empty($anime->studio)) $missing[] = 'studio'; + if (empty($anime->type)) $missing[] = 'type'; + if (empty($anime->status)) $missing[] = 'status'; + if (!$anime->rating) $missing[] = 'rating'; + if (empty($anime->title_en)) $missing[] = 'title_en'; + if ($anime->genres->isEmpty()) $missing[] = 'genres'; + return $missing; + } +} diff --git a/app/Console/Commands/FixSubtitleMismatch.php b/app/Console/Commands/FixSubtitleMismatch.php new file mode 100644 index 0000000..5b048f2 --- /dev/null +++ b/app/Console/Commands/FixSubtitleMismatch.php @@ -0,0 +1,112 @@ +option('dry-run'); + $animeId = $this->option('anime-id'); + + $this->info("Anizium Altyazı Uyuşmazlık Düzeltici"); + $this->info("====================================="); + if ($dryRun) $this->warn("DRY-RUN modu — hiçbir şey silinmeyecek"); + + $query = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select( + 'subtitles.id as subtitle_id', + 'subtitles.episode_id', + 'subtitles.language', + 'subtitles.url', + 'seasons.season_number', + 'episodes.episode_number', + 'episodes.anime_id', + ); + + if ($animeId) { + $query->where('episodes.anime_id', (int) $animeId); + } + + $subtitles = $query->get(); + $this->info("Kontrol edilecek Anizium altyazısı: {$subtitles->count()}"); + + $mismatchIds = []; + + foreach ($subtitles as $sub) { + $url = $sub->url; + $season = (int) $sub->season_number; + $episode = (int) $sub->episode_number; + $lang = $sub->language; + + // URL'den name parametresini çıkar + $parsed = parse_url($url); + if (!isset($parsed['query'])) continue; + + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + + if (!$name) continue; + + // Beklenen: s{season}_b{episode}_{lang} + $expectedPrefix = "s{$season}_b{$episode}_"; + if (!str_starts_with($name, $expectedPrefix)) { + $mismatchIds[] = $sub->subtitle_id; + $this->line( + "[MISMATCH] sub_id={$sub->subtitle_id} " + . "anime_id={$sub->anime_id} " + . "S{$season}E{$episode} {$lang} " + . "| name={$name} " + . "(beklenen prefix: {$expectedPrefix})" + ); + } + } + + $this->newLine(); + $this->info("Uyuşmazlık bulunan: " . count($mismatchIds)); + + if (empty($mismatchIds)) { + $this->info("Düzeltilecek altyazı bulunamadı."); + return 0; + } + + if ($dryRun) { + $this->warn("--dry-run: {" . count($mismatchIds) . "} altyazı silinecekti."); + return 0; + } + + $deleted = Subtitle::whereIn('id', $mismatchIds)->delete(); + $this->info("Silindi: {$deleted} yanlış altyazı."); + $this->info("Botları yeniden çalıştırarak doğru altyazıları yeniden indirebilirsiniz."); + $this->info(" python anizium_scraper/bot2_upload.py --daemon"); + + return 0; + } +} diff --git a/app/Console/Commands/GenerateBlogPosts.php b/app/Console/Commands/GenerateBlogPosts.php new file mode 100644 index 0000000..ea32bc8 --- /dev/null +++ b/app/Console/Commands/GenerateBlogPosts.php @@ -0,0 +1,135 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'); + return self::FAILURE; + } + + $count = (int) $this->option('count'); + $animeId = $this->option('anime'); + $force = $this->option('force'); + + if ($animeId) { + $animes = Anime::where('id', $animeId)->where('is_published', true)->with('genres')->get(); + } else { + $alreadyBlogged = $force ? [] : BlogPost::whereNotNull('anime_id')->pluck('anime_id')->toArray(); + $animesQuery = Anime::where('is_published', true) + ->whereNotIn('id', $alreadyBlogged) + ->with('genres') + ->orderByDesc('rating') + ->limit($count * 3) + ->get(); + + $randomResult = $animesQuery->random(min($count, $animesQuery->count())); + $animes = collect($randomResult); + } + + if ($animes->isEmpty()) { + $this->info('Blog yazısı üretilecek anime bulunamadı.'); + return self::SUCCESS; + } + + $generated = 0; + + foreach ($animes->take($count) as $anime) { + $this->info("Blog üretiliyor: {$anime->title}..."); + + // Aynı türden ilgili animeler bul + $genreIds = $anime->genres->pluck('id'); + $related = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds)) + ->orderByDesc('rating') + ->limit(5) + ->get(['id', 'title', 'slug']) + ->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title]) + ->toArray(); + + $data = $deepseek->generateBlogPost($anime, $related); + + if (!$data || empty($data['content'])) { + $this->warn(" [{$anime->title}] için içerik üretilemedi: " . $deepseek->lastError); + continue; + } + + // [LINK:slug]Title[/LINK] placeholder'larını gerçek URL'lerle değiştir + $content = preg_replace_callback( + '/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/', + function ($m) { + $slug = trim($m[1]); + $label = trim($m[2]); + try { + $url = route('anime.show', $slug); + return "{$label}"; + } catch (\Exception $e) { + return $label; + } + }, + $data['content'] ?? '' + ); + + $title = $data['title'] ?? ($anime->title . ' İzle — Animexe Rehberi'); + $slug = BlogPost::generateSlug($title); + + // Linked anime IDs + $linkedIds = []; + if (!empty($data['linked_slugs'])) { + $linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray(); + } + + $readingTime = max(3, (int) (str_word_count(strip_tags($content)) / 200)); + + BlogPost::create([ + 'title' => $title, + 'slug' => $slug, + 'excerpt' => $data['excerpt'] ?? '', + 'content' => $content, + 'cover_image' => $anime->cover_image, + 'focus_keyword' => $data['focus_keyword'] ?? $anime->title, + 'meta_title' => $data['title'] ?? null, + 'meta_description' => $data['meta_description'] ?? $data['excerpt'] ?? '', + 'meta_keywords' => implode(', ', array_filter([ + $anime->title, + $anime->title . ' izle', + 'türkçe anime', + $data['focus_keyword'] ?? '', + ])), + 'status' => 'published', + 'ai_generated' => true, + 'anime_id' => $anime->id, + 'linked_anime_ids' => $linkedIds, + 'faq' => $data['faq'] ?? [], + 'reading_time' => $readingTime, + 'published_at' => now(), + ]); + + $this->info(" ✓ Blog yazısı oluşturuldu: {$title}"); + $generated++; + + // API rate limit + sleep(2); + } + + $this->info("Tamamlandı. {$generated} blog yazısı üretildi."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GenerateDiscoveryHooks.php b/app/Console/Commands/GenerateDiscoveryHooks.php new file mode 100644 index 0000000..5c934ce --- /dev/null +++ b/app/Console/Commands/GenerateDiscoveryHooks.php @@ -0,0 +1,59 @@ +isConfigured()) { + $this->error('DeepSeek API anahtarı ayarlanmamış.'); + return 1; + } + + $limit = (int) $this->option('limit'); + $force = $this->option('force'); + + $query = Anime::where('is_published', true)->with('genres:id,name'); + if (!$force) { + $query->whereNull('discovery_hook'); + } + + $animes = $query->limit($limit)->get(); + + if ($animes->isEmpty()) { + $this->info('Hook üretilecek anime bulunamadı.'); + return 0; + } + + $this->info("Toplam {$animes->count()} anime için hook üretiliyor..."); + $bar = $this->output->createProgressBar($animes->count()); + $bar->start(); + + $done = 0; $failed = 0; + foreach ($animes as $anime) { + $hook = $ai->generateDiscoveryHook($anime); + if ($hook) { + $anime->updateQuietly(['discovery_hook' => $hook]); + $done++; + } else { + $failed++; + } + $bar->advance(); + usleep(300_000); // Rate limit — 0.3sn ara + } + + $bar->finish(); + $this->newLine(); + $this->info("Tamamlandı: {$done} başarılı, {$failed} başarısız."); + + return 0; + } +} diff --git a/app/Http/Controllers/Admin/ActivationCodeController.php b/app/Http/Controllers/Admin/ActivationCodeController.php new file mode 100644 index 0000000..b73b8e8 --- /dev/null +++ b/app/Http/Controllers/Admin/ActivationCodeController.php @@ -0,0 +1,142 @@ +latest(); + + if ($request->filled('plan_id')) { + $query->where('plan_id', $request->plan_id); + } + + if ($request->filled('batch')) { + $query->where('batch', $request->batch); + } + + match ($request->status) { + 'used' => $query->whereNotNull('used_at'), + 'unused' => $query->whereNull('used_at'), + default => null, + }; + + $codes = $query->paginate(50)->withQueryString(); + $plans = MembershipPlan::where('is_active', true)->orderBy('sort_order')->get(); + $batches = ActivationCode::select('batch')->whereNotNull('batch') + ->distinct()->orderBy('batch', 'desc')->pluck('batch'); + + $stats = [ + 'total' => ActivationCode::count(), + 'used' => ActivationCode::whereNotNull('used_at')->count(), + 'unused' => ActivationCode::whereNull('used_at')->count(), + ]; + + return view('admin.activation-codes.index', compact('codes', 'plans', 'stats', 'batches')); + } + + public function generate(Request $request) + { + $request->validate([ + 'plan_id' => 'required|exists:membership_plans,id', + 'quantity' => 'required|integer|min:1|max:500', + 'expires_at' => 'nullable|date|after:today', + 'notes' => 'nullable|string|max:500', + 'batch' => 'nullable|string|max:64', + ]); + + $batch = $request->batch ?: 'toplu-' . now()->format('Ymd-His'); + $generated = []; + + DB::transaction(function () use ($request, $batch, &$generated) { + for ($i = 0; $i < $request->quantity; $i++) { + $code = ActivationCode::create([ + 'code' => ActivationCode::generateCode(), + 'plan_id' => $request->plan_id, + 'expires_at' => $request->expires_at ?: null, + 'batch' => $batch, + 'notes' => $request->notes, + 'created_by' => auth()->id(), + ]); + $generated[] = $code->code; + } + }); + + return back() + ->with('generated_codes', $generated) + ->with('success', count($generated) . ' adet aktivasyon kodu oluşturuldu. (Batch: ' . $batch . ')'); + } + + public function destroy(ActivationCode $activationCode) + { + if ($activationCode->isUsed()) { + return back()->withErrors(['error' => 'Kullanılmış kodlar silinemez.']); + } + + $activationCode->delete(); + + return back()->with('success', 'Aktivasyon kodu silindi.'); + } + + public function destroyBatch(Request $request) + { + $request->validate(['batch' => 'required|string|max:64']); + + $count = ActivationCode::where('batch', $request->batch) + ->whereNull('used_at') + ->delete(); + + return back()->with('success', $count . ' adet kullanılmamış kod silindi.'); + } + + public function destroySelected(Request $request) + { + $request->validate(['ids' => 'required|array|min:1', 'ids.*' => 'integer|exists:activation_codes,id']); + + $count = ActivationCode::whereIn('id', $request->ids) + ->whereNull('used_at') + ->delete(); + + return back()->with('success', $count . ' adet aktivasyon kodu silindi.'); + } + + public function export(Request $request) + { + $query = ActivationCode::with('plan')->whereNull('used_at'); + + if ($request->filled('plan_id')) { + $query->where('plan_id', $request->plan_id); + } + + if ($request->filled('batch')) { + $query->where('batch', $request->batch); + } + + $codes = $query->orderBy('batch')->orderBy('created_at')->get(); + + $csv = "\xEF\xBB\xBF"; // UTF-8 BOM (Excel için) + $csv .= "Kod,Plan,Batch,Son Kullanma,Oluşturulma\n"; + + foreach ($codes as $code) { + $csv .= implode(',', [ + $code->code, + '"' . str_replace('"', '""', $code->plan->name) . '"', + $code->batch ?? '-', + $code->expires_at?->format('d.m.Y') ?? '-', + $code->created_at->format('d.m.Y H:i'), + ]) . "\n"; + } + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="aktivasyon-kodlari-' . now()->format('Ymd') . '.csv"', + ]); + } +} diff --git a/app/Http/Controllers/Admin/AdController.php b/app/Http/Controllers/Admin/AdController.php new file mode 100644 index 0000000..766890f --- /dev/null +++ b/app/Http/Controllers/Admin/AdController.php @@ -0,0 +1,187 @@ +get(); + + $settings = [ + 'vad_enabled' => Setting::get('vad_enabled', '0'), + 'vad_freq_episodes' => Setting::get('vad_freq_episodes', 2), + 'vad_freq_minutes' => Setting::get('vad_freq_minutes', 5), + 'vad_upsell_percent' => Setting::get('vad_upsell_percent', 20), + 'banner_ads_enabled' => Setting::get('banner_ads_enabled', '0'), + ]; + + $stats = [ + 'total_impressions' => $ads->sum('impressions'), + 'total_clicks' => $ads->sum('clicks'), + 'avg_ctr' => $ads->sum('impressions') > 0 + ? round($ads->sum('clicks') / $ads->sum('impressions') * 100, 2) : 0, + 'active_count' => $ads->where('is_active', true)->count(), + ]; + + return view('admin.ads.index', compact('ads', 'settings', 'stats')); + } + + public function store(Request $request) + { + $data = $this->validateAd($request); + + if ($request->hasFile('media_file')) { + $data['file_path'] = $this->storeMedia($request->file('media_file')); + } + + unset($data['media_file']); + Ad::create($data); + + return back()->with('success', 'Reklam eklendi.'); + } + + public function edit(Ad $ad) + { + return view('admin.ads.edit', compact('ad')); + } + + public function update(Request $request, Ad $ad) + { + $data = $this->validateAd($request, $ad); + + if ($request->hasFile('media_file')) { + $newPath = $this->storeMedia($request->file('media_file')); + if ($ad->file_path) Storage::disk('public')->delete($ad->file_path); + $data['file_path'] = $newPath; + } + + unset($data['media_file']); + $ad->update($data); + + return redirect()->route('admin.ads.index')->with('success', 'Reklam güncellendi.'); + } + + public function destroy(Ad $ad) + { + if ($ad->file_path) Storage::disk('public')->delete($ad->file_path); + $ad->delete(); + + return back()->with('success', 'Reklam silindi.'); + } + + public function toggle(Ad $ad) + { + $ad->update(['is_active' => !$ad->is_active]); + return back()->with('success', $ad->is_active ? 'Reklam aktifleştirildi.' : 'Reklam durduruldu.'); + } + + public function saveSettings(Request $request) + { + Setting::set('vad_enabled', $request->boolean('vad_enabled') ? '1' : '0', 'ads'); + Setting::set('vad_freq_episodes', max(1, (int) $request->input('vad_freq_episodes', 2)), 'ads'); + Setting::set('vad_freq_minutes', max(1, (int) $request->input('vad_freq_minutes', 5)), 'ads'); + Setting::set('vad_upsell_percent', min(100, max(0, (int) $request->input('vad_upsell_percent', 20))), 'ads'); + Setting::set('banner_ads_enabled', $request->boolean('banner_ads_enabled') ? '1' : '0', 'ads'); + + return back()->with('success', 'Reklam ayarları kaydedildi.'); + } + + private function validateAd(Request $request, ?Ad $existing = null): array + { + $type = $request->input('type', 'video'); + + // Sunucu upload limitini aşan dosya: PHP boş/bozuk upload gönderir. + // Sessizce medyasız reklam kaydetmek yerine net hata ver. + $this->guardUploadError($request); + + // Yüklenmiş dosya da dış URL de yoksa reklam gösterilemez (media_url null olur). + // Düzenlemede mevcut dosya varsa yeniden yükleme zorunlu değil. + $hasExisting = $existing?->file_path || $existing?->external_url; + $needsMedia = !$request->hasFile('media_file') && !$hasExisting; + + $data = $request->validate([ + 'name' => 'required|string|max:120', + 'type' => 'required|in:video,banner', + 'placement' => 'required|in:preroll,home_mid,home_bottom', + 'media_file' => [ + 'nullable', 'file', + $type === 'video' ? 'mimes:mp4,m4v' : 'mimes:jpg,jpeg,png,webp,gif', + $type === 'video' ? 'max:102400' : 'max:20480', // video 100MB, görsel/gif 20MB + ], + 'external_url' => [$needsMedia ? 'required' : 'nullable', 'nullable', 'url', 'max:2000'], + 'click_url' => 'nullable|url|max:2000', + 'skip_after' => 'required|integer|min:0|max:60', + 'weight' => 'required|integer|min:1|max:100', + 'is_active' => 'boolean', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after:starts_at', + ], [ + 'external_url.required' => 'Bir medya dosyası yükleyin veya dış URL girin. ' + . 'Dosya seçtiyseniz sunucu yükleme limitini aşmış olabilir (maks. ' + . ini_get('upload_max_filesize') . ').', + 'media_file.mimes' => $type === 'video' + ? 'Video dosyası MP4 formatında olmalı.' + : 'Görsel JPG, PNG, WebP veya GIF formatında olmalı.', + 'media_file.max' => 'Dosya çok büyük.', + ]); + + // Checkbox işaretli değilse request'te hiç gelmez — açıkça boolean'a çevir + $data['is_active'] = $request->boolean('is_active'); + + return $data; + } + + /** PHP upload hatalarını (limit aşımı, kısmi yükleme) net mesajla yüzeye çıkar. */ + private function guardUploadError(Request $request): void + { + $file = $request->file('media_file'); + if (!$file || $file->isValid()) { + return; + } + + $msg = match ($file->getError()) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => + 'Dosya sunucunun yükleme limitini aşıyor (maks. ' . ini_get('upload_max_filesize') + . '). Daha küçük bir dosya seçin veya hosting limitini yükseltin.', + UPLOAD_ERR_PARTIAL => 'Dosya yalnızca kısmen yüklendi, lütfen tekrar deneyin.', + UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => + 'Sunucu dosyayı geçici klasöre yazamadı. Hosting sağlayıcınıza bildirin.', + default => 'Dosya yüklenemedi (hata kodu: ' . $file->getError() . ').', + }; + + throw \Illuminate\Validation\ValidationException::withMessages(['media_file' => $msg]); + } + + /** Dosyayı public diske yaz ve tam yazıldığını doğrula. */ + private function storeMedia(\Illuminate\Http\UploadedFile $file): string + { + // NOT: klasör adı bilerek nötr ('ads' değil) — adblocker /media/ads/ yolunu + // ERR_BLOCKED_BY_CLIENT ile engelliyor. 'content' engellenmez. + $expected = $file->getSize(); + $path = $file->store('content', 'public'); + + if (!$path || !Storage::disk('public')->exists($path)) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'media_file' => 'Dosya sunucuya kaydedilemedi. storage/app/public klasörünün yazma izni olduğundan emin olun.', + ]); + } + + // Kısmi yazma (disk dolu / kesilen upload) sessizce bozuk reklam bırakmasın + $written = Storage::disk('public')->size($path); + if ($expected > 0 && $written !== $expected) { + Storage::disk('public')->delete($path); + throw \Illuminate\Validation\ValidationException::withMessages([ + 'media_file' => "Dosya eksik yüklendi ({$written}/{$expected} byte). Tekrar deneyin.", + ]); + } + + return $path; + } +} diff --git a/app/Http/Controllers/Admin/AiController.php b/app/Http/Controllers/Admin/AiController.php new file mode 100644 index 0000000..8548904 --- /dev/null +++ b/app/Http/Controllers/Admin/AiController.php @@ -0,0 +1,267 @@ +whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->count(); + + $noGenres = Anime::doesntHave('genres')->count(); + + return view('admin.ai.anime-meta', compact('total', 'missing', 'noGenres')); + } + + /** + * POST /admin/ai/anime-meta-ids — eksik animelerin ID listesini döndür. + */ + public function animeMetaIds(Request $request) + { + $force = $request->boolean('force'); + + $query = Anime::with('genres:id')->select('id', 'title', 'description', 'release_year', 'studio', 'type', 'status', 'rating', 'title_en', 'title_jp'); + + if (!$force) { + $query->where(function ($q) { + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ->orWhereNull('studio')->orWhere('studio', '') + ->orWhereNull('type')->orWhere('type', '') + ->orWhereNull('status')->orWhere('status', ''); + })->orDoesntHave('genres'); + } + + $animes = $query->orderBy('id')->get()->map(function ($a) { + $missing = []; + if (empty($a->description)) $missing[] = 'açıklama'; + if (empty($a->release_year)) $missing[] = 'yıl'; + if (empty($a->studio)) $missing[] = 'stüdyo'; + if (empty($a->type)) $missing[] = 'tür'; + if (empty($a->status)) $missing[] = 'durum'; + if (!$a->rating) $missing[] = 'puan'; + if ($a->genres->isEmpty()) $missing[] = 'kategoriler'; + return ['id' => $a->id, 'title' => $a->title, 'missing' => $missing]; + }); + + return response()->json(['animes' => $animes]); + } + + /** + * POST /admin/ai/fill-anime-meta — tek anime için meta doldur ve kaydet. + */ + public function fillAnimeMeta(Request $request) + { + $anime = Anime::with('genres:id,name')->findOrFail($request->anime_id); + $force = $request->boolean('force'); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + + if (!$meta) { + Log::channel('daily')->warning("[FillAnimeMeta-UI] HATA [{$anime->id}] {$anime->title}"); + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü.'], 500); + } + + $updates = []; + $fillIfEmpty = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) $updates[$field] = $value; + }; + + $fillIfEmpty('description', $meta['description'] ?? null); + $fillIfEmpty('release_year', $meta['release_year'] ?? null); + $fillIfEmpty('studio', $meta['studio'] ?? null); + $fillIfEmpty('type', $meta['type'] ?? null); + $fillIfEmpty('status', $meta['status'] ?? null); + $fillIfEmpty('title_en', $meta['title_en'] ?? null); + $fillIfEmpty('title_jp', $meta['title_jp'] ?? null); + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) $anime->update($updates); + + $syncedGenres = []; + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $ids = []; + foreach ($meta['genres'] as $name) { + $g = Genre::firstOrCreate(['name' => $name], ['slug' => Str::slug($name)]); + $ids[] = $g->id; + } + if ($ids) { + $force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids); + $syncedGenres = $meta['genres']; + } + } + + $filled = array_keys($updates); + if ($syncedGenres) $filled[] = 'kategoriler'; + + Log::channel('daily')->info("[FillAnimeMeta-UI] OK [{$anime->id}] {$anime->title} → " . implode(', ', $filled)); + + return response()->json([ + 'ok' => true, + 'filled' => $filled, + 'meta' => array_merge($updates, ['genres' => $syncedGenres]), + ]); + } + + /** + * Toplu açıklama yazma sayfası. + */ + public function descriptionsPage() + { + $animes = Anime::orderBy('title') + ->withCount(['episodes as total_eps' => fn($q) => $q->whereNull('description')->orWhere('description', '')]) + ->get() + ->filter(fn($a) => $a->total_eps > 0); + + $totalMissing = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count(); + + return view('admin.ai.descriptions', compact('animes', 'totalMissing')); + } + + /** + * Açıklaması olmayan bölüm ID'lerini döndür (JS için). + * POST { anime_id: 0=tümü } + */ + public function episodeIds(Request $request) + { + $query = Episode::where(fn($q) => $q->whereNull('description')->orWhere('description', '')); + + if ($request->anime_id && $request->anime_id != '0') { + $query->where('anime_id', $request->anime_id); + } + + $ids = $query->with('anime:id,title')->get()->map(fn($ep) => [ + 'id' => $ep->id, + 'label' => ($ep->anime->title ?? '?') . ' — ' . $ep->episode_number . '. Bölüm' . ($ep->title ? ' — '.$ep->title : ''), + ]); + + return response()->json(['episodes' => $ids]); + } + + /** + * Tek bir bölüme açıklama yaz ve kaydet. + * POST { episode_id } + */ + public function fillOne(Request $request) + { + $episode = Episode::with('anime:id,title')->findOrFail($request->episode_id); + + if (!empty($episode->description)) { + return response()->json(['ok' => true, 'skipped' => true, 'description' => $episode->description]); + } + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $desc = $ai->generateEpisodeDescription( + $episode->anime->title ?? 'Bilinmeyen', + $episode->episode_number, + $episode->title ?? '' + ); + + if (!$desc) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500); + } + + $episode->update(['description' => $desc]); + return response()->json(['ok' => true, 'description' => $desc]); + } + + /** + * Anime için tüm meta verileri AI ile doldur. + * POST { anime_id, title?, title_jp? } + * Döner: { description, release_year, studio, type, status, rating, title_en, title_jp, genres[] } + */ + public function animeMeta(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $title = trim($request->input('title', '')); + $titleJp = trim($request->input('title_jp', '')); + + if (!$title) { + return response()->json(['error' => 'Başlık boş olamaz.'], 422); + } + + $meta = $ai->generateAnimeMeta($title, $titleJp); + + if (!$meta) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya hata oluştu.'], 500); + } + + return response()->json(['ok' => true, 'meta' => $meta]); + } + + /** + * DeepSeek ile Türkçe açıklama üret. + * POST body: { type: 'anime'|'episode', title, title_jp?, anime_title?, episode_number?, genres? } + */ + public function generate(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek bölümüne ekleyin.'], 422); + } + + $type = $request->input('type', 'anime'); + $title = trim($request->input('title', '')); + if (!$title) { + return response()->json(['error' => 'Başlık boş olamaz.'], 422); + } + + if ($type === 'episode') { + $desc = $ai->generateEpisodeDescription( + trim($request->input('anime_title', $title)), + (int) $request->input('episode_number', 1), + trim($request->input('episode_title', '')) + ); + } else { + $desc = $ai->generateAnimeDescription( + $title, + trim($request->input('title_jp', '')), + trim($request->input('genres', '')) + ); + } + + if (!$desc) { + return response()->json(['error' => 'DeepSeek boş yanıt döndürdü veya bağlantı hatası.'], 500); + } + + return response()->json(['description' => $desc]); + } +} diff --git a/app/Http/Controllers/Admin/AnalyticsController.php b/app/Http/Controllers/Admin/AnalyticsController.php new file mode 100644 index 0000000..d6614c1 --- /dev/null +++ b/app/Http/Controllers/Admin/AnalyticsController.php @@ -0,0 +1,358 @@ +input('period', '7d'); + $from = match ($period) { + 'today' => now()->startOfDay(), + '30d' => now()->subDays(30), + '90d' => now()->subDays(90), + default => now()->subDays(7), + }; + + $cacheKey = 'admin_analytics_' . $period; + $cached = Cache::remember($cacheKey, 300, function () use ($from, $period) { + return $this->buildAnalytics($from, $period); + }); + extract($cached); + + // Gerçek zamanlı veriler (cache'lenmiyor) + $recentViews = PageView::with('user:id,name') + ->where('created_at', '>=', $from) + ->orderByDesc('id') + ->limit(20) + ->get(); + + $blockedIps = collect(); + $recentBots = collect(); + try { + $blockedIps = DB::table('blocked_ips')->orderByDesc('blocked_at')->limit(20)->get(); + $recentBots = DB::table('analytics_bot_logs')->orderByDesc('id')->limit(30)->get(); + } catch (\Exception) {} + + return view('admin.analytics.index', compact( + 'period', 'from', + 'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers', + 'viewsDelta', 'todayViews', 'yesterdayViews', + 'trendLabels', 'trendData', 'watchTrendData', + 'hourlyData', + 'topAnimes', + 'topEpisodes', + 'deviceStats', 'browserStats', 'pageTypeStats', + 'geoStats', + 'activeUsers', + 'aiByType', 'aiTopQuestions', 'aiTopUsers', + 'recentViews', + 'referrerStats', 'directTraffic', + 'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName', + 'blockedIps', 'recentBots', + 'sessions', 'avgSessionTime', 'avgPages', + )); + } + + private function buildAnalytics($from, string $period): array + { + // ── Özet kartlar ────────────────────────────────────────────────────── + $totalViews = PageView::where('created_at', '>=', $from)->count(); + $uniqueVisitors = PageView::where('created_at', '>=', $from)->distinct('session_id')->count('session_id'); + $watchSeconds = WatchEvent::where('created_at', '>=', $from)->sum('seconds_watched'); + $aiTotal = AiQuery::where('created_at', '>=', $from)->count(); + $newUsers = User::where('created_at', '>=', $from)->count(); + + $yesterday = now()->subDay(); + $todayViews = PageView::where('created_at', '>=', now()->startOfDay())->count(); + $yesterdayViews = PageView::whereBetween('created_at', [$yesterday->startOfDay(), $yesterday->endOfDay()])->count(); + $viewsDelta = $yesterdayViews > 0 ? round(($todayViews - $yesterdayViews) / $yesterdayViews * 100) : 0; + + // ── Görüntüleme trendi (gün bazlı) ──────────────────────────────────── + $viewsByDay = PageView::where('created_at', '>=', $from) + ->selectRaw('DATE(created_at) as date, COUNT(*) as cnt') + ->groupBy('date') + ->orderBy('date') + ->pluck('cnt', 'date'); + + $trendLabels = []; + $trendData = []; + $cur = clone $from; + while ($cur->lte(now())) { + $key = $cur->format('Y-m-d'); + $trendLabels[] = $cur->format($period === 'today' ? 'H:i' : 'd M'); + $trendData[] = $viewsByDay[$key] ?? 0; + $cur->addDay(); + } + + // ── Saatlik dağılım (bugün) ─────────────────────────────────────────── + $hourlyRaw = PageView::where('created_at', '>=', now()->startOfDay()) + ->selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt') + ->groupBy('hour') + ->pluck('cnt', 'hour'); + $hourlyData = array_map(fn($h) => $hourlyRaw[$h] ?? 0, range(0, 23)); + + // ── İzleme süresi trendi ───────────────────────────────────────────── + $watchByDay = WatchEvent::where('created_at', '>=', $from) + ->selectRaw('DATE(created_at) as date, ROUND(SUM(seconds_watched)/3600, 1) as hours') + ->groupBy('date') + ->orderBy('date') + ->pluck('hours', 'date'); + $watchTrendData = array_map(fn($k) => (float)($watchByDay[$k] ?? 0), array_keys(array_flip($trendLabels))); + + // ── Top 10 anime ───────────────────────────────────────────────────── + $topAnimeIds = PageView::where('created_at', '>=', $from) + ->whereNotNull('anime_id') + ->selectRaw('anime_id, COUNT(*) as cnt') + ->groupBy('anime_id') + ->orderByDesc('cnt') + ->limit(10) + ->pluck('cnt', 'anime_id'); + + $topAnimes = Anime::whereIn('id', $topAnimeIds->keys()) + ->get(['id', 'title', 'cover_image']) + ->map(fn($a) => [ + 'title' => $a->title, + 'views' => $topAnimeIds[$a->id] ?? 0, + 'cover' => $a->cover_url, + 'slug' => $a->slug, + ]) + ->sortByDesc('views') + ->values(); + + // ── Top bölümler ───────────────────────────────────────────────────── + $topEpisodes = WatchEvent::where('analytics_watch_events.created_at', '>=', $from) + ->selectRaw('anime_id, season_number, episode_number, episode_id, + SUM(seconds_watched) as total_sec, + COUNT(*) as plays, + ROUND(AVG(percent_complete), 0) as avg_pct') + ->groupBy('anime_id', 'season_number', 'episode_number', 'episode_id') + ->orderByDesc('total_sec') + ->limit(10) + ->get(); + + $epAnimes = Anime::whereIn('id', $topEpisodes->pluck('anime_id')->unique())->pluck('title', 'id'); + $topEpisodes = $topEpisodes->map(fn($e) => [ + 'anime' => $epAnimes[$e->anime_id] ?? 'Bilinmiyor', + 'label' => "S{$e->season_number}E{$e->episode_number}", + 'plays' => $e->plays, + 'hours' => round($e->total_sec / 3600, 1), + 'avg_pct' => $e->avg_pct, + ]); + + // ── Cihaz / tarayıcı / sayfa türü ──────────────────────────────────── + $deviceStats = PageView::where('created_at', '>=', $from) + ->selectRaw('device, COUNT(*) as cnt') + ->groupBy('device') + ->pluck('cnt', 'device'); + + $browserStats = PageView::where('created_at', '>=', $from) + ->selectRaw('browser, COUNT(*) as cnt') + ->groupBy('browser') + ->orderByDesc('cnt') + ->pluck('cnt', 'browser'); + + $pageTypeStats = PageView::where('created_at', '>=', $from) + ->selectRaw('page_type, COUNT(*) as cnt') + ->groupBy('page_type') + ->orderByDesc('cnt') + ->pluck('cnt', 'page_type'); + + // ── Coğrafi dağılım ─────────────────────────────────────────────────── + $geoStats = PageView::where('created_at', '>=', $from) + ->whereNotNull('city') + ->selectRaw('city, country, COUNT(*) as cnt') + ->groupBy('city', 'country') + ->orderByDesc('cnt') + ->limit(15) + ->get(['city', 'country', DB::raw('COUNT(*) as cnt')]); + + // ── En aktif kullanıcılar ───────────────────────────────────────────── + $activeUserIds = PageView::where('created_at', '>=', $from) + ->whereNotNull('user_id') + ->selectRaw('user_id, COUNT(*) as views, COUNT(DISTINCT DATE(created_at)) as days') + ->groupBy('user_id') + ->orderByDesc('views') + ->limit(10) + ->get(); + + $activeUserList = User::whereIn('id', $activeUserIds->pluck('user_id')) + ->get(['id', 'name', 'email', 'created_at']) + ->keyBy('id'); + + $activeUsers = $activeUserIds->map(fn($r) => [ + 'user' => $activeUserList[$r->user_id] ?? null, + 'views' => $r->views, + 'days' => $r->days, + ])->filter(fn($r) => $r['user']); + + // ── AI istatistikleri ───────────────────────────────────────────────── + $aiByType = AiQuery::where('created_at', '>=', $from) + ->selectRaw('query_type, COUNT(*) as cnt') + ->groupBy('query_type') + ->orderByDesc('cnt') + ->pluck('cnt', 'query_type'); + + $aiTopQuestions = AiQuery::where('created_at', '>=', $from) + ->where('query_type', 'chat') + ->whereNotNull('query_text') + ->selectRaw('query_text, COUNT(*) as cnt') + ->groupBy('query_text') + ->orderByDesc('cnt') + ->limit(10) + ->get(); + + $aiByUser = AiQuery::where('created_at', '>=', $from) + ->whereNotNull('user_id') + ->selectRaw('user_id, COUNT(*) as cnt') + ->groupBy('user_id') + ->orderByDesc('cnt') + ->limit(5) + ->get(); + + $aiUserList = User::whereIn('id', $aiByUser->pluck('user_id'))->pluck('name', 'id'); + $aiTopUsers = $aiByUser->map(fn($r) => [ + 'name' => $aiUserList[$r->user_id] ?? 'Bilinmiyor', + 'cnt' => $r->cnt, + ]); + + // ── Referrer ───────────────────────────────────────────────────────── + $referrerRaw = PageView::where('created_at', '>=', $from) + ->whereNotNull('referrer') + ->where('referrer', '!=', '') + ->selectRaw('referrer, COUNT(*) as cnt') + ->groupBy('referrer') + ->orderByDesc('cnt') + ->limit(30) + ->pluck('cnt', 'referrer'); + + $referrerStats = collect(); + foreach ($referrerRaw as $url => $cnt) { + try { + $parsed = parse_url($url); + $domain = $parsed['host'] ?? $url; + $domain = preg_replace('/^www\./', '', $domain); + } catch (\Throwable) { + $domain = $url; + } + if ($referrerStats->has($domain)) { + $referrerStats[$domain] += $cnt; + } else { + $referrerStats[$domain] = $cnt; + } + } + $referrerStats = $referrerStats->sortDesc()->take(15); + + $directTraffic = PageView::where('created_at', '>=', $from) + ->where(fn($q) => $q->whereNull('referrer')->orWhere('referrer', '')) + ->count(); + + // ── Bot istatistikleri ──────────────────────────────────────────────── + $botViews = 0; + $humanViews = 0; + $botRatio = 0; + $botTopIps = collect(); + $botByName = collect(); + + try { + $botViews = PageView::where('created_at', '>=', $from)->where('is_bot', 1)->count(); + $humanViews = PageView::where('created_at', '>=', $from)->where('is_bot', 0)->count(); + $botRatio = ($botViews + $humanViews) > 0 ? round($botViews / ($botViews + $humanViews) * 100) : 0; + + $botTopIps = DB::table('analytics_bot_logs') + ->where('created_at', '>=', $from) + ->selectRaw('ip, COUNT(*) as cnt, MAX(user_agent) as ua, MAX(action) as action') + ->groupBy('ip') + ->orderByDesc('cnt') + ->limit(15) + ->get(); + + $botByName = DB::table('analytics_bot_logs') + ->where('created_at', '>=', $from) + ->selectRaw('bot_name, COUNT(*) as cnt, action') + ->groupBy('bot_name', 'action') + ->orderByDesc('cnt') + ->limit(20) + ->get(); + } catch (\Exception) {} + + // ── Oturum istatistikleri ───────────────────────────────────────────── + $sessions = collect(); + $avgSessionTime = 0; + $avgPages = 0; + + try { + $avgSessionTime = (int) DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->where('is_bot', 0) + ->avg('total_seconds'); + + $avgPages = round((float) DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->where('is_bot', 0) + ->avg('pages_visited'), 1); + + $sessions = DB::table('analytics_sessions') + ->where('started_at', '>=', $from) + ->orderByDesc('started_at') + ->limit(30) + ->get(); + } catch (\Exception) {} + + return compact( + 'totalViews', 'uniqueVisitors', 'watchSeconds', 'aiTotal', 'newUsers', + 'viewsDelta', 'todayViews', 'yesterdayViews', + 'trendLabels', 'trendData', 'watchTrendData', + 'hourlyData', + 'topAnimes', 'topEpisodes', + 'deviceStats', 'browserStats', 'pageTypeStats', + 'geoStats', + 'activeUsers', + 'aiByType', 'aiTopQuestions', 'aiTopUsers', + 'referrerStats', 'directTraffic', + 'botViews', 'humanViews', 'botRatio', 'botTopIps', 'botByName', + 'sessions', 'avgSessionTime', 'avgPages' + ); + } + + public function blockIp(Request $request) + { + $data = $request->validate([ + 'ip' => 'required|ip', + 'reason' => 'nullable|string|max:255', + 'expires_at' => 'nullable|date|after:now', + ]); + + DB::table('blocked_ips')->updateOrInsert( + ['ip' => $data['ip']], + [ + 'reason' => $data['reason'] ?? 'Manuel engel', + 'auto_blocked' => 0, + 'blocked_at' => now(), + 'expires_at' => $data['expires_at'] ?? null, + ] + ); + + \Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $data['ip']); + return back()->with('success', $data['ip'] . ' engellendi.'); + } + + public function unblockIp(Request $request) + { + $ip = $request->input('ip'); + DB::table('blocked_ips')->where('ip', $ip)->delete(); + \Illuminate\Support\Facades\Cache::forget('blocked_ip_' . $ip); + return back()->with('success', $ip . ' engeli kaldırıldı.'); + } +} diff --git a/app/Http/Controllers/Admin/AnimeController.php b/app/Http/Controllers/Admin/AnimeController.php new file mode 100644 index 0000000..7439a38 --- /dev/null +++ b/app/Http/Controllers/Admin/AnimeController.php @@ -0,0 +1,412 @@ +latest(); + + if ($request->search) { + $query->where('title', 'like', '%' . $request->search . '%'); + } + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->type) { + $query->where('type', $request->type); + } + if ($request->no_episodes) { + $query->whereDoesntHave('episodes'); + } + + $animes = $query->paginate(20)->withQueryString(); + $zeroEpisodeCount = Anime::whereDoesntHave('episodes')->count(); + return view('admin.animes.index', compact('animes', 'zeroEpisodeCount')); + } + + public function destroyZeroEpisodes() + { + $animes = Anime::whereDoesntHave('episodes')->get(); + $count = $animes->count(); + foreach ($animes as $anime) { + $anime->delete(); + } + return response()->json(['success' => true, 'count' => $count]); + } + + public function create() + { + $genres = Genre::where('is_active', true)->get(); + $permissions = PermissionSetting::all(); + return view('admin.animes.create', compact('genres', 'permissions')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'title_en' => 'nullable|string|max:255', + 'title_jp' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:series,movie,ova,ona,special', + 'status' => 'required|in:ongoing,completed,upcoming', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'studio' => 'nullable|string|max:255', + 'rating' => 'nullable|numeric|min:0|max:10', + 'mal_id' => 'nullable|string|max:50', + 'trailer_url' => 'nullable|url', + 'is_featured' => 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + ]); + + $data['slug'] = Str::slug($data['title']); + $data['is_featured'] = $request->boolean('is_featured'); + $data['is_published'] = $request->boolean('is_published'); + $data['is_dubbed'] = $request->boolean('is_dubbed'); + + // Auto-fetch MAL ID if not provided + if (empty($data['mal_id'])) { + try { + $data['mal_id'] = (new JikanService())->searchMalId( + $data['title'], + $data['title_en'] ?? null, + $data['title_jp'] ?? null, + $data['type'] ?? null, + ); + } catch (\Throwable) {} + } + + if ($request->hasFile('cover_image')) { + $data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover'); + } + if ($request->hasFile('banner_image')) { + $data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner'); + } + + $anime = Anime::create($data); + + if ($request->genres) { + $anime->genres()->sync($request->genres); + } + + // Auto-fill season MAL IDs if mal_id was found + if ($anime->mal_id) { + dispatch(function () use ($anime) { + try { + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (isset($chain[$i])) $season->update(['mal_id' => $chain[$i]]); + } + } catch (\Throwable) {} + })->afterResponse(); + } + + return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime eklendi.'); + } + + public function show(Anime $anime) + { + $anime->load(['genres', 'seasons.episodes']); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.animes.show', compact('anime', 'permissions', 'contentPerms')); + } + + public function edit(Anime $anime) + { + $genres = Genre::where('is_active', true)->get(); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.animes.edit', compact('anime', 'genres', 'permissions', 'contentPerms')); + } + + public function update(Request $request, Anime $anime) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'title_en' => 'nullable|string|max:255', + 'title_jp' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'type' => 'required|in:series,movie,ova,ona,special', + 'status' => 'required|in:ongoing,completed,upcoming', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'studio' => 'nullable|string|max:255', + 'rating' => 'nullable|numeric|min:0|max:10', + 'mal_id' => 'nullable|string|max:50', + 'trailer_url' => 'nullable|url', + 'is_featured' => 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + ]); + + $data['is_featured'] = $request->boolean('is_featured'); + $data['is_published'] = $request->boolean('is_published'); + $data['is_dubbed'] = $request->boolean('is_dubbed'); + + // Auto-fetch MAL ID if not provided and anime doesn't already have one + if (empty($data['mal_id']) && empty($anime->mal_id)) { + try { + $data['mal_id'] = (new JikanService())->searchMalId( + $data['title'], + $data['title_en'] ?? null, + $data['title_jp'] ?? null, + ); + } catch (\Throwable) {} + } + + if ($request->hasFile('cover_image')) { + ImageOptimizer::delete($anime->cover_image); + $data['cover_image'] = ImageOptimizer::store($request->file('cover_image'), 'covers', 'cover'); + } + if ($request->hasFile('banner_image')) { + ImageOptimizer::delete($anime->banner_image); + $data['banner_image'] = ImageOptimizer::store($request->file('banner_image'), 'banners', 'banner'); + } + + $anime->update($data); + + if ($request->has('genres')) { + $anime->genres()->sync($request->genres ?? []); + } + + // MAL ID değiştiyse: AniSkip cache'lerini temizle + sezon MAL ID'lerini doldur + if ($anime->mal_id) { + dispatch(function () use ($anime) { + try { + // AniSkip null cache'lerini temizle (tüm bölümler için) + foreach ($anime->seasons as $s) { + if ($s->mal_id) { + foreach ($anime->episodes()->where('season_id', $s->id)->pluck('episode_number') as $epNum) { + \Illuminate\Support\Facades\Cache::forget("aniskip_{$s->mal_id}_{$epNum}"); + } + } + } + // S1 için doğrudan anime.mal_id kullan + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) { + $s1->update(['mal_id' => $anime->mal_id]); + foreach ($anime->episodes()->where('season_id', $s1->id)->pluck('episode_number') as $epNum) { + \Illuminate\Support\Facades\Cache::forget("aniskip_{$anime->mal_id}_{$epNum}"); + } + } + // S2+ için Jikan chain + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + \Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + } catch (\Throwable) {} + })->afterResponse(); + } + + return redirect()->route('admin.animes.show', $anime)->with('success', 'Anime güncellendi.'); + } + + public function destroy(Anime $anime) + { + // CDN klasörü için örnek bir video_url al (anime_XXXXX/ path'ini çıkarmak için) + $sampleVideoUrl = $anime->episodes()->whereNotNull('video_url')->value('video_url'); + + $anime->delete(); + + // CDN'den tüm anime klasörünü arka planda sil (anime_XXXXX/season_X/...) + dispatch(function () use ($sampleVideoUrl) { + \App\Services\BunnyCdnStorage::deleteAnimeFolder($sampleVideoUrl); + })->afterResponse(); + + return redirect()->route('admin.animes.index')->with('success', 'Anime silindi.'); + } + + public function updatePermissions(Request $request, Anime $anime) + { + $permissions = $request->permissions ?? []; + + // Mevcut override'ları sil + ContentPermission::where('content_type', 'anime') + ->where('content_id', $anime->id) + ->delete(); + + // Yeni override'ları kaydet + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + ContentPermission::create([ + 'content_type' => 'anime', + 'content_id' => $anime->id, + 'permission_key' => $key, + 'required_membership' => $value, + ]); + } + } + + return back()->with('success', 'İzinler güncellendi.'); + } + + /** + * POST admin/animes/{anime}/fetch-mal-seasons + * Walks the Jikan sequel chain and fills seasons.mal_id automatically. + */ + public function fetchMalSeasons(Request $request, Anime $anime) + { + // Formdan gelen mal_id varsa önce güncelle + if ($request->filled('mal_id')) { + $anime->update(['mal_id' => $request->input('mal_id')]); + } + + if (!$anime->mal_id) { + return response()->json(['error' => 'MAL ID girilmemiş. MyAnimeList.net\'ten anime sayfasını açıp URL\'deki numarayı gir.'], 422); + } + + $jikan = new JikanService(); + $chain = $jikan->fetchSeasonMalIds($anime->mal_id); + + if (empty($chain)) { + return response()->json(['error' => 'Jikan API\'den veri alınamadı.'], 502); + } + + $seasons = Season::where('anime_id', $anime->id) + ->orderBy('season_number') + ->get(); + + $updated = []; + foreach ($seasons as $index => $season) { + $malId = $chain[$index] ?? null; + if ($malId) { + $season->update(['mal_id' => $malId]); + $updated[] = [ + 'season' => $season->season_number, + 'mal_id' => $malId, + ]; + } + } + + // If anime has more seasons than chain entries, remaining seasons stay null + return response()->json([ + 'success' => true, + 'chain' => $chain, + 'updated' => $updated, + 'message' => count($updated) . ' sezon güncellendi.', + ]); + } + + /** + * POST admin/animes/{anime}/fetch-mal + * Tek bir anime için MAL ID arar ve kaydeder. + */ + public function fetchMalSingle(Anime $anime) + { + try { + $malId = (new JikanService())->searchMalId( + $anime->title, $anime->title_en, $anime->title_jp, $anime->type + ); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]); + return response()->json(['found' => true, 'mal_id' => $malId]); + } + return response()->json(['found' => false]); + } catch (\Throwable $e) { + return response()->json(['found' => false, 'error' => $e->getMessage()], 500); + } + } + + public function bulkDestroy(Request $request) + { + if ($request->boolean('all')) { + $query = Anime::query(); + $f = $request->input('filters', []); + if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%'); + if (!empty($f['status'])) $query->where('status', $f['status']); + if (!empty($f['type'])) $query->where('type', $f['type']); + $animes = $query->get(); + } else { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $animes = Anime::whereIn('id', $request->ids)->get(); + } + + $sampleUrls = []; + foreach ($animes as $anime) { + $url = $anime->episodes()->whereNotNull('video_url')->value('video_url'); + if ($url) $sampleUrls[] = $url; + $anime->delete(); + } + + dispatch(function () use ($sampleUrls) { + foreach ($sampleUrls as $url) { + \App\Services\BunnyCdnStorage::deleteAnimeFolder($url); + } + })->afterResponse(); + + return response()->json(['success' => true, 'deleted' => count($animes)]); + } + + /** + * POST admin/animes/bulk-find-mal + * MAL ID'si olmayan animeleri Jikan title search ile toplu doldurur. + * Her seferinde 1 anime işler (AJAX loop), Jikan rate limit aşılmaz. + */ + public function bulkFindMal(Request $request) + { + $skipIds = $request->input('skip_ids', []); + + $anime = Anime::where(fn($q) => $q->whereNull('mal_id')->orWhere('mal_id', '')) + ->when($skipIds, fn($q) => $q->whereNotIn('id', $skipIds)) + ->orderBy('id') + ->first(); + + if (!$anime) { + return response()->json(['done' => true, 'message' => 'Tüm animelerin MAL ID\'si dolu!']); + } + + $jikan = new JikanService(); + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp, $anime->type); + + if ($malId) { + $anime->update(['mal_id' => $malId]); + + // S1 için season.mal_id de doldur + $s1 = $anime->seasons()->where('season_number', 1)->first(); + if ($s1 && !$s1->mal_id) $s1->update(['mal_id' => $malId]); + + return response()->json([ + 'done' => false, + 'found' => true, + 'anime' => $anime->title, + 'mal_id' => $malId, + 'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count(), + ]); + } + + // Bulunamadı — bir sonrakine geç (geçici olarak dummy değer koy, sonra temizle) + return response()->json([ + 'done' => false, + 'found' => false, + 'anime' => $anime->title, + 'mal_id' => null, + 'remaining' => Anime::whereNull('mal_id')->orWhere('mal_id', '')->count() - 1, + 'skipped_id' => $anime->id, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AnimeRequestController.php b/app/Http/Controllers/Admin/AnimeRequestController.php new file mode 100644 index 0000000..43fa471 --- /dev/null +++ b/app/Http/Controllers/Admin/AnimeRequestController.php @@ -0,0 +1,45 @@ +input('status', 'pending'); + + $requests = AnimeRequest::with('user:id,name,email') + ->when($status !== 'all', fn($q) => $q->where('status', $status)) + ->orderByDesc('vote_count') + ->orderByDesc('created_at') + ->paginate(30); + + $counts = AnimeRequest::selectRaw('status, COUNT(*) as cnt') + ->groupBy('status') + ->pluck('cnt', 'status'); + + return view('admin.anime-requests.index', compact('requests', 'counts', 'status')); + } + + public function update(Request $request, AnimeRequest $animeRequest) + { + $data = $request->validate([ + 'status' => 'required|in:pending,approved,rejected,added', + 'admin_note' => 'nullable|string|max:500', + ]); + + $animeRequest->update($data); + + return back()->with('success', 'İstek güncellendi.'); + } + + public function destroy(AnimeRequest $animeRequest) + { + $animeRequest->delete(); + return back()->with('success', 'İstek silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php new file mode 100644 index 0000000..22bdeb7 --- /dev/null +++ b/app/Http/Controllers/Admin/AuthController.php @@ -0,0 +1,43 @@ +isAdmin()) { + return redirect()->route('admin.dashboard'); + } + return view('admin.auth.login'); + } + + public function login(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) { + if (!Auth::user()->isAdmin() && !Auth::user()->isModerator()) { + Auth::logout(); + return back()->withErrors(['email' => 'Bu hesabın yönetici yetkisi yok.']); + } + return redirect()->route('admin.dashboard'); + } + + return back()->withErrors(['email' => 'E-posta veya şifre hatalı.']); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + return redirect()->route('admin.login'); + } +} diff --git a/app/Http/Controllers/Admin/BannerController.php b/app/Http/Controllers/Admin/BannerController.php new file mode 100644 index 0000000..1c5b3d1 --- /dev/null +++ b/app/Http/Controllers/Admin/BannerController.php @@ -0,0 +1,65 @@ +route('admin.banners.index'); } + public function show(Banner $banner) { return redirect()->route('admin.banners.index'); } + public function edit(Banner $banner) { return redirect()->route('admin.banners.index'); } + + public function index() + { + $banners = Banner::orderBy('sort_order')->get(); + return view('admin.banners.index', compact('banners')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'link' => 'nullable|url', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + if ($request->hasFile('image')) { + $data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner'); + } else { + return back()->withErrors(['image' => 'Görsel zorunludur.']); + } + + $data['is_active'] = $request->boolean('is_active'); + Banner::create($data); + return back()->with('success', 'Banner eklendi.'); + } + + public function update(Request $request, Banner $banner) + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'link' => 'nullable|url', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + ]); + + if ($request->hasFile('image')) { + $data['image'] = ImageOptimizer::store($request->file('image'), 'banners', 'site_banner'); + } + + $data['is_active'] = $request->boolean('is_active'); + $banner->update($data); + return back()->with('success', 'Banner güncellendi.'); + } + + public function destroy(Banner $banner) + { + $banner->delete(); + return back()->with('success', 'Banner silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/BlogController.php b/app/Http/Controllers/Admin/BlogController.php new file mode 100644 index 0000000..6dde35f --- /dev/null +++ b/app/Http/Controllers/Admin/BlogController.php @@ -0,0 +1,156 @@ +get('q'); + $posts = BlogPost::with('anime') + ->when($q, fn($query) => $query->where('title', 'like', "%{$q}%")) + ->orderByDesc('created_at') + ->paginate(20); + + $stats = [ + 'total' => BlogPost::count(), + 'published' => BlogPost::where('status', 'published')->count(), + 'draft' => BlogPost::where('status', 'draft')->count(), + 'ai' => BlogPost::where('ai_generated', true)->count(), + ]; + + return view('admin.blog.index', compact('posts', 'stats', 'q')); + } + + public function create() + { + $animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']); + $post = new BlogPost(); + return view('admin.blog.edit', compact('post', 'animes')); + } + + public function store(Request $request) + { + $data = $this->validated($request); + $data['slug'] = BlogPost::generateSlug($data['title']); + $data['published_at'] = $data['status'] === 'published' ? now() : null; + BlogPost::create($data); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı oluşturuldu.'); + } + + public function edit(BlogPost $blog) + { + $animes = Anime::where('is_published', true)->orderBy('title')->get(['id', 'title']); + return view('admin.blog.edit', compact('blog', 'animes')); + } + + public function update(Request $request, BlogPost $blog) + { + $data = $this->validated($request); + if ($data['status'] === 'published' && !$blog->published_at) { + $data['published_at'] = now(); + } + $blog->update($data); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı güncellendi.'); + } + + public function destroy(BlogPost $blog) + { + $blog->delete(); + return redirect()->route('admin.blog.index')->with('success', 'Blog yazısı silindi.'); + } + + public function generateAi(Request $request, DeepSeekService $deepseek) + { + $request->validate(['anime_id' => 'required|exists:animes,id']); + + if (!$deepseek->isConfigured()) { + return response()->json(['error' => 'DeepSeek API anahtarı ayarlanmamış. Admin > Ayarlar > deepseek_api_key'], 422); + } + + set_time_limit(120); + + $anime = Anime::with('genres')->findOrFail($request->anime_id); + $genreIds = $anime->genres->pluck('id'); + $related = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $genreIds)) + ->orderByDesc('rating') + ->limit(5) + ->get(['id', 'title', 'slug']) + ->map(fn($a) => ['slug' => $a->slug, 'title' => $a->title]) + ->toArray(); + + $data = $deepseek->generateBlogPost($anime, $related); + + if (!$data || empty($data['content'])) { + return response()->json(['error' => 'AI içerik üretemedi: ' . $deepseek->lastError], 422); + } + + $content = preg_replace_callback( + '/\[LINK:([^\]]+)\]([^\[]*)\[\/LINK\]/', + function ($m) { + $slug = trim($m[1]); + $label = trim($m[2]); + try { + return '' . $label . ''; + } catch (\Exception $e) { + return $label; + } + }, + $data['content'] + ); + + $linkedIds = []; + if (!empty($data['linked_slugs'])) { + $linkedIds = Anime::whereIn('slug', $data['linked_slugs'])->pluck('id')->toArray(); + } + + return response()->json([ + 'title' => $data['title'] ?? '', + 'excerpt' => $data['excerpt'] ?? '', + 'content' => $content, + 'focus_keyword' => $data['focus_keyword'] ?? $anime->title, + 'meta_description' => $data['meta_description'] ?? '', + 'faq' => $data['faq'] ?? [], + 'linked_anime_ids' => $linkedIds, + ]); + } + + public function bulkGenerate(Request $request) + { + $count = min(5, (int) $request->get('count', 3)); + set_time_limit(300); + try { + \Artisan::call('animexe:generate-blogs', ['--count' => $count, '--force' => false]); + $output = \Artisan::output(); + return redirect()->route('admin.blog.index')->with('success', 'AI blog üretimi tamamlandı: ' . trim($output)); + } catch (\Throwable $e) { + return redirect()->route('admin.blog.index')->with('error', 'Hata: ' . $e->getMessage()); + } + } + + private function validated(Request $request): array + { + return $request->validate([ + 'title' => 'required|string|max:255', + 'excerpt' => 'nullable|string', + 'content' => 'nullable|string', + 'cover_image' => 'nullable|string|max:500', + 'focus_keyword' => 'nullable|string|max:255', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + 'meta_keywords' => 'nullable|string', + 'status' => 'required|in:draft,published', + 'anime_id' => 'nullable|exists:animes,id', + 'reading_time' => 'nullable|integer|min:1|max:60', + ]); + } +} diff --git a/app/Http/Controllers/Admin/CommentController.php b/app/Http/Controllers/Admin/CommentController.php new file mode 100644 index 0000000..aac2c2a --- /dev/null +++ b/app/Http/Controllers/Admin/CommentController.php @@ -0,0 +1,82 @@ + fn(MorphTo $m) => $m->constrain([ + \App\Models\Episode::class => fn($q) => $q->with('season.anime'), + \App\Models\Anime::class => fn($q) => $q, + ]), + ])->latest(); + + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->where('content', 'like', '%' . $request->search . '%'); + } + if ($request->user_id) { + $query->where('user_id', $request->user_id); + } + + $comments = $query->paginate(30)->withQueryString(); + return view('admin.comments.index', compact('comments')); + } + + public function show(Comment $comment) + { + $comment->load(['user', 'replies.user', 'parent.user']); + return view('admin.comments.show', compact('comment')); + } + + public function approve(Comment $comment) + { + $comment->update(['status' => 'approved']); + return back()->with('success', 'Yorum onaylandı.'); + } + + public function reject(Comment $comment) + { + $comment->update(['status' => 'rejected']); + return back()->with('success', 'Yorum reddedildi.'); + } + + public function pin(Comment $comment) + { + $comment->update(['is_pinned' => !$comment->is_pinned]); + $msg = $comment->is_pinned ? 'Yorum sabitlendi.' : 'Yorum sabit kaldırıldı.'; + return back()->with('success', $msg); + } + + public function destroy(Comment $comment) + { + $comment->delete(); + return back()->with('success', 'Yorum silindi.'); + } + + public function reply(Request $request, Comment $comment) + { + $data = $request->validate(['content' => 'required|string|max:2000']); + + Comment::create([ + 'user_id' => auth()->id(), + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + 'parent_id' => $comment->id, + 'content' => $data['content'], + 'status' => 'approved', + ]); + + return back()->with('success', 'Yanıt gönderildi.'); + } +} diff --git a/app/Http/Controllers/Admin/ContentStatsController.php b/app/Http/Controllers/Admin/ContentStatsController.php new file mode 100644 index 0000000..d1fd8dd --- /dev/null +++ b/app/Http/Controllers/Admin/ContentStatsController.php @@ -0,0 +1,136 @@ +count(); + $totalEpisodes = Episode::count(); + $publishedEps = Episode::where('is_published', true)->count(); + $totalSeasons = Season::count(); + + // ── Son 365 gün — günlük bölüm yükleme (ısı haritası için) ─────────── + $epsByDay = Episode::selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subYear()) + ->groupBy('day') + ->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Son 365 gün — günlük anime yükleme ─────────────────────────────── + $animesByDay = Anime::selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subYear()) + ->groupBy('day') + ->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Son 90 gün trend (chart için) ───────────────────────────────────── + $from90 = now()->subDays(89)->startOfDay(); + $trendLabels = []; + $epTrendData = []; + $animeTrendData = []; + $cur = clone $from90; + while ($cur->lte(now())) { + $key = $cur->format('Y-m-d'); + $trendLabels[] = $cur->format('d M'); + $epTrendData[] = (int)($epsByDay[$key] ?? 0); + $animeTrendData[] = (int)($animesByDay[$key] ?? 0); + $cur->addDay(); + } + + // ── Saatlik yükleme dağılımı (tüm zamanlar) ────────────────────────── + $hourlyEps = Episode::selectRaw('HOUR(created_at) as hour, COUNT(*) as cnt') + ->groupBy('hour') + ->pluck('cnt', 'hour'); + $hourlyEpsData = array_map(fn($h) => (int)($hourlyEps[$h] ?? 0), range(0, 23)); + + // ── Haftanın günlerine göre dağılım ─────────────────────────────────── + $weekdayEps = Episode::selectRaw('DAYOFWEEK(created_at) as dow, COUNT(*) as cnt') + ->groupBy('dow') + ->pluck('cnt', 'dow'); + // MySQL DAYOFWEEK: 1=Pazar, 2=Pazartesi, ..., 7=Cumartesi + $weekdayLabels = ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt']; + $weekdayData = array_map(fn($d) => (int)($weekdayEps[$d] ?? 0), range(1, 7)); + + // ── Aylık dağılım (son 24 ay) ───────────────────────────────────────── + $monthlyEps = Episode::selectRaw('DATE_FORMAT(created_at, "%Y-%m") as mon, COUNT(*) as cnt') + ->where('created_at', '>=', now()->subMonths(24)) + ->groupBy('mon') + ->orderBy('mon') + ->pluck('cnt', 'mon'); + + $monthLabels = []; + $monthData = []; + $mCur = now()->subMonths(23)->startOfMonth(); + while ($mCur->lte(now())) { + $key = $mCur->format('Y-m'); + $monthLabels[] = $mCur->format('M y'); + $monthData[] = (int)($monthlyEps[$key] ?? 0); + $mCur->addMonth(); + } + + // ── Top 10 en fazla bölüm olan anime ────────────────────────────────── + $topByEpisodes = Anime::withCount('episodes') + ->orderByDesc('episodes_count') + ->limit(10) + ->get(['id', 'title', 'slug', 'cover_image', 'status', 'type']); + + // ── Son eklenen 20 bölüm ─────────────────────────────────────────────── + $recentEpisodes = Episode::with(['anime:id,title,slug', 'season:id,season_number']) + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + // ── Son eklenen 10 anime ─────────────────────────────────────────────── + $recentAnimes = Anime::orderByDesc('created_at') + ->limit(10) + ->get(['id', 'title', 'slug', 'cover_image', 'type', 'status', 'is_published', 'created_at']); + + // ── Isı haritası verisi (52 hafta × 7 gün) ──────────────────────────── + $heatStart = now()->subWeeks(51)->startOfWeek(\Carbon\Carbon::MONDAY); + $heatData = []; + for ($w = 0; $w < 52; $w++) { + $week = []; + for ($d = 0; $d < 7; $d++) { + $day = $heatStart->copy()->addDays($w * 7 + $d); + $key = $day->format('Y-m-d'); + $week[] = [ + 'date' => $key, + 'cnt' => (int)($epsByDay[$key] ?? 0), + ]; + } + $heatData[] = $week; + } + + // ── Tür bazlı bölüm sayısı ──────────────────────────────────────────── + $genreEpStats = DB::table('anime_genre') + ->join('genres', 'genres.id', '=', 'anime_genre.genre_id') + ->join('episodes', 'episodes.anime_id', '=', 'anime_genre.anime_id') + ->select('genres.name', DB::raw('COUNT(episodes.id) as ep_count')) + ->groupBy('genres.id', 'genres.name') + ->orderByDesc('ep_count') + ->limit(12) + ->get(); + + return view('admin.stats.index', compact( + 'totalAnimes', 'publishedAnimes', 'totalEpisodes', 'publishedEps', 'totalSeasons', + 'trendLabels', 'epTrendData', 'animeTrendData', + 'hourlyEpsData', + 'weekdayLabels', 'weekdayData', + 'monthLabels', 'monthData', + 'topByEpisodes', + 'recentEpisodes', 'recentAnimes', + 'heatData', + 'genreEpStats', + )); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..8df42df --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,32 @@ + User::count(), + 'premium_users' => User::where('membership', 'premium')->count(), + 'total_animes' => Anime::count(), + 'total_episodes' => Episode::count(), + 'total_comments' => Comment::count(), + 'pending_comments' => Comment::where('status', 'pending')->count(), + 'active_subs' => Subscription::where('status', 'active')->count(), + ]; + + $recent_users = User::latest()->take(5)->get(); + $recent_comments = Comment::with('user')->latest()->take(5)->get(); + $recent_animes = Anime::latest()->take(5)->get(); + + return view('admin.dashboard', compact('stats', 'recent_users', 'recent_comments', 'recent_animes')); + } +} diff --git a/app/Http/Controllers/Admin/EpisodeController.php b/app/Http/Controllers/Admin/EpisodeController.php new file mode 100644 index 0000000..3c24282 --- /dev/null +++ b/app/Http/Controllers/Admin/EpisodeController.php @@ -0,0 +1,337 @@ +latest(); + + if ($request->anime_id) { + $query->where('anime_id', $request->anime_id); + } + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->where('title', 'like', '%' . $request->search . '%'); + } + + $episodes = $query->paginate(30)->withQueryString(); + $animes = Anime::orderBy('title')->get(); + + return view('admin.episodes.index', compact('episodes', 'animes')); + } + + public function create(Request $request) + { + $animes = Anime::orderBy('title')->get(); + $seasons = []; + $selectedAnime = null; + + if ($request->anime_id) { + $selectedAnime = Anime::find($request->anime_id); + $seasons = Season::where('anime_id', $request->anime_id)->get(); + } + + $permissions = PermissionSetting::all(); + return view('admin.episodes.create', compact('animes', 'seasons', 'selectedAnime', 'permissions')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_id' => 'required|exists:seasons,id', + 'episode_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'source_url' => 'nullable|string', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'source' => 'required|in:bunnycdn,external,direct', + 'is_published' => 'boolean', + ]); + + $data['status'] = $data['is_published'] ? 'published' : 'pending'; + $data['is_published'] = $request->boolean('is_published'); + + if ($request->hasFile('thumbnail')) { + $data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail'); + } + + $episode = Episode::create($data); + + // İzin override'ları + $this->savePermissions($episode, $request->permissions ?? []); + + // Takipçilere bildirim gönder + if ($episode->is_published) { + $this->notifyFollowers($episode); + } + + return redirect()->route('admin.episodes.index', ['anime_id' => $episode->anime_id]) + ->with('success', 'Bölüm eklendi.'); + } + + public function show(Episode $episode) + { + return redirect()->route('admin.episodes.edit', $episode); + } + + public function edit(Episode $episode) + { + $animes = Anime::orderBy('title')->get(); + $seasons = Season::where('anime_id', $episode->anime_id)->get(); + $permissions = PermissionSetting::all(); + $contentPerms = ContentPermission::where('content_type', 'episode') + ->where('content_id', $episode->id) + ->pluck('required_membership', 'permission_key'); + + return view('admin.episodes.edit', compact('episode', 'animes', 'seasons', 'permissions', 'contentPerms')); + } + + public function update(Request $request, Episode $episode) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_id' => 'required|exists:seasons,id', + 'episode_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'intro_start' => 'nullable|integer|min:0', + 'intro_end' => 'nullable|integer|min:0', + 'source_url' => 'nullable|string', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'source' => 'required|in:bunnycdn,external,direct', + 'is_published' => 'boolean', + ]); + + $wasPublished = $episode->is_published; + + $data['is_published'] = $request->boolean('is_published'); + $data['status'] = $data['is_published'] ? 'published' : 'pending'; + + if ($request->hasFile('thumbnail')) { + $data['thumbnail'] = ImageOptimizer::store($request->file('thumbnail'), 'thumbnails', 'thumbnail'); + } + + $episode->update($data); + $this->savePermissions($episode, $request->permissions ?? []); + + // Sadece yeni yayınlandıysa bildirim gönder (zaten yayındaysa tekrar gönderme) + if (!$wasPublished && $episode->is_published) { + $this->notifyFollowers($episode); + } + + return redirect()->route('admin.episodes.edit', $episode)->with('success', 'Bölüm güncellendi.'); + } + + public function destroy(Episode $episode) + { + $animeId = $episode->anime_id; + $videoUrl = $episode->video_url; + $subUrls = $episode->subtitles()->pluck('url')->all(); + + $episode->delete(); + + // CDN'den dosyaları arka planda sil + dispatch(function () use ($videoUrl, $subUrls) { + \App\Services\BunnyCdnStorage::deleteFile($videoUrl); + foreach ($subUrls as $url) { + \App\Services\BunnyCdnStorage::deleteFile($url); + } + })->afterResponse(); + + return redirect()->route('admin.episodes.index', ['anime_id' => $animeId]) + ->with('success', 'Bölüm silindi.'); + } + + private function notifyFollowers(Episode $episode): void + { + $anime = Anime::find($episode->anime_id); + $season = Season::find($episode->season_id); + + if (!$anime) return; + + $followers = \App\Models\AnimeFollow::where('anime_id', $episode->anime_id) + ->join('users', 'users.id', '=', 'anime_follows.user_id') + ->select('users.id as user_id', 'users.fcm_token') + ->get(); + + if ($followers->isEmpty()) return; + + $seasonNum = $season?->season_number ?? 1; + $notifData = json_encode([ + 'anime_id' => $anime->id, + 'anime_title' => $anime->title, + 'anime_slug' => $anime->slug, + 'episode_number' => $episode->episode_number, + 'season_number' => $seasonNum, + 'episode_title' => $episode->title, + ]); + + $rows = []; + $now = now(); + foreach ($followers as $follower) { + $rows[] = [ + 'user_id' => $follower->user_id, + 'type' => 'episode', + 'data' => $notifData, + 'created_at' => $now, + ]; + } + + \App\Models\UserNotification::insert($rows); + + // FCM Push + $fcmTokens = $followers->pluck('fcm_token')->filter()->values()->toArray(); + if (!empty($fcmTokens)) { + $title = $anime->title . ' — Yeni Bölüm!'; + $body = "Sezon {$seasonNum}, {$episode->episode_number}. Bölüm" + . ($episode->title ? ' — ' . $episode->title : '') . ' eklendi.'; + $fcm = new \App\Services\FcmService(); + $fcm->sendToTokens($fcmTokens, $title, $body, [ + 'type' => 'episode', + 'anime_slug' => $anime->slug, + 'season_number' => (string)$seasonNum, + 'episode_number' => (string)$episode->episode_number, + ]); + } + } + + private function savePermissions(Episode $episode, array $permissions): void + { + ContentPermission::where('content_type', 'episode') + ->where('content_id', $episode->id) + ->delete(); + + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + ContentPermission::create([ + 'content_type' => 'episode', + 'content_id' => $episode->id, + 'permission_key' => $key, + 'required_membership' => $value, + ]); + } + } + } + + public function bulkDestroy(Request $request) + { + if ($request->boolean('all')) { + $query = Episode::query(); + $f = $request->input('filters', []); + if (!empty($f['anime_id'])) $query->where('anime_id', $f['anime_id']); + if (!empty($f['status'])) $query->where('status', $f['status']); + if (!empty($f['search'])) $query->where('title', 'like', '%'.$f['search'].'%'); + $episodes = $query->get(); + } else { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + $episodes = Episode::whereIn('id', $request->ids)->get(); + } + + $videoUrls = []; + $subUrls = []; + foreach ($episodes as $ep) { + if ($ep->video_url) $videoUrls[] = $ep->video_url; + foreach ($ep->subtitles()->pluck('url') as $u) $subUrls[] = $u; + $ep->delete(); + } + + dispatch(function () use ($videoUrls, $subUrls) { + foreach ($videoUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url); + foreach ($subUrls as $url) \App\Services\BunnyCdnStorage::deleteFile($url); + })->afterResponse(); + + return response()->json(['success' => true, 'deleted' => count($episodes)]); + } + + public function bulkIntro(Request $request) + { + $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season' => 'required|integer|min:0', + 'intro_start' => 'required|integer|min:0', + 'intro_end' => 'required|integer|min:1', + ]); + + $query = Episode::where('anime_id', $request->anime_id); + + if ((int)$request->season > 0) { + $season = \App\Models\Season::where('anime_id', $request->anime_id) + ->where('season_number', $request->season)->first(); + if ($season) $query->where('season_id', $season->id); + } + + $updated = $query->update([ + 'intro_start' => $request->intro_start, + 'intro_end' => $request->intro_end, + ]); + + return response()->json(['ok' => true, 'updated' => $updated]); + } + + // POST /admin/episodes/{episode}/scan-hevc + // Admin panelinden bölümün HLS kaynaklarını sunucu tarafında tarar, HEVC olanları işaretler + public function scanHevc(Episode $episode) + { + $sources = VideoSource::where('episode_id', $episode->id) + ->where('type', 'hls') + ->get(); + + $results = []; + foreach ($sources as $src) { + $isHevc = $this->probeM3u8ForHevc($src->url); + $src->update(['is_hevc' => $isHevc, 'hevc_checked_at' => now()]); + $results[] = [ + 'id' => $src->id, + 'label' => $src->label, + 'quality' => $src->quality, + 'is_hevc' => $isHevc, + ]; + } + + return response()->json(['ok' => true, 'results' => $results]); + } + + private function probeM3u8ForHevc(string $url): bool + { + try { + $response = Http::timeout(8)->withHeaders(['User-Agent' => 'Mozilla/5.0'])->get($url); + if (!$response->ok()) return false; + $text = $response->body(); + + preg_match_all('/#EXT-X-STREAM-INF:([^\n]+)/i', $text, $matches); + if (empty($matches[1])) return false; + + $isHevcCodec = fn($attrs) => (bool) preg_match('/CODECS="[^"]*(?:hev1|hvc1|dvh1)[^"]*"/i', $attrs); + + foreach ($matches[1] as $attrs) { + // CODECS tag yoksa bilinmiyor — H.264 uyumlu say, HEVC değil + if (!str_contains(strtoupper($attrs), 'CODECS=')) return false; + if (!$isHevcCodec($attrs)) return false; + } + + return true; // tüm stream'ler HEVC + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Http/Controllers/Admin/GenreController.php b/app/Http/Controllers/Admin/GenreController.php new file mode 100644 index 0000000..05ba70b --- /dev/null +++ b/app/Http/Controllers/Admin/GenreController.php @@ -0,0 +1,46 @@ +get(); + return view('admin.genres.index', compact('genres')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:7', + ]); + $data['slug'] = Str::slug($data['name']); + Genre::create($data); + return back()->with('success', 'Tür eklendi.'); + } + + public function update(Request $request, Genre $genre) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'nullable|string|max:7', + 'is_active' => 'boolean', + ]); + $data['is_active'] = $request->boolean('is_active'); + $genre->update($data); + return back()->with('success', 'Tür güncellendi.'); + } + + public function destroy(Genre $genre) + { + $genre->delete(); + return back()->with('success', 'Tür silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/HealthController.php b/app/Http/Controllers/Admin/HealthController.php new file mode 100644 index 0000000..519a209 --- /dev/null +++ b/app/Http/Controllers/Admin/HealthController.php @@ -0,0 +1,227 @@ +whereNotNull('mal_id') + ->where('mal_id', '>', 0) + ->select('mal_id', DB::raw('COUNT(*) as cnt')) + ->groupBy('mal_id') + ->having('cnt', '>', 1) + ->get() + ->map(function ($row) { + $animes = Anime::where('mal_id', $row->mal_id) + ->withCount('episodes') + ->get(['id', 'title', 'slug', 'mal_id', 'created_at']); + return ['mal_id' => $row->mal_id, 'animes' => $animes]; + }); + + // 2. Karışık kaynak — aynı anime içinde hem animecix hem anizium bölüm var + $mixedSources = DB::table('episodes') + ->whereIn('source', ['anizium', 'animecix']) + ->whereNotNull('anime_id') + ->select('anime_id', 'source', DB::raw('COUNT(*) as cnt')) + ->groupBy('anime_id', 'source') + ->get() + ->groupBy('anime_id') + ->filter(fn($group) => $group->pluck('source')->unique()->count() > 1) + ->map(function ($group) { + $anime = Anime::find($group->first()->anime_id, ['id', 'title', 'slug']); + if (!$anime) return null; + $sources = $group->mapWithKeys(fn($r) => [$r->source => $r->cnt]); + return ['anime' => $anime, 'sources' => $sources]; + }) + ->filter() + ->values(); + + // 3. Eksik bölümler — episode_count > gerçek bölüm sayısı + $missingEpisodes = Anime::whereNotNull('episode_count') + ->where('episode_count', '>', 0) + ->withCount('episodes') + ->get(['id', 'title', 'slug', 'episode_count']) + ->filter(fn($a) => $a->episodes_count < $a->episode_count) + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'expected' => $a->episode_count, + 'actual' => $a->episodes_count, + 'missing' => $a->episode_count - $a->episodes_count, + ]) + ->sortByDesc('missing') + ->values(); + + // 4. Harici CDN bölümler — BunnyCDN'e taşınmamış, Anizium CDN'de kalan + $externalCount = Episode::whereNull('video_url') + ->where(function ($q) { + $q->where('m3u8_url', 'like', '%aniziumserver%') + ->orWhere('m3u8_url', 'like', '%anizium%'); + }) + ->count(); + + $externalSample = Episode::whereNull('video_url') + ->where(function ($q) { + $q->where('m3u8_url', 'like', '%aniziumserver%') + ->orWhere('m3u8_url', 'like', '%anizium%'); + }) + ->with('anime:id,title,slug') + ->select('id', 'anime_id', 'season_id', 'episode_number', 'm3u8_url', 'source') + ->orderByDesc('id') + ->limit(100) + ->get(); + + // 5. Sıfır bölümlü animeler + $zeroEpisodeAnimes = Anime::whereDoesntHave('episodes') + ->get(['id', 'title', 'slug', 'created_at']); + + return view('admin.health.index', compact( + 'malDuplicates', + 'mixedSources', + 'missingEpisodes', + 'externalCount', + 'externalSample', + 'zeroEpisodeAnimes' + )); + } + + // ── Sistem Temizliği ───────────────────────────────────────────────────── + + /** + * Depolama istatistiklerini döndür — inode tüketimini gösterir. + */ + public function storageStats() + { + $dirs = [ + 'seg_cache' => storage_path('app/seg_cache'), + 'cache_data' => storage_path('framework/cache/data'), + 'sessions' => storage_path('framework/sessions'), + 'views' => storage_path('framework/views'), + 'logs' => storage_path('logs'), + 'app_public' => storage_path('app/public'), + ]; + + $stats = []; + foreach ($dirs as $key => $path) { + if (!is_dir($path)) { + $stats[$key] = ['count' => 0, 'size' => 0, 'path' => $path]; + continue; + } + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)); + $count = 0; + $size = 0; + foreach ($files as $f) { + $count++; + $size += $f->getSize(); + } + $stats[$key] = ['count' => $count, 'size' => $size, 'path' => $path]; + } + + return response()->json(['stats' => $stats, 'total_files' => array_sum(array_column($stats, 'count'))]); + } + + /** + * Belirtilen depolama dizinini temizle. + */ + public function cleanupStorage(Request $request) + { + $target = $request->input('target'); + $allowed = [ + 'seg_cache' => storage_path('app/seg_cache'), + 'cache_data' => storage_path('framework/cache/data'), + 'sessions' => storage_path('framework/sessions'), + 'views' => storage_path('framework/views'), + 'old_logs' => storage_path('logs'), + ]; + + if (!array_key_exists($target, $allowed)) { + return response()->json(['error' => 'Geçersiz hedef.'], 422); + } + + $path = $allowed[$target]; + $deleted = 0; + + if (!is_dir($path)) { + return response()->json(['ok' => true, 'deleted' => 0, 'message' => 'Dizin yok.']); + } + + if ($target === 'old_logs') { + // Logları tamamen silme — sadece 7 günden eskilerini sil + foreach (glob($path . '/*.log') ?: [] as $f) { + if (filemtime($f) < time() - 604800) { // 7 gün + @unlink($f); + $deleted++; + } + } + // Laravel her gün yeni log açar, bugünküne dokunma + } else { + // Diğer dizinler: tümünü temizle + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $f) { + if ($f->isFile()) { + @unlink($f->getRealPath()); + $deleted++; + } elseif ($f->isDir()) { + @rmdir($f->getRealPath()); + } + } + } + + // Laravel cache'i PHP seviyesinde de temizle + if ($target === 'cache_data') { + try { \Illuminate\Support\Facades\Cache::flush(); } catch (\Throwable) {} + } + + return response()->json([ + 'ok' => true, + 'deleted' => $deleted, + 'message' => "{$deleted} dosya silindi.", + ]); + } + + /** + * Session driver bilgisi + önerisi. + */ + public function sessionInfo() + { + $driver = config('session.driver', 'file'); + $sessionPath = storage_path('framework/sessions'); + $sessionCount = is_dir($sessionPath) ? count(glob($sessionPath . '/*') ?: []) : 0; + + return response()->json([ + 'driver' => $driver, + 'session_files' => $sessionCount, + 'recommendation'=> $driver === 'file' + ? 'SESSION_DRIVER=database veya cookie kullanmanız önerilir (inode tasarrufu).' + : 'Session sürücüsü inode-dostu.', + ]); + } + + public function deleteAnime(Request $request, Anime $anime) + { + $title = $anime->title; + $anime->delete(); + return back()->with('success', "\"$title\" silindi."); + } + + public function deleteSourceEpisodes(Request $request, Anime $anime) + { + $source = $request->validate(['source' => 'required|in:anizium,animecix'])['source']; + $count = Episode::where('anime_id', $anime->id)->where('source', $source)->count(); + Episode::where('anime_id', $anime->id)->where('source', $source)->delete(); + return back()->with('success', "$anime->title — $source kaynağından $count bölüm silindi."); + } +} diff --git a/app/Http/Controllers/Admin/ImportController.php b/app/Http/Controllers/Admin/ImportController.php new file mode 100644 index 0000000..68851b3 --- /dev/null +++ b/app/Http/Controllers/Admin/ImportController.php @@ -0,0 +1,375 @@ +paginate(20); + + // Araçlar paneli için istatistikler + $stats = [ + 'total_animes' => \App\Models\Anime::where('is_published', true)->count(), + 'anizium_done' => ImportJob::where('source', 'anizium')->where('status', 'done')->count(), + 'animecix_done' => ImportJob::where('source', 'animecix')->where('status', 'done')->count(), + 'video_sources_total' => VideoSource::count(), + 'anizium_sources' => VideoSource::where('source', 'anizium')->count(), + 'animecix_sources' => VideoSource::where('source', 'animecix')->count(), + 'subtitle_mismatch' => $this->countSubtitleMismatch(), + ]; + + return view('admin.import.index', compact('jobs', 'stats')); + } + + public function store(Request $request) + { + $request->validate([ + 'source_url' => 'required|url|max:500', + 'cdn_id' => 'nullable|string|max:50', + 'anime_title' => 'nullable|string|max:255', + 'season_ranges' => 'nullable|array', + 'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1', + ]); + + $watchId = null; + if ($request->source_url) { + preg_match('/\/(?:anime|watch)\/(\d+)/', $request->source_url, $m); + $watchId = $m[1] ?? null; + } + + $ranges = null; + if ($request->filled('season_ranges')) { + $ranges = []; + foreach ($request->season_ranges as $r) { + if (empty($r['season']) || empty($r['from']) || empty($r['to'])) continue; + $from = (int) $r['from']; + $to = (int) $r['to']; + if ($from > $to) [$from, $to] = [$to, $from]; + $ranges[] = ['season' => (int)$r['season'], 'from' => $from, 'to' => $to]; + } + if (empty($ranges)) $ranges = null; + } + + $job = ImportJob::create([ + 'source_url' => $request->source_url, + 'watch_id' => $watchId, + 'cdn_id' => $request->cdn_id ? trim($request->cdn_id) : null, + 'anime_title' => $request->anime_title, + 'season_ranges' => $ranges, + 'status' => 'pending', + ]); + + return redirect()->route('admin.import.show', $job) + ->with('success', "Import job #{$job->id} oluşturuldu. Python script'i başlatın."); + } + + public function show(ImportJob $import) + { + return view('admin.import.show', compact('import')); + } + + public function destroy(ImportJob $import) + { + $import->delete(); + return redirect()->route('admin.import.index')->with('success', 'Job silindi.'); + } + + public function destroyFailed() + { + $count = ImportJob::where('status', 'failed')->count(); + ImportJob::where('status', 'failed')->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} hatalı job silindi."); + } + + public function destroyPending() + { + $count = ImportJob::where('status', 'pending')->count(); + ImportJob::where('status', 'pending')->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} bekleyen job silindi."); + } + + public function destroyStuck() + { + // fetching/downloading/uploading ama 2 saatten fazladır güncellenmemiş = takılı kalmış + $cutoff = now()->subHours(2); + $count = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->count(); + ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->delete(); + return redirect()->route('admin.import.index')->with('success', "{$count} takılı kalmış job silindi."); + } + + public function bulkCounts() + { + $cutoff = now()->subHours(2); + return response()->json([ + 'failed' => ImportJob::where('status', 'failed')->count(), + 'pending' => ImportJob::where('status', 'pending')->count(), + 'stuck' => ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->where('updated_at', '<', $cutoff) + ->count(), + ]); + } + + public function destroyByStatus(Request $request) + { + $statuses = $request->input('statuses', []); + $hours = (int) $request->input('stuck_hours', 2); + + $allowed = ['pending', 'failed', 'fetching', 'downloading', 'uploading']; + $statuses = array_intersect($statuses, $allowed); + + if (empty($statuses)) { + return response()->json(['ok' => false, 'message' => 'Geçerli status seçilmedi.'], 422); + } + + $query = ImportJob::whereIn('status', $statuses); + + // Aktif statüler için sadece belirtilen saatten eskilerini sil + $activeStatuses = array_intersect($statuses, ['fetching', 'downloading', 'uploading']); + if (!empty($activeStatuses) && count($activeStatuses) === count($statuses)) { + $query->where('updated_at', '<', now()->subHours($hours)); + } + + $count = $query->count(); + $query->delete(); + + return response()->json(['ok' => true, 'deleted' => $count]); + } + + // ── ARAÇLAR: Terminal gerektirmez, admin panelden çalışır ───────────────── + + /** + * Altyazı uyuşmazlığı düzelt (Anizium episode-1 cache bug). + * Subtitle URL'sindeki name=s1_b1_XX yanlış bölümü işaret edenleri siler. + */ + public function fixSubtitles(Request $request) + { + $dryRun = $request->boolean('dry_run', false); + $animeId = $request->input('anime_id'); + + $query = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select( + 'subtitles.id as subtitle_id', + 'subtitles.language', + 'subtitles.url', + 'seasons.season_number', + 'episodes.episode_number', + 'episodes.anime_id', + ); + + if ($animeId) { + $query->where('episodes.anime_id', (int) $animeId); + } + + $subtitles = $query->get(); + $mismatchIds = []; + $details = []; + + foreach ($subtitles as $sub) { + $parsed = parse_url($sub->url); + if (!isset($parsed['query'])) continue; + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + if (!$name) continue; + + $expectedPrefix = "s{$sub->season_number}_b{$sub->episode_number}_"; + if (!str_starts_with($name, $expectedPrefix)) { + $mismatchIds[] = $sub->subtitle_id; + $details[] = [ + 'anime_id' => $sub->anime_id, + 'season' => $sub->season_number, + 'episode' => $sub->episode_number, + 'lang' => $sub->language, + 'name' => $name, + 'expected' => $expectedPrefix . $sub->language, + ]; + } + } + + $deleted = 0; + if (!$dryRun && !empty($mismatchIds)) { + $deleted = Subtitle::whereIn('id', $mismatchIds)->delete(); + } + + return response()->json([ + 'ok' => true, + 'dry_run' => $dryRun, + 'checked' => $subtitles->count(), + 'mismatch' => count($mismatchIds), + 'deleted' => $deleted, + 'details' => array_slice($details, 0, 30), + ]); + } + + /** + * Anizium done job'larını yeniden pending yap (yeni video_sources eklemek için). + * Her job'ın done_episodes sıfırlanır; Anizium bot yeniden çalışınca + * doneEpisodes() artık source='anizium' kontrolü yaptığından + * sadece video_sources'ta anizium kaydı OLMAYAN bölümleri yeniden işler. + */ + public function requeueAnizium(Request $request) + { + $limit = (int) $request->input('limit', 50); + $animeId = $request->input('anime_id'); + + $query = ImportJob::where('source', 'anizium') + ->where('status', 'done') + ->whereNotNull('watch_id') + ->latest(); + + if ($animeId) { + $query->where('anime_id', (int) $animeId); + } + + $jobs = $query->limit($limit)->get(); + $requeued = 0; + + foreach ($jobs as $job) { + // Zaten pending/işleniyor olan var mı? + $active = ImportJob::where('watch_id', $job->watch_id) + ->where('source', 'anizium') + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) + ->exists(); + + if (!$active) { + $job->update([ + 'status' => 'pending', + 'done_episodes'=> 0, + 'error_log' => null, + 'current_step' => 'Çapraz re-import — video_sources yenileme', + ]); + $requeued++; + } + } + + return response()->json([ + 'ok' => true, + 'checked' => $jobs->count(), + 'requeued' => $requeued, + ]); + } + + /** + * AnimeCix done job'larını yeniden pending yap. + */ + public function requeueAnimecix(Request $request) + { + $limit = (int) $request->input('limit', 50); + $animeId = $request->input('anime_id'); + + $query = ImportJob::where('source', 'animecix') + ->where('status', 'done') + ->whereNotNull('animecix_title_id') + ->latest(); + + if ($animeId) { + $query->where('anime_id', (int) $animeId); + } + + $jobs = $query->limit($limit)->get(); + $requeued = 0; + + foreach ($jobs as $job) { + $active = ImportJob::where('animecix_title_id', $job->animecix_title_id) + ->where('source', 'animecix') + ->whereIn('status', ['pending', 'fetching']) + ->exists(); + + if (!$active) { + $job->update([ + 'status' => 'pending', + 'done_episodes'=> 0, + 'error_log' => null, + 'current_step' => 'Çapraz re-import — video_sources yenileme', + ]); + $requeued++; + } + } + + return response()->json([ + 'ok' => true, + 'checked' => $jobs->count(), + 'requeued' => $requeued, + ]); + } + + /** + * video_sources istatistikleri (AJAX için). + */ + public function sourceStats() + { + $animeCount = \App\Models\Anime::where('is_published', true)->count(); + + $episodesWithBoth = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->where('is_published', true) + ->count(); + + $episodesOnlyAnizium = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->where('is_published', true) + ->count(); + + $episodesOnlyAnimecix = DB::table('episodes') + ->whereExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'animecix')) + ->whereNotExists(fn($q) => $q->from('video_sources')->whereColumn('video_sources.episode_id', 'episodes.id')->where('video_sources.source', 'anizium')) + ->where('is_published', true) + ->count(); + + return response()->json([ + 'anime_count' => $animeCount, + 'episodes_with_both' => $episodesWithBoth, + 'episodes_only_anizium' => $episodesOnlyAnizium, + 'episodes_only_animecix' => $episodesOnlyAnimecix, + 'subtitle_mismatch' => $this->countSubtitleMismatch(), + 'anizium_pending_jobs' => ImportJob::where('source', 'anizium')->where('status', 'pending')->count(), + 'animecix_pending_jobs' => ImportJob::where('source', 'animecix')->where('status', 'pending')->count(), + ]); + } + + // ── Yardımcı ───────────────────────────────────────────────────────────── + + private function countSubtitleMismatch(): int + { + $rows = Subtitle::query() + ->join('episodes', 'subtitles.episode_id', '=', 'episodes.id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('subtitles.url') + ->where('subtitles.url', 'like', '%anizium%') + ->select('subtitles.url', 'seasons.season_number', 'episodes.episode_number') + ->get(); + + $count = 0; + foreach ($rows as $r) { + $parsed = parse_url($r->url); + if (!isset($parsed['query'])) continue; + parse_str($parsed['query'], $params); + $name = $params['name'] ?? ''; + if ($name && !str_starts_with($name, "s{$r->season_number}_b{$r->episode_number}_")) { + $count++; + } + } + return $count; + } +} diff --git a/app/Http/Controllers/Admin/MobileAppController.php b/app/Http/Controllers/Admin/MobileAppController.php new file mode 100644 index 0000000..fa053fd --- /dev/null +++ b/app/Http/Controllers/Admin/MobileAppController.php @@ -0,0 +1,69 @@ + Setting::get('mobile_min_version', '1.0.0'), + 'mobile_current_version' => Setting::get('mobile_current_version', '1.0.0'), + 'mobile_apk_url' => Setting::get('mobile_apk_url', ''), + 'mobile_maintenance_mode' => Setting::get('mobile_maintenance_mode', '0'), + 'mobile_maintenance_message'=> Setting::get('mobile_maintenance_message', 'Uygulama şu anda bakımda. Lütfen daha sonra tekrar deneyin.'), + 'mobile_force_update_msg' => Setting::get('mobile_force_update_msg', 'Uygulamayı kullanmaya devam etmek için lütfen güncelleyin.'), + ]; + + // Stats + $stats = [ + 'total_users' => User::count(), + 'fcm_tokens' => User::whereNotNull('fcm_token')->where('fcm_token', '!=', '')->count(), + 'notifications_sent'=> UserNotification::count(), + 'notifs_today' => UserNotification::whereDate('created_at', today())->count(), + 'notifs_unread' => UserNotification::whereNull('read_at')->count(), + ]; + + // Active users (logged in last 30 days, via tokens) + try { + $stats['active_30d'] = DB::table('personal_access_tokens') + ->where('tokenable_type', User::class) + ->where('last_used_at', '>=', now()->subDays(30)) + ->distinct('tokenable_id') + ->count('tokenable_id'); + } catch (\Throwable $e) { + $stats['active_30d'] = '–'; + } + + return view('admin.mobile.index', compact('settings', 'stats')); + } + + public function update(Request $request) + { + $data = $request->validate([ + 'mobile_min_version' => 'required|string|max:20', + 'mobile_current_version' => 'required|string|max:20', + 'mobile_apk_url' => 'nullable|url|max:500', + 'mobile_maintenance_mode' => 'boolean', + 'mobile_maintenance_message' => 'required|string|max:300', + 'mobile_force_update_msg' => 'required|string|max:300', + ]); + + // Checkbox absent = unchecked → force '0' + $data['mobile_maintenance_mode'] = $request->boolean('mobile_maintenance_mode') ? '1' : '0'; + + foreach ($data as $key => $value) { + Setting::set($key, $value ?? '', 'mobile'); + } + + return back()->with('success', 'Mobil uygulama ayarları güncellendi.'); + } +} diff --git a/app/Http/Controllers/Admin/ModeratorController.php b/app/Http/Controllers/Admin/ModeratorController.php new file mode 100644 index 0000000..973c948 --- /dev/null +++ b/app/Http/Controllers/Admin/ModeratorController.php @@ -0,0 +1,111 @@ +withCount('moderatorPermissions') + ->with('moderatorPermissions:user_id,permission') + ->orderByDesc('created_at') + ->paginate(20); + + return view('admin.moderators.index', [ + 'moderators' => $moderators, + 'groups' => ModeratorPermission::$groups, + ]); + } + + public function edit(User $user) + { + abort_if($user->isAdmin(), 403); + + $permissions = ModeratorPermission::where('user_id', $user->id) + ->pluck('permission') + ->flip() // key = permission, value = true for fast lookup + ->all(); + + return view('admin.moderators.edit', [ + 'moderator' => $user, + 'groups' => ModeratorPermission::$groups, + 'permissions' => $permissions, + ]); + } + + /** Admin assigns a user the moderator role */ + public function promote(Request $request) + { + $request->validate(['user_id' => 'required|exists:users,id']); + + $user = User::findOrFail($request->user_id); + abort_if($user->isAdmin(), 403, 'Admin kullanıcı düzenlenemez.'); + + $user->update(['role' => 'moderator']); + + return back()->with('success', "{$user->name} moderatör yapıldı."); + } + + /** Remove moderator role */ + public function demote(User $user) + { + abort_if($user->isAdmin(), 403); + $user->update(['role' => 'user']); + ModeratorPermission::where('user_id', $user->id)->delete(); + $user->flushPermCache(); + + return back()->with('success', "{$user->name} moderatörlükten çıkarıldı."); + } + + /** Save permission checkboxes */ + public function savePermissions(Request $request, User $user) + { + abort_if($user->isAdmin(), 403); + abort_if($user->role !== 'moderator', 422, 'Kullanıcı moderatör değil.'); + + $allKeys = ModeratorPermission::allKeys(); + $submitted = array_intersect($request->input('permissions', []), $allKeys); + + // Delete old, insert new + ModeratorPermission::where('user_id', $user->id)->delete(); + foreach ($submitted as $perm) { + ModeratorPermission::create([ + 'user_id' => $user->id, + 'permission' => $perm, + 'granted_by' => auth()->id(), + ]); + } + + $user->flushPermCache(); + + return back()->with('success', 'İzinler kaydedildi. (' . count($submitted) . ' izin aktif)'); + } + + /** Quick permission toggle via AJAX */ + public function togglePermission(Request $request, User $user) + { + abort_if($user->isAdmin(), 403); + $perm = $request->input('permission'); + abort_unless(in_array($perm, ModeratorPermission::allKeys()), 422); + + $existing = ModeratorPermission::where('user_id', $user->id) + ->where('permission', $perm)->first(); + if ($existing) { + $existing->delete(); + $active = false; + } else { + ModeratorPermission::create(['user_id' => $user->id, 'permission' => $perm, 'granted_by' => auth()->id()]); + $active = true; + } + + $user->flushPermCache(); + + return response()->json(['active' => $active]); + } +} diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php new file mode 100644 index 0000000..4f34c16 --- /dev/null +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -0,0 +1,89 @@ +orderByDesc('created_at') + ->limit(50) + ->get(); + + $stats = [ + 'total' => UserNotification::count(), + 'unread' => UserNotification::whereNull('read_at')->count(), + 'users' => User::count(), + 'today' => UserNotification::whereDate('created_at', today())->count(), + ]; + + return view('admin.notifications.index', compact('recent', 'stats')); + } + + public function send(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:100', + 'body' => 'required|string|max:500', + 'url' => 'nullable|url|max:300', + 'target' => 'required|in:all,premium,free', + 'icon' => 'nullable|string|max:50', + ]); + + $query = User::query(); + + if ($data['target'] === 'premium') { + $query->where('membership', 'premium') + ->where(fn($q) => $q->whereNull('premium_expires_at')->orWhere('premium_expires_at', '>', now())); + } elseif ($data['target'] === 'free') { + $query->where(fn($q) => $q->where('membership', '!=', 'premium')->orWhere('premium_expires_at', '<=', now())); + } + + $users = $query->select('id', 'fcm_token')->get(); + + if ($users->isEmpty()) { + return back()->with('error', 'Hedef kullanıcı bulunamadı.'); + } + + $notifData = json_encode([ + 'title' => $data['title'], + 'body' => $data['body'], + 'url' => $data['url'] ?? null, + 'icon' => $data['icon'] ?? 'bi-megaphone-fill', + 'admin' => true, + ]); + + $now = now(); + $rows = $users->map(fn($u) => [ + 'user_id' => $u->id, + 'type' => 'admin', + 'data' => $notifData, + 'created_at' => $now, + ])->toArray(); + + // In-app notifications + foreach (array_chunk($rows, 500) as $chunk) { + UserNotification::insert($chunk); + } + + // FCM Push notifications + $fcmTokens = $users->pluck('fcm_token')->filter()->values()->toArray(); + if (!empty($fcmTokens)) { + $fcm = new FcmService(); + $fcm->sendToTokens($fcmTokens, $data['title'], $data['body'], [ + 'type' => 'admin', + 'url' => $data['url'] ?? '', + ]); + } + + return back()->with('success', count($rows) . ' kullanıcıya bildirim gönderildi' . (!empty($fcmTokens) ? ' (' . count($fcmTokens) . ' push)' : '') . '.'); + } +} diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php new file mode 100644 index 0000000..fc151f5 --- /dev/null +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -0,0 +1,29 @@ +permissions ?? []; + + foreach ($permissions as $key => $value) { + if (in_array($value, ['free', 'premium'])) { + PermissionSetting::where('key', $key)->update(['required_membership' => $value]); + } + } + + return back()->with('success', 'Global izinler güncellendi.'); + } +} diff --git a/app/Http/Controllers/Admin/PlanController.php b/app/Http/Controllers/Admin/PlanController.php new file mode 100644 index 0000000..42783df --- /dev/null +++ b/app/Http/Controllers/Admin/PlanController.php @@ -0,0 +1,115 @@ +get(); + return view('admin.plans.index', compact('plans')); + } + + public function create() + { + $allPerks = PremiumFeatures::grouped(); + return view('admin.plans.create', compact('allPerks')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'purchase_link' => 'nullable|url|max:1000', + 'duration_days' => 'required|integer|min:1', + 'trial_days' => 'nullable|integer|min:0', + 'badge_label' => 'nullable|string|max:32', + 'accent_color' => 'nullable|string|max:16', + 'features' => 'nullable|array', + 'features.*' => 'string', + 'perks' => 'nullable|array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'nullable|date', + 'sort_order' => 'integer', + ]); + + $data['slug'] = Str::slug($data['name']); + $data['is_active'] = $request->boolean('is_active'); + $data['is_public'] = $request->boolean('is_public', true); + $data['trial_days'] = (int) ($request->input('trial_days', 0)); + $data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null; + $data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null; + $data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null; + $data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null; + $data['features'] = array_values(array_filter($request->features ?? [])); + + $perks = []; + foreach (array_keys(PremiumFeatures::ALL) as $key) { + $perks[$key] = in_array($key, $request->input('perks', [])); + } + $data['perks'] = $perks; + + MembershipPlan::create($data); + return redirect()->route('admin.plans.index')->with('success', 'Plan eklendi.'); + } + + public function edit(MembershipPlan $plan) + { + $allPerks = PremiumFeatures::grouped(); + return view('admin.plans.edit', compact('plan', 'allPerks')); + } + + public function update(Request $request, MembershipPlan $plan) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'price' => 'required|numeric|min:0', + 'purchase_link' => 'nullable|url|max:1000', + 'duration_days' => 'required|integer|min:1', + 'trial_days' => 'nullable|integer|min:0', + 'badge_label' => 'nullable|string|max:32', + 'accent_color' => 'nullable|string|max:16', + 'features' => 'nullable|array', + 'features.*' => 'string', + 'perks' => 'nullable|array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'nullable|date', + 'sort_order' => 'integer', + ]); + + $data['is_active'] = $request->boolean('is_active'); + $data['is_public'] = $request->boolean('is_public', true); + $data['trial_days'] = (int) ($request->input('trial_days', 0)); + $data['purchase_link'] = $request->filled('purchase_link') ? $request->input('purchase_link') : null; + $data['badge_label'] = $request->filled('badge_label') ? $request->input('badge_label') : null; + $data['accent_color'] = $request->filled('accent_color') ? $request->input('accent_color') : null; + $data['visible_until'] = $request->filled('visible_until') ? $request->input('visible_until') : null; + $data['features'] = array_values(array_filter($request->features ?? [])); + + $perks = []; + foreach (array_keys(PremiumFeatures::ALL) as $key) { + $perks[$key] = in_array($key, $request->input('perks', [])); + } + $data['perks'] = $perks; + + $plan->update($data); + return redirect()->route('admin.plans.index')->with('success', 'Plan güncellendi.'); + } + + public function destroy(MembershipPlan $plan) + { + $plan->delete(); + return redirect()->route('admin.plans.index')->with('success', 'Plan silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/SeasonController.php b/app/Http/Controllers/Admin/SeasonController.php new file mode 100644 index 0000000..7bc4d9e --- /dev/null +++ b/app/Http/Controllers/Admin/SeasonController.php @@ -0,0 +1,59 @@ +route('admin.animes.show', $anime); } + public function create(Anime $anime) { return redirect()->route('admin.animes.show', $anime); } + public function show(Season $season) { return redirect()->route('admin.animes.show', $season->anime_id); } + + public function store(Request $request, Anime $anime) + { + $data = $request->validate([ + 'season_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'is_published' => 'boolean', + ]); + + $data['anime_id'] = $anime->id; + $data['is_published'] = $request->boolean('is_published'); + + Season::create($data); + return redirect()->route('admin.animes.show', $anime)->with('success', 'Sezon eklendi.'); + } + + public function edit(Season $season) + { + return view('admin.seasons.edit', compact('season')); + } + + public function update(Request $request, Season $season) + { + $data = $request->validate([ + 'season_number' => 'required|integer|min:1', + 'title' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'release_year' => 'nullable|integer|min:1900|max:2099', + 'is_published' => 'boolean', + ]); + + $data['is_published'] = $request->boolean('is_published'); + $season->update($data); + return redirect()->route('admin.animes.show', $season->anime_id)->with('success', 'Sezon güncellendi.'); + } + + public function destroy(Season $season) + { + $animeId = $season->anime_id; + $season->delete(); + return redirect()->route('admin.animes.show', $animeId)->with('success', 'Sezon silindi.'); + } +} diff --git a/app/Http/Controllers/Admin/SeoController.php b/app/Http/Controllers/Admin/SeoController.php new file mode 100644 index 0000000..46dab2a --- /dev/null +++ b/app/Http/Controllers/Admin/SeoController.php @@ -0,0 +1,971 @@ + 'Animexe', + 'seo_title_template' => '%s — Animexe | Türkçe Anime İzle', + 'seo_home_title' => 'Animexe — Türkçe Anime İzle | Ücretsiz HD', + 'seo_home_description' => 'Animexe\'de binlerce anime dizisi ve filmini Türkçe altyazılı veya dublajlı, ücretsiz ve yüksek kalitede izleyin.', + 'seo_home_keywords' => 'anime izle, türkçe anime, anime dizi, anime film, ücretsiz anime izle, hd anime, türkçe altyazılı anime, türkçe dublajlı anime', + 'seo_og_image' => '/logo.jpg', + 'seo_twitter_site' => '', + 'seo_facebook_app_id' => '', + 'seo_canonical_domain' => '', + 'seo_google_analytics' => '', + 'seo_gtm_id' => '', + 'seo_gsc_verification' => '', + 'seo_bing_verification' => '', + 'seo_yandex_verification' => '', + 'seo_enable_schema' => '1', + 'seo_enable_breadcrumb' => '1', + 'seo_noindex_search' => '1', + 'seo_noindex_profile' => '1', + 'seo_noindex_watch' => '0', + 'seo_org_logo' => '/logo.jpg', + 'seo_org_twitter' => '', + 'seo_org_facebook' => '', + 'seo_org_instagram' => '', + 'seo_robots_custom' => '', + 'seo_pagespeed_api_key' => '', + 'seo_looker_embed_url' => '', + 'seo_enable_faq_schema' => '1', + 'seo_enable_video_schema' => '1', + ]; + + public function index() + { + $settings = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key')->toArray(); + foreach ($this->defaults as $key => $val) { + if (!array_key_exists($key, $settings)) { + $settings[$key] = $val; + } + } + + $robotsPath = public_path('robots.txt'); + $robotsTxt = File::exists($robotsPath) ? File::get($robotsPath) : ''; + $audit = $this->runAudit(); + + $sitemapStats = [ + 'anime_count' => Anime::where('is_published', true)->count(), + 'genre_count' => Genre::where('is_active', true)->count(), + 'static_count' => 2, + 'last_updated' => Setting::get('seo_sitemap_generated_at', null), + ]; + + // Keyword tracker + $keywords = SeoKeyword::orderBy('keyword')->get(); + + // Redirect manager + $redirects = SeoRedirect::orderByDesc('hits')->paginate(25, ['*'], 'rpage'); + + // Bulk SEO — animelerin SEO verileri (seo_title veya seo_meta_desc eksik olanlar önce) + $animes = Anime::where('is_published', true) + ->orderByRaw('(seo_title IS NULL OR seo_title = "") DESC') + ->orderBy('title') + ->select('id', 'title', 'slug', 'description', 'seo_title', 'seo_meta_desc', 'seo_keywords') + ->paginate(30, ['*'], 'apage'); + + $animeSeoCoverage = [ + 'total' => Anime::where('is_published', true)->count(), + 'has_seo_title'=> Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(), + 'has_seo_desc' => Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(), + ]; + + // Image alt audit — animes with cover + $missingAlt = Anime::where('is_published', true) + ->whereNotNull('cover_image')->where('cover_image', '!=', '') + ->whereNull('title')->count(); // titles serve as alt text, so just check no-title + + // Duplicate descriptions + $dupDesc = DB::table('animes') + ->select('description', DB::raw('COUNT(*) as cnt')) + ->where('is_published', true) + ->whereNotNull('description') + ->where('description', '!=', '') + ->groupBy('description') + ->having('cnt', '>', 1) + ->count(); + + // ── Analytics stats for Google tab ─────────────────────────────────── + $analyticsStats = [ + 'total_anime' => Anime::where('is_published', true)->count(), + 'total_episodes' => class_exists(Episode::class) ? Episode::count() : 0, + 'total_users' => User::count(), + 'total_genres' => Genre::where('is_active', true)->count(), + 'total_comments' => class_exists(Comment::class) ? Comment::count() : 0, + 'total_watchlists' => class_exists(Watchlist::class) ? Watchlist::count() : 0, + 'total_ratings' => class_exists(AnimeRating::class) ? AnimeRating::count() : 0, + 'total_blog_posts' => class_exists(BlogPost::class) ? BlogPost::count() : 0, + 'total_redirects' => SeoRedirect::where('is_active', true)->count(), + 'total_redirect_hits'=> SeoRedirect::sum('hits'), + 'seo_title_pct' => $sitemapStats['anime_count'] > 0 + ? round($animeSeoCoverage['has_seo_title'] / $sitemapStats['anime_count'] * 100) + : 0, + 'seo_desc_pct' => $sitemapStats['anime_count'] > 0 + ? round($animeSeoCoverage['has_seo_desc'] / $sitemapStats['anime_count'] * 100) + : 0, + 'new_anime_this_month' => Anime::where('is_published', true) + ->where('created_at', '>=', now()->startOfMonth())->count(), + 'new_users_this_month' => User::where('created_at', '>=', now()->startOfMonth())->count(), + ]; + + // Integration status + $integrations = [ + 'ga4' => !empty($settings['seo_google_analytics'] ?? ''), + 'gtm' => !empty($settings['seo_gtm_id'] ?? ''), + 'gsc' => !empty($settings['seo_gsc_verification'] ?? ''), + 'bing' => !empty($settings['seo_bing_verification'] ?? ''), + 'yandex' => !empty($settings['seo_yandex_verification'] ?? ''), + ]; + + return view('admin.seo.index', compact( + 'settings', 'robotsTxt', 'audit', 'sitemapStats', + 'keywords', 'redirects', 'animes', 'animeSeoCoverage', 'dupDesc', + 'analyticsStats', 'integrations' + )); + } + + public function update(Request $request) + { + // Tüm alanlar opsiyonel — her tab kendi alanlarını gönderir (partial update) + $rules = [ + 'seo_site_name' => 'nullable|string|max:100', + 'seo_title_template' => 'nullable|string|max:200', + 'seo_home_title' => 'nullable|string|max:200', + 'seo_home_description' => 'nullable|string|max:500', + 'seo_home_keywords' => 'nullable|string|max:500', + 'seo_og_image' => 'nullable|string|max:500', + 'seo_twitter_site' => 'nullable|string|max:100', + 'seo_facebook_app_id' => 'nullable|string|max:100', + 'seo_canonical_domain' => 'nullable|url|max:200', + 'seo_google_analytics' => 'nullable|string|max:50', + 'seo_gtm_id' => 'nullable|string|max:50', + 'seo_gsc_verification' => 'nullable|string|max:200', + 'seo_bing_verification' => 'nullable|string|max:200', + 'seo_yandex_verification' => 'nullable|string|max:200', + 'seo_org_logo' => 'nullable|string|max:500', + 'seo_org_twitter' => 'nullable|string|max:200', + 'seo_org_facebook' => 'nullable|string|max:200', + 'seo_org_instagram' => 'nullable|string|max:200', + 'seo_pagespeed_api_key' => 'nullable|string|max:100', + 'seo_looker_embed_url' => 'nullable|string|max:500', + ]; + + $validated = $request->validate($rules); + + // Checkbox alanları: sadece request'te varsa güncelle + $checkboxes = [ + 'seo_enable_schema', 'seo_enable_breadcrumb', 'seo_noindex_search', + 'seo_noindex_profile', 'seo_noindex_watch', 'seo_enable_faq_schema', 'seo_enable_video_schema', + ]; + foreach ($checkboxes as $key) { + if ($request->has($key) || $request->has('_seo_section')) { + $value = $request->input($key); + $validated[$key] = ($value === '1' || $value === 'on') ? '1' : '0'; + } + } + + // Sadece gönderilen (non-null) alanları kaydet + foreach ($validated as $key => $value) { + if ($value !== null) { + Setting::set($key, $value, 'seo'); + } + } + + cache()->forget('seo_settings'); + + if ($request->wantsJson()) { + return response()->json(['ok' => true, 'message' => 'SEO ayarları kaydedildi.']); + } + return back()->with('success', 'SEO ayarları başarıyla kaydedildi.'); + } + + public function updateRobots(Request $request) + { + $request->validate(['robots_txt' => 'required|string|max:10000']); + File::put(public_path('robots.txt'), $request->input('robots_txt')); + return back()->with('success', 'robots.txt güncellendi.'); + } + + public function pingSearchEngines(Request $request) + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + $sitemapUrl = urlencode($domain . '/sitemap.xml'); + $results = []; + + foreach (['google' => "https://www.google.com/ping?sitemap={$sitemapUrl}", 'bing' => "https://www.bing.com/ping?sitemap={$sitemapUrl}"] as $engine => $url) { + try { + $r = Http::timeout(5)->get($url); + $results[$engine] = $r->successful() ? 'success' : 'error'; + } catch (\Throwable) { + $results[$engine] = 'error'; + } + } + + Setting::set('seo_sitemap_pinged_at', now()->toDateTimeString(), 'seo'); + return back()->with('ping_results', $results)->with('success', 'Arama motorlarına bildirim gönderildi.'); + } + + public function auditJson() + { + return response()->json($this->runAudit()); + } + + // ── Keyword Tracker ─────────────────────────────────────────────────────── + + public function storeKeyword(Request $request) + { + $data = $request->validate([ + 'keyword' => 'required|string|max:255', + 'target_url' => 'nullable|string|max:500', + 'search_volume' => 'nullable|integer|min:0', + 'difficulty' => 'nullable|integer|min:0|max:100', + 'notes' => 'nullable|string|max:1000', + ]); + SeoKeyword::create($data); + return back()->with('success', 'Anahtar kelime eklendi.'); + } + + public function destroyKeyword(SeoKeyword $keyword) + { + $keyword->delete(); + return back()->with('success', 'Anahtar kelime silindi.'); + } + + // ── Redirect Manager ───────────────────────────────────────────────────── + + public function storeRedirect(Request $request) + { + $data = $request->validate([ + 'from_path' => 'required|string|max:500', + 'to_path' => 'required|string|max:500', + 'type' => 'required|in:301,302', + ]); + + $data['from_path'] = '/' . ltrim($data['from_path'], '/'); + + SeoRedirect::updateOrCreate(['from_path' => $data['from_path']], $data); + cache()->forget('seo_redirect_' . md5($data['from_path'])); + return back()->with('success', 'Yönlendirme eklendi/güncellendi.'); + } + + public function destroyRedirect(SeoRedirect $redirect) + { + cache()->forget('seo_redirect_' . md5($redirect->from_path)); + $redirect->delete(); + return back()->with('success', 'Yönlendirme silindi.'); + } + + public function toggleRedirect(SeoRedirect $redirect) + { + $redirect->update(['is_active' => !$redirect->is_active]); + cache()->forget('seo_redirect_' . md5($redirect->from_path)); + return response()->json(['is_active' => $redirect->is_active]); + } + + // ── Bulk Anime SEO ──────────────────────────────────────────────────────── + + public function bulkSaveAnime(Request $request) + { + $data = $request->validate([ + 'animes' => 'required|array', + 'animes.*.id' => 'required|integer|exists:animes,id', + 'animes.*.seo_title' => 'nullable|string|max:100', + 'animes.*.seo_meta_desc' => 'nullable|string|max:320', + 'animes.*.seo_keywords' => 'nullable|string|max:500', + ]); + + foreach ($data['animes'] as $row) { + Anime::where('id', $row['id'])->update([ + 'seo_title' => $row['seo_title'] ?? null, + 'seo_meta_desc' => $row['seo_meta_desc'] ?? null, + 'seo_keywords' => $row['seo_keywords'] ?? null, + ]); + } + + return back()->with('success', count($data['animes']) . ' anime için SEO verileri kaydedildi.'); + } + + public function generateAnimeSeo(Anime $anime) + { + $title = trim($anime->title); + $seoTitle = $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe'; + $seoTitle = mb_substr($seoTitle, 0, 70); + + $desc = $anime->description + ? mb_substr(strip_tags($anime->description), 0, 130) + : ''; + $seoDesc = $desc + ? $desc . ' Animexe\'de Türkçe altyazılı izle.' + : $title . '\'yi Türkçe altyazılı veya dublajlı, ücretsiz ve HD kalitede Animexe\'de izleyin.'; + $seoDesc = mb_substr($seoDesc, 0, 160); + + $keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe, ' . strtolower($title) . ' türkçe altyazılı'; + + $anime->update([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + + return response()->json([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + } + + public function bulkGenerateAllSeo(Request $request) + { + $animes = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->get(['id', 'title', 'type', 'description']); + + $count = 0; + foreach ($animes as $anime) { + $title = trim($anime->title); + $seoTitle = mb_substr($title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime Dizi') . ' | Animexe', 0, 70); + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : ''; + $seoDesc = mb_substr($desc ? $desc . ' Animexe\'de Türkçe izle.' : $title . '\'yi Animexe\'de ücretsiz izleyin.', 0, 160); + $keywords = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı'; + + $anime->update([ + 'seo_title' => $seoTitle, + 'seo_meta_desc' => $seoDesc, + 'seo_keywords' => $keywords, + ]); + $count++; + } + + return response()->json(['generated' => $count]); + } + + /** + * DeepSeek ile toplu AI SEO üretimi — SEO başlığı olmayan animeleri işler. + * Her batch 10 anime, aralarında 1s bekleme (rate limit önlemi). + * İstek başına max 10 anime işler; frontend'den tekrar tekrar çağrılarak tamamlanır. + */ + public function aiBulkGenerateSeo(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $batchSize = min((int)$request->input('batch', 10), 20); + + $animes = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->with('genres:id,name') + ->limit($batchSize) + ->get(); + + $remaining = Anime::where('is_published', true) + ->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')) + ->count(); + + $done = 0; + $errors = 0; + + foreach ($animes as $anime) { + $result = $ai->generateAnimeSeoMeta($anime); + if ($result) { + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + $done++; + } else { + $errors++; + } + sleep(1); // DeepSeek rate limit + } + + return response()->json([ + 'done' => $done, + 'errors' => $errors, + 'remaining' => max(0, $remaining - $done), + ]); + } + + // ── PageSpeed ───────────────────────────────────────────────────────────── + + public function pagespeedCheck(Request $request) + { + $request->validate(['url' => 'required|url', 'strategy' => 'in:mobile,desktop']); + + $apiKey = Setting::get('seo_pagespeed_api_key', ''); + $url = $request->url; + $strategy = $request->input('strategy', 'mobile'); + + if (empty($apiKey)) { + return response()->json(['error' => 'PageSpeed API anahtarı girilmemiş. SEO ayarlarından ekleyin.'], 422); + } + + try { + $endpoint = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=" . urlencode($url) . "&strategy={$strategy}&key={$apiKey}"; + $resp = Http::timeout(20)->get($endpoint); + + if (!$resp->successful()) { + return response()->json(['error' => 'PageSpeed API hatası: ' . $resp->status()], 422); + } + + $data = $resp->json(); + $categories = $data['lighthouseResult']['categories'] ?? []; + $audits = $data['lighthouseResult']['audits'] ?? []; + + $scores = [ + 'performance' => round(($categories['performance']['score'] ?? 0) * 100), + 'accessibility' => round(($categories['accessibility']['score'] ?? 0) * 100), + 'seo' => round(($categories['seo']['score'] ?? 0) * 100), + 'best_practices'=> round(($categories['best-practices']['score'] ?? 0) * 100), + ]; + + $opportunities = []; + foreach ($audits as $id => $audit) { + if (($audit['score'] ?? 1) < 0.9 && isset($audit['details']['type']) && $audit['details']['type'] === 'opportunity') { + $opportunities[] = [ + 'title' => $audit['title'], + 'description' => $audit['description'] ?? '', + 'savings' => $audit['details']['overallSavingsMs'] ?? null, + ]; + } + } + + $fcp = $audits['first-contentful-paint']['displayValue'] ?? null; + $lcp = $audits['largest-contentful-paint']['displayValue'] ?? null; + $cls = $audits['cumulative-layout-shift']['displayValue'] ?? null; + $tbt = $audits['total-blocking-time']['displayValue'] ?? null; + + return response()->json([ + 'scores' => $scores, + 'vitals' => compact('fcp', 'lcp', 'cls', 'tbt'), + 'opportunities' => array_slice($opportunities, 0, 8), + ]); + } catch (\Throwable $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + + // ── Internal Links Audit ────────────────────────────────────────────────── + + public function internalLinksAudit() + { + // Find animes with no other anime referencing them in descriptions (orphaned) + $allAnimes = Anime::where('is_published', true)->get(['id', 'title', 'slug']); + $result = []; + + foreach ($allAnimes as $anime) { + $mentionedIn = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->where('description', 'like', '%' . $anime->title . '%') + ->count(); + $result[] = [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'mentioned_in'=> $mentionedIn, + ]; + } + + usort($result, fn($a, $b) => $a['mentioned_in'] <=> $b['mentioned_in']); + + return response()->json(array_slice($result, 0, 50)); + } + + // ── Duplicate Content ───────────────────────────────────────────────────── + + public function duplicateContent() + { + $dups = DB::table('animes') + ->select('description', DB::raw('COUNT(*) as cnt'), DB::raw('GROUP_CONCAT(title ORDER BY title SEPARATOR ", ") as titles')) + ->where('is_published', true) + ->whereNotNull('description') + ->where('description', '!=', '') + ->groupBy('description') + ->having('cnt', '>', 1) + ->get(); + + return response()->json($dups); + } + + // ── AI SEO Methods ──────────────────────────────────────────────────────── + + public function aiChat(Request $request) + { + $request->validate(['messages' => 'required|array', 'messages.*.role' => 'required|in:user,assistant', 'messages.*.content' => 'required|string|max:4000']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar sayfasından ekleyin.'], 422); + } + + $total = Anime::where('is_published', true)->count(); + $covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $audit = $this->runAudit(); + $sitemap = $total + Genre::where('is_active', true)->count() + 2; + + $context = [ + 'anime_count' => $total, + 'seo_covered' => $covered, + 'seo_score' => $audit['score'], + 'sitemap_urls' => $sitemap, + ]; + + $reply = $ai->seoChat($request->messages, $context); + + if (!$reply) { + return response()->json(['error' => 'DeepSeek yanıt vermedi. API anahtarını kontrol edin.'], 500); + } + + return response()->json(['reply' => $reply]); + } + + public function aiGenerateAnimeSeo(Anime $anime) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $anime->loadMissing('genres'); + $result = $ai->generateAnimeSeoMeta($anime); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + + return response()->json($result); + } + + public function aiKeywordSuggest(Request $request) + { + $request->validate(['topic' => 'required|string|max:200']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $result = $ai->suggestKeywords($request->topic); + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiPageAnalysis(Request $request) + { + $request->validate(['url' => 'required|url', 'title' => 'nullable|string', 'description' => 'nullable|string', 'content' => 'nullable|string']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $result = $ai->analyzePageSeo( + $request->url, + $request->input('title', ''), + $request->input('description', ''), + $request->input('content', '') + ); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiFaqSchema(Request $request) + { + $request->validate(['anime_id' => 'required|integer|exists:animes,id']); + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $anime = Anime::with('genres')->findOrFail($request->anime_id); + $result = $ai->generateFaqSchema($anime); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json($result); + } + + public function aiContentStrategy(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $total = Anime::where('is_published', true)->count(); + $covered = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $audit = $this->runAudit(); + $kwds = SeoKeyword::orderBy('search_volume', 'desc')->take(10)->pluck('keyword')->toArray(); + + $strategy = $ai->generateContentStrategy([ + 'seo_score' => $audit['score'], + 'anime_count' => $total, + 'seo_covered' => $covered, + ], $kwds); + + if (!$strategy) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json(['strategy' => $strategy]); + } + + public function aiRobotsTxt(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil.'], 422); + } + + $domain = Setting::get('seo_canonical_domain', 'animexe.com'); + $result = $ai->generateRobotsTxt($domain); + + if (!$result) { + return response()->json(['error' => 'AI yanıt vermedi.'], 500); + } + + return response()->json(['robots_txt' => $result]); + } + + // ── Toplu Doldurma (Batch) ──────────────────────────────────────────────── + + /** + * Yayınlanan animelerin coverage istatistiklerini döndür. + * GET /admin/seo/bulk-fill-stats + */ + public function bulkFillStats() + { + $total = Anime::where('is_published', true)->count(); + $hasDesc = Anime::where('is_published', true)->whereNotNull('description')->where('description', '!=', '')->count(); + $hasSeoTitle = Anime::where('is_published', true)->whereNotNull('seo_title')->where('seo_title', '!=', '')->count(); + $hasSeoDesc = Anime::where('is_published', true)->whereNotNull('seo_meta_desc')->where('seo_meta_desc', '!=', '')->count(); + $hasYear = Anime::where('is_published', true)->whereNotNull('release_year')->count(); + $hasGenres = Anime::where('is_published', true)->has('genres')->count(); + + // Kaç adet işlenecek (her mode için) + $needsSeo = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count(); + $needsMeta = Anime::where('is_published', true)->where(fn($q) => + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + )->count(); + $needsAll = Anime::where('is_published', true)->where(fn($q) => + $q->whereNull('seo_title')->orWhere('seo_title', '') + ->orWhereNull('description')->orWhere('description', '') + )->count(); + + return response()->json([ + 'total' => $total, + 'has_desc' => $hasDesc, + 'has_seo_title'=> $hasSeoTitle, + 'has_seo_desc' => $hasSeoDesc, + 'has_year' => $hasYear, + 'has_genres' => $hasGenres, + 'needs_seo' => $needsSeo, + 'needs_meta' => $needsMeta, + 'needs_all' => $needsAll, + 'pct_seo' => $total > 0 ? round($hasSeoTitle / $total * 100) : 0, + 'pct_desc' => $total > 0 ? round($hasDesc / $total * 100) : 0, + ]); + } + + /** + * Toplu doldurma — batch tabanlı, timeout olmaz. + * + * POST /admin/seo/bulk-fill-batch + * body: { + * mode: 'template_seo' | 'ai_seo' | 'ai_meta' | 'ai_all', + * last_id: 0, // son işlenen anime id'si (pagination için) + * batch_size: 5, // kaç anime işlensin + * force: false, // dolu alanları da üzerine yaz + * } + * returns: { done, errors, last_id, remaining, total } + */ + public function bulkFillBatch(Request $request) + { + $mode = $request->input('mode', 'template_seo'); + $lastId = (int) $request->input('last_id', 0); + $batchSize = min((int) $request->input('batch_size', 10), 50); + $force = $request->boolean('force', false); + + $isAi = str_starts_with($mode, 'ai_'); + + if ($isAi) { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'DeepSeek API Key tanımlı değil. Ayarlar > DeepSeek ekleyin.'], 422); + } + } + + // Hangi animelere ihtiyaç var? + $query = Anime::where('is_published', true)->where('id', '>', $lastId); + + if (!$force) { + if ($mode === 'template_seo' || $mode === 'ai_seo') { + $query->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', '')); + } elseif ($mode === 'ai_meta') { + $query->where(fn($q) => + $q->whereNull('description')->orWhere('description', '') + ->orWhereNull('release_year') + ); + } elseif ($mode === 'ai_all') { + $query->where(fn($q) => + $q->whereNull('seo_title')->orWhere('seo_title', '') + ->orWhereNull('description')->orWhere('description', '') + ); + } + } + + $total = $query->clone()->count(); + $animes = $query->with('genres:id,name')->orderBy('id')->limit($batchSize)->get(); + + $done = 0; + $errors = 0; + $newLastId = $lastId; + + foreach ($animes as $anime) { + $newLastId = $anime->id; + + try { + if ($mode === 'template_seo') { + // Hızlı template — AI çağrısı yok + $title = trim($anime->title); + $seoTitle = mb_substr( + $title . ' — Türkçe ' . ($anime->type === 'movie' ? 'Anime Film' : 'Anime') . ' İzle | Animexe', + 0, 70 + ); + $desc = $anime->description ? mb_substr(strip_tags($anime->description), 0, 130) : ''; + $seoDesc = mb_substr( + $desc + ? $desc . ' Animexe\'de Türkçe altyazılı izle.' + : $title . '\'yi Türkçe altyazılı veya dublajlı ücretsiz HD olarak Animexe\'de izleyin.', + 0, 160 + ); + $kwds = strtolower($title) . ' izle, ' . strtolower($title) . ' türkçe altyazılı, ' . strtolower($title) . ' türkçe dublaj'; + + $updates = ['seo_title' => $seoTitle, 'seo_meta_desc' => $seoDesc, 'seo_keywords' => $kwds]; + if ($force) { + $anime->update($updates); + } else { + $anime->update(array_filter($updates, fn($v) => !empty($v))); + } + $done++; + + } elseif ($mode === 'ai_seo') { + $result = $ai->generateAnimeSeoMeta($anime); + if ($result) { + $updates = array_filter([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + if ($force || empty($anime->seo_title)) { + $anime->update($updates); + } + $done++; + } else { + $errors++; + } + + } elseif ($mode === 'ai_meta') { + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + if ($meta) { + $this->applyAnimeMeta($anime, $meta, $force); + $done++; + } else { + $errors++; + } + + } elseif ($mode === 'ai_all') { + // Meta + SEO birlikte — 2 AI çağrısı + $meta = $ai->generateAnimeMeta($anime->title, $anime->title_jp ?? ''); + if ($meta) { + $this->applyAnimeMeta($anime->fresh(), $meta, $force); + } + + $anime->loadMissing('genres'); + $seoResult = $ai->generateAnimeSeoMeta($anime->fresh(['genres'])); + if ($seoResult) { + $anime->update(array_filter([ + 'seo_title' => $seoResult['seo_title'] ?? null, + 'seo_meta_desc' => $seoResult['seo_meta_desc'] ?? null, + 'seo_keywords' => $seoResult['seo_keywords'] ?? null, + ])); + $done++; + } else { + $errors++; + } + } + + } catch (\Throwable $e) { + $errors++; + \Illuminate\Support\Facades\Log::warning("[bulkFillBatch] Hata [{$anime->id}] {$anime->title}: " . $e->getMessage()); + } + + // AI çağrıları arası kısa bekleme (rate limit önlemi) + if ($isAi && $done + $errors < count($animes)) { + usleep(800_000); // 0.8s + } + } + + // Kalan animeler (bu batch'ten sonra) + $remaining = max(0, $total - $done - $errors); + + return response()->json([ + 'done' => $done, + 'errors' => $errors, + 'last_id' => $newLastId, + 'remaining' => $remaining, + 'total' => $total, + 'finished' => $animes->count() < $batchSize || $remaining === 0, + ]); + } + + private function applyAnimeMeta(Anime $anime, array $meta, bool $force): void + { + $updates = []; + $fill = function (string $field, $value) use ($anime, $force, &$updates) { + if ($value === null || $value === '') return; + if ($force || empty($anime->$field)) $updates[$field] = $value; + }; + + $fill('description', $meta['description'] ?? null); + $fill('release_year', $meta['release_year'] ?? null); + $fill('studio', $meta['studio'] ?? null); + $fill('type', $meta['type'] ?? null); + $fill('status', $meta['status'] ?? null); + $fill('title_en', $meta['title_en'] ?? null); + $fill('title_jp', $meta['title_jp'] ?? null); + if (!empty($meta['rating']) && ($force || !$anime->rating)) { + $updates['rating'] = min(10, max(0, (float) $meta['rating'])); + } + + if (!empty($updates)) $anime->update($updates); + + if (!empty($meta['genres']) && ($force || $anime->genres->isEmpty())) { + $ids = []; + foreach ($meta['genres'] as $name) { + $g = \App\Models\Genre::firstOrCreate( + ['name' => $name], + ['slug' => \Illuminate\Support\Str::slug($name)] + ); + $ids[] = $g->id; + } + if ($ids) { + $force ? $anime->genres()->sync($ids) : $anime->genres()->syncWithoutDetaching($ids); + } + } + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function runAudit(): array + { + $total = Anime::where('is_published', true)->count(); + $noDesc = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('description')->orWhere('description', ''))->count(); + $noCover = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('cover_image')->orWhere('cover_image', ''))->count(); + $shortDesc = Anime::where('is_published', true)->whereNotNull('description')->whereRaw('CHAR_LENGTH(description) < 100')->count(); + $noSlug = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('slug')->orWhere('slug', ''))->count(); + $noSeoTitle = Anime::where('is_published', true)->where(fn($q) => $q->whereNull('seo_title')->orWhere('seo_title', ''))->count(); + + $seo = Setting::where('key', 'like', 'seo_%')->pluck('value', 'key'); + $robots = File::exists(public_path('robots.txt')) ? File::get(public_path('robots.txt')) : ''; + $hasSitemap = file_exists(public_path('sitemap.xml')); + + $dupDesc = DB::table('animes')->select('description')->where('is_published', true) + ->whereNotNull('description')->where('description', '!=', '') + ->groupBy('description')->havingRaw('COUNT(*) > 1')->count(); + + $checks = []; + + // Site config + $checks[] = $this->check('site_name', !empty($seo['seo_site_name']), 'Site Adı Ayarlandı', 'Site adı eksik', 10); + $checks[] = $this->check('home_title', !empty($seo['seo_home_title']), 'Anasayfa Başlığı Mevcut', 'Anasayfa başlığı eksik', 10); + $checks[] = $this->check('home_desc', !empty($seo['seo_home_description']), 'Anasayfa Meta Açıklaması Mevcut', 'Anasayfa meta açıklaması eksik', 10); + $checks[] = $this->check('title_length', strlen($seo['seo_home_title'] ?? '') <= 70 && strlen($seo['seo_home_title'] ?? '') >= 30, 'Başlık Uzunluğu İdeal (30–70)', 'Başlık çok kısa veya çok uzun', 5); + $checks[] = $this->check('desc_length', strlen($seo['seo_home_description'] ?? '') <= 160 && strlen($seo['seo_home_description'] ?? '') >= 100, 'Meta Açıklama Uzunluğu İdeal', 'Meta açıklama 100–160 karakter arası olmalı', 5); + $checks[] = $this->check('og_image', !empty($seo['seo_og_image']), 'OG Görseli Tanımlandı', 'Varsayılan OG görseli eksik', 8); + $checks[] = $this->check('canonical', !empty($seo['seo_canonical_domain']), 'Canonical Domain Ayarlı', 'Canonical domain ayarlanmamış', 8); + $checks[] = $this->check('analytics', !empty($seo['seo_google_analytics']), 'Google Analytics Entegre', 'GA4 ID girilmemiş', 7); + $checks[] = $this->check('gsc', !empty($seo['seo_gsc_verification']), 'Search Console Doğrulandı', 'GSC doğrulama kodu eksik', 7); + $checks[] = $this->check('schema', ($seo['seo_enable_schema'] ?? '1') === '1', 'Schema.org İşaretleme Aktif', 'Schema.org işaretleme kapalı', 7); + $checks[] = $this->check('faq_schema', ($seo['seo_enable_faq_schema'] ?? '1') === '1', 'FAQ Schema Aktif', 'FAQ şema kapalı (rich snippets kayıp)', 5); + $checks[] = $this->check('video_schema', ($seo['seo_enable_video_schema'] ?? '1') === '1', 'Video Schema Aktif', 'Video şema kapalı', 5); + + // Technical SEO + $checks[] = $this->check('sitemap', $hasSitemap, 'Sitemap Mevcut', 'sitemap.xml bulunamadı', 8); + $checks[] = $this->check('robots_exists', !empty($robots), 'robots.txt Mevcut', 'robots.txt yok veya boş', 6); + $checks[] = $this->check('robots_admin', str_contains($robots, 'Disallow: /admin'), 'robots.txt Admin Kapalı', 'robots.txt /admin dizini kapalı değil', 6); + $checks[] = $this->check('noindex_search', ($seo['seo_noindex_search'] ?? '1') === '1', 'Arama Sayfası Noindex', 'Arama sayfası indexleniyor', 5); + $checks[] = $this->check('twitter', !empty($seo['seo_twitter_site']), 'Twitter Card Yapılandırıldı', 'Twitter hesabı girilmemiş', 4); + $checks[] = $this->check('bing', !empty($seo['seo_bing_verification']), 'Bing Webmaster Doğrulandı', 'Bing doğrulama kodu eksik', 3); + + // Content quality + $checks[] = $this->check('anime_desc', $noDesc === 0, 'Tüm Animelerin Açıklaması Var', "{$noDesc} animenin açıklaması eksik", 8); + $checks[] = $this->check('anime_cover', $noCover === 0, 'Tüm Animelerin Kapağı Var', "{$noCover} animenin görseli eksik", 7); + $checks[] = $this->check('desc_quality', $shortDesc < max(1, $total * 0.1), 'Açıklama Kalitesi İyi', "{$shortDesc} animenin açıklaması çok kısa", 4); + $checks[] = $this->check('slug_coverage', $noSlug === 0, 'Tüm Animeler URL Slug\'a Sahip', "{$noSlug} animenin slug\'u eksik", 6); + $checks[] = $this->check('seo_titles', $noSeoTitle < $total * 0.2, 'Anime SEO Başlıkları Yeterli', "{$noSeoTitle} animenin SEO başlığı eksik", 6); + $checks[] = $this->check('dup_desc', $dupDesc === 0, 'Tekrarlayan İçerik Yok', "{$dupDesc} grup tekrarlayan açıklama var", 5); + + $score = $weight = 0; + foreach ($checks as $c) { + $weight += $c['weight']; + if ($c['pass']) $score += $c['weight']; + } + + $scorePercent = $weight > 0 ? round(($score / $weight) * 100) : 0; + + return [ + 'score' => $scorePercent, + 'checks' => $checks, + 'totals' => ['total' => $total, 'noDesc' => $noDesc, 'noCover' => $noCover, 'shortDesc' => $shortDesc, 'noSeoTitle' => $noSeoTitle, 'dupDesc' => $dupDesc], + 'pass_count' => collect($checks)->where('pass', true)->count(), + 'fail_count' => collect($checks)->where('pass', false)->count(), + ]; + } + + private function check(string $id, bool $pass, string $passMsg, string $failMsg, int $weight): array + { + return compact('id', 'pass', 'passMsg', 'failMsg', 'weight'); + } +} diff --git a/app/Http/Controllers/Admin/SettingController.php b/app/Http/Controllers/Admin/SettingController.php new file mode 100644 index 0000000..5e7a742 --- /dev/null +++ b/app/Http/Controllers/Admin/SettingController.php @@ -0,0 +1,160 @@ +keyBy('key'); + return view('admin.settings.index', compact('settings')); + } + + public function update(Request $request) + { + $data = $request->except(['_token', '_method', 'intro_video_file']); + + // Checkbox keys: explicitly set to '0' when not present in request + $booleanKeys = [ + 'comments_enabled', 'comments_require_approval', + 'intro_enabled', 'nav_show_messages', + 'ai_auto_description', 'ai_auto_seo', + 'premium_free_mode', + 'ads_enabled', + ]; + foreach ($booleanKeys as $k) { + if (!array_key_exists($k, $data)) { + $data[$k] = '0'; + } + } + + foreach ($data as $key => $value) { + Setting::set($key, $value); + } + + cache()->forget('premium_free_mode'); + + return back()->with('success', 'Ayarlar kaydedildi.'); + } + + /** + * Favicon yükle — public/favicon.{ext} olarak kaydet, setting'e yaz. + */ + public function uploadFavicon(Request $request) + { + $request->validate(['favicon_file' => 'required|file|mimes:png,ico,svg,jpg,jpeg|max:2048']); + + $file = $request->file('favicon_file'); + $ext = strtolower($file->getClientOriginalExtension()) ?: 'png'; + $dest = public_path('favicon.' . $ext); + + // Eski favicon dosyalarını temizle + foreach (['png', 'ico', 'svg', 'jpg', 'jpeg'] as $e) { + $old = public_path('favicon.' . $e); + if (file_exists($old) && $old !== $dest) @unlink($old); + } + + $file->move(public_path(), 'favicon.' . $ext); + + $url = '/favicon.' . $ext; + Setting::set('site_favicon', $url); + + return back()->with('favicon_success', 'Favicon güncellendi.'); + } + + /** + * Intro videoyu BunnyCDN Storage'a yükle, URL'yi ayarlara kaydet. + */ + public function uploadIntro(Request $request) + { + $request->validate(['intro_video_file' => 'required|file|mimes:mp4,webm|max:204800']); // max 200MB + + $zone = Setting::get('bunnycdn_zone'); + $apiKey = Setting::get('bunnycdn_api_key'); + $pullUrl = rtrim(Setting::get('bunnycdn_pull_url', ''), '/'); + + if (!$zone || !$apiKey || !$pullUrl) { + return back()->with('intro_error', 'Önce BunnyCDN ayarlarını kaydedin (Zone, API Key, Pull URL).'); + } + + $file = $request->file('intro_video_file'); + $ext = $file->getClientOriginalExtension() ?: 'mp4'; + $fileName = 'intro/site-intro.' . $ext; + $apiUrl = "https://storage.bunnycdn.com/{$zone}/{$fileName}"; + + $response = Http::withHeaders([ + 'AccessKey' => $apiKey, + 'Content-Type' => $file->getMimeType(), + ])->withBody(file_get_contents($file->getRealPath()), $file->getMimeType()) + ->put($apiUrl); + + if (!$response->successful()) { + return back()->with('intro_error', 'BunnyCDN yükleme başarısız: ' . $response->status() . ' — ' . $response->body()); + } + + $cdnUrl = $pullUrl . '/' . $fileName; + Setting::set('intro_video_url', $cdnUrl, 'intro'); + + return back()->with('intro_success', 'Intro video yüklendi ve URL kaydedildi.'); + } + + public function testMail(Request $request) + { + $request->validate(['test_mail_to' => 'required|email'], [ + 'test_mail_to.required' => 'Alıcı e-posta adresi zorunludur.', + 'test_mail_to.email' => 'Geçerli bir e-posta adresi girin.', + ]); + + // DB'deki ayarları runtime'da uygula + $keys = ['mail_host','mail_port','mail_username','mail_password', + 'mail_from_address','mail_from_name','mail_encryption']; + $rows = Setting::whereIn('key', $keys)->pluck('value', 'key'); + + if (!$rows->get('mail_host')) { + return back()->with('mail_error', 'Önce SMTP ayarlarını kaydedin.'); + } + + $encryption = strtolower($rows->get('mail_encryption', 'tls')); + $port = (int) $rows->get('mail_port', 587); + + Config::set('mail.mailers.smtp.host', $rows->get('mail_host')); + Config::set('mail.mailers.smtp.port', $port); + Config::set('mail.mailers.smtp.username', $rows->get('mail_username')); + Config::set('mail.mailers.smtp.password', $rows->get('mail_password')); + Config::set('mail.mailers.smtp.encryption', $encryption); + Config::set('mail.mailers.smtp.timeout', 15); + Config::set('mail.mailers.smtp.stream', [ + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ]); + Config::set('mail.from.address', $rows->get('mail_from_address')); + Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name'))); + Config::set('mail.default', 'smtp'); + Mail::purge('smtp'); + + // Socket timeout — PHP default 60s, düşür + $prevTimeout = ini_get('default_socket_timeout'); + ini_set('default_socket_timeout', '15'); + set_time_limit(30); + + try { + Mail::to($request->test_mail_to)->send(new TestMail()); + ini_set('default_socket_timeout', $prevTimeout); + return back()->with('mail_success', 'Test e-postası başarıyla gönderildi → ' . $request->test_mail_to); + } catch (\Throwable $e) { + ini_set('default_socket_timeout', $prevTimeout); + return back()->with('mail_error', 'Gönderi başarısız: ' . $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/Admin/SubscriptionController.php b/app/Http/Controllers/Admin/SubscriptionController.php new file mode 100644 index 0000000..3827b1b --- /dev/null +++ b/app/Http/Controllers/Admin/SubscriptionController.php @@ -0,0 +1,71 @@ +latest(); + + if ($request->status) { + $query->where('status', $request->status); + } + if ($request->search) { + $query->whereHas('user', fn($q) => + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%') + ); + } + + $subscriptions = $query->paginate(30)->withQueryString(); + return view('admin.subscriptions.index', compact('subscriptions')); + } + + public function show(Subscription $subscription) + { + $subscription->load(['user', 'plan']); + return view('admin.subscriptions.show', compact('subscription')); + } + + public function store(Request $request) + { + // Manuel abonelik ekleme (UserController.givePremium ile aynı mantık) + $request->validate([ + 'user_id' => 'required|exists:users,id', + 'plan_id' => 'required|exists:membership_plans,id', + ]); + + $plan = MembershipPlan::findOrFail($request->plan_id); + $user = User::findOrFail($request->user_id); + + $hasEverSubscribed = Subscription::where('user_id', $user->id)->exists(); + $bonusDays = (!$hasEverSubscribed && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0; + $expiresAt = now()->addDays($plan->duration_days + $bonusDays); + + $user->update(['membership' => 'premium', 'premium_expires_at' => $expiresAt]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'manual', + ]); + + return back()->with('success', 'Abonelik eklendi.'); + } + + public function destroy(Subscription $subscription) + { + $subscription->update(['status' => 'cancelled']); + return back()->with('success', 'Abonelik iptal edildi.'); + } +} diff --git a/app/Http/Controllers/Admin/TrendingController.php b/app/Http/Controllers/Admin/TrendingController.php new file mode 100644 index 0000000..fd8e038 --- /dev/null +++ b/app/Http/Controllers/Admin/TrendingController.php @@ -0,0 +1,240 @@ +where('is_published', true) + ->orderBy('trending_order') + ->get(); + + $autoTrending = $this->getAutoTrending(20); + + // Son skor hesaplama zamanı + $lastComputed = cache()->get('trending_score_computed_at'); + + return view('admin.trending.index', compact('manual', 'autoTrending', 'lastComputed')); + } + + // ── Manuel trending yönetimi ────────────────────────────────────────────── + + public function toggle(Request $request, Anime $anime) + { + $newState = !$anime->is_trending; + + if ($newState) { + $maxOrder = Anime::where('is_trending', true)->max('trending_order') ?? 0; + $anime->update([ + 'is_trending' => true, + 'trending_order' => $maxOrder + 1, + 'trending_score' => $anime->trending_score + 200, // Manuel boost + ]); + } else { + $anime->update(['is_trending' => false, 'trending_order' => 0]); + $this->reorderAll(); + } + + if ($request->wantsJson()) { + return response()->json(['ok' => true, 'is_trending' => $newState]); + } + return back()->with('success', $newState + ? '"'.$anime->title.'" trend listesine eklendi.' + : '"'.$anime->title.'" trend listesinden çıkarıldı.'); + } + + public function reorder(Request $request) + { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer']); + foreach ($request->ids as $i => $id) { + Anime::where('id', $id)->update(['trending_order' => $i + 1]); + } + return response()->json(['ok' => true]); + } + + public function move(Request $request, Anime $anime) + { + $direction = $request->input('direction'); + $current = $anime->trending_order; + + if ($direction === 'up' && $current > 1) { + $swap = Anime::where('is_trending', true)->where('trending_order', $current - 1)->first(); + if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current - 1]); } + } elseif ($direction === 'down') { + $swap = Anime::where('is_trending', true)->where('trending_order', $current + 1)->first(); + if ($swap) { $swap->update(['trending_order' => $current]); $anime->update(['trending_order' => $current + 1]); } + } + + return back(); + } + + public function search(Request $request) + { + $results = Anime::where('is_published', true) + ->where('title', 'like', "%{$request->query('q', '')}%") + ->select('id', 'title', 'cover_image', 'is_trending', 'release_year', 'trending_score') + ->take(8)->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->coverUrl, + 'is_trending' => (bool) $a->is_trending, + 'year' => $a->release_year, + 'trending_score'=> round($a->trending_score, 1), + ]); + return response()->json(['results' => $results]); + } + + // ── Trend Skoru Hesaplama (YouTube algoritması) ─────────────────────────── + + /** + * Admin butonu: tüm animelerin trend skorunu hesapla ve kaydet. + * POST /admin/trending/compute-scores + */ + public function computeScores() + { + $count = self::runScoreComputation(); + cache()->put('trending_score_computed_at', now()->toDateTimeString(), 3600); + cache()->flush(); // Anasayfa cache'ini temizle + + return response()->json([ + 'ok' => true, + 'updated' => $count, + 'message' => "{$count} anime için trend skoru güncellendi.", + ]); + } + + /** + * YouTube-benzeri Trend Skoru Algoritması + * ───────────────────────────────────────── + * score = view_24h × 12 ← Son 24 saatin izlenme sayısı (en yüksek ağırlık) + * + view_7d × 4 ← Son 7 günün izlenme sayısı + * + view_30d × 1 ← Son 30 günün izlenme sayısı + * + watch_minutes_7d × 0.8 ← Gerçek izleme dakikası (kalite sinyali) + * + new_episode_bonus ← Yeni bölüm varsa büyük bonus + * + rating × 4 ← Kalite sinyali + * + manual_boost ← Manuel trending = +250 + * + * Decay: Eski içeriklerin skoru doğal olarak düşer (view_count azalır). + * Herhangi bir yeni bölüm veya izlenme olmadan skor sıfıra yaklaşır. + */ + public static function runScoreComputation(): int + { + $now = now(); + $day1 = $now->copy()->subDay(); + $day7 = $now->copy()->subDays(7); + $day30 = $now->copy()->subDays(30); + + $animes = DB::table('animes') + ->where('is_published', true) + ->select('id', 'rating', 'is_trending', 'status') + ->get(); + + $updated = 0; + + foreach ($animes as $anime) { + // ── Bölüm izlenme sayıları (view_count zaman dilimine göre) ────── + // Episode.updated_at → son izleme zamanının proxy'si + $views = DB::table('episodes') + ->where('anime_id', $anime->id) + ->where('is_published', true) + ->selectRaw(" + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v24h, + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v7d, + SUM(CASE WHEN updated_at >= ? THEN view_count ELSE 0 END) as v30d + ", [$day1, $day7, $day30]) + ->first(); + + // ── Gerçek izleme dakikası (analytics_watch_events) ───────────── + $watchMinutes = 0; + try { + $watchMinutes = DB::table('analytics_watch_events') + ->where('anime_id', $anime->id) + ->where('created_at', '>=', $day7) + ->sum('seconds_watched') / 60; + } catch (\Throwable) {} + + // ── Yeni bölüm bonusu ──────────────────────────────────────────── + $newEpBonus = 0; + $latestEpDate = DB::table('episodes') + ->where('anime_id', $anime->id) + ->where('is_published', true) + ->max('created_at'); + + if ($latestEpDate) { + $epAge = now()->diffInHours($latestEpDate); + if ($epAge <= 24) $newEpBonus = 80; // Bugün yeni bölüm → çok büyük boost + elseif ($epAge <= 72) $newEpBonus = 40; // Son 3 gün + elseif ($epAge <= 168) $newEpBonus = 15; // Son 7 gün + elseif ($epAge <= 720) $newEpBonus = 5; // Son 30 gün + } + + // ── Ongoing bonus ───────────────────────────────────────────────── + $ongoingBonus = ($anime->status === 'ongoing') ? 10 : 0; + + // ── Manuel trending boost ───────────────────────────────────────── + $manualBoost = $anime->is_trending ? 250 : 0; + + // ── Skor hesapla ───────────────────────────────────────────────── + $score = + ($views->v24h ?? 0) * 12 + + ($views->v7d ?? 0) * 4 + + ($views->v30d ?? 0) * 1 + + $watchMinutes * 0.8 + + $newEpBonus + + $ongoingBonus + + ((float)($anime->rating ?? 5)) * 4 + + $manualBoost; + + DB::table('animes') + ->where('id', $anime->id) + ->update(['trending_score' => round($score, 2)]); + + $updated++; + } + + return $updated; + } + + /** + * Auto-trending: trending_score'a göre sırala. + * Fallback: score kolonu yoksa eski yönteme dön. + */ + public static function getAutoTrending(int $limit = 12): \Illuminate\Support\Collection + { + try { + return Anime::where('is_published', true) + ->orderByDesc('trending_score') + ->take($limit) + ->get(); + } catch (\Throwable) { + // trending_score kolonu henüz oluşturulmamış → eski yöntem + return Anime::where('is_published', true) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take($limit) + ->get(); + } + } + + private function reorderAll(): void + { + $animes = Anime::where('is_trending', true)->orderBy('trending_order')->get(); + foreach ($animes as $i => $a) { + $a->update(['trending_order' => $i + 1]); + } + } +} diff --git a/app/Http/Controllers/Admin/UserAnalyticsController.php b/app/Http/Controllers/Admin/UserAnalyticsController.php new file mode 100644 index 0000000..f483cc3 --- /dev/null +++ b/app/Http/Controllers/Admin/UserAnalyticsController.php @@ -0,0 +1,140 @@ +input('tab', 'overview'); // overview | bots | activity | country + $country = $request->input('country'); + $period = (int) $request->input('period', 30); // days + $from = now()->subDays($period); + + // ── Overview stats ──────────────────────────────────────────────────── + $totalReal = User::where('role', '!=', 'admin')->count(); + $newReal = User::where('role', '!=', 'admin')->where('created_at', '>=', $from)->count(); + $active30 = DB::table('analytics_pageviews') + ->where('is_bot', 0)->where('created_at', '>=', $from) + ->distinct('user_id')->whereNotNull('user_id')->count('user_id'); + $botViews = DB::table('analytics_pageviews') + ->where('is_bot', 1)->where('created_at', '>=', $from)->count(); + $realViews = DB::table('analytics_pageviews') + ->where('is_bot', 0)->where('created_at', '>=', $from)->count(); + + // ── Daily new users (chart) ─────────────────────────────────────────── + $dailyNew = DB::table('users') + ->selectRaw('DATE(created_at) as day, COUNT(*) as cnt') + ->where('role', '!=', 'admin') + ->where('created_at', '>=', $from) + ->groupBy('day')->orderBy('day') + ->pluck('cnt', 'day'); + + // ── Country breakdown ───────────────────────────────────────────────── + $countriesQuery = DB::table('analytics_pageviews') + ->selectRaw('country, COUNT(*) as views, COUNT(DISTINCT user_id) as users') + ->where('is_bot', 0) + ->where('created_at', '>=', $from) + ->whereNotNull('country') + ->groupBy('country') + ->orderByDesc('views'); + if ($country) $countriesQuery->where('country', $country); + $countries = $countriesQuery->limit(50)->get(); + + // ── Bot analysis ────────────────────────────────────────────────────── + $botStats = DB::table('analytics_bot_logs') + ->selectRaw('bot_name, action, COUNT(*) as cnt') + ->where('created_at', '>=', $from) + ->groupBy('bot_name', 'action') + ->orderByDesc('cnt') + ->limit(30)->get(); + + $topBotIps = DB::table('analytics_bot_logs') + ->selectRaw('ip, COUNT(*) as cnt') + ->where('created_at', '>=', $from) + ->groupBy('ip') + ->orderByDesc('cnt') + ->limit(20)->get(); + + $blockedIps = DB::table('blocked_ips') + ->orderByDesc('blocked_at') + ->limit(30)->get(); + + // ── User activity log ───────────────────────────────────────────────── + $actQuery = UserActivityLog::with('user:id,name,username,avatar') + ->where('created_at', '>=', $from); + if ($country) $actQuery->where('country', $country); + if ($request->input('user_id')) $actQuery->where('user_id', $request->input('user_id')); + if ($request->input('action')) $actQuery->where('action', $request->input('action')); + $actQuery->orderByDesc('created_at'); + $actLogs = $actQuery->paginate(50)->withQueryString(); + + // ── Top active users ────────────────────────────────────────────────── + $topUsers = DB::table('user_activity_logs') + ->selectRaw('user_id, COUNT(*) as actions') + ->where('is_bot', 0)->whereNotNull('user_id') + ->where('created_at', '>=', $from) + ->groupBy('user_id')->orderByDesc('actions') + ->limit(10)->get(); + $topUserIds = $topUsers->pluck('user_id'); + $topUserMap = User::whereIn('id', $topUserIds)->get()->keyBy('id'); + + // ── Action breakdown ────────────────────────────────────────────────── + $actionBreakdown = DB::table('user_activity_logs') + ->selectRaw('action, COUNT(*) as cnt') + ->where('is_bot', 0) + ->where('created_at', '>=', $from) + ->groupBy('action')->orderByDesc('cnt') + ->get(); + + // ── Device breakdown ────────────────────────────────────────────────── + $deviceBreakdown = DB::table('analytics_pageviews') + ->selectRaw('device, COUNT(*) as cnt') + ->where('is_bot', 0)->where('created_at', '>=', $from) + ->groupBy('device')->orderByDesc('cnt')->get(); + + return view('admin.analytics.users', compact( + 'tab', 'period', 'country', + 'totalReal', 'newReal', 'active30', 'botViews', 'realViews', + 'dailyNew', 'countries', 'botStats', 'topBotIps', 'blockedIps', + 'actLogs', 'topUsers', 'topUserMap', 'actionBreakdown', 'deviceBreakdown' + )); + } + + public function userDetail(Request $request, User $user) + { + $period = (int) $request->input('period', 30); + $from = now()->subDays($period); + + $logs = UserActivityLog::where('user_id', $user->id) + ->where('created_at', '>=', $from) + ->orderByDesc('created_at') + ->paginate(50)->withQueryString(); + + $actBreakdown = DB::table('user_activity_logs') + ->selectRaw('action, COUNT(*) as cnt') + ->where('user_id', $user->id)->where('created_at', '>=', $from) + ->groupBy('action')->orderByDesc('cnt')->get(); + + $pageviews = DB::table('analytics_pageviews') + ->where('user_id', $user->id)->where('created_at', '>=', $from) + ->orderByDesc('created_at')->limit(100)->get(); + + $watchEvents = DB::table('analytics_watch_events as we') + ->join('episodes as e', 'e.id', '=', 'we.episode_id') + ->join('animes as a', 'a.id', '=', 'we.anime_id') + ->selectRaw('we.created_at, a.title as anime_title, e.episode_number, we.percent_complete, we.seconds_watched') + ->where('we.user_id', $user->id)->where('we.created_at', '>=', $from) + ->orderByDesc('we.created_at')->limit(50)->get(); + + return view('admin.analytics.user-detail', compact( + 'user', 'logs', 'actBreakdown', 'pageviews', 'watchEvents', 'period' + )); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..b66d0dd --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,153 @@ +search) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%'); + }); + } + if ($request->membership) { + $query->where('membership', $request->membership); + } + if ($request->role) { + $query->where('role', $request->role); + } + if ($request->banned) { + $query->where('is_banned', true); + } + + $users = $query->paginate(30)->withQueryString(); + return view('admin.users.index', compact('users')); + } + + public function show(User $user) + { + $user->load(['subscriptions.plan', 'comments']); + $plans = MembershipPlan::where('is_active', true)->get(); + return view('admin.users.show', compact('user', 'plans')); + } + + public function edit(User $user) + { + return view('admin.users.edit', compact('user')); + } + + public function update(Request $request, User $user) + { + if ($user->isAdmin() && !auth()->user()->isAdmin()) { + return back()->with('error', 'Admin kullanıcı düzenlenemez.'); + } + + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email,' . $user->id, + 'role' => 'required|in:user,moderator,admin', + 'password' => 'nullable|string|min:8', + 'admin_badge' => 'nullable|string|max:32', + ]); + + if (!empty($data['password'])) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + + $user->update($data); + return redirect()->route('admin.users.show', $user)->with('success', 'Kullanıcı güncellendi.'); + } + + public function destroy(User $user) + { + if ($user->id === auth()->id()) { + return back()->with('error', 'Kendinizi silemezsiniz.'); + } + if ($user->isAdmin()) { + return back()->with('error', 'Admin kullanıcı silinemez.'); + } + $user->delete(); + return redirect()->route('admin.users.index')->with('success', 'Kullanıcı silindi.'); + } + + public function ban(Request $request, User $user) + { + $request->validate(['ban_reason' => 'nullable|string|max:500']); + + if ($user->isAdmin()) { + return back()->with('error', 'Admin kullanıcı banlanamaz.'); + } + + $user->update([ + 'is_banned' => true, + 'ban_reason' => $request->ban_reason, + 'banned_at' => now(), + ]); + + return back()->with('success', $user->name . ' banlandı.'); + } + + public function unban(User $user) + { + $user->update([ + 'is_banned' => false, + 'ban_reason' => null, + 'banned_at' => null, + ]); + return back()->with('success', $user->name . ' bandan çıkarıldı.'); + } + + public function givePremium(Request $request, User $user) + { + $request->validate([ + 'plan_id' => 'required|exists:membership_plans,id', + ]); + + $plan = MembershipPlan::findOrFail($request->plan_id); + $expiresAt = now()->addDays($plan->duration_days); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $expiresAt, + ]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'manual', + 'notes' => 'Admin tarafından verildi: ' . auth()->user()->name, + ]); + + return back()->with('success', $user->name . "'e {$plan->duration_days} günlük premium verildi."); + } + + public function removePremium(User $user) + { + $user->update([ + 'membership' => 'free', + 'premium_expires_at' => null, + ]); + + Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->update(['status' => 'cancelled']); + + return back()->with('success', $user->name . "'in premiumu kaldırıldı."); + } +} diff --git a/app/Http/Controllers/Api/AdApiController.php b/app/Http/Controllers/Api/AdApiController.php new file mode 100644 index 0000000..ef9c69c --- /dev/null +++ b/app/Http/Controllers/Api/AdApiController.php @@ -0,0 +1,23 @@ +increment('impressions'); + return response()->json(['ok' => true]); + } + + // POST /api/ads/{ad}/click + public function click(Ad $ad) + { + $ad->increment('clicks'); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/AiApiController.php b/app/Http/Controllers/Api/AiApiController.php new file mode 100644 index 0000000..ee6ac9b --- /dev/null +++ b/app/Http/Controllers/Api/AiApiController.php @@ -0,0 +1,37 @@ +ai = $ai; + } + + public function chat(Request $request) + { + return $this->ai->chat($request); + } + + public function recommend(Request $request) + { + return $this->ai->recommend($request); + } + + public function similar(Request $request) + { + return $this->ai->similar($request); + } + + public function search(Request $request) + { + return $this->ai->search($request); + } +} diff --git a/app/Http/Controllers/Api/AnimeApiController.php b/app/Http/Controllers/Api/AnimeApiController.php new file mode 100644 index 0000000..614ee0b --- /dev/null +++ b/app/Http/Controllers/Api/AnimeApiController.php @@ -0,0 +1,534 @@ +where('is_published', true) + ->with('genres', 'seasons', 'episodes') + ->latest()->take(5)->get(); + + if ($featured->isEmpty()) { + $featured = Anime::where('is_published', true)->with('genres', 'seasons', 'episodes') + ->where('rating', '>=', 1)->orderByDesc('rating')->take(5)->get(); + } + + $latest = Anime::where('is_published', true)->latest()->take(20)->get(); + $topRated = Anime::where('is_published', true)->where('rating', '>=', 7) + ->orderByDesc('rating')->take(12)->get(); + + try { + $manualTrending = Anime::where('is_trending', true)->where('is_published', true) + ->orderBy('trending_order')->take(12)->get(); + + if ($manualTrending->count() >= 6) { + $trending = $manualTrending->take(12); + } else { + $autoIds = $manualTrending->pluck('id')->toArray(); + $autoFill = Anime::where('is_published', true) + ->whereNotIn('id', $autoIds) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take(12 - $manualTrending->count())->get(); + $trending = $manualTrending->concat($autoFill); + } + } catch (\Throwable) { + $trending = collect(); + } + + if ($trending->isEmpty()) $trending = $latest->take(12); + + $newEpisodes = Episode::with(['anime', 'season']) + ->where('is_published', true)->latest()->take(12)->get() + ->filter(fn($e) => $e->anime && $e->season)->values(); + + $genres = Genre::where('is_active', true)->take(16)->get(); + + $continueWatching = collect(); + $recommended = collect(); + + $authUser = auth('sanctum')->user(); + if ($authUser) { + try { + $continueWatching = ContinueWatching::where('user_id', $authUser->id) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '>=', 5) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at')->limit(10)->get(); + + $watchedIds = ContinueWatching::where('user_id', $authUser->id)->pluck('anime_id'); + if ($watchedIds->isNotEmpty()) { + $topGenreIds = DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id')->orderByDesc('cnt')->limit(3)->pluck('genre_id'); + + if ($topGenreIds->isNotEmpty()) { + $recommended = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds)) + ->where('rating', '>=', 6)->inRandomOrder()->take(12)->get(); + } + } + } catch (\Throwable $e) {} + } + + return response()->json([ + 'featured' => $featured->map(fn($a) => $this->animeResource($a, true)), + 'trending' => $trending->values()->map(fn($a) => $this->animeResource($a)), + 'latest' => $latest->map(fn($a) => $this->animeResource($a)), + 'top_rated' => $topRated->map(fn($a) => $this->animeResource($a)), + 'new_episodes' => $newEpisodes->map(fn($e) => $this->episodeCardResource($e)), + 'genres' => $genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug]), + 'continue_watching'=> $continueWatching->map(fn($cw) => $this->continueWatchingResource($cw)), + 'recommended' => $recommended->map(fn($a) => $this->animeResource($a)), + ]); + } + + // GET /api/animes + public function index(Request $request) + { + $q = $request->input('q', ''); + $genre = $request->input('genre'); + $type = $request->input('type'); + $status = $request->input('status'); + $year = $request->input('year'); + $sort = $request->input('sort', 'latest'); // latest|rating|views + + $query = Anime::where('is_published', true)->with('genres'); + + if ($q) { + $query->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }); + } + if ($genre) $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre)); + if ($type) $query->where('type', $type); + if ($status) $query->where('status', $status); + if ($year) $query->where('release_year', $year); + + match ($sort) { + 'rating' => $query->orderByDesc('rating'), + default => $query->latest(), + }; + + $results = $query->paginate(24)->withQueryString(); + + return response()->json([ + 'data' => collect($results->items())->map(fn($a) => $this->animeResource($a)), + 'total' => $results->total(), + 'per_page' => $results->perPage(), + 'current_page'=> $results->currentPage(), + 'last_page' => $results->lastPage(), + ]); + } + + // GET /api/animes/{slug} + public function show(Request $request, string $slug) + { + $anime = Anime::where('slug', $slug)->where('is_published', true) + ->with(['genres', 'seasons', 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number')]) + ->firstOrFail(); + + $userId = $request->user()?->id; + + $inWatchlist = false; + $userRating = null; + $isFollowing = false; + + if ($userId) { + $wl = \App\Models\Watchlist::where('user_id', $userId)->where('anime_id', $anime->id)->first(); + $inWatchlist = $wl !== null; + $watchlistStatus = $wl?->status; + $userRating = \App\Models\AnimeRating::where('user_id', $userId)->where('anime_id', $anime->id)->value('rating'); + $isFollowing = \App\Models\AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->exists(); + } + + $seasons = $anime->seasons->map(function ($season) { + return [ + 'id' => $season->id, + 'season_number' => $season->season_number, + 'title' => $season->title, + 'episodes' => $season->episodes->map(fn($ep) => $this->episodeResource($ep)), + ]; + }); + + return response()->json([ + 'anime' => $this->animeResource($anime, true), + 'seasons' => $seasons, + 'in_watchlist' => $inWatchlist, + 'watchlist_status'=> $watchlistStatus ?? null, + 'user_rating' => $userRating, + 'is_following' => $isFollowing, + ]); + } + + // GET /api/genres/{slug} + public function genre(Request $request, string $slug) + { + $genre = Genre::where('slug', $slug)->where('is_active', true)->firstOrFail(); + $animes = $genre->animes()->where('is_published', true)->latest()->paginate(24); + + return response()->json([ + 'genre' => ['id'=>$genre->id,'name'=>$genre->name,'slug'=>$genre->slug], + 'data' => collect($animes->items())->map(fn($a) => $this->animeResource($a)), + 'total' => $animes->total(), + 'last_page' => $animes->lastPage(), + 'current_page' => $animes->currentPage(), + ]); + } + + // GET /api/genres + public function genres() + { + $genres = Genre::where('is_active', true)->orderBy('name')->get(); + return response()->json($genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])); + } + + // GET /api/watch/{slug}/{season}/{episode} + public function watch(Request $request, string $slug, int $season, int $episode) + { + $anime = Anime::where('slug', $slug)->where('is_published', true)->firstOrFail(); + $seasonModel = $anime->seasons()->where('season_number', $season)->firstOrFail(); + $ep = $seasonModel->episodes()->where('episode_number', $episode)->where('is_published', true)->firstOrFail(); + + $ep->increment('view_count'); + + $prev = $seasonModel->episodes()->where('episode_number', '<', $episode)->where('is_published', true)->orderByDesc('episode_number')->first(); + $next = $seasonModel->episodes()->where('episode_number', '>', $episode)->where('is_published', true)->orderBy('episode_number')->first(); + + // Cross-season next + if (!$next) { + $nextSeason = $anime->seasons()->where('season_number', $season + 1)->first(); + if ($nextSeason) { + $next = $nextSeason->episodes()->where('episode_number', 1)->where('is_published', true)->first(); + } + } + + // Subtitles + $subtitles = []; + if (method_exists($ep, 'subtitles')) { + $subtitles = $ep->subtitles()->get()->map(fn($s) => [ + 'label' => $s->label, + 'lang' => $s->language, + 'url' => $s->url, + 'is_default' => (bool)($s->is_default ?? false), + ])->values()->toArray(); + } + + // Dub sources from m3u8 URL + $dubSources = []; + if ($ep->m3u8_url) { + $rawDubs = $ep->available_dubs ?? null; + $availableDubs = is_string($rawDubs) ? json_decode($rawDubs, true) : (is_array($rawDubs) ? $rawDubs : null); + [$dubSources] = \App\Http\Controllers\Frontend\PlayerController::resolveDubSourcesPublic( + $ep->m3u8_url, $availableDubs + ); + } + + // Quality sources (legacy — episode.video_url / m3u8_url) + $sources = collect(); + if ($ep->m3u8_url) $sources->push(['quality'=>'Auto (HLS)','url'=>$ep->m3u8_url,'type'=>'hls']); + if ($ep->video_url) { + $isHls = str_ends_with($ep->video_url, '.m3u8') || str_contains($ep->video_url, 'master.m3u8'); + $type = $isHls ? 'hls' : 'mp4'; + $sources->push(['quality'=>'Auto','url'=>$ep->video_url,'type'=>$type]); + } + if ($ep->video_url_1080 ?? null) $sources->push(['quality'=>'1080p','url'=>$ep->video_url_1080,'type'=>'mp4']); + if ($ep->video_url_720 ?? null) $sources->push(['quality'=>'720p','url'=>$ep->video_url_720,'type'=>'mp4']); + if ($ep->video_url_480 ?? null) $sources->push(['quality'=>'480p','url'=>$ep->video_url_480,'type'=>'mp4']); + + // Çok kaynak desteği (video_sources tablosu) + // Her translator/kaynak bir grup → [{key, label, url, type, quality}] + $multiSources = \App\Models\VideoSource::where('episode_id', $ep->id) + ->orderBy('sort_order') + ->get() + ->groupBy(fn($vs) => $vs->translator_id ?: $vs->label) + ->map(function ($group) { + $default = $group->firstWhere('is_default', true) ?? $group->first(); + // Tüm kaliteler (1080p, 720p, vb.) + $qualities = $group->map(fn($vs) => [ + 'quality' => $vs->quality ?: 'Auto', + 'url' => $vs->url, + 'type' => $vs->type ?? 'mp4', + ])->values()->toArray(); + + return [ + 'key' => $default->translator_id + ?: \Illuminate\Support\Str::slug($default->label ?? 'kaynak'), + 'label' => $default->label ?: 'Kaynak', + 'url' => $default->url, + 'type' => $default->type ?? 'mp4', + 'quality' => $default->quality ?: 'Auto', + 'source' => $default->source ?? 'animecix', + 'qualities' => $qualities, + 'is_default'=> (bool) $default->is_default, + ]; + }) + ->values() + ->toArray(); + + // Player settings + $skipSeconds = (int) \App\Models\Setting::get('main_video_skip_seconds', 10); + $wmCoverSeconds = (int) \App\Models\Setting::get('watermark_cover_seconds', 11); + $introEnabled = \App\Models\Setting::get('intro_enabled') == '1'; + $introUrl = $introEnabled ? (\App\Models\Setting::get('intro_video_url') ?: null) : null; + $introSkipAfter = (int) \App\Models\Setting::get('intro_skip_after', 5); + + // AniSkip is fetched via separate /api/aniskip endpoint to avoid blocking video load + $aniSkipData = null; + + // All episodes list for in-player episode switcher + $allEpisodes = $seasonModel->episodes()->where('is_published', true)->orderBy('episode_number') + ->get()->map(fn($e) => [ + 'id' => $e->id, + 'episode_number' => $e->episode_number, + 'season_number' => $season, + 'title' => $e->title, + 'thumbnail_url' => $e->thumbnail_url ?? null, + ]); + + return response()->json([ + 'anime' => ['id'=>$anime->id,'title'=>$anime->title,'slug'=>$anime->slug,'cover_url'=>$anime->coverUrl], + 'season' => ['id'=>$seasonModel->id,'season_number'=>$seasonModel->season_number,'title'=>$seasonModel->title], + 'episode' => $this->episodeResource($ep), + 'sources' => $sources->values(), + 'multi_sources'=> $multiSources, // Çok kaynak (Anizium "4K" + AnimeCix çevirmenler) + 'dub_sources' => $dubSources, + 'subtitles' => $subtitles, + 'episodes' => $allEpisodes, + 'prev_episode' => $prev ? ['season'=>$prev->season?->season_number ?? $season,'episode'=>$prev->episode_number] : null, + 'next_episode' => $next ? ['season'=>$next->season?->season_number ?? $season,'episode'=>$next->episode_number] : null, + 'settings' => [ + 'skip_seconds' => $skipSeconds, + 'wm_cover_seconds' => $wmCoverSeconds, + 'intro_url' => $introUrl, + 'intro_skip_after' => $introSkipAfter, + // AniSkip timestamps (null if not available) + 'aniskip' => $aniSkipData, // {'op':{'start':X,'end':Y}, 'ed':{'start':X,'end':Y}} + ], + ]); + } + + // ── Resources ───────────────────────────────────────────────────────────── + + private function animeResource(Anime $a, bool $full = false): array + { + $base = [ + 'id' => $a->id, + 'title' => $a->title, + 'title_en' => $a->title_en, + 'title_jp' => $a->title_jp, + 'slug' => $a->slug, + 'cover_url' => $a->coverUrl, + 'banner_url' => $a->bannerUrl, + 'type' => $a->type, + 'status' => $a->status, + 'rating' => $a->rating ? (float)$a->rating : null, + 'release_year' => $a->release_year, + 'episode_count' => $a->episode_count, + 'genres' => $a->relationLoaded('genres') + ? $a->genres->map(fn($g) => ['id'=>$g->id,'name'=>$g->name,'slug'=>$g->slug])->values() + : [], + ]; + + if ($full) { + $base['description'] = $a->description; + $base['studio'] = $a->studio ?? null; + $base['duration'] = $a->duration ?? null; + $base['is_featured'] = $a->is_featured; + + // First episode for "watch now" button + if ($a->relationLoaded('seasons') && $a->seasons->isNotEmpty()) { + $firstSeason = $a->seasons->first(); + $eps = $a->relationLoaded('episodes') ? $a->episodes : $firstSeason->episodes; + $firstEp = $eps->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first(); + $base['first_watch'] = ($firstSeason && $firstEp) ? [ + 'season' => $firstSeason->season_number, + 'episode' => $firstEp->episode_number, + ] : null; + } + } + + return $base; + } + + private function episodeResource(Episode $ep): array + { + return [ + 'id' => $ep->id, + 'episode_number' => $ep->episode_number, + 'title' => $ep->title, + 'thumbnail_url' => $ep->thumbnailUrl ?? null, + 'duration' => $ep->duration, + 'view_count' => $ep->view_count, + 'created_at' => $ep->created_at?->toISOString(), + ]; + } + + private function episodeCardResource(Episode $ep): array + { + return [ + 'id' => $ep->id, + 'episode_number' => $ep->episode_number, + 'season_number' => $ep->season?->season_number, + 'title' => $ep->title, + 'thumbnail_url' => $ep->thumbnailUrl ?? null, + 'created_at' => $ep->created_at?->diffForHumans(), + 'anime' => [ + 'id' => $ep->anime->id, + 'title' => $ep->anime->title, + 'slug' => $ep->anime->slug, + 'cover_url' => $ep->anime->coverUrl, + 'rating' => $ep->anime->rating ? (float)$ep->anime->rating : null, + 'description' => $ep->anime->description, + ], + ]; + } + + // ── AniSkip endpoint ───────────────────────────────────────────────────── + + // GET /api/aniskip/{slug}/{season}/{episode} + // Fully automatic: finds MAL ID by title if missing, caches everything + public function aniSkip(string $slug, int $season, int $episode) + { + $anime = Anime::where('slug', $slug)->first(); + if (!$anime) return response()->json(['aniskip' => null]); + + $seasonModel = $anime->seasons()->where('season_number', $season)->first(); + if (!$seasonModel) return response()->json(['aniskip' => null]); + + $seasonMalId = $seasonModel->mal_id; + + try { + // anime.mal_id yoksa title search (bir kez, cache'lenir) + if (!$anime->mal_id) { + $found = (new \App\Services\JikanService())->searchMalId($anime->title, $anime->title_en, $anime->title_jp); + if ($found) $anime->update(['mal_id' => $found]); + } + + if (!$seasonMalId && $anime->mal_id) { + // S1 için anime.mal_id direkt kullan — Jikan'a gitme + if ($season === 1) { + $seasonMalId = $anime->mal_id; + $seasonModel->update(['mal_id' => $seasonMalId]); + } else { + // Diğer sezonlar: sadece cache'ten bak, yoksa null dön (page load bloke olmasın) + $chain = \Illuminate\Support\Facades\Cache::get("jikan_chain_{$anime->mal_id}"); + if ($chain) { + $seasonMalId = $chain[$season - 1] ?? $chain[0] ?? null; + if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]); + } + } + } + + if ($seasonMalId) { + $data = (new \App\Services\AniSkipService())->getSkipTimes((string)$seasonMalId, $episode); + return response()->json(['aniskip' => $data]); + } + } catch (\Throwable) {} + + return response()->json(['aniskip' => null]); + } + + // ── Skip segments ───────────────────────────────────────────────────────── + + // POST /api/episodes/{episode}/skip-event (anonim OK, rate-limited) + public function recordSkipEvent(Request $request, Episode $episode) + { + $from = (int) $request->input('from_sec', 0); + $to = (int) $request->input('to_sec', 0); + + // Basic sanity: must skip forward at least 5s, not more than 10 min + if ($to <= $from + 4 || ($to - $from) > 600) { + return response()->json(['ok' => false]); + } + + DB::table('episode_skip_events')->insert([ + 'episode_id' => $episode->id, + 'from_sec' => $from, + 'to_sec' => $to, + 'created_at' => now(), + ]); + + return response()->json(['ok' => true]); + } + + // GET /api/episodes/{episode}/skip-segments + // Returns segments where >= 10 users skipped from within a 30-second window + public function skipSegments(Episode $episode) + { + // İntro tespiti: ilk 3 dakika içinde 45-150sn ileri atlama = intro skip + // Kümeleme: 20sn bucket, to_sec standart sapması <= 15sn, en az 2 farklı kullanıcı + $rows = DB::table('episode_skip_events') + ->where('episode_id', $episode->id) + ->where('from_sec', '<', 180) + ->whereRaw('(to_sec - from_sec) BETWEEN 45 AND 150') + ->selectRaw(' + FLOOR(from_sec / 20) * 20 AS bucket_start, + AVG(to_sec) AS avg_to, + STDDEV_POP(to_sec) AS stddev_to, + COUNT(*) AS cnt + ') + ->groupByRaw('FLOOR(from_sec / 20) * 20') + ->havingRaw('cnt >= 2 AND (STDDEV_POP(to_sec) <= 15 OR cnt = 1)') + ->orderBy('cnt', 'desc') + ->limit(1) + ->get(); + + $segments = $rows->map(fn($r) => [ + 'from' => (int) $r->bucket_start, + 'to' => (int) round($r->avg_to), + 'count'=> (int) $r->cnt, + ])->values(); + + return response()->json(['segments' => $segments]); + } + + private function continueWatchingResource($cw): array + { + return [ + 'id' => $cw->id, + 'season_number' => $cw->season_number, + 'episode_number' => $cw->episode_number, + 'percent_complete' => $cw->percent_complete, + 'anime' => $cw->anime ? [ + 'id' => $cw->anime->id, + 'title' => $cw->anime->title, + 'slug' => $cw->anime->slug, + 'cover_url' => \App\Support\MediaUrl::fromStoragePath($cw->anime->cover_image), + ] : null, + ]; + } + + // POST /api/sources/flag-hevc + // Player tarafından çağrılır: HEVC hatası alınan kaynak URL'sini DB'ye işler + public function flagHevc(Request $request) + { + $url = $request->input('url'); + if (!$url) return response()->json(['ok' => false]); + + \App\Models\VideoSource::where('url', $url)->update([ + 'is_hevc' => true, + 'hevc_checked_at' => now(), + ]); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/AuthApiController.php b/app/Http/Controllers/Api/AuthApiController.php new file mode 100644 index 0000000..1e1bf6f --- /dev/null +++ b/app/Http/Controllers/Api/AuthApiController.php @@ -0,0 +1,150 @@ +validate([ + 'name' => 'required|string|max:100', + 'username' => 'required|string|max:50|unique:users|alpha_dash', + 'email' => 'required|email|unique:users', + 'password' => 'required|string|min:6|confirmed', + ]); + + $user = User::create([ + 'name' => $data['name'], + 'username' => $data['username'], + 'email' => $data['email'], + 'password' => $data['password'], + 'role' => 'user', + 'membership' => 'free', + ]); + + $token = $user->createToken('animexe-app')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => $this->userResource($user), + ], 201); + } + + public function login(Request $request) + { + $data = $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + $user = User::where('email', $data['email'])->first(); + + if (!$user || !Hash::check($data['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['E-posta veya şifre hatalı.'], + ]); + } + + if ($user->is_banned) { + return response()->json([ + 'message' => 'Hesabınız yasaklandı. Sebep: ' . ($user->ban_reason ?? 'Belirtilmedi'), + ], 403); + } + + $token = $user->createToken('animexe-app')->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => $this->userResource($user), + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + return response()->json(['message' => 'Çıkış yapıldı.']); + } + + public function me(Request $request) + { + return response()->json(['user' => $this->userResource($request->user())]); + } + + public function saveFcmToken(Request $request) + { + $data = $request->validate(['token' => 'required|string|max:500']); + $request->user()->update(['fcm_token' => $data['token']]); + return response()->json(['ok' => true]); + } + + public function updateProfile(Request $request) + { + $user = $request->user(); + + $request->validate([ + 'name' => 'sometimes|string|max:100', + 'username' => 'sometimes|string|max:50|unique:users,username,' . $user->id . '|alpha_dash', + 'password' => 'sometimes|string|min:6|confirmed', + 'bio' => 'sometimes|nullable|string|max:300', + 'website' => 'sometimes|nullable|string|max:100', + 'twitter' => 'sometimes|nullable|string|max:50', + 'instagram' => 'sometimes|nullable|string|max:50', + 'discord' => 'sometimes|nullable|string|max:50', + 'avatar' => 'sometimes|nullable|image|max:3072', + 'banner' => 'sometimes|nullable|image|max:6144', + ]); + + $data = $request->only(['name', 'username', 'bio', 'website', 'twitter', 'instagram', 'discord']); + $data = array_filter($data, fn($v) => $v !== null); + + if ($request->filled('password')) { + $data['password'] = bcrypt($request->input('password')); + } + + if ($request->hasFile('avatar')) { + $data['avatar'] = $request->file('avatar')->store('avatars', 'public'); + } + + if ($request->hasFile('banner')) { + $data['banner_image'] = $request->file('banner')->store('banners', 'public'); + } + + if (!empty($data)) { + $user->update($data); + } + + return response()->json(['user' => $this->userResource($user->fresh())]); + } + + private function userResource(User $user): array + { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'email' => $user->email, + 'bio' => $user->bio, + 'website' => $user->website, + 'twitter' => $user->twitter, + 'instagram' => $user->instagram, + 'discord' => $user->discord, + 'avatar' => $user->avatar + ? (\App\Support\MediaUrl::fromStoragePath($user->avatar)) + : null, + 'banner_image' => $user->banner_image + ? (\App\Support\MediaUrl::fromStoragePath($user->banner_image)) + : null, + 'role' => $user->role, + 'membership' => $user->membership, + 'is_premium' => $user->isPremium(), + 'premium_expires_at' => $user->premium_expires_at?->toISOString(), + 'created_at' => $user->created_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Controllers/Api/CommentApiController.php b/app/Http/Controllers/Api/CommentApiController.php new file mode 100644 index 0000000..af2ac10 --- /dev/null +++ b/app/Http/Controllers/Api/CommentApiController.php @@ -0,0 +1,108 @@ +input('anime_id'); + $episodeId = $request->input('episode_id'); + + $query = Comment::with('user:id,name,username,avatar') + ->where('status', 'approved') + ->orderByDesc('is_pinned') + ->orderByDesc('created_at'); + + if ($episodeId) { + $query->where('commentable_type', \App\Models\Episode::class) + ->where('commentable_id', $episodeId); + } elseif ($animeId) { + $query->where('commentable_type', Anime::class) + ->where('commentable_id', $animeId); + } + + $items = $query->paginate(20); + $userId = $request->user()?->id; + + return response()->json([ + 'data' => collect($items->items())->map(fn($c) => $this->fmt($c, $userId)), + 'total' => $items->total(), + 'last_page' => $items->lastPage(), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'nullable|exists:animes,id', + 'episode_id' => 'nullable|exists:episodes,id', + 'body' => 'required|string|max:1000', + 'gif_url' => 'nullable|url|max:500', + ]); + + if (empty($data['anime_id']) && empty($data['episode_id'])) { + return response()->json(['error' => 'anime_id veya episode_id gerekli.'], 422); + } + + $isEpisode = !empty($data['episode_id']); + $comment = Comment::create([ + 'user_id' => $request->user()->id, + 'commentable_type' => $isEpisode ? \App\Models\Episode::class : Anime::class, + 'commentable_id' => $isEpisode ? $data['episode_id'] : $data['anime_id'], + 'content' => $data['body'], + 'gif_url' => $data['gif_url'] ?? null, + 'status' => 'approved', + ]); + + $comment->load('user:id,name,username,avatar'); + + return response()->json($this->fmt($comment, $request->user()->id), 201); + } + + public function like(Request $request, Comment $comment) + { + $userId = $request->user()->id; + $existing = CommentLike::where('user_id', $userId) + ->where('comment_id', $comment->id)->first(); + + if ($existing) { + $existing->delete(); + $comment->decrement('like_count'); + return response()->json(['liked' => false, 'likes' => $comment->fresh()->like_count]); + } + + CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]); + $comment->increment('like_count'); + return response()->json(['liked' => true, 'likes' => $comment->fresh()->like_count]); + } + + private function fmt(Comment $c, ?int $userId): array + { + return [ + 'id' => $c->id, + 'body' => $c->content, + 'gif_url' => $c->gif_url, + 'likes_count' => $c->like_count ?? 0, + 'is_pinned' => $c->is_pinned ?? false, + 'created_at' => $c->created_at?->diffForHumans(), + 'user_liked' => $userId + ? CommentLike::where('user_id', $userId)->where('comment_id', $c->id)->exists() + : false, + 'user' => $c->user ? [ + 'id' => $c->user->id, + 'name' => $c->user->name, + 'username' => $c->user->username, + 'avatar' => $c->user->avatar + ? \App\Support\MediaUrl::fromStoragePath($c->user->avatar) + : null, + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/Api/ImportApiController.php b/app/Http/Controllers/Api/ImportApiController.php new file mode 100644 index 0000000..8d9672c --- /dev/null +++ b/app/Http/Controllers/Api/ImportApiController.php @@ -0,0 +1,1100 @@ +first(); + if ($anime) return $anime; + } + + // 2. slug + $slug = Str::slug($data['title'] ?? ''); + if ($slug) { + $anime = Anime::where('slug', $slug)->first(); + if ($anime) { + // mal_id eksikse güncelle + if (!empty($data['mal_id']) && !$anime->mal_id) { + $anime->update(['mal_id' => $data['mal_id']]); + } + return $anime; + } + } + + // 3. Büyük/küçük harf duyarsız başlık + title_en eşleşmesi + $lowerTitle = strtolower(trim($data['title'] ?? '')); + if ($lowerTitle) { + $anime = Anime::whereRaw('LOWER(title) = ?', [$lowerTitle]) + ->orWhereRaw('LOWER(title_en) = ?', [$lowerTitle]) + ->first(); + if ($anime) { + if (!empty($data['mal_id']) && !$anime->mal_id) { + $anime->update(['mal_id' => $data['mal_id']]); + } + return $anime; + } + } + + // 4. Bulunamadı → yeni oluştur + return Anime::create([ + 'title' => $data['title'], + 'title_en' => $data['title_en'] ?? '', + 'slug' => $slug ?: Str::slug($data['title'] ?? 'anime-' . uniqid()), + 'type' => $data['type'] ?? 'series', + 'status' => 'ongoing', + 'is_published' => false, + 'cover_image' => $data['cover'] ?? null, + 'mal_id' => $data['mal_id'] ?? null, + ]); + } + + // ── Auto-Import API'leri ───────────────────────────────────────────────── + + /** + * Programatik job oluşturma. + * AnimeCix ve Anizium her iki kaynak için tek endpoint. + */ + public function createJob(Request $request) + { + $source = $request->input('source', 'anizium'); + + // ── AnimeCix job ────────────────────────────────────────────────────── + if ($source === 'animecix') { + if ($request->has('year') && $request->year !== null) { + $request->merge(['year' => (string) $request->year]); + } + + $data = $request->validate([ + 'animecix_title_id' => 'required|string|max:50', + 'slug' => 'required|string|max:300', + 'title' => 'required|string|max:300', + 'title_en' => 'nullable|string|max:300', + 'cover' => 'nullable|string|max:500', + 'episode_count' => 'nullable|integer', + 'type' => 'nullable|string|max:30', + 'year' => 'nullable|string|max:10', + 'genres' => 'nullable|array', + 'mal_id' => 'nullable|integer', + 'priority' => 'nullable|integer|min:0|max:2', + ]); + + // Dedup — aynı title zaten aktif/bitti mi? + $existing = ImportJob::where('source', 'animecix') + ->where('animecix_title_id', $data['animecix_title_id']) + ->whereIn('status', ['pending', 'fetching', 'done']) + ->latest()->first(); + + if ($existing) { + return response()->json([ + 'job_id' => $existing->id, + 'status' => 'existing', + ]); + } + + // Anime bul veya oluştur (unified matcher) + $anime = $this->findOrCreateAnime([ + 'mal_id' => $data['mal_id'] ?? null, + 'title' => $data['title'], + 'title_en' => $data['title_en'] ?? '', + 'slug' => Str::slug($data['title']), + 'type' => $data['type'] ?: 'series', + 'cover' => $data['cover'] ?? null, + ]); + + // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla + $priority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); + if (!isset($data['priority']) && !$anime->wasRecentlyCreated) { + $hasAnizium = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) + ->where('source', 'anizium')->exists(); + $priority = $hasAnizium ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; + } + + $job = ImportJob::create([ + 'source' => 'animecix', + 'animecix_title_id' => $data['animecix_title_id'], + 'animecix_slug' => $data['slug'], + 'anime_title' => $data['title'], + 'anime_id' => $anime->id, + 'status' => 'pending', + 'priority' => $priority, + ]); + + // AniList resimlerini arka planda doldur + if (empty($anime->cover_image) || empty($anime->banner_image)) { + dispatch(function () use ($anime) { + try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } + catch (\Throwable) {} + })->afterResponse(); + } + + return response()->json(['job_id' => $job->id, 'status' => 'created', 'anime_id' => $anime->id], 201); + } + + // ── Anizium job ─────────────────────────────────────────────────────── + $data = $request->validate([ + 'source_url' => 'required|string|max:500', + 'anime_title' => 'required|string|max:300', + 'watch_id' => 'required|string|max:50', + 'season_ranges' => 'nullable|array', + 'season_ranges.*.season' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.from' => 'required_with:season_ranges|integer|min:1', + 'season_ranges.*.to' => 'required_with:season_ranges|integer|min:1', + 'priority' => 'nullable|integer|min:0|max:2', + ]); + + // Aktif job var mı? + $active = ImportJob::where('watch_id', $data['watch_id']) + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading']) + ->latest()->first(); + + if ($active) { + return response()->json(['job_id' => $active->id, 'status' => 'existing', 'msg' => 'Aktif job zaten var.']); + } + + // Daha önce bitti mi? (anime_id'yi al) + $prevDone = ImportJob::where('watch_id', $data['watch_id']) + ->where('status', 'done')->whereNotNull('anime_id')->latest()->first(); + + // Anime eşleştir (watch_id'den tanınan anime_id varsa kullan, yoksa title arama) + $anime = null; + if ($prevDone?->anime_id) { + $anime = Anime::find($prevDone->anime_id); + } + if (!$anime) { + // Başlık ile mevcut anime bul (farklı kaynaktan yüklenmiş olabilir) + $slug = Str::slug($data['anime_title']); + $lower = strtolower(trim($data['anime_title'])); + $anime = Anime::where('slug', $slug) + ->orWhereRaw('LOWER(title) = ?', [$lower]) + ->orWhereRaw('LOWER(title_en) = ?', [$lower]) + ->first(); + } + + // Priority: request'ten geliyorsa kullan, yoksa otomatik hesapla + $aniziumPriority = (int) ($data['priority'] ?? ImportJob::PRIORITY_NEW); + if (!isset($data['priority']) && $anime?->id) { + $hasAnimecix = VideoSource::whereHas('episode', fn($q) => $q->where('anime_id', $anime->id)) + ->where('source', 'animecix')->exists(); + $aniziumPriority = $hasAnimecix ? ImportJob::PRIORITY_CROSSFILL : ImportJob::PRIORITY_NEW; + } + + $job = ImportJob::create([ + 'source' => 'anizium', + 'source_url' => $data['source_url'], + 'anime_title' => $data['anime_title'], + 'watch_id' => $data['watch_id'], + 'status' => 'pending', + 'anime_id' => $anime?->id, + 'season_ranges' => $data['season_ranges'] ?? null, + 'priority' => $aniziumPriority, + ]); + + return response()->json(['job_id' => $job->id, 'status' => 'created', 'priority' => $aniziumPriority], 201); + } + + // ── Anime arama endpoint'i — Python botları için ────────────────────────── + // GET /api/import/anime/lookup?mal_id=xxx OR ?title=yyy OR ?slug=zzz + + public function animeLookup(Request $request) + { + // 1. mal_id + if ($mal_id = $request->input('mal_id')) { + $anime = Anime::where('mal_id', (int) $mal_id)->first(); + if ($anime) { + return response()->json([ + 'found' => true, + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'mal_id' => $anime->mal_id, + ]); + } + } + + // 2. Slug veya başlık + if ($title = $request->input('title')) { + $slug = Str::slug($title); + $lower = strtolower(trim($title)); + $anime = Anime::where('slug', $slug) + ->orWhereRaw('LOWER(title) = ?', [$lower]) + ->orWhereRaw('LOWER(title_en) = ?', [$lower]) + ->first(); + if ($anime) { + return response()->json([ + 'found' => true, + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'mal_id' => $anime->mal_id, + ]); + } + } + + return response()->json(['found' => false, 'anime_id' => null]); + } + + // ── Animecix: bekleyen job listesi ──────────────────────────────────────── + public function animecixPendingJobs() + { + try { + $jobs = ImportJob::where('source', 'animecix') + ->where('status', 'pending') + ->orderByDesc('priority') + ->orderBy('id') + ->limit(20) + ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id', 'priority']); + } catch (\Throwable) { + $jobs = ImportJob::where('source', 'animecix') + ->where('status', 'pending') + ->orderBy('id') + ->limit(20) + ->get(['id', 'animecix_title_id', 'animecix_slug', 'anime_title', 'anime_id']); + } + + return response()->json(['jobs' => $jobs, 'count' => $jobs->count()]); + } + + // ── Animecix: episode'lara video kaynakları kaydet ─────────────────────── + public function saveVideoSources(Request $request, Episode $episode) + { + $data = $request->validate([ + 'sources' => 'required|array|min:1', + 'sources.*.label' => 'nullable|string|max:120', + 'sources.*.url' => 'required|string|max:2000', + 'sources.*.type' => 'nullable|in:mp4,hls,embed', + 'sources.*.quality' => 'nullable|string|max:20', + 'sources.*.translator_id' => 'nullable|string|max:60', + 'sources.*.sort' => 'nullable|integer', + ]); + + // Önceki AnimeCix kaynaklarını sil (idempotent yeniden çalıştırma) + VideoSource::where('episode_id', $episode->id)->where('source', 'animecix')->delete(); + + // AnimeCix kaynakları eklendiğinde Anizium '4K' kaynağını secondary yap + VideoSource::where('episode_id', $episode->id) + ->where('source', 'anizium') + ->update(['is_default' => false, 'sort_order' => 99]); + + $isDefault = true; + foreach ($data['sources'] as $idx => $src) { + VideoSource::create([ + 'episode_id' => $episode->id, + 'label' => $src['label'] ?? '', + 'url' => $src['url'], + 'type' => $src['type'] ?? 'mp4', + 'quality' => $src['quality'] ?? '', + 'translator_id' => $src['translator_id'] ?? null, + 'sort_order' => $src['sort'] ?? $idx, + 'is_default' => $isDefault, + 'source' => 'animecix', + ]); + $isDefault = false; + + // İlk AnimeCix kaynağını episode video_url olarak da kaydet + if ($idx === 0) { + $url = $src['url']; + $type = $src['type'] ?? 'mp4'; + if ($type === 'mp4') { + $episode->update(['video_url' => $url, 'source' => 'animecix']); + } elseif ($type === 'hls') { + $episode->update(['m3u8_url' => $url, 'source' => 'animecix']); + } + } + } + + // Anime yayınla (ilk bölüm geldiğinde) + if ($episode->anime_id) { + Anime::where('id', $episode->anime_id)->where('is_published', false)->update(['is_published' => true]); + } + + return response()->json(['ok' => true, 'saved' => count($data['sources'])]); + } + + /** + * Import edilmiş tüm watch_id'leri döndürür (discover.py karşılaştırması için). + * NOT: Artık sadece failed-olmayanlar "mevcut" sayılır. + */ + public function importedIds() + { + $ids = ImportJob::whereNotNull('watch_id') + ->where('watch_id', '!=', '') + ->whereIn('status', ['pending', 'fetching', 'downloading', 'uploading', 'done']) + ->pluck('watch_id') + ->map(fn($id) => (string) $id) + ->unique() + ->values(); + + return response()->json(['watch_ids' => $ids, 'count' => $ids->count()]); + } + + /** + * Animexe'deki tüm yayınlanan anime başlıklarını döndürür. + */ + public function importedTitles() + { + $titles = Anime::where('is_published', true) + ->pluck('title') + ->filter() + ->unique() + ->values(); + + return response()->json(['titles' => $titles, 'count' => $titles->count()]); + } + + /** + * Bot 3 güncelleme botu için: Anizium watch_id'si olan TÜM animeleri döndür. + * ongoing/completed/finished fark etmez — her anime eksik bölüm kontrolüne tabi. + * Her animenin mevcut sezon/bölüm durumu da dahil. + */ + public function allAniziumAnimes() + { + $animes = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.watch_id') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->select( + 'import_jobs.watch_id', + 'import_jobs.anime_title', + 'import_jobs.anime_id', + 'animes.status as anime_status' + ) + ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id', 'animes.status') + ->get(); + + $result = $animes->map(function ($a) { + $seasonData = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $a->anime_id) + ->where('episodes.is_published', true) + ->select( + 'seasons.season_number', + \DB::raw('MAX(episodes.episode_number) as max_episode'), + \DB::raw('COUNT(*) as episode_count') + ) + ->groupBy('seasons.season_number') + ->orderBy('seasons.season_number') + ->get(); + + $seasons = []; + foreach ($seasonData as $s) { + $seasons[(string) $s->season_number] = [ + 'count' => (int) $s->episode_count, + 'max' => (int) $s->max_episode, + ]; + } + + return [ + 'watch_id' => $a->watch_id, + 'anime_title' => $a->anime_title, + 'anime_id' => (int) $a->anime_id, + 'anime_status' => $a->anime_status, + 'seasons' => $seasons, + ]; + }); + + return response()->json(['animes' => $result, 'count' => $result->count()]); + } + + // ── Bot ayarlarını döndür ───────────────────────────────────────────────── + public function settings() + { + $get = fn($key) => \App\Models\Setting::where('key', $key)->value('value') ?? ''; + + return response()->json([ + 'bunnycdn' => [ + 'zone' => $get('bunnycdn_zone'), + 'api_key' => $get('bunnycdn_api_key'), + 'pull_url' => $get('bunnycdn_pull_url'), + ], + ]); + } + + // Bağlantı testi + public function test() + { + $pendingBySource = ImportJob::where('status', 'pending') + ->selectRaw('COALESCE(source, "anizium") as source, COUNT(*) as cnt') + ->groupBy('source') + ->pluck('cnt', 'source'); + + return response()->json([ + 'ok' => true, + 'message' => 'Laravel API erişilebilir', + 'db' => \DB::connection()->getDatabaseName(), + 'pending' => ImportJob::where('status', 'pending')->count(), + 'pending_anizium' => (int) ($pendingBySource['anizium'] ?? 0), + 'pending_animecix' => (int) ($pendingBySource['animecix'] ?? 0), + 'total' => ImportJob::count(), + 'timestamp' => now()->toDateTimeString(), + ]); + } + + // Dashboard istatistikleri + public function stats() + { + $counts = ImportJob::selectRaw('status, COUNT(*) as cnt') + ->groupBy('status') + ->pluck('cnt', 'status'); + + $byStatus = [ + 'pending' => (int) ($counts['pending'] ?? 0), + 'fetching' => (int) ($counts['fetching'] ?? 0), + 'downloading' => (int) ($counts['downloading'] ?? 0), + 'uploading' => (int) ($counts['uploading'] ?? 0), + 'done' => (int) ($counts['done'] ?? 0), + 'failed' => (int) ($counts['failed'] ?? 0), + ]; + + $active = ImportJob::whereIn('status', ['fetching', 'downloading', 'uploading']) + ->latest()->first(); + + $ongoingCount = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->where('animes.status', 'ongoing') + ->distinct('import_jobs.watch_id') + ->count('import_jobs.watch_id'); + + return response()->json([ + 'total' => array_sum($byStatus), + 'by_status' => $byStatus, + 'ongoing_count' => $ongoingCount, + 'active_job' => $active ? [ + 'id' => $active->id, + 'title' => $active->anime_title, + 'status' => $active->status, + 'current_step' => $active->current_step, + 'total_episodes' => (int) ($active->total_episodes ?? 0), + 'done_episodes' => (int) ($active->done_episodes ?? 0), + 'progress_pct' => $active->progress_percent, + ] : null, + ]); + } + + // Python: belirli bir job'u al + public function getJob(ImportJob $job) + { + return response()->json(['job' => $job]); + } + + /** + * Python: bekleyen job var mı? — DB lock ile atomik al. + * Her kaynak kendi job'larını alır; çapraz engelleme KALDIRILDI. + * AnimeCix kendi kuyruğunu /animecix/pending ile alıyor. + * Bu endpoint sadece Anizium (ve untagged legacy) job'larını döndürür. + */ + public function nextJob() + { + $job = \DB::transaction(function () { + // Priority sırası: 2 (cross-fill) → 1 (ongoing) → 0 (yeni keşif) + // priority kolonu henüz yoksa (migration çalıştırılmadıysa) sadece id sıralaması + try { + $job = ImportJob::where('status', 'pending') + ->where(fn($q) => + $q->where('source', 'anizium') + ->orWhere('source', '') + ->orWhereNull('source') + ) + ->orderByDesc('priority') + ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') + ->orderBy('id') + ->lockForUpdate() + ->first(); + } catch (\Throwable) { + $job = ImportJob::where('status', 'pending') + ->where(fn($q) => + $q->where('source', 'anizium') + ->orWhere('source', '') + ->orWhereNull('source') + ) + ->orderByRaw('CASE WHEN season_ranges IS NOT NULL THEN 1 ELSE 0 END DESC') + ->orderBy('id') + ->lockForUpdate() + ->first(); + } + + if ($job) { + $job->update(['status' => 'fetching']); + } + return $job; + }); + + return response()->json(['job' => $job]); + } + + // Daemon: import edilmiş ongoing animeleri döndür (yeni bölüm kontrolü için) + public function ongoingAnimes() + { + $animes = ImportJob::where('import_jobs.status', 'done') + ->whereNotNull('import_jobs.watch_id') + ->whereNotNull('import_jobs.anime_id') + ->join('animes', 'animes.id', '=', 'import_jobs.anime_id') + ->where('animes.status', 'ongoing') + ->select( + 'import_jobs.watch_id', + 'import_jobs.anime_title', + 'import_jobs.anime_id' + ) + ->groupBy('import_jobs.watch_id', 'import_jobs.anime_title', 'import_jobs.anime_id') + ->get(); + + $result = $animes->map(function ($a) { + $seasonData = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $a->anime_id) + ->where('episodes.is_published', true) + ->select( + 'seasons.season_number', + \DB::raw('MAX(episodes.episode_number) as max_episode'), + \DB::raw('COUNT(*) as episode_count') + ) + ->groupBy('seasons.season_number') + ->orderBy('seasons.season_number') + ->get(); + + $seasons = []; + foreach ($seasonData as $s) { + $seasons[(string) $s->season_number] = [ + 'count' => (int) $s->episode_count, + 'max' => (int) $s->max_episode, + ]; + } + + return [ + 'watch_id' => $a->watch_id, + 'anime_title' => $a->anime_title, + 'anime_id' => (int) $a->anime_id, + 'seasons' => $seasons, + ]; + }); + + return response()->json(['animes' => $result, 'count' => $result->count()]); + } + + // Python: job durumunu güncelle + public function updateStatus(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'status' => 'sometimes|in:pending,fetching,downloading,uploading,done,failed', + 'current_step' => 'nullable|string', + 'total_episodes' => 'nullable|integer', + 'done_episodes' => 'nullable|integer', + 'failed_episodes' => 'nullable|integer', + 'error_log' => 'nullable|string', + ]); + + $update = array_intersect_key($data, array_flip(array_keys($request->all()))); + if (!empty($update)) { + $job->update($update); + } + + return response()->json(['ok' => true]); + } + + // Python: bir bölüm tamamlandı, DB'ye kaydet + public function saveEpisode(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'season' => 'required|integer|min:1', + 'episode' => 'required|integer|min:0', + 'title' => 'nullable|string', + 'description' => 'nullable|string', + 'duration' => 'nullable|integer', + 'video_url' => 'nullable|string', + 'm3u8_url' => 'nullable|string', + 'bunny_video_id' => 'nullable|string', + 'source_url' => 'nullable|string', + 'thumbnail' => 'nullable|string', + 'available_dubs' => 'nullable|array', + 'available_dubs.*'=> 'string|max:32', + 'embed_source' => 'nullable|string|max:32', + 'extra_sources' => 'nullable|array', + 'extra_sources.*.url' => 'required|string|max:2000', + 'extra_sources.*.quality' => 'nullable|string|max:20', + 'extra_sources.*.label' => 'nullable|string|max:60', + ]); + + $isAnizium = in_array($job->source ?? 'anizium', ['anizium', '', null], true) + || is_null($job->source); + + // Anime bul veya oluştur + if ($job->anime_id) { + $anime = Anime::find($job->anime_id); + } else { + $baseTitle = $job->anime_title ?: "Anime CDN-{$job->cdn_id}"; + $slug = Str::slug($baseTitle) . '-' . ($job->cdn_id ?: $job->id); + + $anime = Anime::firstOrCreate( + ['slug' => $slug], + [ + 'title' => $baseTitle, + 'type' => 'series', + 'status' => 'ongoing', + 'is_published' => true, + ] + ); + $job->update(['anime_id' => $anime->id]); + + // Auto-fetch MAL ID (fire-and-forget) + if (!$anime->mal_id) { + dispatch(function () use ($anime) { + try { + $jikan = new JikanService(); + $malId = $jikan->searchMalId($anime->title, $anime->title_en, $anime->title_jp); + if ($malId) { + $anime->update(['mal_id' => $malId]); + $chain = $jikan->fetchSeasonMalIds($malId); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $season) { + if (!$season->mal_id && isset($chain[$i])) { + $season->update(['mal_id' => $chain[$i]]); + } + } + (new \App\Services\AniListService())->fillImages($anime->fresh()); + } + } catch (\Throwable) {} + })->afterResponse(); + } + + if (empty($anime->cover_image) || empty($anime->banner_image)) { + dispatch(function () use ($anime) { + try { (new \App\Services\AniListService())->fillImages($anime->fresh()); } + catch (\Throwable) {} + })->afterResponse(); + } + + if (Setting::get('ai_auto_seo') === '1' && empty($anime->seo_title)) { + dispatch(function () use ($anime) { + try { + $ai = new \App\Services\DeepSeekService(); + $result = $ai->generateAnimeSeoMeta($anime->fresh(['genres'])); + if ($result) { + $anime->update([ + 'seo_title' => $result['seo_title'] ?? null, + 'seo_meta_desc' => $result['seo_meta_desc'] ?? null, + 'seo_keywords' => $result['seo_keywords'] ?? null, + ]); + } + } catch (\Throwable) {} + })->afterResponse(); + } + } + + // Sezon bul/oluştur + $season = Season::firstOrCreate( + ['anime_id' => $anime->id, 'season_number' => $data['season']], + ['is_published' => true] + ); + + if (!$season->mal_id && $anime->mal_id) { + dispatch(function () use ($anime, $season) { + try { + $chain = (new JikanService())->fetchSeasonMalIds($anime->mal_id); + $idx = $season->season_number - 1; + if (isset($chain[$idx])) $season->update(['mal_id' => $chain[$idx]]); + } catch (\Throwable) {} + })->afterResponse(); + } + + // Mevcut episode var mı? (başka kaynaktan yüklenmiş olabilir) + $existingEpisode = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode']) + ->first(); + + // Başlık stratejisi: + // Anizium → her zaman başlığı set eder (kullanıcı isteği: başlık aniziumdan gelsin) + // AnimeCix → sadece mevcut başlık boşsa set eder + $titleValue = $data['title'] ?? null; + if (!$isAnizium && $existingEpisode && $existingEpisode->title) { + $titleValue = $existingEpisode->title; // AnimeCix mevcut başlığı ezip geçmez + } + + $episodeValues = [ + 'anime_id' => $anime->id, + 'title' => $titleValue, + 'description' => $data['description'] ?? null, + 'duration' => $data['duration'] ?? null, + 'source_url' => $data['source_url'] ?? null, + 'thumbnail' => $data['thumbnail'] ?? null, + 'status' => 'published', + 'is_published' => true, + ]; + + // video_url / m3u8_url sadece Anizium koyar (AnimeCix video_sources'tan gider) + if ($isAnizium) { + $episodeValues['video_url'] = $data['video_url'] ?? null; + $episodeValues['m3u8_url'] = $data['m3u8_url'] ?? null; + $episodeValues['bunny_video_id'] = $data['bunny_video_id'] ?? null; + $episodeValues['available_dubs'] = isset($data['available_dubs']) ? json_encode($data['available_dubs']) : null; + $episodeValues['source'] = isset($data['bunny_video_id']) ? 'bunnycdn' : ($data['embed_source'] ?? 'anizium'); + } + + try { + Episode::updateOrCreate( + ['season_id' => $season->id, 'episode_number' => $data['episode']], + $episodeValues + ); + } catch (\Illuminate\Database\QueryException $e) { + if (str_contains($e->getMessage(), 'available_dubs')) { + unset($episodeValues['available_dubs']); + Episode::updateOrCreate( + ['season_id' => $season->id, 'episode_number' => $data['episode']], + $episodeValues + ); + } else { + throw $e; + } + } + + $savedEp = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode']) + ->first(); + + // ── Anizium bölümlerini video_sources tablosuna kaydet ── + if ($isAnizium && $savedEp) { + $vsUrl = $data['video_url'] ?? $data['m3u8_url'] ?? null; + $vsType = (!empty($data['video_url'])) ? 'mp4' + : (!empty($data['m3u8_url']) ? 'hls' : null); + + if ($vsUrl && $vsType) { + $hasAnimecix = VideoSource::where('episode_id', $savedEp->id) + ->where('source', 'animecix') + ->exists(); + + // Mevcut anizium kaynaklarını temizle, yeniden ekle + VideoSource::where('episode_id', $savedEp->id) + ->where('source', 'anizium') + ->delete(); + + // Ana kaynak (en yüksek kalite) + VideoSource::create([ + 'episode_id' => $savedEp->id, + 'source' => 'anizium', + 'label' => '4K', + 'url' => $vsUrl, + 'type' => $vsType, + 'quality' => '4K', + 'sort_order' => $hasAnimecix ? 99 : 0, + 'is_default' => !$hasAnimecix, + ]); + + // Yedek kaliteler (720p, 480p vs. — HEVC failse browser bunları dener) + foreach (($data['extra_sources'] ?? []) as $idx => $src) { + VideoSource::create([ + 'episode_id' => $savedEp->id, + 'source' => 'anizium', + 'label' => $src['label'] ?? ($src['quality'] ?? 'Yedek'), + 'url' => $src['url'], + 'type' => 'hls', + 'quality' => $src['quality'] ?? null, + 'sort_order' => ($hasAnimecix ? 99 : 0) + $idx + 1, + 'is_default' => false, + ]); + } + } + } + + $anime->update(['episode_count' => $anime->episodes()->count()]); + $job->increment('done_episodes'); + + // Anime yayınla + Anime::where('id', $anime->id)->where('is_published', false)->update(['is_published' => true]); + + // Auto açıklama üretimi (Anizium için) + if ($isAnizium && Setting::get('ai_auto_description') === '1' && $savedEp && empty($savedEp->description)) { + $ai = new DeepSeekService(); + $desc = $ai->generateEpisodeDescription($anime->title, $data['episode'], $data['title'] ?? ''); + if ($desc) $savedEp->update(['description' => $desc]); + } + + return response()->json(['ok' => true, 'anime_id' => $anime->id, 'episode_id' => $savedEp?->id]); + } + + /** + * Python: job için tamamlanmış bölümleri döndür (resume desteği). + * + * Her kaynak sadece KENDİ kaydettiği bölümleri "done" sayar. + * Anizium → video_sources.source='anizium' olan bölümler + * AnimeCix → video_sources.source='animecix' olan bölümler + * Legacy (source belirsiz) → eski davranış (is_published=true) + */ + public function doneEpisodes(ImportJob $job) + { + if (!$job->anime_id) { + return response()->json(['done' => (object)[]]); + } + + $source = $job->source ?? 'anizium'; + + if (in_array($source, ['anizium', 'animecix'], true)) { + // Kaynak bazlı: sadece bu kaynağın video_sources kayıtları olan bölümler + $rows = \DB::table('video_sources') + ->join('episodes', 'episodes.id', '=', 'video_sources.episode_id') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $job->anime_id) + ->where('video_sources.source', $source) + ->select('seasons.season_number as s', 'episodes.episode_number as e') + ->distinct() + ->get(); + } else { + // Legacy: is_published=true olan tüm bölümler + $rows = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->where('episodes.anime_id', $job->anime_id) + ->where('episodes.is_published', true) + ->select('seasons.season_number as s', 'episodes.episode_number as e') + ->get(); + } + + $done = []; + foreach ($rows as $r) { + $done[(string)$r->s][(string)$r->e] = true; + } + + return response()->json(['done' => $done ?: (object)[]]); + } + + // Python: bir bölüme altyazı kaydet + public function saveSubtitle(Request $request, ImportJob $job) + { + $data = $request->validate([ + 'season' => 'required|integer|min:1', + 'episode' => 'required|integer|min:1', + 'language' => 'required|string|max:10', + 'label' => 'required|string|max:50', + 'url' => 'required|string', + 'is_default' => 'boolean', + ]); + + $season = Season::where('anime_id', $job->anime_id) + ->where('season_number', $data['season'])->first(); + if (!$season) { + return response()->json(['ok' => false, 'msg' => 'Season bulunamadı'], 404); + } + + $episode = Episode::where('season_id', $season->id) + ->where('episode_number', $data['episode'])->first(); + if (!$episode) { + return response()->json(['ok' => false, 'msg' => 'Episode bulunamadı'], 404); + } + + Subtitle::updateOrCreate( + ['episode_id' => $episode->id, 'language' => $data['language']], + ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] + ); + + return response()->json(['ok' => true]); + } + + // Anizium kaynaklı tüm bölümleri watch_id bazında döndür (altyazı yenileme için) + // ?only_missing_subs=1 → subtitles tablosunda kaydı olmayan bölümler + // ?fix_anizium_subs=1 → altyazısı var ama URL'i hâlâ ham Anizium linki olan bölümler (b-cdn.net değil) + public function aniziumEpisodes(Request $request) + { + $onlyMissing = $request->boolean('only_missing_subs', false); + $fixAniziumSubs = $request->boolean('fix_anizium_subs', false); + + $query = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->whereNotNull('episodes.source_url') + ->where('episodes.source_url', 'like', '%anizium.co/watch/%') + ->select( + 'episodes.id as episode_id', + 'seasons.season_number as season', + 'episodes.episode_number as episode', + 'episodes.source_url', + 'episodes.view_count' + ) + ->orderBy('episodes.id'); + + if ($onlyMissing) { + $query->whereNotExists(function ($sub) { + $sub->select(\DB::raw(1)) + ->from('subtitles') + ->whereColumn('subtitles.episode_id', 'episodes.id'); + }); + } elseif ($fixAniziumSubs) { + // Altyazısı var ama en az bir URL b-cdn.net içermiyor (ham Anizium linki) + $query->whereExists(function ($sub) { + $sub->select(\DB::raw(1)) + ->from('subtitles') + ->whereColumn('subtitles.episode_id', 'episodes.id') + ->where('subtitles.url', 'not like', '%b-cdn.net%'); + }); + } + + $rows = $query->get(); + + $grouped = []; + $viewCounts = []; + foreach ($rows as $r) { + if (!preg_match('#anizium\.co/watch/(\w+)#', $r->source_url, $m)) continue; + $wid = $m[1]; + if (!isset($grouped[$wid])) { + $grouped[$wid] = []; + $viewCounts[$wid] = 0; + } + $grouped[$wid][] = [ + 'episode_id' => $r->episode_id, + 'season' => $r->season, + 'episode' => $r->episode, + ]; + $viewCounts[$wid] += (int) ($r->view_count ?? 0); + } + + // Popularity'e göre sırala (en çok izlenen önce) + $sortedAnimes = []; + foreach ($grouped as $wid => $eps) { + $sortedAnimes[$wid] = [ + 'episodes' => $eps, + 'view_count' => $viewCounts[$wid], + ]; + } + uasort($sortedAnimes, fn($a, $b) => $b['view_count'] - $a['view_count']); + + return response()->json([ + 'total' => $rows->count(), + 'animes' => $sortedAnimes, + ]); + } + + // Sağlık kontrolü: Anizium kaynaklı anime + bölüm URL'leri + public function aniziumHealthData() + { + $rows = \DB::table('episodes') + ->join('seasons', 'seasons.id', '=', 'episodes.season_id') + ->join('animes', 'animes.id', '=', 'episodes.anime_id') + ->join('import_jobs', function ($j) { + $j->on('import_jobs.anime_id', '=', 'animes.id') + ->where('import_jobs.source', 'anizium') + ->where('import_jobs.status', 'done'); + }) + ->where('episodes.is_published', true) + ->whereNotNull('episodes.source_url') + ->where('episodes.source_url', 'like', '%anizium.co/watch/%') + ->select( + 'animes.id as anime_id', + 'animes.title', + 'animes.slug', + 'import_jobs.watch_id', + 'episodes.id as episode_id', + 'seasons.season_number as season', + 'episodes.episode_number as episode', + 'episodes.m3u8_url', + 'episodes.video_url' + ) + ->orderBy('animes.id') + ->orderBy('seasons.season_number') + ->orderBy('episodes.episode_number') + ->get(); + + $animes = []; + foreach ($rows as $r) { + $aid = $r->anime_id; + if (!isset($animes[$aid])) { + $animes[$aid] = [ + 'anime_id' => $aid, + 'title' => $r->title, + 'slug' => $r->slug, + 'watch_id' => $r->watch_id, + 'episodes' => [], + ]; + } + $url = $r->m3u8_url ?: $r->video_url; + if ($url) { + $animes[$aid]['episodes'][] = [ + 'episode_id' => $r->episode_id, + 'season' => $r->season, + 'episode' => $r->episode, + 'url' => $url, + ]; + } + } + + return response()->json([ + 'anime_count' => count($animes), + 'episode_count' => $rows->count(), + 'animes' => array_values($animes), + ]); + } + + // Anime'yi inaktife al (Anizium bozuk, yeniden import edilecek) + public function deactivateAnime(Request $request) + { + $data = $request->validate(['anime_id' => 'required|integer|exists:animes,id']); + + Anime::where('id', $data['anime_id'])->update(['is_published' => false]); + ImportJob::where('anime_id', $data['anime_id']) + ->where('source', 'anizium') + ->update(['status' => 'failed', 'error_log' => 'CDN URL broken — replaced']); + + return response()->json(['ok' => true]); + } + + // Altyazıyı doğrudan episode_id ile kaydet + public function saveSubtitleDirect(Request $request) + { + $data = $request->validate([ + 'episode_id' => 'required|integer|exists:episodes,id', + 'language' => 'required|string|max:10', + 'label' => 'required|string|max:50', + 'url' => 'required|string', + 'is_default' => 'boolean', + ]); + + Subtitle::updateOrCreate( + ['episode_id' => $data['episode_id'], 'language' => $data['language']], + ['label' => $data['label'], 'url' => $data['url'], 'is_default' => $data['is_default'] ?? false] + ); + + return response()->json(['ok' => true]); + } + + // ── Çapraz re-import: yayınlanan animeler için tamamlanmış job'ları döndür ─ + // Kullanım: cross_reimport.py scripti bu endpoint'i çağırır + public function publishedAnimesWithJobs() + { + $animes = Anime::where('is_published', true) + ->with(['importJobs' => fn($q) => $q->where('status', 'done')->select( + 'id', 'anime_id', 'source', 'watch_id', 'animecix_title_id', 'animecix_slug', 'status' + )]) + ->select('id', 'title', 'title_en', 'slug', 'mal_id', 'type', 'status') + ->get() + ->map(function ($anime) { + $jobs = $anime->importJobs; + return [ + 'anime_id' => $anime->id, + 'title' => $anime->title, + 'title_en' => $anime->title_en, + 'slug' => $anime->slug, + 'mal_id' => $anime->mal_id, + 'type' => $anime->type, + 'status' => $anime->status, + 'has_anizium' => $jobs->where('source', 'anizium')->isNotEmpty(), + 'has_animecix' => $jobs->where('source', 'animecix')->isNotEmpty(), + 'anizium_watch_ids' => $jobs->where('source', 'anizium')->pluck('watch_id')->filter()->unique()->values(), + 'animecix_title_ids' => $jobs->where('source', 'animecix')->pluck('animecix_title_id')->filter()->unique()->values(), + 'animecix_slugs' => $jobs->where('source', 'animecix')->pluck('animecix_slug')->filter()->unique()->values(), + ]; + }); + + return response()->json(['animes' => $animes, 'count' => $animes->count()]); + } +} diff --git a/app/Http/Controllers/Api/MessageApiController.php b/app/Http/Controllers/Api/MessageApiController.php new file mode 100644 index 0000000..c3772cb --- /dev/null +++ b/app/Http/Controllers/Api/MessageApiController.php @@ -0,0 +1,184 @@ +conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->limit(50) + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + $last = $conv->lastMessage; + $unread = $conv->unreadCountFor($user->id); + + $preview = null; + if ($last) { + if (str_starts_with($last->body, 'IMAGE::')) $preview = '📷 Fotoğraf'; + elseif (str_starts_with($last->body, 'GIF::')) $preview = '🎞 GIF'; + elseif (str_starts_with($last->body, 'ANIMESHARE::')) { + try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime'); } catch (\Throwable) {} + } else { + $isMine = $last->user_id === $user->id; + $preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 60); + } + } + + return [ + 'id' => $conv->id, + 'other_user' => $other ? [ + 'id' => $other->id, + 'name' => $other->name, + 'username' => $other->username, + 'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + ] : null, + 'last_message' => $last ? [ + 'body' => $preview ?? '', + 'user_id' => $last->user_id, + 'created_at' => $last->created_at?->toISOString(), + ] : null, + 'unread_count' => $unread, + 'updated_at' => $conv->updated_at?->toISOString(), + ]; + }); + + return response()->json(['conversations' => $convs]); + } + + // GET /api/messages/{conversation} — messages in a conversation + public function show(Conversation $conversation) + { + $user = Auth::user(); + + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $other = $conversation->participants()->where('user_id', '!=', $user->id)->first(); + + $messages = $conversation->messages() + ->with('user') + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'user_id' => $m->user_id, + 'body' => $m->body, + 'created_at' => $m->created_at?->toISOString(), + 'author' => [ + 'id' => $m->user?->id, + 'name' => $m->user?->name, + 'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + ], + ]); + + // Mark as read + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json([ + 'messages' => $messages, + 'other_user' => $other ? [ + 'id' => $other->id, + 'name' => $other->name, + 'username' => $other->username, + 'avatar' => $other->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + ] : null, + ]); + } + + // POST /api/messages/{conversation} — send a message + public function send(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $request->validate(['body' => 'required|string|max:5000']); + + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'user_id' => $user->id, + 'body' => $request->body, + ]); + + $conversation->touch(); + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json([ + 'id' => $message->id, + 'user_id' => $user->id, + 'body' => $message->body, + 'created_at' => $message->created_at->toISOString(), + 'author' => [ + 'id' => $user->id, + 'name' => $user->name, + 'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null, + ], + ]); + } + + // POST /api/messages/start/{user} — start or open conversation + public function startConversation(User $user) + { + $me = Auth::user(); + + if ($me->id === $user->id) abort(422, 'Kendinize mesaj gönderemezsiniz.'); + + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $user->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $user) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $user->id]); + return $c; + }); + } + + return response()->json(['conversation_id' => $conv->id]); + } + + // GET /api/messages/{conv}/poll?after={id} — poll for new messages (mobile) + public function poll(Request $request, Conversation $conversation) + { + $user = Auth::user(); + abort_unless($conversation->participants()->where('user_id', $user->id)->exists(), 403); + + $after = (int) $request->query('after', 0); + + $messages = $conversation->messages() + ->with('user') + ->where('id', '>', $after) + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'user_id' => $m->user_id, + 'body' => $m->body, + 'created_at' => $m->created_at?->toISOString(), + 'author' => [ + 'id' => $m->user?->id, + 'name' => $m->user?->name, + 'avatar' => $m->user?->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + ], + ]); + + $conversation->participants()->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json(['messages' => $messages]); + } +} diff --git a/app/Http/Controllers/Api/PlanApiController.php b/app/Http/Controllers/Api/PlanApiController.php new file mode 100644 index 0000000..e77cff8 --- /dev/null +++ b/app/Http/Controllers/Api/PlanApiController.php @@ -0,0 +1,66 @@ +where('is_public', true) + ->orderBy('sort_order') + ->orderBy('price') + ->get() + ->map(fn($p) => $this->fmtPlan($p)); + + $subscription = null; + $user = $request->user(); + if ($user) { + $sub = Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->where('expires_at', '>', now()) + ->with('plan') + ->latest() + ->first(); + + if ($sub) { + $subscription = [ + 'plan_id' => $sub->plan_id, + 'plan_name' => $sub->plan?->name, + 'plan_slug' => $sub->plan?->slug, + 'status' => $sub->status, + 'expires_at' => $sub->expires_at?->toISOString(), + ]; + } + } + + return response()->json([ + 'plans' => $plans, + 'subscription' => $subscription, + 'is_premium' => $user?->isPremium() ?? false, + ]); + } + + private function fmtPlan(MembershipPlan $p): array + { + return [ + 'id' => $p->id, + 'name' => $p->name, + 'slug' => $p->slug, + 'description' => $p->description, + 'price' => $p->price, + 'purchase_link' => $p->purchase_link, + 'duration_days' => $p->duration_days, + 'trial_days' => $p->trial_days, + 'features' => $p->features ?? [], + 'perks' => $p->perks ?? [], + 'badge_label' => $p->badge_label, + 'accent_color' => $p->accent_color, + ]; + } +} diff --git a/app/Http/Controllers/Api/SocialApiController.php b/app/Http/Controllers/Api/SocialApiController.php new file mode 100644 index 0000000..403c87c --- /dev/null +++ b/app/Http/Controllers/Api/SocialApiController.php @@ -0,0 +1,462 @@ +where('episode_id', $episode->id) + ->where('is_hidden', false) + ->orderBy('timestamp_sec') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'user_id' => $c->user_id, + 'timestamp_sec' => $c->timestamp_sec, + 'body' => $c->body, + 'color' => $c->color, + 'username' => $c->user?->username ?? 'misafir', + ]); + + return response()->json(['comments' => $comments]); + } + + public function timestampCommentStore(Request $request, Episode $episode) + { + $data = $request->validate([ + 'timestamp_sec' => 'required|integer|min:0|max:86400', + 'body' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + ]); + + $me = Auth::user(); + + $recent = EpisodeTimestampComment::where('user_id', $me->id) + ->where('episode_id', $episode->id) + ->where('created_at', '>=', now()->subSeconds(5)) + ->count(); + + if ($recent >= 2) { + return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429); + } + + $comment = EpisodeTimestampComment::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'timestamp_sec' => $data['timestamp_sec'], + 'body' => $data['body'], + 'color' => $data['color'] ?? '#ffffff', + ]); + + return response()->json(['ok' => true, 'id' => $comment->id]); + } + + // ── Tahmin Oyunu ───────────────────────────────────────────────────────── + + public function predictions(Episode $episode) + { + $me = Auth::id(); + + $predictions = EpisodePrediction::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($p) => [ + 'id' => $p->id, + 'body' => $p->body, + 'is_correct' => $p->is_correct, + 'vote_count' => $p->vote_count, + 'username' => $p->user?->username, + 'is_mine' => $me && $p->user_id === $me, + 'voted' => $me + ? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists() + : false, + 'created_at' => $p->created_at->diffForHumans(), + ]); + + $myPrediction = $me + ? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id + : null; + + return response()->json([ + 'predictions' => $predictions, + 'my_prediction' => $myPrediction, + ]); + } + + public function predictionStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate(['body' => 'required|string|min:5|max:280']); + + $existing = EpisodePrediction::where('episode_id', $episode->id) + ->where('user_id', $me->id)->first(); + + if ($existing) { + return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422); + } + + $prediction = EpisodePrediction::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $prediction->id]); + } + + public function predictionVote(EpisodePrediction $prediction) + { + $me = Auth::user(); + $existing = PredictionVote::where('prediction_id', $prediction->id)->where('user_id', $me->id)->first(); + + if ($existing) { + $existing->delete(); + $prediction->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]); + } + + PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]); + $prediction->increment('vote_count'); + return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]); + } + + // ── Watch Party ────────────────────────────────────────────────────────── + + public function partyCreate(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'episode_id' => 'required|exists:episodes,id', + 'is_private' => 'boolean', + 'password' => 'nullable|string|max:30', + 'max_members' => 'nullable|integer|min:2|max:20', + ]); + + WatchParty::where('host_user_id', $me->id)->delete(); + + $party = WatchParty::create([ + 'room_code' => WatchParty::generateCode(), + 'host_user_id' => $me->id, + 'episode_id' => $data['episode_id'], + 'is_private' => $data['is_private'] ?? false, + 'password' => isset($data['password']) ? Hash::make($data['password']) : null, + 'max_members' => $data['max_members'] ?? 10, + ]); + + WatchPartyMember::create(['party_id' => $party->id, 'user_id' => $me->id]); + + return response()->json([ + 'ok' => true, + 'room_code' => $party->room_code, + 'party' => $this->partyData($party), + ]); + } + + public function partyJoin(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + if ($party->is_private && $party->password) { + if (!Hash::check($request->input('password', ''), $party->password)) { + return response()->json(['error' => 'Yanlış şifre.'], 403); + } + } + + if ($party->activeMembers()->count() >= $party->max_members) { + return response()->json(['error' => 'Oda dolu.'], 403); + } + + WatchPartyMember::updateOrCreate( + ['party_id' => $party->id, 'user_id' => $me->id], + ['last_ping' => now()] + ); + + return response()->json([ + 'ok' => true, + 'party' => $this->partyData($party), + ]); + } + + public function partySync(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + if ($party->host_user_id === $me->id) { + $data = $request->validate([ + 'current_sec' => 'required|integer|min:0', + 'is_playing' => 'required|boolean', + ]); + $party->update([ + 'current_sec' => $data['current_sec'], + 'is_playing' => $data['is_playing'], + ]); + } + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id) + ->update(['last_ping' => now()]); + + $fresh = $party->fresh(); + return response()->json([ + 'current_sec' => $fresh->current_sec, + 'is_playing' => $fresh->is_playing, + 'members' => $this->memberList($party), + ]); + } + + public function partyLeave(string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete(); + + if ($party->host_user_id === $me->id) { + $party->delete(); + return response()->json(['ok' => true, 'dissolved' => true]); + } + + return response()->json(['ok' => true, 'dissolved' => false]); + } + + public function partyInfo(string $roomCode) + { + $party = WatchParty::with(['episode.anime', 'episode.season']) + ->where('room_code', $roomCode)->firstOrFail(); + return response()->json(['party' => $this->partyData($party)]); + } + + private function partyData(WatchParty $party): array + { + $party->loadMissing(['episode.anime', 'episode.season']); + return [ + 'room_code' => $party->room_code, + 'host_id' => $party->host_user_id, + 'episode_id' => $party->episode_id, + 'current_sec' => $party->current_sec, + 'is_playing' => $party->is_playing, + 'is_private' => $party->is_private, + 'max_members' => $party->max_members, + 'members' => $this->memberList($party), + 'anime_title' => $party->episode?->anime?->title, + 'anime_slug' => $party->episode?->anime?->slug, + 'episode_num' => $party->episode?->episode_number, + 'season_num' => $party->episode?->season?->season_number ?? 1, + ]; + } + + private function memberList(WatchParty $party): array + { + return $party->activeMembers()->with('user:id,name,username')->get() + ->map(fn($m) => [ + 'id' => $m->user_id, + 'name' => $m->user?->name, + 'username'=> $m->user?->username, + 'is_host' => $m->user_id === $party->host_user_id, + ])->toArray(); + } + + // ── Spoiler Kutular ────────────────────────────────────────────────────── + + public function spoilerBoxes(Episode $episode) + { + $me = Auth::id(); + $boxes = SpoilerBox::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('likes') + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => [ + 'id' => $b->id, + 'body' => $b->body, + 'is_spoiler' => $b->is_spoiler, + 'spoiler_score' => $b->spoiler_score, + 'likes' => $b->likes, + 'username' => $b->user?->username, + 'is_mine' => $me && $b->user_id === $me, + 'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false, + 'created_at' => $b->created_at->diffForHumans(), + ]); + + return response()->json(['boxes' => $boxes]); + } + + public function spoilerBoxStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate(['body' => 'required|string|min:3|max:600']); + + $isSpoiler = false; + $spoilerScore = 0; + $ai = new DeepSeekService(); + if ($ai->isConfigured()) { + try { + $raw = $ai->checkSpoiler($data['body']); + if ($raw) { + $isSpoiler = $raw['is_spoiler'] ?? false; + $spoilerScore = $raw['score'] ?? 0; + } + } catch (\Throwable $e) {} + } + + $box = SpoilerBox::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + 'is_spoiler' => $isSpoiler, + 'spoiler_score' => $spoilerScore, + ]); + + return response()->json(['ok' => true, 'id' => $box->id, 'is_spoiler' => $isSpoiler]); + } + + public function spoilerBoxLike(SpoilerBox $box) + { + $me = Auth::id(); + $existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $box->decrement('likes'); + return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]); + } + + SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]); + $box->increment('likes'); + return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]); + } + + // ── Zaman Kapsülü ──────────────────────────────────────────────────────── + + public function capsuleIndex() + { + $capsules = TimeCapsule::with('anime:id,title,slug,cover_image') + ->where('user_id', Auth::id()) + ->orderBy('unlock_at') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'anime_title' => $c->anime?->title, + 'anime_slug' => $c->anime?->slug, + 'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null, + 'unlock_at' => $c->unlock_at->toIso8601String(), + 'unlocked' => $c->isUnlocked(), + 'opened' => $c->isOpened(), + 'message' => ($c->isOpened() || $c->isUnlocked()) ? $c->message : null, + 'created_at' => $c->created_at->toIso8601String(), + ]); + + return response()->json(['capsules' => $capsules]); + } + + public function capsuleStore(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'message' => 'required|string|min:5|max:1000', + 'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(), + ]); + + $data['user_id'] = $me->id; + $capsule = TimeCapsule::create($data); + return response()->json(['ok' => true, 'id' => $capsule->id]); + } + + public function capsuleOpen(TimeCapsule $capsule) + { + if ($capsule->user_id !== Auth::id()) { + return response()->json(['error' => 'Yetkisiz.'], 403); + } + if (!$capsule->isUnlocked()) { + return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422); + } + + $capsule->update(['opened_at' => now()]); + return response()->json(['ok' => true, 'message' => $capsule->message]); + } + + // ── Ruh Hali Motoru ────────────────────────────────────────────────────── + + private static array $moodGenres = [ + 'sad' => ['Drama', 'Romantizm'], + 'funny' => ['Komedi', 'Slice of Life'], + 'hype' => ['Aksiyon', 'Shounen', 'Spor'], + 'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'], + 'romance' => ['Romantizm', 'Shoujo'], + 'scary' => ['Korku', 'Supernatural', 'Gerilim'], + ]; + + public function moodRecommend(Request $request) + { + $mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood']; + $genres = self::$moodGenres[$mood] ?? []; + + $animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres)) + ->where('is_published', true) + ->inRandomOrder() + ->limit(6) + ->get(['id', 'title', 'cover_image', 'slug', 'rating']); + + return response()->json([ + 'animes' => $animes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_image ? MediaUrl::fromStoragePath($a->cover_image) : null, + 'rating' => $a->rating, + ]), + ]); + } + + // ── Kullanıcı Takip ────────────────────────────────────────────────────── + + public function followToggle(User $user) + { + $me = Auth::user(); + if ($me->id === $user->id) { + return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422); + } + + $existing = \App\Models\UserFollow::where('follower_id', $me->id) + ->where('following_id', $user->id)->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + \App\Models\UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]); + $following = true; + } + + return response()->json([ + 'following' => $following, + 'followers_count' => \App\Models\UserFollow::where('following_id', $user->id)->count(), + ]); + } +} diff --git a/app/Http/Controllers/Api/TribunalApiController.php b/app/Http/Controllers/Api/TribunalApiController.php new file mode 100644 index 0000000..9c3b03d --- /dev/null +++ b/app/Http/Controllers/Api/TribunalApiController.php @@ -0,0 +1,205 @@ +withCount('votes'); + + if ($request->filled('anime_id')) { + $query->where('anime_id', $request->anime_id); + } + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $tribunals = $query->latest()->paginate(15); + + return response()->json([ + 'data' => collect($tribunals->items())->map(fn($t) => $this->formatTribunal($t))->values(), + 'has_more' => $tribunals->hasMorePages(), + 'next_page' => $tribunals->hasMorePages() ? $tribunals->currentPage() + 1 : null, + ]); + } + + public function show(Tribunal $tribunal) + { + $tribunal->load(['anime:id,title,slug,cover_image', 'creator:id,name,username']); + $me = Auth::id(); + $sides = $tribunal->allSides(); + + $vcounts = []; + foreach (array_keys($sides) as $key) { + $vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + $myVote = $me ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') : null; + $myArg = $me ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() : null; + + $arguments = TribunalArgument::with('user:id,name,username') + ->where('tribunal_id', $tribunal->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'side' => $a->side, + 'body' => $a->body, + 'vote_count' => $a->vote_count, + 'username' => $a->user?->username, + 'is_mine' => $me && $a->user_id === $me, + 'voted' => $me ? TribunalArgumentVote::where('argument_id', $a->id)->where('user_id', $me)->exists() : false, + ]); + + return response()->json([ + 'tribunal' => $this->formatTribunal($tribunal), + 'sides' => $sides, + 'vote_counts' => $vcounts, + 'total_votes' => array_sum($vcounts), + 'my_vote' => $myVote, + 'my_argument' => $myArg ? ['id' => $myArg->id, 'side' => $myArg->side, 'body' => $myArg->body] : null, + 'arguments' => $arguments, + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'question' => 'required|string|min:10|max:280', + 'side_a' => 'required|string|min:2|max:100', + 'side_b' => 'required|string|min:2|max:100', + 'extra_sides' => 'nullable|array|max:4', + 'extra_sides.*'=> 'required|string|min:2|max:100', + 'closes_at' => 'nullable|date|after:today', + ]); + + $me = Auth::user(); + + $tribunal = Tribunal::create([ + 'anime_id' => $data['anime_id'], + 'created_by' => $me->id, + 'question' => $data['question'], + 'side_a' => $data['side_a'], + 'side_b' => $data['side_b'], + 'extra_sides' => $data['extra_sides'] ?? [], + 'status' => 'open', + 'closes_at' => $data['closes_at'] ?? now()->addDays(7), + ]); + + return response()->json(['ok' => true, 'id' => $tribunal->id]); + } + + public function vote(Request $request, Tribunal $tribunal) + { + if ($tribunal->status !== 'open') { + return response()->json(['error' => 'Bu dava kapalı.'], 422); + } + + $sides = array_keys($tribunal->allSides()); + $data = $request->validate(['side' => 'required|in:' . implode(',', $sides)]); + $me = Auth::id(); + + $existing = TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first(); + + if ($existing) { + if ($existing->side === $data['side']) { + $existing->delete(); + $voted = null; + } else { + $existing->update(['side' => $data['side']]); + $voted = $data['side']; + } + } else { + TribunalVote::create(['tribunal_id' => $tribunal->id, 'user_id' => $me, 'side' => $data['side']]); + $voted = $data['side']; + } + + $vcounts = []; + foreach (array_keys($tribunal->allSides()) as $key) { + $vcounts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + return response()->json(['voted' => $voted, 'vote_counts' => $vcounts, 'total_votes' => array_sum($vcounts)]); + } + + public function argue(Request $request, Tribunal $tribunal) + { + if ($tribunal->status !== 'open') { + return response()->json(['error' => 'Bu dava kapalı.'], 422); + } + + $sides = array_keys($tribunal->allSides()); + $data = $request->validate([ + 'side' => 'required|in:' . implode(',', $sides), + 'body' => 'required|string|min:5|max:500', + ]); + + $me = Auth::user(); + $existing = TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me->id)->first(); + + if ($existing) { + $existing->update(['side' => $data['side'], 'body' => $data['body']]); + return response()->json(['ok' => true, 'id' => $existing->id, 'updated' => true]); + } + + $arg = TribunalArgument::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me->id, + 'side' => $data['side'], + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $arg->id, 'updated' => false]); + } + + public function argVote(TribunalArgument $argument) + { + $me = Auth::id(); + $existing = TribunalArgumentVote::where('argument_id', $argument->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $argument->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]); + } + + TribunalArgumentVote::create(['argument_id' => $argument->id, 'user_id' => $me]); + $argument->increment('vote_count'); + return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]); + } + + private function formatTribunal(Tribunal $t): array + { + return [ + 'id' => $t->id, + 'question' => $t->question, + 'status' => $t->status, + 'sides' => $t->allSides(), + 'votes_count' => $t->votes_count ?? TribunalVote::where('tribunal_id', $t->id)->count(), + 'closes_at' => $t->closes_at?->toIso8601String(), + 'created_at' => $t->created_at->diffForHumans(), + 'anime' => $t->anime ? [ + 'id' => $t->anime->id, + 'title' => $t->anime->title, + 'slug' => $t->anime->slug, + 'cover' => $t->anime->cover_image ? MediaUrl::fromStoragePath($t->anime->cover_image) : null, + ] : null, + 'creator' => $t->creator ? [ + 'name' => $t->creator->name, + 'username' => $t->creator->username, + ] : null, + ]; + } +} diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php new file mode 100644 index 0000000..02c2edb --- /dev/null +++ b/app/Http/Controllers/Api/UserApiController.php @@ -0,0 +1,300 @@ +input('status'); // watching|completed|plan_to_watch|dropped + + $query = Watchlist::where('user_id', $request->user()->id) + ->with('anime:id,title,slug,cover_image,rating,episode_count,status,release_year'); + + if ($status) $query->where('status', $status); + + $items = $query->orderByDesc('updated_at')->paginate(24); + + return response()->json([ + 'data' => collect($items->items())->map(fn($w) => [ + 'id' => $w->id, + 'status' => $w->status, + 'anime' => $w->anime ? [ + 'id' => $w->anime->id, + 'title' => $w->anime->title, + 'slug' => $w->anime->slug, + 'cover_url' => \App\Support\MediaUrl::fromStoragePath($w->anime->cover_image), + 'rating' => $w->anime->rating, + 'episode_count' => $w->anime->episode_count, + 'status' => $w->anime->status, + 'release_year' => $w->anime->release_year, + ] : null, + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function watchlistToggle(Request $request, Anime $anime) + { + $user = $request->user(); + $status = $request->input('status', 'plan_to_watch'); + + $existing = Watchlist::where('user_id', $user->id)->where('anime_id', $anime->id)->first(); + + if ($existing) { + if ($existing->status === $status) { + $existing->delete(); + return response()->json(['in_watchlist' => false, 'status' => null]); + } + $existing->update(['status' => $status]); + return response()->json(['in_watchlist' => true, 'status' => $status]); + } + + Watchlist::create(['user_id' => $user->id, 'anime_id' => $anime->id, 'status' => $status]); + return response()->json(['in_watchlist' => true, 'status' => $status]); + } + + // ── Continue Watching ────────────────────────────────────────────────────── + + public function continueWatchingUpdate(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'season_number' => 'required|integer|min:1', + 'episode_number' => 'required|integer|min:1', + 'percent_complete' => 'required|numeric|min:0|max:100', + ]); + + ContinueWatching::updateOrCreate( + ['user_id' => $request->user()->id, 'anime_id' => $data['anime_id']], + [ + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'percent_complete' => $data['percent_complete'], + ] + ); + + return response()->json(['ok' => true]); + } + + // ── Anime Rate ───────────────────────────────────────────────────────────── + + public function animeRate(Request $request, Anime $anime) + { + $data = $request->validate(['rating' => 'required|numeric|min:1|max:10']); + + AnimeRating::updateOrCreate( + ['user_id' => $request->user()->id, 'anime_id' => $anime->id], + ['rating' => $data['rating']] + ); + + $avg = AnimeRating::where('anime_id', $anime->id)->avg('rating'); + $anime->update(['rating' => round($avg, 1)]); + + return response()->json(['rating' => $data['rating'], 'avg' => round($avg, 1)]); + } + + // ── Follow ───────────────────────────────────────────────────────────────── + + public function followToggle(Request $request, Anime $anime) + { + $user = $request->user(); + $existing = AnimeFollow::where('user_id', $user->id)->where('anime_id', $anime->id)->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['following' => false]); + } + + AnimeFollow::create(['user_id' => $user->id, 'anime_id' => $anime->id]); + return response()->json(['following' => true]); + } + + // ── Notifications ────────────────────────────────────────────────────────── + + public function notifications(Request $request) + { + $items = UserNotification::where('user_id', $request->user()->id) + ->orderByDesc('created_at')->paginate(20); + + // Mark all as read + UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]); + + return response()->json([ + 'data' => collect($items->items())->map(fn($n) => [ + 'id' => $n->id, + 'type' => $n->type, + 'data' => $n->data ?? [], + 'is_read' => $n->is_read, + 'created_at' => $n->created_at?->diffForHumans(), + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function notificationsCount(Request $request) + { + if (!$request->user()) return response()->json(['count' => 0]); + $count = UserNotification::where('user_id', $request->user()->id)->whereNull('read_at')->count(); + return response()->json(['count' => $count]); + } + + // ── Achievements ─────────────────────────────────────────────────────────── + + public function achievements(Request $request) + { + $all = Achievement::orderBy('points')->get(); + $earned = UserAchievement::where('user_id', $request->user()->id)->pluck('achievement_id')->toArray(); + $total = UserAchievement::where('user_id', $request->user()->id)->join('achievements','achievements.id','=','user_achievements.achievement_id')->sum('achievements.points'); + + return response()->json([ + 'total_points' => (int)$total, + 'data' => $all->map(fn($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'description' => $a->description, + 'icon' => $a->icon, + 'points' => $a->points, + 'earned' => in_array($a->id, $earned), + 'earned_at' => in_array($a->id, $earned) + ? UserAchievement::where('user_id', $request->user()->id)->where('achievement_id', $a->id)->value('created_at')?->toISOString() + : null, + ]), + ]); + } + + // ── Episode Notes ────────────────────────────────────────────────────────── + + public function noteStore(Request $request, $episodeId) + { + $data = $request->validate(['note' => 'required|string|max:1000']); + + $note = EpisodeNote::create([ + 'user_id' => $request->user()->id, + 'episode_id' => $episodeId, + 'note' => $data['note'], + ]); + + return response()->json(['id' => $note->id, 'note' => $note->note, 'created_at' => $note->created_at?->toISOString()], 201); + } + + public function noteDelete(Request $request, $noteId) + { + $note = EpisodeNote::where('id', $noteId)->where('user_id', $request->user()->id)->firstOrFail(); + $note->delete(); + return response()->json(['ok' => true]); + } + + public function episodeNotesList(Request $request, $episodeId) + { + $notes = EpisodeNote::where('user_id', $request->user()->id) + ->where('episode_id', $episodeId) + ->orderByDesc('created_at')->get(); + + return response()->json($notes->map(fn($n) => [ + 'id' => $n->id, + 'note' => $n->note, + 'created_at' => $n->created_at?->toISOString(), + ])); + } + + // ── Anime Requests ───────────────────────────────────────────────────────── + + public function requestIndex(Request $request) + { + $items = AnimeRequest::withCount('votes') + ->orderByDesc('votes_count')->orderByDesc('created_at')->paginate(20); + + return response()->json([ + 'data' => collect($items->items())->map(fn($r) => [ + 'id' => $r->id, + 'title' => $r->title, + 'note' => $r->note, + 'status' => $r->status, + 'votes_count' => $r->votes_count, + 'created_at' => $r->created_at?->diffForHumans(), + 'user_voted' => $request->user() + ? AnimeRequestVote::where('user_id', $request->user()->id)->where('anime_request_id', $r->id)->exists() + : false, + ]), + 'total' => $items->total(), + 'last_page'=> $items->lastPage(), + ]); + } + + public function requestStore(Request $request) + { + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'note' => 'nullable|string|max:500', + ]); + + $req = AnimeRequest::create([ + 'user_id' => $request->user()->id, + 'title' => $data['title'], + 'note' => $data['note'] ?? null, + 'status' => 'pending', + ]); + + return response()->json(['id' => $req->id, 'title' => $req->title], 201); + } + + public function requestVote(Request $request, AnimeRequest $animeRequest) + { + $user = $request->user(); + $existing = AnimeRequestVote::where('user_id', $user->id)->where('anime_request_id', $animeRequest->id)->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['voted' => false, 'votes' => $animeRequest->votes()->count()]); + } + + AnimeRequestVote::create(['user_id' => $user->id, 'anime_request_id' => $animeRequest->id]); + return response()->json(['voted' => true, 'votes' => $animeRequest->votes()->count()]); + } + + // ── Profile Stats ────────────────────────────────────────────────────────── + + public function profileStats(Request $request) + { + $userId = $request->user()->id; + + $watchlistCount = Watchlist::where('user_id', $userId)->count(); + $completedCount = Watchlist::where('user_id', $userId)->where('status', 'completed')->count(); + $notifCount = UserNotification::where('user_id', $userId)->where('is_read', false)->count(); + $achPoints = UserAchievement::where('user_id', $userId) + ->join('achievements','achievements.id','=','user_achievements.achievement_id') + ->sum('achievements.points'); + $achCount = UserAchievement::where('user_id', $userId)->count(); + $commentCount = \App\Models\Comment::where('user_id', $userId)->count(); + + return response()->json([ + 'watchlist_count' => $watchlistCount, + 'completed_count' => $completedCount, + 'notif_count' => (int)$notifCount, + 'achievement_points'=> (int)$achPoints, + 'achievement_count' => $achCount, + 'comment_count' => $commentCount, + ]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +validate([ + 'code' => 'required|string|max:32', + ], [ + 'code.required' => 'Aktivasyon kodu boş bırakılamaz.', + ]); + + $rawCode = strtoupper(preg_replace('/[^A-Z0-9\-]/', '', trim($request->code))); + + $code = ActivationCode::with('plan') + ->where('code', $rawCode) + ->first(); + + if (! $code) { + return back()->withInput()->withErrors(['code' => 'Geçersiz aktivasyon kodu. Kodu kontrol edip tekrar deneyin.']); + } + + if ($code->isUsed()) { + return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodu daha önce kullanılmış.']); + } + + if ($code->isExpired()) { + return back()->withInput()->withErrors(['code' => 'Bu aktivasyon kodunun süresi dolmuş.']); + } + + $user = auth()->user(); + $plan = $code->plan; + + // Mevcut premium bitiş tarihine ekle (stack), yoksa şimdiden başla + $baseDate = ($user->premium_expires_at && $user->premium_expires_at->isFuture()) + ? $user->premium_expires_at + : now(); + $newExpiry = $baseDate->addDays($plan->duration_days); + + DB::transaction(function () use ($code, $user, $plan, $newExpiry) { + $code->update([ + 'used_by' => $user->id, + 'used_at' => now(), + ]); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $newExpiry, + 'payment_method' => 'activation_code', + 'payment_ref' => $code->code, + ]); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $newExpiry, + ]); + }); + + return redirect()->route('premium.plans')->with('activation_success', [ + 'plan' => $plan->name, + 'expires_at' => $newExpiry->format('d.m.Y'), + ]); + } +} diff --git a/app/Http/Controllers/Frontend/AiController.php b/app/Http/Controllers/Frontend/AiController.php new file mode 100644 index 0000000..7f716da --- /dev/null +++ b/app/Http/Controllers/Frontend/AiController.php @@ -0,0 +1,270 @@ +get(['id', 'name']); + return view('frontend.ai.index', compact('genres')); + } + + /** + * POST /ai/chat — sohbet turu. + * Body: { messages: [{role, content}, ...] } + */ + public function chat(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $messages = $request->input('messages', []); + if (empty($messages)) { + return response()->json(['error' => 'Mesaj boş.'], 422); + } + + // Validate structure + $messages = array_filter($messages, fn($m) => isset($m['role'], $m['content']) && in_array($m['role'], ['user', 'assistant'])); + $messages = array_values($messages); + + $context = $ai->getAnimeContext(); + + // Sayfa bağlamı — kullanıcı anime/player sayfasındaysa AI'ya söyle + $pageCtx = trim($request->input('page_context', '')); + if ($pageCtx) { + $context .= "\n\n== KULLANICI ŞU AN BU SAYFADA ==\n{$pageCtx}"; + } + + $rawReply = $ai->chat($messages, $context); + + if (!$rawReply) { + return response()->json(['error' => 'AI yanıt vermedi, tekrar dene.'], 500); + } + + // [SUGGEST:id1,id2,id3] satırını parse et + $animeCards = []; + $cleanReply = $rawReply; + if (preg_match('/\[SUGGEST:([\d,\s]+)\]\s*$/m', $rawReply, $m)) { + $cleanReply = trim(str_replace($m[0], '', $rawReply)); + $ids = array_filter(array_map('intval', explode(',', $m[1]))); + if ($ids) { + $animes = Anime::whereIn('id', $ids) + ->where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'cover_image', 'rating', 'type', 'episode_count']); + $animeMap = $animes->keyBy('id'); + foreach ($ids as $id) { + if ($a = $animeMap[$id] ?? null) { + $animeCards[] = [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_url, + 'rating' => $a->rating, + 'type' => $a->type, + 'episode_count' => $a->episode_count, + 'genres' => $a->genres->pluck('name')->take(3)->join(', '), + ]; + } + } + } + } + + // Log + $lastUser = collect($messages)->last(fn($m) => $m['role'] === 'user'); + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'chat','query_text'=>substr($lastUser['content']??'',0,500),'created_at'=>now()]); + + return response()->json(['reply' => $cleanReply, 'anime_cards' => $animeCards]); + } + + /** + * POST /ai/recommend — kişisel öneri. + * Body: { mood?, genres[]?, type? } + */ + public function recommend(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $mood = trim($request->input('mood', '')); + $genres = $request->input('genres', []); + $type = $request->input('type', ''); + + $prefs = []; + if ($mood) $prefs[] = "Ruh hali / tema: {$mood}"; + if ($genres) $prefs[] = 'Tercih edilen türler: ' . implode(', ', array_slice((array)$genres, 0, 6)); + if ($type) $prefs[] = 'İçerik tipi: ' . ($type === 'movie' ? 'Film' : 'Dizi'); + $prefStr = $prefs ? implode("\n", $prefs) : 'Genel tavsiye, en beğenilen animeler'; + + $animes = Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'status', 'rating', 'release_year', 'cover_image', 'episode_count']); + + $result = $ai->recommend($prefStr, $animes->toArray()); + + if (!$result) { + return response()->json(['error' => 'Öneri üretilemedi.'], 500); + } + + $animeMap = $animes->keyBy('id'); + $recs = array_values(array_filter(array_map(function ($item) use ($animeMap) { + $anime = $animeMap[$item['id'] ?? 0] ?? null; + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'cover' => $anime->cover_url, + 'rating' => $anime->rating, + 'type' => $anime->type, + 'episode_count' => $anime->episode_count, + 'reason' => $item['reason'] ?? '', + ]; + }, $result))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'recommend','query_text'=>substr($prefStr,0,500),'created_at'=>now()]); + + return response()->json(['recommendations' => $recs]); + } + + /** + * POST /ai/search — doğal dil ile anime ara. + * Body: { query } + */ + public function search(Request $request) + { + $query = trim($request->input('query', '')); + if (!$query) { + return response()->json(['error' => 'Sorgu boş.'], 422); + } + + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $animes = Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']); + + $ids = $ai->naturalSearch($query, $animes->toArray()); + if (!$ids) { + return response()->json(['results' => []]); + } + + $animeMap = $animes->keyBy('id'); + $results = array_values(array_filter(array_map(function ($id) use ($animeMap) { + $anime = $animeMap[(int)$id] ?? null; + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'title' => $anime->title, + 'slug' => $anime->slug, + 'cover' => $anime->cover_url, + 'rating' => $anime->rating, + 'type' => $anime->type, + ]; + }, $ids))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'search','query_text'=>substr($query,0,500),'created_at'=>now()]); + + return response()->json(['results' => $results]); + } + + /** + * POST /ai/episode-info — bölüm hakkında AI analizi. + * Body: { anime_title, episode_number, episode_title?, description? } + */ + public function episodeInfo(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $animeTitle = trim($request->input('anime_title', '')); + $episodeNumber = (int) $request->input('episode_number', 1); + $episodeTitle = trim($request->input('episode_title', '')); + $description = trim($request->input('description', '')); + + if (!$animeTitle) { + return response()->json(['error' => 'Anime adı gerekli.'], 422); + } + + $info = $ai->episodeInfo($animeTitle, $episodeNumber, $episodeTitle, $description); + if (!$info) { + return response()->json(['error' => 'Analiz yapılamadı.'], 500); + } + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'episode_info','query_text'=>"{$animeTitle} E{$episodeNumber}",'created_at'=>now()]); + + return response()->json(['info' => $info]); + } + + /** + * POST /ai/similar — benzer animeler. + * Body: { anime_id } + */ + public function similar(Request $request) + { + $ai = new DeepSeekService(); + if (!$ai->isConfigured()) { + return response()->json(['error' => 'AI servisi şu an kullanılamıyor.'], 503); + } + + $anime = Anime::with('genres:id,name')->find($request->input('anime_id')); + if (!$anime) { + return response()->json(['error' => 'Anime bulunamadı.'], 404); + } + + $genres = $anime->genres->pluck('name')->join(', '); + $prefStr = "Şu anime ile benzer: {$anime->title}\n" + . "Türler: {$genres}\n" + . "Tip: " . ($anime->type === 'movie' ? 'Film' : 'Dizi') . "\n" + . "Bu animeyi beğenen izleyicilere benzer içerik öner. Aynı animeyi önerme!"; + + $animes = Anime::where('is_published', true) + ->where('id', '!=', $anime->id) + ->with('genres:id,name') + ->get(['id', 'title', 'slug', 'type', 'rating', 'release_year', 'cover_image']); + + $result = $ai->recommend($prefStr, $animes->toArray()); + if (!$result) { + return response()->json(['similar' => []]); + } + + $animeMap = $animes->keyBy('id'); + $similar = array_values(array_filter(array_map(function ($item) use ($animeMap) { + $a = $animeMap[$item['id'] ?? 0] ?? null; + if (!$a) return null; + return [ + 'id' => $a->id, + 'title' => $a->title, + 'slug' => $a->slug, + 'cover' => $a->cover_url, + 'rating' => $a->rating, + 'type' => $a->type, + 'reason' => $item['reason'] ?? '', + ]; + }, $result))); + + AiQuery::create(['user_id'=>auth()->id(),'query_type'=>'similar','query_text'=>$anime->title,'created_at'=>now()]); + + return response()->json(['similar' => $similar]); + } +} diff --git a/app/Http/Controllers/Frontend/AnimeController.php b/app/Http/Controllers/Frontend/AnimeController.php new file mode 100644 index 0000000..36bfceb --- /dev/null +++ b/app/Http/Controllers/Frontend/AnimeController.php @@ -0,0 +1,64 @@ +is_published, 404); + + $anime->load([ + 'genres', + 'seasons' => fn($q) => $q->orderBy('season_number'), + 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'), + ]); + + $related = Anime::whereHas('genres', fn($q) => + $q->whereIn('genres.id', $anime->genres->pluck('id')) + ) + ->where('id', '!=', $anime->id) + ->where('is_published', true) + ->take(10) + ->get(); + + // Auth kullanıcı verileri + $userWatchlist = null; + $userRating = null; + $continueEp = null; + $userFollowing = false; + + if (auth()->check()) { + $userWatchlist = Watchlist::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->first(); + $userRating = AnimeRating::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->value('rating'); + $continueEp = ContinueWatching::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->where('percent_complete', '<', 95) + ->first(); + $userFollowing = AnimeFollow::where('user_id', auth()->id()) + ->where('anime_id', $anime->id)->exists(); + } + + // Sosyal: Bu animeyi listeleyen son kullanıcılar + $watchers = Watchlist::where('anime_id', $anime->id) + ->when(auth()->id(), fn($q) => $q->where('user_id', '!=', auth()->id())) + ->with('user:id,name,username,avatar') + ->latest() + ->limit(8) + ->get() + ->map(fn($w) => $w->user) + ->filter(); + $watcherCount = Watchlist::where('anime_id', $anime->id)->count(); + + return view('frontend.anime', compact('anime', 'related', 'userWatchlist', 'userRating', 'continueEp', 'userFollowing', 'watchers', 'watcherCount')); + } +} diff --git a/app/Http/Controllers/Frontend/AuthController.php b/app/Http/Controllers/Frontend/AuthController.php new file mode 100644 index 0000000..be84b2b --- /dev/null +++ b/app/Http/Controllers/Frontend/AuthController.php @@ -0,0 +1,174 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + ], [ + 'email.required' => 'E-posta zorunludur.', + 'email.email' => 'Geçerli bir e-posta girin.', + 'password.required' => 'Şifre zorunludur.', + ]); + + $credentials = $request->only('email', 'password'); + $remember = $request->boolean('remember'); + + if (Auth::attempt($credentials, $remember)) { + $user = Auth::user(); + if ($user->is_banned) { + Auth::logout(); + return back()->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]); + } + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request); + return redirect()->intended(route('home')); + } + + return back()->withErrors(['email' => 'E-posta veya şifre hatalı.'])->withInput($request->only('email')); + } + + public function showRegister() + { + return view('frontend.auth.register'); + } + + public function register(Request $request) + { + $request->validate([ + 'name' => 'required|string|min:2|max:60', + 'email' => 'required|email|unique:users,email', + 'password' => ['required', 'confirmed', Password::min(6)], + ], [ + 'name.required' => 'İsim zorunludur.', + 'name.min' => 'İsim en az 2 karakter olmalıdır.', + 'email.required' => 'E-posta zorunludur.', + 'email.unique' => 'Bu e-posta zaten kayıtlı.', + 'password.required' => 'Şifre zorunludur.', + 'password.confirmed' => 'Şifreler eşleşmiyor.', + 'password.min' => 'Şifre en az 6 karakter olmalıdır.', + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'role' => 'user', + 'membership' => 'free', + ]); + + Auth::login($user); + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request); + + // Doğrulama e-postası gönder (SMTP ayarlıysa) + try { + EmailVerificationController::sendVerificationMail($user); + } catch (\Throwable) {} + + return redirect(route('home')); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect(route('home')); + } + + // ── Social Auth ─────────────────────────────────────────────────────────── + + private const ALLOWED_PROVIDERS = ['google', 'discord']; + + public function socialRedirect(string $provider) + { + if (!in_array($provider, self::ALLOWED_PROVIDERS)) { + abort(404); + } + + return Socialite::driver($provider)->redirect(); + } + + public function socialCallback(string $provider, Request $request) + { + if (!in_array($provider, self::ALLOWED_PROVIDERS)) { + abort(404); + } + + try { + $socialUser = Socialite::driver($provider)->user(); + } catch (\Throwable $e) { + return redirect()->route('frontend.login') + ->withErrors(['email' => 'Sosyal giriş başarısız, lütfen tekrar deneyin.']); + } + + $email = $socialUser->getEmail(); + $name = $socialUser->getName() ?: $socialUser->getNickname() ?: 'Kullanıcı'; + $avatar = $socialUser->getAvatar(); + $socialId = $socialUser->getId(); + + // Aynı provider + social_id ile kayıtlı kullanıcı var mı? + $user = User::where('social_provider', $provider) + ->where('social_id', $socialId) + ->first(); + + if (!$user && $email) { + // Aynı e-posta ile kayıtlı normal hesap var mı? + $user = User::where('email', $email)->first(); + if ($user) { + // Mevcut hesaba sosyal giriş bilgisini bağla + $user->update([ + 'social_provider' => $provider, + 'social_id' => $socialId, + 'avatar' => $user->avatar ?: $avatar, + ]); + } + } + + if (!$user) { + // Yeni kullanıcı oluştur + $user = User::create([ + 'name' => $name, + 'email' => $email, + 'avatar' => $avatar, + 'social_provider' => $provider, + 'social_id' => $socialId, + 'password' => null, + 'role' => 'user', + 'membership' => 'free', + ]); + \App\Support\ActivityLogger::log('register', $user->id, null, null, null, $request); + } + + if ($user->is_banned) { + return redirect()->route('frontend.login') + ->withErrors(['email' => 'Hesabınız yasaklanmıştır: ' . ($user->ban_reason ?: 'İhlal.')]); + } + + Auth::login($user, true); + $request->session()->regenerate(); + \App\Support\ActivityLogger::log('login', $user->id, null, null, null, $request); + + return redirect()->intended(route('home')); + } +} diff --git a/app/Http/Controllers/Frontend/BlogController.php b/app/Http/Controllers/Frontend/BlogController.php new file mode 100644 index 0000000..d444c06 --- /dev/null +++ b/app/Http/Controllers/Frontend/BlogController.php @@ -0,0 +1,53 @@ +published() + ->orderByDesc('published_at') + ->paginate(12); + + $recent = BlogPost::published()->orderByDesc('published_at')->limit(5)->get(); + $popular = BlogPost::published()->orderByDesc('views')->limit(5)->get(); + + return view('frontend.blog.index', compact('posts', 'recent', 'popular')); + } + + public function show(string $slug) + { + $post = BlogPost::with('anime.genres') + ->where('slug', $slug) + ->where('status', 'published') + ->firstOrFail(); + + $post->increment('views'); + + // İlgili yazılar: aynı anime veya benzer anahtar kelimeler + $related = BlogPost::published() + ->where('id', '!=', $post->id) + ->when($post->anime_id, fn($q) => $q->where('anime_id', $post->anime_id) + ->orWhere('focus_keyword', 'like', '%' . explode(' ', $post->focus_keyword ?? '')[0] . '%') + ) + ->orderByDesc('published_at') + ->limit(4) + ->get(); + + // Linked anime'ler + $linkedAnimes = collect(); + if (!empty($post->linked_anime_ids)) { + $linkedAnimes = Anime::whereIn('id', $post->linked_anime_ids) + ->where('is_published', true) + ->get(); + } + + return view('frontend.blog.show', compact('post', 'related', 'linkedAnimes')); + } +} diff --git a/app/Http/Controllers/Frontend/CheckoutController.php b/app/Http/Controllers/Frontend/CheckoutController.php new file mode 100644 index 0000000..e648057 --- /dev/null +++ b/app/Http/Controllers/Frontend/CheckoutController.php @@ -0,0 +1,183 @@ +setApiKey(config('iyzico.api_key')); + $opt->setSecretKey(config('iyzico.secret_key')); + $opt->setBaseUrl(config('iyzico.base_url')); + return $opt; + } + + public function show(MembershipPlan $plan) + { + abort_if(!$plan->is_active || !$plan->is_public, 404); + return view('frontend.checkout.show', compact('plan')); + } + + public function initialize(Request $request, MembershipPlan $plan) + { + abort_if(!$plan->is_active || !$plan->is_public, 404); + + $v = $request->validate([ + 'full_name' => 'required|string|max:100', + 'phone' => 'required|string|max:20', + 'city' => 'required|string|max:80', + 'address' => 'required|string|max:300', + 'identity_no' => 'nullable|digits:11', + ]); + + $user = Auth::user(); + $conversationId = Str::uuid()->toString(); + $price = number_format($plan->price, 2, '.', ''); + + $parts = explode(' ', trim($v['full_name']), 2); + $firstName = $parts[0]; + $lastName = $parts[1] ?? '-'; + + $payment = Payment::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'conversation_id' => $conversationId, + 'amount' => $plan->price, + 'status' => 'pending', + ]); + + $req = new \Iyzipay\Request\CreateCheckoutFormInitializeRequest(); + $req->setLocale(\Iyzipay\Model\Locale::TR); + $req->setConversationId($conversationId); + $req->setPrice($price); + $req->setPaidPrice($price); + $req->setCurrency(\Iyzipay\Model\Currency::TL); + $req->setBasketId('payment-' . $payment->id); + $req->setPaymentGroup(\Iyzipay\Model\PaymentGroup::PRODUCT); + $req->setCallbackUrl(route('checkout.callback')); + $req->setEnabledInstallments([1, 2, 3, 6, 9, 12]); + + $buyer = new \Iyzipay\Model\Buyer(); + $buyer->setId('u' . $user->id); + $buyer->setName($firstName); + $buyer->setSurname($lastName); + $buyer->setGsmNumber('+9' . preg_replace('/\D/', '', $v['phone'])); + $buyer->setEmail($user->email); + $buyer->setIdentityNumber($v['identity_no'] ?: '11111111111'); + $buyer->setRegistrationAddress($v['address']); + $buyer->setIp($request->ip()); + $buyer->setCity($v['city']); + $buyer->setCountry('Turkey'); + $req->setBuyer($buyer); + + $addr = new \Iyzipay\Model\Address(); + $addr->setContactName($v['full_name']); + $addr->setCity($v['city']); + $addr->setCountry('Turkey'); + $addr->setAddress($v['address']); + $req->setBillingAddress($addr); + $req->setShippingAddress($addr); + + $item = new \Iyzipay\Model\BasketItem(); + $item->setId('plan' . $plan->id); + $item->setName($plan->name . ' Premium (' . $plan->duration_days . ' gün)'); + $item->setCategory1('Dijital Ürün'); + $item->setItemType(\Iyzipay\Model\BasketItemType::VIRTUAL); + $item->setPrice($price); + $req->setBasketItems([$item]); + + $form = \Iyzipay\Model\CheckoutFormInitialize::create($req, $this->options()); + + if ($form->getStatus() !== 'success') { + $payment->update(['status' => 'failed', 'error_message' => $form->getErrorMessage()]); + return back()->withErrors(['general' => 'Ödeme başlatılamadı: ' . $form->getErrorMessage()]); + } + + $payment->update(['token' => $form->getToken()]); + + return view('frontend.checkout.form', [ + 'plan' => $plan, + 'formContent' => $form->getCheckoutFormContent(), + ]); + } + + public function callback(Request $request) + { + $token = $request->input('token'); + + if (!$token) { + return redirect()->route('checkout.failed'); + } + + $payment = Payment::where('token', $token)->where('status', 'pending')->first(); + + if (!$payment) { + return redirect()->route('checkout.failed'); + } + + $req = new \Iyzipay\Request\RetrieveCheckoutFormRequest(); + $req->setLocale(\Iyzipay\Model\Locale::TR); + $req->setConversationId($payment->conversation_id); + $req->setToken($token); + + $result = \Iyzipay\Model\CheckoutForm::retrieve($req, $this->options()); + + if ($result->getStatus() === 'success' && $result->getPaymentStatus() === 'SUCCESS') { + $payment->update([ + 'status' => 'success', + 'iyzico_payment_id' => $result->getPaymentId(), + 'paid_at' => now(), + ]); + + $plan = $payment->plan; + $user = $payment->user; + $hasEver = Subscription::where('user_id', $user->id)->exists(); + $bonus = ($hasEver === false && ($plan->trial_days ?? 0) > 0) ? $plan->trial_days : 0; + $expiresAt = now()->addDays($plan->duration_days + $bonus); + + Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'expires_at' => $expiresAt, + 'payment_method' => 'iyzico', + 'payment_ref' => $result->getPaymentId(), + ]); + + $user->update([ + 'membership' => 'premium', + 'premium_expires_at' => $expiresAt, + ]); + + session(['checkout_plan_name' => $plan->name]); + return redirect()->route('checkout.success'); + } + + $payment->update([ + 'status' => 'failed', + 'error_message' => $result->getErrorMessage(), + ]); + + return redirect()->route('checkout.failed'); + } + + public function success() + { + return view('frontend.checkout.success'); + } + + public function failed() + { + return view('frontend.checkout.failed'); + } +} diff --git a/app/Http/Controllers/Frontend/CommentController.php b/app/Http/Controllers/Frontend/CommentController.php new file mode 100644 index 0000000..e2e0d65 --- /dev/null +++ b/app/Http/Controllers/Frontend/CommentController.php @@ -0,0 +1,287 @@ +hasPerk('extended_comments')) ? 1000 : 500; + + $request->validate([ + 'commentable_type' => 'required|in:episode,anime', + 'commentable_id' => 'required|integer', + 'content' => 'nullable|string|max:' . $maxLength, + 'gif_url' => 'nullable|url|max:500', + 'parent_id' => 'nullable|integer|exists:comments,id', + ]); + + if (empty(trim($request->content ?? '')) && empty($request->gif_url)) { + return response()->json(['error' => 'Yorum boş olamaz.'], 422); + } + + if (!empty($request->gif_url) && (!$user || !$user->hasPerk('comment_gif'))) { + return response()->json(['error' => 'GIF eklemek için premium üyelik gerekiyor.'], 403); + } + + $commentsEnabled = Setting::get('comments_enabled', '1') === '1'; + + if (!$commentsEnabled) { + return response()->json(['error' => 'Yorumlar şu an kapalı.'], 403); + } + + $content = trim($request->content ?? ''); + + // AI moderasyon (sadece metin içeren yorumlar için, GIF yorumları direkt onaylanır) + $aiService = new DeepSeekService(); + $pendingReason = null; + $status = 'approved'; + + if (!empty($content) && $aiService->isConfigured()) { + $mod = $aiService->moderateComment($content); + + if ($mod['is_rude']) { + $status = 'pending'; + $pendingReason = 'rude'; + } elseif ($mod['is_spoiler']) { + $status = 'pending'; + $pendingReason = 'spoiler'; + } + } elseif (Setting::get('comments_require_approval', '0') === '1') { + $status = 'pending'; + $pendingReason = 'manual'; + } + + $comment = Comment::create([ + 'user_id' => Auth::id(), + 'commentable_type' => $request->commentable_type, + 'commentable_id' => $request->commentable_id, + 'parent_id' => $request->parent_id ?: null, + 'content' => $content, + 'gif_url' => $request->gif_url ?: null, + 'status' => $status, + 'like_count' => 0, + ]); + + $comment->load('user'); + + if ($status !== 'approved') { + return response()->json([ + 'ok' => true, + 'pending' => true, + 'pending_reason' => $pendingReason, + ]); + } + + return response()->json([ + 'ok' => true, + 'pending' => false, + 'comment' => $this->formatComment($comment, Auth::id()), + ]); + } + + /** + * POST /comments/{comment}/like — beğen/beğenmekten vazgeç (toggle) + */ + public function like(Comment $comment) + { + $userId = Auth::id(); + + $existing = CommentLike::where('user_id', $userId) + ->where('comment_id', $comment->id) + ->first(); + + if ($existing) { + $existing->delete(); + $comment->decrement('like_count'); + $liked = false; + } else { + CommentLike::create(['user_id' => $userId, 'comment_id' => $comment->id]); + $comment->increment('like_count'); + $liked = true; + } + + return response()->json([ + 'ok' => true, + 'liked' => $liked, + 'like_count' => $comment->fresh()->like_count, + ]); + } + + /** + * GET /comments/gif-search — GIF arama (Giphy öncelikli, Tenor fallback) + */ + public function gifSearch(Request $request) + { + $query = $request->query('q', 'anime reaction'); + $giphyKey = Setting::get('giphy_api_key', ''); + $tenorKey = Setting::get('tenor_api_key', ''); + + // Giphy + if (!empty($giphyKey)) { + return $this->searchGiphy($query, $giphyKey); + } + + // Tenor + if (!empty($tenorKey)) { + return $this->searchTenor($query, $tenorKey); + } + + return response()->json(['results' => [], 'error' => 'no_key']); + } + + private function searchGiphy(string $query, string $apiKey) + { + try { + $res = Http::timeout(8)->get('https://api.giphy.com/v1/gifs/search', [ + 'api_key' => $apiKey, + 'q' => $query, + 'limit' => 24, + 'rating' => 'pg-13', + 'lang' => 'en', + ]); + + if (!$res->successful()) { + \Log::warning('Giphy API failed', ['status' => $res->status()]); + return response()->json(['results' => [], 'error' => 'giphy_fail']); + } + + $gifs = collect($res->json('data', []))->map(function ($r) { + $images = $r['images'] ?? []; + $preview = $images['fixed_height_small']['url'] + ?? $images['fixed_height']['url'] + ?? $images['downsized']['url'] + ?? null; + $full = $images['downsized_medium']['url'] + ?? $images['fixed_height']['url'] + ?? $images['original']['url'] + ?? $preview; + if (!$preview || !$full) return null; + return [ + 'id' => $r['id'], + 'preview' => $preview, + 'url' => $full, + 'title' => $r['title'] ?? '', + ]; + })->filter()->values(); + + return response()->json(['results' => $gifs]); + } catch (\Exception $e) { + \Log::error('Giphy error: ' . $e->getMessage()); + return response()->json(['results' => [], 'error' => $e->getMessage()]); + } + } + + private function searchTenor(string $query, string $apiKey) + { + try { + $res = Http::timeout(8)->get('https://tenor.googleapis.com/v2/search', [ + 'q' => $query, + 'key' => $apiKey, + 'limit' => 24, + 'media_filter' => 'tinygif,gif', + 'contentfilter' => 'medium', + ]); + + if (!$res->successful()) { + return response()->json(['results' => [], 'error' => 'tenor_fail']); + } + + $gifs = collect($res->json('results', []))->map(function ($r) { + $formats = $r['media_formats'] ?? []; + $preview = $formats['tinygif']['url'] ?? $formats['mediumgif']['url'] ?? $formats['gif']['url'] ?? null; + $full = $formats['gif']['url'] ?? $formats['mediumgif']['url'] ?? $preview ?? null; + if (!$preview || !$full) return null; + return [ + 'id' => $r['id'], + 'preview' => $preview, + 'url' => $full, + 'title' => $r['content_description'] ?? '', + ]; + })->filter()->values(); + + return response()->json(['results' => $gifs]); + } catch (\Exception $e) { + return response()->json(['results' => [], 'error' => $e->getMessage()]); + } + } + + /** + * GET /comments — bölüm yorumlarını getir (AJAX sayfalama) + */ + public function index(Request $request) + { + $userId = Auth::id(); + + $query = Comment::where('commentable_type', $request->type) + ->where('commentable_id', $request->id) + ->whereNull('parent_id') + ->where('status', 'approved') + ->with(['user', 'replies' => fn($q) => $q->where('status', 'approved')->with('user')->orderBy('created_at')]) + ->orderByDesc('is_pinned') + ->orderByDesc('like_count') + ->orderByDesc('created_at'); + + $comments = $query->paginate(20); + + return response()->json([ + 'data' => $comments->map(fn($c) => $this->formatComment($c, $userId, true)), + 'has_more' => $comments->hasMorePages(), + 'next_page'=> $comments->currentPage() + 1, + ]); + } + + private function formatComment(Comment $c, ?int $userId, bool $withReplies = false): array + { + $data = [ + 'id' => $c->id, + 'content' => $c->content, + 'gif_url' => $c->gif_url, + 'like_count' => $c->like_count, + 'is_liked' => $userId ? $c->likes()->where('user_id', $userId)->exists() : false, + 'is_pinned' => $c->is_pinned, + 'parent_id' => $c->parent_id, + 'created_at' => $c->created_at?->diffForHumans(), + 'user' => $c->user ? [ + 'id' => $c->user->id, + 'name' => $c->user->name, + 'username' => $c->user->username, + 'avatar' => $c->user->gif_avatar && $c->user->hasPerk('gif_avatar') + ? $c->user->gif_avatar + : ($c->user->avatar ? \App\Support\MediaUrl::fromStoragePath($c->user->avatar) : null), + 'role' => $c->user->role, + 'is_following' => $userId && $userId !== $c->user->id + ? \App\Models\UserFollow::where('follower_id', $userId)->where('following_id', $c->user->id)->exists() + : false, + 'comment_bg' => $c->user->comment_bg, + 'comment_glow' => $c->user->comment_glow, + 'comment_signature' => $c->user->comment_signature, + 'username_color' => $c->user->username_color, + 'username_effect' => $c->user->username_effect, + 'profile_frame' => $c->user->profile_frame, + 'profile_badge' => $c->user->profile_badge, + 'admin_badge' => $c->user->admin_badge, + 'watch_rank' => $c->user->watchRank(), + ] : null, + ]; + + if ($withReplies && $c->relationLoaded('replies')) { + $data['replies'] = $c->replies->map(fn($r) => $this->formatComment($r, $userId))->toArray(); + } + + return $data; + } +} diff --git a/app/Http/Controllers/Frontend/DiscoverController.php b/app/Http/Controllers/Frontend/DiscoverController.php new file mode 100644 index 0000000..4c93463 --- /dev/null +++ b/app/Http/Controllers/Frontend/DiscoverController.php @@ -0,0 +1,253 @@ +orderBy('name')->get(['id', 'name', 'slug']); + return view('frontend.discover', compact('genres')); + } + + public function cards(Request $request) + { + $genreSlug = $request->input('genre', ''); + $type = $request->input('type', ''); + $limit = min((int) $request->input('limit', 10), 10); + + // Auth state'i al + $isAuth = auth()->check(); + $uid = $isAuth ? auth()->id() : null; + + try { + $idQuery = Anime::where('is_published', true)->select('id'); + + if ($genreSlug) { + $idQuery->whereHas('genres', fn($q) => $q->where('slug', $genreSlug)); + } + if ($type) { + $idQuery->where('type', $type); + } + + if ($isAuth && $uid) { + $exclude = AnimeSwipe::where('user_id', $uid)->pluck('anime_id') + ->merge(Watchlist::where('user_id', $uid)->pluck('anime_id')) + ->unique(); + if ($exclude->isNotEmpty()) { + $idQuery->whereNotIn('id', $exclude); + } + } + + $ids = $idQuery->pluck('id'); + if ($ids->isEmpty()) { + return response()->json(['cards' => [], 'has_more' => false]); + } + + $randomIds = $ids->shuffle()->take($limit); + + $animes = Anime::whereIn('id', $randomIds) + ->with('genres:id,name') + ->get() + ->shuffle(); + + $cards = $animes->map(function (Anime $anime) { + $hook = $anime->discovery_hook + ?: ($anime->description ? Str::limit(strip_tags($anime->description), 130) : null); + + return [ + 'id' => $anime->id, + 'slug' => $anime->slug, + 'title' => $anime->title, + 'cover_url' => $anime->coverUrl, + 'banner_url' => $anime->bannerUrl, + 'rating' => $anime->rating ? number_format($anime->rating, 1) : null, + 'year' => $anime->release_year, + 'type' => $anime->type, + 'status' => $anime->status, + 'episode_count' => $anime->episode_count, + 'genres' => $anime->genres->take(3)->pluck('name')->values(), + 'hook' => $hook, + 'description' => $anime->description ? Str::limit(strip_tags($anime->description), 420) : null, + ]; + }); + + return response()->json([ + 'cards' => $cards, + 'has_more' => $animes->count() === $limit, + ]); + + } catch (\Exception $e) { + \Log::error('Discover cards error: ' . $e->getMessage()); + return response()->json(['cards' => [], 'has_more' => false, 'error' => true]); + } + } + + public function swipe(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|integer|exists:animes,id', + 'direction' => 'required|in:like,skip', + ]); + + if (auth()->check()) { + $uid = auth()->id(); + AnimeSwipe::updateOrCreate( + ['user_id' => $uid, 'anime_id' => $data['anime_id']], + ['direction' => $data['direction']] + ); + + if ($data['direction'] === 'like') { + Watchlist::updateOrCreate( + ['user_id' => $uid, 'anime_id' => $data['anime_id']], + ['status' => 'plan'] + ); + } + } else { + $seen = session('guest_swipes', []); + $seen[] = $data['anime_id']; + session(['guest_swipes' => array_unique(array_slice($seen, -150))]); + } + + return response()->json(['ok' => true]); + } + + public function reset() + { + if (auth()->check()) { + AnimeSwipe::where('user_id', auth()->id())->delete(); + } else { + session()->forget('guest_swipes'); + } + return response()->json(['ok' => true]); + } + + public function results(Request $request) + { + // Beğenilen animeler + if (auth()->check()) { + $uid = auth()->id(); + $swipes = AnimeSwipe::where('user_id', $uid) + ->with('anime:id,title,slug,cover_image,rating,release_year,type') + ->orderByDesc('created_at') + ->get()->filter(fn($s) => $s->anime); + + $likedAnimes = $swipes->where('direction', 'like') + ->map(fn($s) => $s->anime) + ->values(); + + $allSwipedIds = $swipes->pluck('anime_id'); + $likeCount = $swipes->where('direction', 'like')->count(); + $skipCount = $swipes->where('direction', 'skip')->count(); + } else { + $seen = session('guest_swipes', []); + $likedAnimes = collect(); + $allSwipedIds= collect($seen); + $likeCount = 0; + $skipCount = count($seen); + } + + // AI önerileri — beğenilen animelerin türlerine benzer, henüz görülmemiş + $recommendations = collect(); + if ($likedAnimes->isNotEmpty()) { + $ai = app(DeepSeekService::class); + + // Beğenilen animelerin genre'larını topla + $likedWithGenres = Anime::whereIn('id', $likedAnimes->pluck('id')) + ->with('genres:id,name') + ->get(); + $genreIds = $likedWithGenres->flatMap(fn($a) => $a->genres->pluck('id'))->unique(); + + // Benzer ama henüz görülmemiş animeler al — ID shuffle ile ORDER BY RAND() önlenir + $candidateIds = Anime::where('is_published', true) + ->whereNotIn('id', $allSwipedIds) + ->whereHas('genres', fn($q) => $q->whereIn('id', $genreIds)) + ->pluck('id') + ->shuffle() + ->take(30); + + $candidateAnimes = Anime::whereIn('id', $candidateIds) + ->with('genres:id,name') + ->withCount('episodes') + ->get() + ->shuffle(); + + if ($ai->isConfigured() && $candidateAnimes->isNotEmpty()) { + $likedTitles = $likedAnimes->pluck('title')->take(5)->join(', '); + $preferences = "Kullanıcının beğendiği animeler: {$likedTitles}. Bunlara benzer, aynı türde ya da aynı atmosferde animeler öner."; + + $candidateData = $candidateAnimes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'type' => $a->type, + 'release_year' => $a->release_year, + 'rating' => $a->rating, + 'genres' => $a->genres->map(fn($g) => ['name' => $g->name])->toArray(), + ])->values()->toArray(); + + $aiRecs = Cache::remember( + 'dsc_recs_' . md5($likedAnimes->pluck('id')->sort()->join(',')), + 60 * 60 * 6, + fn() => $ai->recommend($preferences, $candidateData) + ); + + if ($aiRecs) { + $recIds = collect($aiRecs)->pluck('id')->map('intval'); + $recAnimes = $candidateAnimes->whereIn('id', $recIds)->keyBy('id'); + + $recommendations = collect($aiRecs)->take(6)->map(function ($rec) use ($recAnimes) { + $anime = $recAnimes->get((int)$rec['id']); + if (!$anime) return null; + return [ + 'id' => $anime->id, + 'slug' => $anime->slug, + 'title' => $anime->title, + 'cover_url' => $anime->coverUrl, + 'rating' => $anime->rating ? number_format($anime->rating, 1) : null, + 'year' => $anime->release_year, + 'genres' => $anime->genres->take(2)->pluck('name')->values(), + 'reason' => $rec['reason'] ?? null, + ]; + })->filter()->values(); + } + } + + // AI yoksa genre-based fallback + if ($recommendations->isEmpty()) { + $recommendations = $candidateAnimes->take(6)->map(fn($a) => [ + 'id' => $a->id, + 'slug' => $a->slug, + 'title' => $a->title, + 'cover_url' => $a->coverUrl, + 'rating' => $a->rating ? number_format($a->rating, 1) : null, + 'year' => $a->release_year, + 'genres' => $a->genres->take(2)->pluck('name')->values(), + 'reason' => null, + ])->values(); + } + } + + return response()->json([ + 'liked' => $likedAnimes->map(fn($a) => [ + 'id' => $a->id, + 'slug' => $a->slug, + 'title' => $a->title, + 'cover_url' => $a->coverUrl, + ])->values(), + 'recommendations' => $recommendations, + 'like_count' => $likeCount, + 'skip_count' => $skipCount, + 'is_auth' => auth()->check(), + ]); + } +} diff --git a/app/Http/Controllers/Frontend/EmailVerificationController.php b/app/Http/Controllers/Frontend/EmailVerificationController.php new file mode 100644 index 0000000..309ec7e --- /dev/null +++ b/app/Http/Controllers/Frontend/EmailVerificationController.php @@ -0,0 +1,65 @@ +user()->email_verified_at) { + return redirect()->route('home'); + } + return view('frontend.auth.verify-email'); + } + + public function verify(Request $request, int $id, string $hash) + { + $user = \App\Models\User::findOrFail($id); + + if (!hash_equals(sha1($user->email), $hash)) { + abort(403); + } + + if (!$user->email_verified_at) { + $user->email_verified_at = now(); + $user->save(); + } + + return redirect()->route('home')->with('status', 'E-posta adresin doğrulandı!'); + } + + public function resend(Request $request) + { + $user = $request->user(); + + if ($user->email_verified_at) { + return back()->with('status', 'E-posta zaten doğrulanmış.'); + } + + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addHours(24), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name)); + + return back()->with('status', 'Doğrulama e-postası tekrar gönderildi.'); + } + + public static function sendVerificationMail(\App\Models\User $user): void + { + $url = URL::temporarySignedRoute( + 'verification.verify', + now()->addHours(24), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + Mail::to($user->email)->send(new VerifyEmailMail($url, $user->name)); + } +} diff --git a/app/Http/Controllers/Frontend/HomeController.php b/app/Http/Controllers/Frontend/HomeController.php new file mode 100644 index 0000000..543b6db --- /dev/null +++ b/app/Http/Controllers/Frontend/HomeController.php @@ -0,0 +1,447 @@ +timestamp / 900); // 15 dk = 900 sn + + // ── Latest + Top Rated (cache'li) ──────────────────────────────────── + $latest = cache()->remember("home.latest.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->latest()->take(20)->get() + ); + + $topRated = cache()->remember("home.toprated.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('rating', '>=', 7) + ->orderByDesc('rating')->take(14)->get() + ); + + $genres = cache()->remember('home.genres', 3600, fn() => + Genre::where('is_active', true) + ->withCount(['animes' => fn($q) => $q->where('is_published', true)]) + ->orderByDesc('animes_count') + ->take(16)->get() + ); + + $newEpisodes = cache()->remember("home.newepisodes.{$rotationSlot}", 900, fn() => + Episode::with(['anime', 'season']) + ->where('is_published', true) + ->latest()->take(14)->get() + ); + + // ── Trending: YouTube-benzeri skor ─────────────────────────────────── + $trending = cache()->remember("home.trending.{$rotationSlot}", 900, function () use ($latest) { + try { + // trending_score kolonu varsa kullan (migration çalıştırıldıysa) + $byScore = Anime::where('is_published', true) + ->where(fn($q) => $q->where('trending_score', '>', 0)->orWhere('is_trending', true)) + ->orderByDesc('trending_score') + ->take(12) + ->get(); + + if ($byScore->count() >= 6) return $byScore; + } catch (\Throwable) {} + + // Fallback: manuel + view_count bazlı + $manual = Anime::where('is_trending', true)->where('is_published', true) + ->orderBy('trending_order')->take(12)->get(); + if ($manual->count() >= 6) return $manual->take(12); + + $autoFill = Anime::where('is_published', true) + ->whereNotIn('id', $manual->pluck('id')) + ->withSum(['episodes as recent_views' => fn($q) => + $q->where('is_published', true)->where('updated_at', '>=', now()->subDays(30)) + ], 'view_count') + ->orderByDesc('recent_views') + ->take(12 - $manual->count())->get(); + + $merged = $manual->concat($autoFill); + return $merged->isEmpty() ? $latest->take(12) : $merged; + }); + + // Rotasyon: top 8 sabit, son 4 her 15dk'da shuffle + $top8 = $trending->take(8)->values(); + $bottom4 = $trending->slice(8)->shuffle()->values(); + $trending = $top8->concat($bottom4)->take(12)->values(); + + // ── Devam Ediyor (Bu Sezon) ─────────────────────────────────────────── + $ongoing = cache()->remember("home.ongoing.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('status', 'ongoing') + ->orderByDesc('rating')->take(12)->get() + ); + + // ── Popüler Filmler ─────────────────────────────────────────────────── + $popularMovies = cache()->remember("home.movies.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('type', 'movie') + ->where('rating', '>=', 6)->orderByDesc('rating')->take(12)->get() + ); + + // ── Türkçe Dublaj ───────────────────────────────────────────────────── + $dubbed = cache()->remember("home.dubbed.{$rotationSlot}", 900, fn() => + Anime::where('is_published', true)->where('is_dubbed', true) + ->orderByDesc('rating')->take(20)->get() + ); + + // ── Tür Spotlight (2 farklı tür, her birinde top 8 anime) ──────────── + $genreSpotlights = cache()->remember("home.genre_spots.{$rotationSlot}", 900, function () { + $spotGenres = Genre::where('is_active', true) + ->whereIn('name', ['Aksiyon', 'Fantezi', 'Romantik', 'Psikolojik', 'Komedi', 'Spor', 'Macera', 'Drama']) + ->inRandomOrder()->take(3)->get(); + + return $spotGenres->map(fn($g) => [ + 'genre' => $g, + 'animes' => $g->animes() + ->where('is_published', true) + ->where('rating', '>=', 6) + ->orderByDesc('rating') + ->take(8)->get(), + ])->filter(fn($s) => $s['animes']->count() >= 3)->values(); + }); + + // ── Featured Hero Slider: YouTube-benzeri trending algoritması ───────── + $featured = cache()->remember("home.featured.{$rotationSlot}", 900, function () use ($trending, $topRated, $latest) { + // trending_score kolonu var mı? (migration çalıştırılmamışsa fallback) + $hasTrendingScore = \Illuminate\Support\Facades\Schema::hasColumn('animes', 'trending_score'); + + $orderBy = fn($q) => $hasTrendingScore + ? $q->orderByDesc('trending_score') + : $q->orderByDesc('rating'); + + $used = collect(); + + // TIER 1: Son 24 saatte yeni bölüm + trend skoru yüksek + banner + try { + $tier1Ids = Episode::where('is_published', true) + ->where('created_at', '>=', now()->subDay()) + ->pluck('anime_id')->unique()->toArray(); + + $tier1 = $orderBy(Anime::where('is_published', true) + ->whereIn('id', $tier1Ids) + ->whereNotNull('banner_image')) + ->take(6)->get(); + $used = $used->concat($tier1->pluck('id')); + } catch (\Throwable) { + $tier1 = collect(); + } + + // TIER 2: Son 3 günde yeni bölüm + banner + $tier2 = collect(); + if ($tier1->count() < 6) { + try { + $tier2Ids = Episode::where('is_published', true) + ->where('created_at', '>=', now()->subDays(3)) + ->pluck('anime_id')->unique()->diff($used)->toArray(); + + $tier2 = $orderBy(Anime::where('is_published', true) + ->whereIn('id', $tier2Ids) + ->whereNotNull('banner_image')) + ->take(6 - $tier1->count())->get(); + $used = $used->concat($tier2->pluck('id')); + } catch (\Throwable) {} + } + + $combined = $tier1->concat($tier2); + + // TIER 3: Yüksek skor + banner + if ($combined->count() < 6) { + try { + $tier3 = $orderBy(Anime::where('is_published', true) + ->whereNotIn('id', $used->toArray()) + ->whereNotNull('banner_image')) + ->take(6 - $combined->count())->get(); + $used = $used->concat($tier3->pluck('id')); + $combined = $combined->concat($tier3); + } catch (\Throwable) {} + } + + // TIER 4: Trending + topRated (banner olmadan) + if ($combined->count() < 5) { + $fill = $trending->whereNotIn('id', $used->toArray())->take(5 - $combined->count()); + $combined = $combined->concat($fill); + } + if ($combined->count() < 5) { + $fill2 = $topRated->whereNotIn('id', $combined->pluck('id'))->take(5 - $combined->count()); + $combined = $combined->concat($fill2); + } + + return $combined->isEmpty() ? $latest->take(5)->values() : $combined->values(); + }); + + $featured->load('genres', 'seasons', 'episodes'); + + // ── Hero slider JSON ────────────────────────────────────────────────── + $statusLabel = ['ongoing' => 'Devam Ediyor', 'completed' => 'Tamamlandı', 'upcoming' => 'Yakında']; + $featuredSlider = $featured->values()->map(function ($a) use ($statusLabel) { + $firstSeason = $a->seasons->sortBy('season_number')->first(); + $firstEp = $firstSeason + ? $a->episodes->where('season_id', $firstSeason->id)->where('is_published', true)->sortBy('episode_number')->first() + : null; + return [ + 'title' => $a->title, + 'description' => $a->description, + 'rating' => $a->rating, + 'year' => $a->release_year, + 'episodes' => $a->episode_count, + 'status' => $a->status, + 'statusLabel' => $statusLabel[$a->status] ?? $a->status, + 'slug' => $a->slug, + 'genres' => $a->genres->pluck('name')->values(), + 'coverUrl' => $a->coverUrl, + 'bannerUrl' => $a->bannerUrl, + 'studio' => $a->studio, + 'watchUrl' => ($firstSeason && $firstEp) + ? route('watch', [$a->slug, $firstSeason->season_number, $firstEp->episode_number]) + : null, + 'detailUrl' => route('anime.show', $a->slug), + ]; + })->toArray(); + + // ── Devam Et (auth) ─────────────────────────────────────────────────── + $continueWatching = collect(); + $recommended = collect(); + $userWatchTitles = ''; + + if (auth()->check()) { + try { + $continueWatching = ContinueWatching::where('user_id', auth()->id()) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '>=', 5) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(12) + ->get(); + + $watchedIds = ContinueWatching::where('user_id', auth()->id())->pluck('anime_id'); + + if ($watchedIds->isNotEmpty()) { + $topGenreIds = DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id') + ->orderByDesc('cnt') + ->limit(5) + ->pluck('genre_id'); + + if ($topGenreIds->isNotEmpty()) { + $recommended = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds) + ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $topGenreIds)) + ->inRandomOrder() + ->take(14) + ->get(); + + // Yeterli değilse rating'e göre topRated'dan dolduralım + if ($recommended->count() < 6) { + $fallback = Anime::where('is_published', true) + ->whereNotIn('id', $watchedIds->merge($recommended->pluck('id'))) + ->where('rating', '>=', 6) + ->inRandomOrder() + ->take(14 - $recommended->count()) + ->get(); + $recommended = $recommended->concat($fallback)->shuffle()->values(); + } + } + + $userWatchTitles = Anime::whereIn('id', $watchedIds->take(8)) + ->pluck('title')->join(', '); + } + } catch (\Throwable $e) {} + } + + // ── Stats ───────────────────────────────────────────────────────────── + $statsAnime = cache()->remember('home.stats.anime', 3600, fn() => Anime::where('is_published', true)->count()); + $statsEpisode = cache()->remember('home.stats.episode', 3600, fn() => Episode::where('is_published', true)->count()); + $statsUser = cache()->remember('home.stats.user', 3600, fn() => User::count()); + $statsGenre = cache()->remember('home.stats.genre', 3600, fn() => Genre::where('is_active', true)->count()); + + $apkUrl = \App\Models\Setting::get('mobile_apk_url', ''); + + // ── Banner reklamlar (premium görmez) ──────────────────────────────── + $bannerAds = ['home_mid' => null, 'home_bottom' => null]; + if (\App\Models\Setting::get('banner_ads_enabled', '0') === '1' + && !(auth()->check() && auth()->user()->isPremium())) { + try { + $bannerAds['home_mid'] = \App\Models\Ad::pickBanner('home_mid'); + $bannerAds['home_bottom'] = \App\Models\Ad::pickBanner('home_bottom'); + } catch (\Throwable $e) {} + } + + return view('frontend.home', compact( + 'featured', 'featuredSlider', 'latest', 'topRated', 'genres', + 'newEpisodes', 'trending', 'continueWatching', 'recommended', 'userWatchTitles', + 'statsAnime', 'statsEpisode', 'statsUser', 'statsGenre', + 'ongoing', 'popularMovies', 'genreSpotlights', 'dubbed', 'apkUrl', 'bannerAds' + )); + } + + public function search() + { + $q = request('q', ''); + $genre = request('genre'); + $type = request('type'); + $status = request('status'); + $year = request('year'); + $sort = request('sort', 'popular'); + + $query = Anime::where('is_published', true) + ->whereNotNull('slug') + ->where('slug', '!=', ''); + + if ($q) { + $query->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }); + } + if ($genre) { + $query->whereHas('genres', fn($qb) => $qb->where('slug', $genre)); + } + if ($type) { + $query->where('type', $type); + } + if ($status) { + $query->where('status', $status); + } + if ($year) { + $query->where('release_year', $year); + } + + // JSON autocomplete modu + if (request()->boolean('json') || request()->expectsJson()) { + $animes = $query->select('id', 'title', 'title_en', 'cover_image', 'type') + ->latest()->limit(8)->get() + ->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->coverUrl, + 'type' => $a->type, + ]); + return response()->json(['animes' => $animes]); + } + + // ── Sıralama ────────────────────────────────────────────────────────── + switch ($sort) { + case 'popular': + $query->withSum(['episodes as total_views' => fn($q) => + $q->where('is_published', true) + ], 'view_count')->orderByDesc('total_views'); + break; + + case 'rating': + $query->orderByDesc('rating')->orderByDesc('created_at'); + break; + + case 'newest': + $query->orderByDesc('release_year')->orderByDesc('created_at'); + break; + + case 'oldest': + $query->orderBy('release_year')->orderBy('created_at'); + break; + + case 'az': + $query->orderBy('title'); + break; + + case 'za': + $query->orderByDesc('title'); + break; + + case 'personalized': + if (auth()->check()) { + $watchedIds = \App\Models\ContinueWatching::where('user_id', auth()->id()) + ->pluck('anime_id'); + + $topGenreIds = $watchedIds->isNotEmpty() + ? DB::table('anime_genre') + ->whereIn('anime_id', $watchedIds) + ->select('genre_id', DB::raw('count(*) as cnt')) + ->groupBy('genre_id') + ->orderByDesc('cnt') + ->limit(6) + ->pluck('genre_id') + : collect(); + + if ($topGenreIds->isNotEmpty()) { + $matchingIds = DB::table('anime_genre') + ->whereIn('genre_id', $topGenreIds) + ->pluck('anime_id') + ->unique() + ->values(); + + $idList = $matchingIds->isEmpty() ? '0' : $matchingIds->join(','); + $query->orderByRaw("CASE WHEN animes.id IN ($idList) THEN 0 ELSE 1 END") + ->orderByDesc('rating'); + } else { + $query->orderByDesc('rating'); + } + } else { + $query->orderByDesc('rating'); + } + break; + + default: + $query->orderByDesc('created_at'); + } + + $results = $query->paginate(24)->withQueryString(); + $genres = Genre::where('is_active', true)->get(); + $years = Anime::where('is_published', true)->whereNotNull('release_year') + ->distinct()->orderByDesc('release_year')->pluck('release_year'); + + return view('frontend.search', compact( + 'results', 'genres', 'years', 'q', 'genre', 'type', 'status', 'year', 'sort' + )); + } + + public function searchSuggest() + { + $q = trim(request('q', '')); + if (strlen($q) < 2) { + return response()->json(['results' => []]); + } + $animes = Anime::where('is_published', true) + ->where(function ($qb) use ($q) { + $qb->where('title', 'like', "%$q%") + ->orWhere('title_en', 'like', "%$q%") + ->orWhere('title_jp', 'like', "%$q%"); + }) + ->select('id', 'title', 'title_en', 'slug', 'cover_image', 'type', 'release_year', 'episode_count') + ->orderByRaw("CASE WHEN title LIKE ? THEN 0 ELSE 1 END, title ASC", ["$q%"]) + ->limit(7) + ->get() + ->map(fn($a) => [ + 'title' => $a->title, + 'title_en' => $a->title_en, + 'slug' => $a->slug, + 'cover' => $a->coverUrl, + 'type' => $a->type, + 'year' => $a->release_year, + 'episodes' => $a->episode_count, + ]); + + return response()->json(['results' => $animes]); + } + + public function genre(Genre $genre) + { + $animes = $genre->animes()->where('is_published', true)->latest()->paginate(24); + return view('frontend.genre', compact('genre', 'animes')); + } +} diff --git a/app/Http/Controllers/Frontend/MessageController.php b/app/Http/Controllers/Frontend/MessageController.php new file mode 100644 index 0000000..d031809 --- /dev/null +++ b/app/Http/Controllers/Frontend/MessageController.php @@ -0,0 +1,263 @@ +conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + return [ + 'id' => $conv->id, + 'other' => $other, + 'last_message' => $conv->lastMessage, + 'unread' => $conv->unreadCountFor($user->id), + 'updated_at' => $conv->updated_at, + ]; + }); + } catch (\Throwable $e) { + $conversations = collect(); + } + + return view('frontend.messages.index', compact('conversations')); + } + + public function show(Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $other = $conversation->participants()->where('user_id', '!=', $user->id)->first(); + + $messages = $conversation->messages() + ->with('user') + ->orderBy('created_at') + ->get(); + + // Mark as read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return view('frontend.messages.show', compact('conversation', 'messages', 'other')); + } + + public function startOrOpen(User $user) + { + $me = Auth::user(); + + if ($me->id === $user->id) abort(422); + + // Find existing conversation between these two users + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $user->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $user) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $user->id]); + return $c; + }); + } + + return redirect()->route('messages.show', $conv); + } + + public function send(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $request->validate(['body' => 'required|string|max:5000']); + + $message = Message::create([ + 'conversation_id' => $conversation->id, + 'user_id' => $user->id, + 'body' => $request->body, + ]); + + $conversation->touch(); + + // Mark sender as read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + if ($request->expectsJson()) { + return response()->json([ + 'id' => $message->id, + 'body' => $message->body, + 'user_id' => $user->id, + 'created_at' => $message->created_at->format('H:i'), + 'avatar' => $user->avatar ? \App\Support\MediaUrl::fromStoragePath($user->avatar) : null, + 'name' => $user->name, + ]); + } + + return back(); + } + + public function poll(Request $request, Conversation $conversation) + { + $user = Auth::user(); + + abort_unless( + $conversation->participants()->where('user_id', $user->id)->exists(), + 403 + ); + + $after = $request->query('after', 0); + + $messages = $conversation->messages() + ->with('user') + ->where('id', '>', $after) + ->orderBy('created_at') + ->get() + ->map(fn($m) => [ + 'id' => $m->id, + 'body' => $m->body, + 'user_id' => $m->user_id, + 'created_at' => $m->created_at->format('H:i'), + 'avatar' => $m->user->avatar ? \App\Support\MediaUrl::fromStoragePath($m->user->avatar) : null, + 'name' => $m->user->name, + ]); + + // Update last_read + $conversation->participants() + ->updateExistingPivot($user->id, ['last_read_at' => now()]); + + return response()->json(['messages' => $messages]); + } + + public function unreadCount() + { + $user = Auth::user(); + if (!$user) return response()->json(['count' => 0]); + + $count = 0; + foreach ($user->conversations()->with(['messages'])->get() as $conv) { + $count += $conv->unreadCountFor($user->id); + } + + return response()->json(['count' => $count]); + } + + public function conversationsJson() + { + $user = Auth::user(); + + $convs = $user->conversations() + ->with(['participants', 'lastMessage.user']) + ->orderByDesc('conversations.updated_at') + ->limit(30) + ->get() + ->map(function ($conv) use ($user) { + $other = $conv->participants->firstWhere('id', '!=', $user->id); + $last = $conv->lastMessage; + $unread = $conv->unreadCountFor($user->id); + + $preview = null; + if ($last) { + if (str_starts_with($last->body, 'ANIMESHARE::')) { + try { $sd = json_decode(substr($last->body, 12), true); $preview = '🎬 ' . ($sd['title'] ?? 'Anime paylaştı'); } catch(\Throwable) {} + } elseif (str_starts_with($last->body, 'IMAGE::')) { + $preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '📷 Fotoğraf'; + } elseif (str_starts_with($last->body, 'GIF::')) { + $preview = ($last->user_id === $user->id ? 'Sen: ' : '') . '🎞 GIF'; + } else { + $isMine = $last->user_id === $user->id; + $preview = ($isMine ? 'Sen: ' : '') . \Illuminate\Support\Str::limit($last->body, 50); + } + } + + return [ + 'conv_id' => $conv->id, + 'id' => $other?->id, + 'name' => $other?->name ?? 'Silinmiş', + 'avatar' => $other?->avatar ? \App\Support\MediaUrl::fromStoragePath($other->avatar) : null, + 'last_preview' => $preview, + 'unread' => $unread, + 'time' => $conv->updated_at ? $conv->updated_at->diffForHumans(null, true) : null, + ]; + }); + + return response()->json($convs); + } + + public function uploadImage(Request $request) + { + $request->validate([ + 'image' => 'required|file|image|max:8192|mimes:jpeg,jpg,png,gif,webp', + ]); + + $path = $request->file('image')->store('chat-images', 'public'); + $url = Storage::disk('public')->url($path); + + return response()->json(['url' => $url]); + } + + public function quickShare(Request $request) + { + $request->validate([ + 'to_user_id' => 'required|integer|exists:users,id', + 'body' => 'required|string|max:3000', + ]); + + $me = Auth::user(); + $target = User::findOrFail($request->to_user_id); + + if ($me->id === $target->id) abort(422, 'Kendinize gönderemezsiniz.'); + + $conv = Conversation::whereHas('participants', fn($q) => $q->where('user_id', $me->id)) + ->whereHas('participants', fn($q) => $q->where('user_id', $target->id)) + ->first(); + + if (!$conv) { + $conv = DB::transaction(function () use ($me, $target) { + $c = Conversation::create(); + $c->participants()->attach([$me->id, $target->id]); + return $c; + }); + } + + $message = Message::create([ + 'conversation_id' => $conv->id, + 'user_id' => $me->id, + 'body' => $request->body, + ]); + + $conv->touch(); + $conv->participants()->updateExistingPivot($me->id, ['last_read_at' => now()]); + + return response()->json([ + 'ok' => true, + 'conversation_id' => $conv->id, + 'message_id' => $message->id, + ]); + } +} diff --git a/app/Http/Controllers/Frontend/PasswordResetController.php b/app/Http/Controllers/Frontend/PasswordResetController.php new file mode 100644 index 0000000..c508410 --- /dev/null +++ b/app/Http/Controllers/Frontend/PasswordResetController.php @@ -0,0 +1,78 @@ +validate(['email' => 'required|email'], [ + 'email.required' => 'E-posta zorunludur.', + 'email.email' => 'Geçerli bir e-posta girin.', + ]); + + $user = User::where('email', $request->email)->first(); + + // Kullanıcı bulunamasa bile aynı mesajı göster (güvenlik) + if ($user) { + $status = Password::sendResetLink( + $request->only('email'), + function (User $user, string $token) { + $url = url(route('password.reset', ['token' => $token, 'email' => $user->email], false)); + Mail::to($user->email)->send(new ResetPasswordMail($url, $user->name)); + } + ); + } + + return back()->with('status', 'Eğer bu e-posta adresine kayıtlı bir hesap varsa şifre sıfırlama bağlantısı gönderildi.'); + } + + public function showReset(Request $request, string $token) + { + return view('frontend.auth.reset-password', [ + 'token' => $token, + 'email' => $request->query('email', ''), + ]); + } + + public function reset(Request $request) + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', PasswordRule::min(6)], + ], [ + 'password.required' => 'Şifre zorunludur.', + 'password.confirmed' => 'Şifreler eşleşmiyor.', + 'password.min' => 'Şifre en az 6 karakter olmalıdır.', + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password) { + $user->forceFill(['password' => Hash::make($password)])->save(); + } + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect()->route('frontend.login') + ->with('status', 'Şifreniz başarıyla sıfırlandı. Giriş yapabilirsiniz.'); + } + + return back()->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Frontend/PlayerController.php b/app/Http/Controllers/Frontend/PlayerController.php new file mode 100644 index 0000000..ad55041 --- /dev/null +++ b/app/Http/Controllers/Frontend/PlayerController.php @@ -0,0 +1,372 @@ +is_published, 404); + + $seasonModel = Season::where('anime_id', $anime->id) + ->where('season_number', $season) + ->firstOrFail(); + + $ep = Episode::where('season_id', $seasonModel->id) + ->where('episode_number', $episode) + ->where('is_published', true) + ->firstOrFail(); + + $ep->increment('view_count'); + $ep->load('subtitles'); + + // Tüm sezonlar + bölümler (playlist için) + türler (bilgi paneli) + $anime->load([ + 'seasons' => fn($q) => $q->orderBy('season_number'), + 'seasons.episodes' => fn($q) => $q->where('is_published', true)->orderBy('episode_number'), + 'genres', + ]); + + // Önceki / sonraki bölüm + $prev = Episode::with('season') + ->where('season_id', $seasonModel->id) + ->where('episode_number', $episode - 1) + ->where('is_published', true) + ->first(); + + $next = Episode::with('season') + ->where('season_id', $seasonModel->id) + ->where('episode_number', $episode + 1) + ->where('is_published', true) + ->first(); + + // Sonraki bölüm yoksa bir sonraki sezona geç + if (!$next) { + $nextSeason = Season::where('anime_id', $anime->id) + ->where('season_number', $season + 1) + ->first(); + if ($nextSeason) { + $next = Episode::where('season_id', $nextSeason->id) + ->where('episode_number', 1) + ->where('is_published', true) + ->first(); + } + } + + // JSON-safe subtitle data (proxy URL for CORS bypass) + $subtitlesData = $ep->subtitles->map(fn($s) => [ + 'label' => $s->label, + 'lang' => $s->language, + 'url' => route('vtt.proxy', ['url' => $s->url]), + 'is_default' => (bool) $s->is_default, + ])->values()->toArray(); + + // Dublaj kaynakları — CDN’deki .../{720p|1080p}-{dub}[/master.m3u8] kalıbından türet + // Embed modda URL video_url’de olabilir (m3u8_url null) — video_url’e fallback + $raw = $ep->available_dubs; + $availableDubs = is_array($raw) ? $raw : null; // null = unknown (show all), array = restrict to listed + $sourceForDubs = $ep->m3u8_url ?: $ep->video_url; + [$dubSources, $activeDub] = self::resolveDubSourcesFromM3u8($sourceForDubs, $availableDubs); + + // Tüm video URL’lerini imzala (BunnyCDN Token Auth) + BunnyCdnSigner::signAll($dubSources); + $ep->m3u8_url = BunnyCdnSigner::sign($ep->m3u8_url); + $ep->video_url = BunnyCdnSigner::sign($ep->video_url); + + // Proxy all external HLS streams through our server (CORS + SSL bypass). + // anizium.co + aniziumserver.* CDN'leri doğrudan yükle (tarayıcı üzerinden). + $isOwn = fn(?string $u) => !$u || str_contains($u, 'b-cdn.net') || str_contains($u, 'animexe.com') + || str_contains($u, 'anizium.co') || str_contains($u, 'aniziumserver.sbs'); + if ($ep->m3u8_url && !$isOwn($ep->m3u8_url)) { + $ep->m3u8_url = route("stream.proxy", ["u" => base64_encode((string) $ep->m3u8_url)]); + } + // video_url — embed modda anizium HLS URL olabilir; HLS ise proxy'den geçir, MP4 ise bırak. + if ($ep->video_url && !$isOwn($ep->video_url)) { + if (str_ends_with((string) $ep->video_url, '.m3u8')) { + $ep->video_url = route("stream.proxy", ["u" => base64_encode((string) $ep->video_url)]); + } + } + foreach (array_keys($dubSources) as $idx) { + $dubUrl = (string) ($dubSources[$idx]["url"] ?? ""); + if ($dubUrl && !$isOwn($dubUrl)) { + $dubSources[$idx]["url"] = route("stream.proxy", ["u" => base64_encode($dubUrl)]); + } + } + + // Video sources — Anizium 1080p → 720p → 4K/diğer, sonra AnimeCix + $videoSourcesData = \App\Models\VideoSource::where('episode_id', $ep->id) + ->orderBy('sort_order') + ->get(['id', 'label', 'url', 'type', 'quality', 'translator_id', 'is_default', 'source', 'sort_order', 'is_hevc']) + ->groupBy(fn($vs) => $vs->translator_id ?: $vs->label) + ->map(function ($group) { + $default = $group->firstWhere('is_default', true) ?? $group->first(); + return [ + 'id' => $default->id, + 'key' => $default->translator_id ?: \Illuminate\Support\Str::slug($default->label), + 'label' => $default->label ?: 'Kaynak', + 'url' => $default->url, + 'type' => $default->type ?? 'hls', + 'source' => $default->source ?? 'animecix', + 'quality' => $default->quality ?? '', + 'sort_order' => $default->sort_order ?? 99, + 'is_hevc' => (bool) $default->is_hevc, + ]; + }) + ->sortBy(function ($item) { + $isAnizium = ($item['source'] === 'anizium'); + $q = strtolower($item['quality'] ?? ''); + if ($isAnizium) { + if (str_contains($q, '1080')) return 0; + if (str_contains($q, '720')) return 1; + return 1000; // 4K / H.265 / diğer → en sona + } + return 10 + ($item['sort_order'] ?? 99); // AnimeCix + }) + ->values() + ->toArray(); + + // Sonraki bölüm URL'si + $nextUrl = null; + if ($next) { + $nextSeasonNum = $next->season?->season_number ?? $seasonModel->season_number; + if (!$next->season) { + $nextSeason2 = Season::find($next->season_id); + $nextSeasonNum = $nextSeason2?->season_number ?? $seasonModel->season_number; + } + $nextUrl = route('watch', [$anime->slug, $nextSeasonNum, $next->episode_number]); + } + + // Intro video ayarları + $introUrl = Setting::get('intro_enabled') == '1' ? (Setting::get('intro_video_url') ?: null) : null; + $introSkipAfter = (int) Setting::get('intro_skip_after', 5); + $mainVideoSkipSec = (int) Setting::get('main_video_skip_seconds', 10); + $wmCoverSeconds = (int) Setting::get('watermark_cover_seconds', 11); + + // İntro atla: önce bölüme elle girilmiş zamanlar, yoksa AniSkip API + $aniSkip = null; + + if ($ep->intro_start !== null && $ep->intro_end !== null && $ep->intro_end > $ep->intro_start) { + // Manuel giriş — en güvenilir + $aniSkip = ['op' => ['start' => (float)$ep->intro_start, 'end' => (float)$ep->intro_end]]; + } else { + $seasonMalId = $seasonModel->mal_id; + + // season.mal_id yoksa akıllı fallback (Jikan'a gitme, bloke olur) + if (!$seasonMalId && $anime->mal_id) { + // Sezon 1 için anime.mal_id direkt kullanılabilir + // Diğer sezonlar için background job yerine cache'li Jikan + if ($seasonModel->season_number === 1) { + $seasonMalId = $anime->mal_id; + $seasonModel->update(['mal_id' => $seasonMalId]); + } else { + // Sequel chain'i sadece cache'li olarak dene (timeout kısa, bloke etmez) + try { + $cacheKey = "jikan_chain_{$anime->mal_id}"; + $chain = \Illuminate\Support\Facades\Cache::get($cacheKey); + if (!$chain) { + // Cache yoksa arka planda doldur, bu istek için atla + dispatch(function () use ($anime) { + $chain = (new \App\Services\JikanService())->fetchSeasonMalIds($anime->mal_id); + if ($chain) { + \Illuminate\Support\Facades\Cache::put("jikan_chain_{$anime->mal_id}", $chain, 60 * 60 * 24 * 7); + foreach ($anime->seasons()->orderBy('season_number')->get() as $i => $s) { + if (!$s->mal_id && isset($chain[$i])) $s->update(['mal_id' => $chain[$i]]); + } + } + })->afterResponse(); + } else { + $idx = $seasonModel->season_number - 1; + $seasonMalId = $chain[$idx] ?? $chain[0] ?? null; + if ($seasonMalId) $seasonModel->update(['mal_id' => $seasonMalId]); + } + } catch (\Throwable) {} + } + } + + // anime.mal_id de yoksa Jikan title search (sadece bir kez, cache'lenir) + if (!$seasonMalId && !$anime->mal_id) { + try { + $found = (new AniSkipService())->searchByTitle($anime->title, $anime->title_en, $anime->title_jp); + if ($found) { + $anime->update(['mal_id' => $found]); + $seasonMalId = $found; + if ($seasonModel->season_number === 1) $seasonModel->update(['mal_id' => $found]); + } + } catch (\Throwable) {} + } + + if ($seasonMalId) { + try { + $aniSkip = (new AniSkipService())->getSkipTimes((string)$seasonMalId, $ep->episode_number); + } catch (\Throwable) {} + } + } + + // İzleme ilerlemeleri (sidebar progress bar için) + $watchProgress = []; + if (auth()->check()) { + $progRows = \App\Models\ContinueWatching::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->get(['episode_id', 'percent_complete']); + foreach ($progRows as $row) { + $watchProgress[$row->episode_id] = (int) $row->percent_complete; + } + } + + // Premium kullanıcı HİÇBİR reklam görmez (hem eski VAST hem yeni MP4 sistemi) + $isPremiumUser = auth()->check() && auth()->user()->isPremium(); + + $adsConfig = [ + 'enabled' => !$isPremiumUser && Setting::get('ads_enabled', '0') === '1', + 'vast_url' => Setting::get('ads_vast_url', ''), + 'freq_episodes' => (int) Setting::get('ads_freq_episodes', 4), + 'freq_minutes' => (int) Setting::get('ads_freq_minutes', 10), + ]; + + // ── Kendi MP4 pre-roll reklam sistemi ──────────────────────────────── + // Premium kullanıcı reklam görmez. mode: 'ad' | 'upsell' | null + $vadConfig = ['mode' => null]; + if (!$isPremiumUser && Setting::get('vad_enabled', '0') === '1') { + $upsellPercent = (int) Setting::get('vad_upsell_percent', 20); + $ad = null; + $mode = null; + if (random_int(1, 100) <= $upsellPercent) { + $mode = 'upsell'; + } else { + $ad = \App\Models\Ad::pickVideo(); + if ($ad && $ad->media_url) { + $mode = 'ad'; + } elseif ($upsellPercent > 0) { + $mode = 'upsell'; // hiç video reklam yoksa upsell göster + } + } + $vadConfig = [ + 'mode' => $mode, + 'ad' => $mode === 'ad' ? [ + 'id' => $ad->id, + 'url' => $ad->media_url, + 'click_url' => $ad->click_url, + 'skip_after' => (int) $ad->skip_after, + ] : null, + 'freq_episodes' => (int) Setting::get('vad_freq_episodes', 2), + 'freq_minutes' => (int) Setting::get('vad_freq_minutes', 5), + 'premium_url' => route('premium.plans'), + ]; + } + + // Kendi reklamımız gösterilecekse IMA/VAST devreye girmesin + if (!empty($vadConfig['mode'])) { + $adsConfig['enabled'] = false; + } + + return response() + ->view('frontend.player', compact( + 'anime', 'ep', 'seasonModel', 'prev', 'next', + 'subtitlesData', 'nextUrl', 'dubSources', 'activeDub', + 'introUrl', 'introSkipAfter', 'mainVideoSkipSec', 'wmCoverSeconds', + 'aniSkip', 'watchProgress', 'videoSourcesData', 'adsConfig', 'vadConfig' + )) + ->header('Cache-Control', 'private, no-store, no-cache, must-revalidate') + ->header('Pragma', 'no-cache') + ->header('X-Player-Version', '3'); + } + + /** + * m3u8 URL içinden kalite+dublaj klasörünü bulup diğer dublaj varyantlarının URL'lerini üretir. + * Örnekler: + * - https://f.aniziumserver.sbs/85937/1/1/1080p-original/master.m3u8 + * - https://host/cdn/x/1/01/720p-trdub/ + * - https://xxx.b-cdn.net/.../1080p_endub/index.m3u8 + * + * @return array{0: array, 1: ?string} + */ + protected static function resolveDubSourcesFromM3u8(?string $m3u8Url, ?array $availableDubs = null): array + { + if (!$m3u8Url || ! is_string($m3u8Url)) { + return [[], null]; + } + + $u = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); + if ($u === '') { + return [[], null]; + } + + // Sondaki playlist dosyasını çıkar (master.m3u8, index.m3u8, video.m3u8, …) + if (preg_match('#/[^/]+\.m3u8$#i', $u)) { + $u = rtrim(preg_replace('#/[^/]+\.m3u8$#i', '', $u), '/'); + } + + // Son segment: 720p-original, 1080p_trdub, 480p-endub + if (! preg_match('#^(.*)/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)$#', $u, $m)) { + return [[], null]; + } + + $parent = $m[1]; + $quality = $m[2]; + $sep = $m[3]; + $activeDub = strtolower($m[4]); + + $dubLabels = [ + 'trdub' => 'Türkçe Dublaj', + 'original' => 'Japonca (Orijinal)', + 'endub' => 'İngilizce Dublaj', + // Dynamic: any unrecognised key gets a generic label below + ]; + + $raw = rtrim(preg_replace('/[?#].*$/', '', trim($m3u8Url)), '/'); + $suffix = ''; + if (preg_match('#/(\d{3,4}p)([-_])([a-zA-Z0-9_-]+)(/.*)$#i', $raw, $tail)) { + $suffix = $tail[4]; + } + + // Which dub keys to include: + // • null → column not yet migrated (legacy): show all standard dubs + // • [] empty array → no dub info, show only active + // • ['trdub','original',...] → restrict to listed keys + if ($availableDubs === null) { + // Bilinmiyor: aktif dub trdub/endub ise orijinal (JP) de büyük ihtimalle var. + // Aktif dub zaten original ise başka dub olmadığını varsay (false positive önle). + $keys = $activeDub !== 'original' ? [$activeDub, 'original'] : [$activeDub]; + } elseif (count($availableDubs) === 0) { + // Bot açıkça "dub bilgisi yok" dedi — sadece aktif + $keys = [$activeDub]; + } else { + $keys = $availableDubs; + } + + // Tekrarları at, aktif dub'ı öne al + $seen = []; + $sources = []; + // Aktif dub her zaman ilk sıraya + if (!in_array($activeDub, $keys)) array_unshift($keys, $activeDub); + foreach ($keys as $key) { + if (isset($seen[$key])) continue; + $seen[$key] = true; + $label = $dubLabels[$key] ?? ucfirst($key) . ' Dublaj'; + $url = $parent . '/' . $quality . $sep . $key . $suffix; + $sources[] = [ + 'key' => $key, + 'label' => $label, + 'url' => $url, + 'active' => $key === $activeDub, + ]; + } + + return [$sources, $activeDub]; + } +} diff --git a/app/Http/Controllers/Frontend/PremiumController.php b/app/Http/Controllers/Frontend/PremiumController.php new file mode 100644 index 0000000..79be11a --- /dev/null +++ b/app/Http/Controllers/Frontend/PremiumController.php @@ -0,0 +1,97 @@ +user(); + + if (!$user->isPremium()) { + return back()->with('error', 'Bu özellik için premium üyelik gerekiyor.'); + } + + $validated = $request->validate([ + 'comment_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_BACKGROUNDS)), + 'comment_glow' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::COMMENT_GLOWS)), + 'username_color' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_COLORS)), + 'username_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::USERNAME_EFFECTS)), + 'profile_frame' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_FRAMES)), + 'profile_badge' => 'nullable|string|max:32', + 'profile_bg' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::PROFILE_BACKGROUNDS)), + 'gif_avatar' => 'nullable|url|max:500', + 'profile_music_url' => 'nullable|url|max:500', + 'comment_signature' => 'nullable|string|max:100', + 'entry_effect' => 'nullable|string|in:' . implode(',', array_keys(PremiumFeatures::ENTRY_EFFECTS)), + 'animated_banner' => 'nullable|boolean', + ]); + + // Her alanı sadece ilgili perk varsa kaydet + $updates = []; + + if ($user->hasPerk('comment_bg')) { + $updates['comment_bg'] = $validated['comment_bg'] ?? null; + } + if ($user->hasPerk('comment_glow')) { + $updates['comment_glow'] = $validated['comment_glow'] ?? null; + } + if ($user->hasPerk('username_color')) { + $updates['username_color'] = $validated['username_color'] ?? null; + } + if ($user->hasPerk('username_effect')) { + $updates['username_effect'] = $validated['username_effect'] ?? null; + } + if ($user->hasPerk('profile_frame')) { + $updates['profile_frame'] = $validated['profile_frame'] ?? null; + } + if ($user->hasPerk('profile_badge')) { + $updates['profile_badge'] = $validated['profile_badge'] ?? null; + } + if ($user->hasPerk('profile_bg')) { + $updates['profile_bg'] = $validated['profile_bg'] ?? null; + } + if ($user->hasPerk('gif_avatar')) { + $updates['gif_avatar'] = $validated['gif_avatar'] ?? null; + } + if ($user->hasPerk('profile_music') && Schema::hasColumn('users', 'profile_music_url')) { + $updates['profile_music_url'] = $validated['profile_music_url'] ?? null; + } + if ($user->hasPerk('comment_signature')) { + $updates['comment_signature'] = $validated['comment_signature'] ?? null; + } + if ($user->hasPerk('entry_effect')) { + $updates['entry_effect'] = $validated['entry_effect'] ?? null; + } + if ($user->hasPerk('animated_banner')) { + $updates['animated_banner'] = $request->boolean('animated_banner'); + } + + if (!empty($updates)) { + $user->update($updates); + } + + return back()->with('success', 'Premium ayarların kaydedildi!'); + } + + /** Public plans/pricing sayfası */ + public function plans() + { + $plans = \App\Models\MembershipPlan::where('is_active', true) + ->where('is_public', true) + ->where(fn($q) => $q->whereNull('visible_until')->orWhere('visible_until', '>', now())) + ->orderBy('sort_order') + ->get(); + + $allFeatures = PremiumFeatures::grouped(); + + return view('frontend.premium.plans', compact('plans', 'allFeatures')); + } +} diff --git a/app/Http/Controllers/Frontend/ProfileController.php b/app/Http/Controllers/Frontend/ProfileController.php new file mode 100644 index 0000000..3a3ccd1 --- /dev/null +++ b/app/Http/Controllers/Frontend/ProfileController.php @@ -0,0 +1,235 @@ +id) + ->where('status', 'approved') + ->orderByDesc('created_at') + ->limit(10) + ->get(); + + $commentCount = Comment::where('user_id', $user->id) + ->where('status', 'approved') + ->count(); + + // İzleme listesi (status gruplu) + $watchlistItems = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,type,rating') + ->orderByDesc('created_at') + ->get() + ->filter(fn($wl) => $wl->anime !== null) + ->groupBy('status'); + + // Devam et listesi + $continueItems = ContinueWatching::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image') + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(12) + ->get(); + + // İzleme istatistikleri + $watchStats = [ + 'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(), + 'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1), + 'watchlist'=> Watchlist::where('user_id', $user->id)->count(), + 'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(), + ]; + + // Başarımlar + AchievementService::check($user); // yeni kazanılanları kontrol et + $achievements = UserAchievement::where('user_id', $user->id) + ->with('achievement') + ->orderByDesc('earned_at') + ->get(); + + $allAchievements = \App\Models\Achievement::all(); + + // İzleme Heatmap (son 365 gün) + $heatmapRaw = DB::table('analytics_watch_events') + ->where('user_id', $user->id) + ->where('created_at', '>=', now()->subDays(365)) + ->selectRaw('DATE(created_at) as d, COUNT(DISTINCT episode_id) as cnt') + ->groupBy('d') + ->pluck('cnt', 'd') + ->toArray(); + + // Bölüm notları (son 20) + $episodeNotes = EpisodeNote::where('user_id', $user->id) + ->with('episode:id,title,episode_number,anime_id', 'anime:id,title,slug') + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + // Keşfet geçmişi (beğenilenler + geçilenler) + $swipeHistory = AnimeSwipe::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,rating,release_year,type') + ->orderByDesc('created_at') + ->limit(60) + ->get() + ->filter(fn($s) => $s->anime !== null); + + return view('frontend.profile', compact( + 'user', 'recentComments', 'commentCount', + 'watchlistItems', 'continueItems', 'watchStats', + 'achievements', 'allAchievements', + 'heatmapRaw', 'episodeNotes', 'swipeHistory' + )); + } + + public function publicProfile(\App\Models\User $user) + { + $commentCount = Comment::where('user_id', $user->id)->where('status', 'approved')->count(); + + $watchlistItems = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,slug,cover_image,type,rating') + ->orderByDesc('created_at') + ->get() + ->filter(fn($wl) => $wl->anime !== null) + ->groupBy('status'); + + $watchStats = [ + 'episodes' => ContinueWatching::where('user_id', $user->id)->where('percent_complete', '>=', 70)->count(), + 'hours' => round(ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1), + 'watchlist'=> Watchlist::where('user_id', $user->id)->count(), + 'ratings' => DB::table('anime_ratings')->where('user_id', $user->id)->count(), + ]; + + $achievements = UserAchievement::where('user_id', $user->id) + ->with('achievement') + ->where('earned_at', '!=', null) + ->orderByDesc('earned_at') + ->get(); + + $recentComments = Comment::where('user_id', $user->id) + ->where('status', 'approved') + ->orderByDesc('created_at') + ->limit(6) + ->get(); + + $isOwnProfile = Auth::id() === $user->id; + $isFollowing = Auth::check() && !$isOwnProfile ? Auth::user()->isFollowing($user->id) : false; + $followerCount = \App\Models\UserFollow::where('following_id', $user->id)->count(); + $followingCount= \App\Models\UserFollow::where('follower_id', $user->id)->count(); + $compatibility = (Auth::check() && !$isOwnProfile) + ? Auth::user()->compatibilityWith($user) + : null; + + return view('frontend.public-profile', compact( + 'user', 'commentCount', 'watchlistItems', + 'watchStats', 'achievements', 'recentComments', 'isOwnProfile', + 'isFollowing', 'followerCount', 'followingCount', 'compatibility' + )); + } + + public function settings() + { + return view('frontend.profile-settings', ['user' => Auth::user()]); + } + + public function update(Request $request) + { + $user = Auth::user(); + + $data = $request->validate([ + 'name' => 'required|string|max:60', + 'username' => 'nullable|string|max:30|alpha_dash|unique:users,username,' . $user->id, + 'bio' => 'nullable|string|max:300', + 'website' => 'nullable|url|max:200', + 'twitter' => 'nullable|string|max:50', + 'instagram' => 'nullable|string|max:50', + 'discord' => 'nullable|string|max:80', + 'profile_color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + 'show_watchlist' => 'boolean', + 'show_activity' => 'boolean', + ]); + + // Checkboxlar false gelince request'te bulunmaz + $data['show_watchlist'] = $request->boolean('show_watchlist'); + $data['show_activity'] = $request->boolean('show_activity'); + + // @ işaretlerini temizle + if (isset($data['twitter'])) $data['twitter'] = ltrim($data['twitter'], '@'); + if (isset($data['instagram'])) $data['instagram'] = ltrim($data['instagram'], '@'); + + $user->update($data); + + return back()->with('success', 'Profil güncellendi.'); + } + + public function updateAvatar(Request $request) + { + $request->validate([ + 'avatar' => 'required|image|mimes:jpg,jpeg,png,webp,gif|max:2048', + ]); + + $user = Auth::user(); + + // Eski avatarı sil + if ($user->avatar && Storage::disk('public')->exists($user->avatar)) { + Storage::disk('public')->delete($user->avatar); + } + + $path = $request->file('avatar')->store('avatars', 'public'); + $user->update(['avatar' => $path]); + + return back()->with('success', 'Profil fotoğrafı güncellendi.'); + } + + public function updateBanner(Request $request) + { + $request->validate([ + 'banner' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120', + ]); + + $user = Auth::user(); + + if ($user->banner_image && Storage::disk('public')->exists($user->banner_image)) { + Storage::disk('public')->delete($user->banner_image); + } + + $path = $request->file('banner')->store('banners', 'public'); + $user->update(['banner_image' => $path]); + + return back()->with('success', 'Profil kapak fotoğrafı güncellendi.'); + } + + public function updatePassword(Request $request) + { + $request->validate([ + 'current_password' => 'required', + 'password' => ['required', 'confirmed', Password::min(8)], + ]); + + $user = Auth::user(); + + if (!Hash::check($request->current_password, $user->password)) { + return back()->withErrors(['current_password' => 'Mevcut şifre yanlış.']); + } + + $user->update(['password' => $request->password]); + + return back()->with('success', 'Şifre güncellendi.'); + } +} diff --git a/app/Http/Controllers/Frontend/SocialController.php b/app/Http/Controllers/Frontend/SocialController.php new file mode 100644 index 0000000..19198e3 --- /dev/null +++ b/app/Http/Controllers/Frontend/SocialController.php @@ -0,0 +1,569 @@ +id === $user->id) { + return response()->json(['error' => 'Kendinizi takip edemezsiniz.'], 422); + } + + $existing = UserFollow::where('follower_id', $me->id) + ->where('following_id', $user->id) + ->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + UserFollow::create(['follower_id' => $me->id, 'following_id' => $user->id]); + $following = true; + } + + return response()->json([ + 'following' => $following, + 'followers_count' => UserFollow::where('following_id', $user->id)->count(), + ]); + } + + public function card(User $user) + { + $me = Auth::user(); + $isFollowing = $me + ? UserFollow::where('follower_id', $me->id)->where('following_id', $user->id)->exists() + : false; + + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'username' => $user->username, + 'avatar' => $user->avatar ? MediaUrl::fromStoragePath($user->avatar) : null, + 'followers' => UserFollow::where('following_id', $user->id)->count(), + 'following' => UserFollow::where('follower_id', $user->id)->count(), + 'is_following' => $isFollowing, + 'profile_url' => route('user.profile', $user), + 'follow_url' => ($me && $me->id !== $user->id) ? route('user.follow', $user) : null, + 'msg_url' => ($me && $me->id !== $user->id) ? route('messages.start', $user) : null, + 'is_me' => $me && $me->id === $user->id, + ]); + } + + public function compatibility(User $user) + { + $me = Auth::user(); + if (!$me) return response()->json(['score' => 0]); + + return response()->json([ + 'score' => $me->compatibilityWith($user), + ]); + } + + // ───────────────────────────────────────────────────────── + // NicoNico — Timestamp Yorumları + // ───────────────────────────────────────────────────────── + + public function timestampComments(Episode $episode) + { + $comments = EpisodeTimestampComment::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->where('is_hidden', false) + ->orderBy('timestamp_sec') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'user_id' => $c->user_id, + 'timestamp_sec' => $c->timestamp_sec, + 'body' => $c->body, + 'color' => $c->color, + 'username' => $c->user?->username ?? 'misafir', + 'created_at' => $c->created_at, + ]); + + return response()->json(['comments' => $comments]); + } + + public function timestampCommentStore(Request $request, Episode $episode) + { + $data = $request->validate([ + 'timestamp_sec' => 'required|integer|min:0|max:86400', + 'body' => 'required|string|max:100', + 'color' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', + ]); + + $me = Auth::user(); + + // Flood koruması: aynı kullanıcı 5 saniye içinde 2+ yorum atmasın + $recent = EpisodeTimestampComment::where('user_id', $me->id) + ->where('episode_id', $episode->id) + ->where('created_at', '>=', now()->subSeconds(5)) + ->count(); + + if ($recent >= 2) { + return response()->json(['error' => 'Çok hızlı yorum yapıyorsunuz.'], 429); + } + + $comment = EpisodeTimestampComment::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'timestamp_sec' => $data['timestamp_sec'], + 'body' => $data['body'], + 'color' => $data['color'] ?? '#ffffff', + ]); + + return response()->json(['ok' => true, 'id' => $comment->id]); + } + + // ───────────────────────────────────────────────────────── + // Tahmin Oyunu + // ───────────────────────────────────────────────────────── + + public function predictions(Episode $episode) + { + $me = Auth::id(); + + $predictions = EpisodePrediction::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('vote_count') + ->get() + ->map(fn($p) => [ + 'id' => $p->id, + 'body' => $p->body, + 'is_correct' => $p->is_correct, + 'vote_count' => $p->vote_count, + 'username' => $p->user?->username, + 'is_mine' => $me && $p->user_id === $me, + 'voted' => $me + ? PredictionVote::where('prediction_id', $p->id)->where('user_id', $me)->exists() + : false, + 'created_at' => $p->created_at->diffForHumans(), + ]); + + $myPrediction = $me + ? EpisodePrediction::where('episode_id', $episode->id)->where('user_id', $me)->first()?->id + : null; + + return response()->json([ + 'predictions' => $predictions, + 'my_prediction' => $myPrediction, + ]); + } + + public function predictionStore(Request $request, Episode $episode) + { + $me = Auth::user(); + + $data = $request->validate([ + 'body' => 'required|string|min:5|max:280', + ]); + + $existing = EpisodePrediction::where('episode_id', $episode->id) + ->where('user_id', $me->id) + ->first(); + + if ($existing) { + return response()->json(['error' => 'Bu bölüm için zaten bir tahmininiz var.'], 422); + } + + $prediction = EpisodePrediction::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $prediction->id]); + } + + public function predictionVote(Request $request, EpisodePrediction $prediction) + { + $me = Auth::user(); + + $existing = PredictionVote::where('prediction_id', $prediction->id) + ->where('user_id', $me->id) + ->first(); + + if ($existing) { + $existing->delete(); + $prediction->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $prediction->fresh()->vote_count]); + } + + PredictionVote::create(['prediction_id' => $prediction->id, 'user_id' => $me->id]); + $prediction->increment('vote_count'); + + return response()->json(['voted' => true, 'vote_count' => $prediction->fresh()->vote_count]); + } + + // ───────────────────────────────────────────────────────── + // Watch Party + // ───────────────────────────────────────────────────────── + + public function partyCreate(Request $request) + { + $me = Auth::user(); + + $data = $request->validate([ + 'episode_id' => 'required|exists:episodes,id', + 'is_private' => 'boolean', + 'password' => 'nullable|string|max:30', + 'max_members'=> 'nullable|integer|min:2|max:20', + ]); + + // Kullanıcının zaten aktif bir odası varsa sil + WatchParty::where('host_user_id', $me->id)->delete(); + + $party = WatchParty::create([ + 'room_code' => WatchParty::generateCode(), + 'host_user_id' => $me->id, + 'episode_id' => $data['episode_id'], + 'is_private' => $data['is_private'] ?? false, + 'password' => isset($data['password']) ? Hash::make($data['password']) : null, + 'max_members' => $data['max_members'] ?? 10, + ]); + + WatchPartyMember::create([ + 'party_id' => $party->id, + 'user_id' => $me->id, + ]); + + return response()->json([ + 'ok' => true, + 'room_code' => $party->room_code, + 'party_url' => route('watch.party', $party->room_code), + ]); + } + + public function partyJoin(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + // Şifre kontrolü + if ($party->is_private && $party->password) { + $pw = $request->input('password', ''); + if (!Hash::check($pw, $party->password)) { + return response()->json(['error' => 'Yanlış şifre.'], 403); + } + } + + // Kapasite + $activeCount = $party->activeMembers()->count(); + if ($activeCount >= $party->max_members) { + return response()->json(['error' => 'Oda dolu.'], 403); + } + + WatchPartyMember::updateOrCreate( + ['party_id' => $party->id, 'user_id' => $me->id], + ['last_ping' => now()] + ); + + return response()->json([ + 'ok' => true, + 'current_sec' => $party->current_sec, + 'is_playing' => $party->is_playing, + 'host_id' => $party->host_user_id, + 'members' => $this->partyMemberList($party), + ]); + } + + public function partySync(Request $request, string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + // Sadece host senkron durumu güncelleyebilir + if ($party->host_user_id === $me->id) { + $data = $request->validate([ + 'current_sec' => 'required|integer|min:0', + 'is_playing' => 'required|boolean', + ]); + $party->update([ + 'current_sec' => $data['current_sec'], + 'is_playing' => $data['is_playing'], + ]); + } + + // Herkes ping atar + WatchPartyMember::where('party_id', $party->id) + ->where('user_id', $me->id) + ->update(['last_ping' => now()]); + + return response()->json([ + 'current_sec' => $party->fresh()->current_sec, + 'is_playing' => $party->fresh()->is_playing, + 'members' => $this->partyMemberList($party), + ]); + } + + public function partyLeave(string $roomCode) + { + $party = WatchParty::where('room_code', $roomCode)->firstOrFail(); + $me = Auth::user(); + + WatchPartyMember::where('party_id', $party->id)->where('user_id', $me->id)->delete(); + + if ($party->host_user_id === $me->id) { + $party->delete(); + return response()->json(['ok' => true, 'dissolved' => true]); + } + + return response()->json(['ok' => true, 'dissolved' => false]); + } + + public function partyShow(string $roomCode) + { + $party = WatchParty::with(['episode.anime', 'host'])->where('room_code', $roomCode)->firstOrFail(); + return view('frontend.watch-party', compact('party')); + } + + private function partyMemberList(WatchParty $party): array + { + return $party->activeMembers()->with('user:id,name,username')->get() + ->map(fn($m) => [ + 'id' => $m->user_id, + 'name' => $m->user?->name, + 'username' => $m->user?->username, + 'is_host' => $m->user_id === $party->host_user_id, + ])->toArray(); + } + + // ───────────────────────────────────────────────────────── + // İlk Kez İzleyenler + // ───────────────────────────────────────────────────────── + + public function firstWatchRegister(Request $request, Episode $episode) + { + $me = Auth::user(); + $sessionId = $request->header('X-Session-ID') ?? session()->getId(); + + FirstWatchSession::updateOrCreate( + [ + 'episode_id' => $episode->id, + 'user_id' => $me?->id, + 'session_id' => $me ? null : $sessionId, + ], + [ + 'is_first_time' => (bool)$request->input('is_first_time', true), + 'last_seen' => now(), + ] + ); + + $count = FirstWatchSession::where('episode_id', $episode->id) + ->where('is_first_time', true) + ->where('last_seen', '>=', now()->subMinutes(10)) + ->count(); + + return response()->json(['ok' => true, 'first_watch_count' => $count]); + } + + public function firstWatchCount(Episode $episode) + { + $count = FirstWatchSession::where('episode_id', $episode->id) + ->where('is_first_time', true) + ->where('last_seen', '>=', now()->subMinutes(10)) + ->count(); + + return response()->json(['count' => $count]); + } + + // ───────────────────────────────────────────────────────── + // Ruh Hali Motoru + // ───────────────────────────────────────────────────────── + + private static array $moodGenres = [ + 'sad' => ['Drama', 'Romantizm'], + 'funny' => ['Komedi', 'Slice of Life'], + 'hype' => ['Aksiyon', 'Shounen', 'Spor'], + 'think' => ['Bilim Kurgu', 'Gerilim', 'Supernatural'], + 'romance' => ['Romantizm', 'Shoujo'], + 'scary' => ['Korku', 'Supernatural', 'Gerilim'], + ]; + + public function moodRecommend(Request $request) + { + $mood = $request->validate(['mood' => 'required|in:sad,funny,hype,think,romance,scary'])['mood']; + $genres = self::$moodGenres[$mood] ?? []; + + $animes = Anime::whereHas('genres', fn($q) => $q->whereIn('name', $genres)) + ->where('is_published', true) + ->inRandomOrder() + ->limit(6) + ->get(['id', 'title', 'cover_image', 'slug', 'rating']); + + return response()->json([ + 'animes' => $animes->map(fn($a) => [ + 'id' => $a->id, + 'title' => $a->title, + 'cover' => $a->cover_image ? \App\Support\MediaUrl::fromStoragePath($a->cover_image) : null, + 'url' => route('anime.show', $a->slug), + 'rating'=> $a->rating, + ]), + ]); + } + + // ───────────────────────────────────────────────────────── + // Zaman Kapsülü + // ───────────────────────────────────────────────────────── + + public function capsuleStore(Request $request) + { + $me = Auth::user(); + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'message' => 'required|string|min:5|max:1000', + 'unlock_at' => 'required|date|after:' . now()->addDays(30)->toDateString(), + ]); + + $data['user_id'] = $me->id; + + $capsule = TimeCapsule::create($data); + + return response()->json(['ok' => true, 'id' => $capsule->id]); + } + + public function capsuleIndex() + { + $capsules = TimeCapsule::with('anime:id,title,slug,cover_image') + ->where('user_id', Auth::id()) + ->orderBy('unlock_at') + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'anime' => $c->anime?->title, + 'anime_url' => $c->anime ? route('anime.show', $c->anime->slug) : null, + 'cover' => $c->anime?->cover_image ? MediaUrl::fromStoragePath($c->anime->cover_image) : null, + 'unlock_at' => $c->unlock_at->format('d.m.Y'), + 'unlocked' => $c->isUnlocked(), + 'opened' => $c->isOpened(), + 'message' => $c->isOpened() || $c->isUnlocked() ? $c->message : null, + 'created_at' => $c->created_at->format('d.m.Y'), + ]); + + return view('frontend.capsules', compact('capsules')); + } + + public function capsuleOpen(TimeCapsule $capsule) + { + if ($capsule->user_id !== Auth::id()) { + return response()->json(['error' => 'Yetkisiz.'], 403); + } + if (!$capsule->isUnlocked()) { + return response()->json(['error' => 'Kapsül henüz açılamaz.'], 422); + } + + $capsule->update(['opened_at' => now()]); + + return response()->json(['ok' => true, 'message' => $capsule->message]); + } + + // ───────────────────────────────────────────────────────── + // Spoiler Kilitli Kutu + // ───────────────────────────────────────────────────────── + + public function spoilerBoxes(Episode $episode) + { + $me = Auth::id(); + $boxes = SpoilerBox::with('user:id,name,username') + ->where('episode_id', $episode->id) + ->orderByDesc('likes') + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => [ + 'id' => $b->id, + 'body' => $b->body, + 'is_spoiler' => $b->is_spoiler, + 'spoiler_score' => $b->spoiler_score, + 'likes' => $b->likes, + 'username' => $b->user?->username, + 'name' => $b->user?->name, + 'is_mine' => $me && $b->user_id === $me, + 'liked' => $me ? SpoilerBoxLike::where('box_id', $b->id)->where('user_id', $me)->exists() : false, + 'created_at' => $b->created_at->diffForHumans(), + ]); + + return response()->json(['boxes' => $boxes]); + } + + public function spoilerBoxStore(Request $request, Episode $episode) + { + $me = Auth::user(); + $data = $request->validate([ + 'body' => 'required|string|min:3|max:600', + ]); + + // AI spoiler tespiti + $isSpoiler = false; + $spoilerScore = 0; + $ai = new DeepSeekService(); + if ($ai->isConfigured()) { + $prompt = "Aşağıdaki metin bir anime bölümü hakkında yazılmış. Bu metin spoiler içeriyor mu? " + . "Sadece JSON döndür: {\"is_spoiler\": true/false, \"score\": 0-100}\n\nMetin: " . $data['body']; + try { + $raw = $ai->checkSpoiler($data['body']); + if ($raw) { + $isSpoiler = $raw['is_spoiler'] ?? false; + $spoilerScore = $raw['score'] ?? 0; + } + } catch (\Throwable $e) {} + } + + $box = SpoilerBox::create([ + 'episode_id' => $episode->id, + 'user_id' => $me->id, + 'body' => $data['body'], + 'is_spoiler' => $isSpoiler, + 'spoiler_score' => $spoilerScore, + ]); + + return response()->json([ + 'ok' => true, + 'id' => $box->id, + 'is_spoiler' => $isSpoiler, + ]); + } + + public function spoilerBoxLike(SpoilerBox $box) + { + $me = Auth::id(); + + $existing = SpoilerBoxLike::where('box_id', $box->id)->where('user_id', $me)->first(); + + if ($existing) { + $existing->delete(); + $box->decrement('likes'); + return response()->json(['liked' => false, 'likes' => $box->fresh()->likes]); + } + + SpoilerBoxLike::create(['box_id' => $box->id, 'user_id' => $me, 'created_at' => now()]); + $box->increment('likes'); + + return response()->json(['liked' => true, 'likes' => $box->fresh()->likes]); + } +} diff --git a/app/Http/Controllers/Frontend/TrackingController.php b/app/Http/Controllers/Frontend/TrackingController.php new file mode 100644 index 0000000..711c29f --- /dev/null +++ b/app/Http/Controllers/Frontend/TrackingController.php @@ -0,0 +1,194 @@ +validate([ + 'page_type' => 'nullable|string|max:30', + 'anime_id' => 'nullable|integer', + 'episode_id' => 'nullable|integer', + 'referrer' => 'nullable|string|max:500', + 'url' => 'nullable|string|max:500', + 'time_on_page'=> 'nullable|integer|min:0|max:86400', + ]); + + $ip = $request->ip(); + $ua = $request->userAgent() ?? ''; + $isBot = (bool) $request->attributes->get('is_bot', false); + $botType= $request->attributes->get('bot_type', null); + $geo = self::geoIp($ip); + $sessId = session()->getId(); + + PageView::create([ + 'user_id' => auth()->id(), + 'session_id' => $sessId, + 'url' => mb_substr($data['url'] ?? $request->header('Referer', ''), 0, 500), + 'page_type' => $data['page_type'] ?? 'other', + 'anime_id' => $data['anime_id'] ?? null, + 'episode_id' => $data['episode_id'] ?? null, + 'ip' => $ip, + 'country' => $geo['country'] ?? null, + 'city' => $geo['city'] ?? null, + 'device' => self::detectDevice($ua), + 'browser' => self::detectBrowser($ua), + 'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null, + 'is_bot' => $isBot ? 1 : 0, + 'user_agent' => mb_substr($ua, 0, 500), + 'time_on_page'=> $data['time_on_page'] ?? 0, + 'created_at' => now(), + ]); + + // Oturum kaydını oluştur / güncelle + $this->trackSession($sessId, $ip, $ua, $geo, $isBot, $botType, $data); + + return response()->json(['ok' => true]); + } + + /** + * POST /track/watch + */ + public function watch(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|integer', + 'episode_id' => 'nullable|integer', + 'season_number' => 'required|integer|min:1', + 'episode_number' => 'required|integer|min:1', + 'seconds' => 'required|integer|min:0', + 'total' => 'nullable|integer|min:0', + 'percent' => 'nullable|integer|min:0|max:100', + ]); + + WatchEvent::create([ + 'user_id' => auth()->id(), + 'session_id' => session()->getId(), + 'anime_id' => $data['anime_id'], + 'episode_id' => $data['episode_id'] ?? null, + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'seconds_watched' => $data['seconds'], + 'total_seconds' => $data['total'] ?? 0, + 'percent_complete'=> $data['percent'] ?? 0, + 'created_at' => now(), + ]); + + // Oturum izleme süresini güncelle + try { + DB::table('analytics_sessions') + ->where('session_id', session()->getId()) + ->increment('total_seconds', (int)$data['seconds']); + } catch (\Exception) {} + + return response()->json(['ok' => true]); + } + + /** + * POST /track/session-end — sayfa kapanırken JS'ten gönderilir + */ + public function sessionEnd(Request $request) + { + $data = $request->validate([ + 'time_on_page' => 'nullable|integer|min:0|max:86400', + ]); + + try { + DB::table('analytics_sessions') + ->where('session_id', session()->getId()) + ->update([ + 'last_seen_at' => now(), + 'total_seconds'=> DB::raw('total_seconds + ' . (int)($data['time_on_page'] ?? 0)), + ]); + } catch (\Exception) {} + + return response()->json(['ok' => true]); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private function trackSession(string $sessId, string $ip, string $ua, array $geo, bool $isBot, ?string $botType, array $data): void + { + try { + $existing = DB::table('analytics_sessions')->where('session_id', $sessId)->first(); + + if ($existing) { + DB::table('analytics_sessions') + ->where('session_id', $sessId) + ->update([ + 'pages_visited' => DB::raw('pages_visited + 1'), + 'last_seen_at' => now(), + 'user_id' => auth()->id() ?? $existing->user_id, + ]); + } else { + DB::table('analytics_sessions')->insert([ + 'session_id' => $sessId, + 'user_id' => auth()->id(), + 'ip' => $ip, + 'country' => $geo['country'] ?? null, + 'city' => $geo['city'] ?? null, + 'device' => self::detectDevice($ua), + 'browser' => self::detectBrowser($ua), + 'referrer' => mb_substr($data['referrer'] ?? '', 0, 500) ?: null, + 'landing_page' => mb_substr($data['url'] ?? '', 0, 500) ?: null, + 'pages_visited'=> 1, + 'total_seconds'=> 0, + 'is_bot' => $isBot ? 1 : 0, + 'bot_type' => $botType, + 'user_agent' => mb_substr($ua, 0, 500), + 'started_at' => now(), + 'last_seen_at' => now(), + ]); + } + } catch (\Exception) {} + } + + private static function geoIp(string $ip): array + { + if ($ip === '127.0.0.1' || str_starts_with($ip, '192.168.') || str_starts_with($ip, '10.')) { + return ['country' => 'Yerel', 'city' => 'Localhost']; + } + + return Cache::remember("geo_{$ip}", 86400 * 7, function () use ($ip) { + try { + $r = Http::timeout(2)->get("http://ip-api.com/json/{$ip}?fields=country,city,status"); + if ($r->ok() && $r->json('status') === 'success') { + return ['country' => $r->json('country'), 'city' => $r->json('city')]; + } + } catch (\Exception) {} + return ['country' => null, 'city' => null]; + }); + } + + private static function detectDevice(string $ua): string + { + $ua = strtolower($ua); + if (str_contains($ua, 'tablet') || str_contains($ua, 'ipad')) return 'tablet'; + if (str_contains($ua, 'mobile') || str_contains($ua, 'android') || str_contains($ua, 'iphone')) return 'mobile'; + return 'desktop'; + } + + private static function detectBrowser(string $ua): string + { + if (str_contains($ua, 'Edg/')) return 'Edge'; + if (str_contains($ua, 'OPR/') || str_contains($ua, 'Opera')) return 'Opera'; + if (str_contains($ua, 'Chrome')) return 'Chrome'; + if (str_contains($ua, 'Firefox')) return 'Firefox'; + if (str_contains($ua, 'Safari')) return 'Safari'; + if (str_contains($ua, 'MSIE') || str_contains($ua, 'Trident')) return 'IE'; + return 'Other'; + } +} diff --git a/app/Http/Controllers/Frontend/TribunalController.php b/app/Http/Controllers/Frontend/TribunalController.php new file mode 100644 index 0000000..718cc51 --- /dev/null +++ b/app/Http/Controllers/Frontend/TribunalController.php @@ -0,0 +1,219 @@ +withCount('votes') + ->latest() + ->paginate(15); + + return view('frontend.tribunal.index', compact('tribunals')); + } + + public function show(Tribunal $tribunal) + { + $tribunal->load(['anime', 'episode', 'creator']); + + $me = Auth::id(); + + $myVote = $me + ? TribunalVote::where('tribunal_id', $tribunal->id)->where('user_id', $me)->value('side') + : null; + + $myArgument = $me + ? TribunalArgument::where('tribunal_id', $tribunal->id)->where('user_id', $me)->first() + : null; + + // Tüm tarafların oy sayımları + $allSides = $tribunal->allSides(); + $voteCounts = []; + $total = 0; + foreach (array_keys($allSides) as $key) { + $cnt = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + $voteCounts[$key] = $cnt; + $total += $cnt; + } + + $arguments = TribunalArgument::with('user:id,name,username') + ->where('tribunal_id', $tribunal->id) + ->orderByDesc('vote_count') + ->get() + ->map(function ($arg) use ($me) { + $voted = $me + ? TribunalArgumentVote::where('argument_id', $arg->id)->where('user_id', $me)->exists() + : false; + return [ + 'id' => $arg->id, + 'side' => $arg->side, + 'body' => $arg->body, + 'vote_count' => $arg->vote_count, + 'username' => $arg->user?->username, + 'name' => $arg->user?->name, + 'is_mine' => $me && $arg->user_id === $me, + 'voted' => $voted, + 'created_at' => $arg->created_at->diffForHumans(), + ]; + }); + + return view('frontend.tribunal.show', compact( + 'tribunal', 'myVote', 'myArgument', 'allSides', 'voteCounts', 'total', 'arguments' + )); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'anime_id' => 'required|exists:animes,id', + 'episode_id' => 'nullable|exists:episodes,id', + 'question' => 'required|string|min:10|max:280', + 'side_a' => 'required|string|min:2|max:100', + 'side_b' => 'required|string|min:2|max:100', + 'extra_sides' => 'nullable|array|max:4', + 'extra_sides.*' => 'required|string|min:2|max:100', + 'closes_at' => 'nullable|date|after:now', + ]); + + $data['created_by'] = Auth::id(); + $data['closes_at'] = $data['closes_at'] ?? now()->addDays(7); + $data['extra_sides'] = array_values(array_filter($data['extra_sides'] ?? [])); + + $tribunal = Tribunal::create($data); + + return response()->json([ + 'ok' => true, + 'url' => route('tribunal.show', $tribunal), + ]); + } + + public function vote(Request $request, Tribunal $tribunal) + { + if ($tribunal->status === 'closed') { + return response()->json(['error' => 'Bu dava kapandı.'], 422); + } + + $validSides = array_keys($tribunal->allSides()); + $data = $request->validate(['side' => 'required|in:' . implode(',', $validSides)]); + $me = Auth::id(); + + $existing = TribunalVote::where('tribunal_id', $tribunal->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + if ($existing->side === $data['side']) { + $existing->delete(); + $voted = null; + } else { + $existing->update(['side' => $data['side']]); + $voted = $data['side']; + } + } else { + TribunalVote::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me, + 'side' => $data['side'], + 'created_at' => now(), + ]); + $voted = $data['side']; + } + + $counts = []; + foreach (array_keys($tribunal->allSides()) as $key) { + $counts[$key] = TribunalVote::where('tribunal_id', $tribunal->id)->where('side', $key)->count(); + } + + return response()->json([ + 'voted' => $voted, + 'counts' => $counts, + 'total' => array_sum($counts), + ]); + } + + public function argue(Request $request, Tribunal $tribunal) + { + if ($tribunal->status === 'closed') { + return response()->json(['error' => 'Bu dava kapandı.'], 422); + } + + $validSides = array_keys($tribunal->allSides()); + $data = $request->validate([ + 'side' => 'required|in:' . implode(',', $validSides), + 'body' => 'required|string|min:10|max:500', + ]); + + $me = Auth::id(); + + $existing = TribunalArgument::where('tribunal_id', $tribunal->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + return response()->json(['error' => 'Bu dava için zaten bir argüman girdiniz.'], 422); + } + + $arg = TribunalArgument::create([ + 'tribunal_id' => $tribunal->id, + 'user_id' => $me, + 'side' => $data['side'], + 'body' => $data['body'], + ]); + + return response()->json(['ok' => true, 'id' => $arg->id]); + } + + public function argVote(Request $request, TribunalArgument $argument) + { + $me = Auth::id(); + + $existing = TribunalArgumentVote::where('argument_id', $argument->id) + ->where('user_id', $me) + ->first(); + + if ($existing) { + $existing->delete(); + $argument->decrement('vote_count'); + return response()->json(['voted' => false, 'vote_count' => $argument->fresh()->vote_count]); + } + + TribunalArgumentVote::create([ + 'argument_id' => $argument->id, + 'user_id' => $me, + 'created_at' => now(), + ]); + $argument->increment('vote_count'); + + return response()->json(['voted' => true, 'vote_count' => $argument->fresh()->vote_count]); + } + + public function forAnime(Anime $anime) + { + $tribunals = Tribunal::where('anime_id', $anime->id) + ->withCount('votes') + ->latest() + ->get() + ->map(fn($t) => [ + 'id' => $t->id, + 'question' => $t->question, + 'side_a' => $t->side_a, + 'side_b' => $t->side_b, + 'status' => $t->status, + 'url' => route('tribunal.show', $t), + 'votes' => $t->votes_count, + ]); + + return response()->json(['tribunals' => $tribunals]); + } +} diff --git a/app/Http/Controllers/Frontend/UserFeatureController.php b/app/Http/Controllers/Frontend/UserFeatureController.php new file mode 100644 index 0000000..1174c32 --- /dev/null +++ b/app/Http/Controllers/Frontend/UserFeatureController.php @@ -0,0 +1,465 @@ +id()) + ->with(['anime.genres']) + ->orderByDesc('created_at') + ->get() + ->groupBy('status'); + + $continues = ContinueWatching::where('user_id', auth()->id()) + ->with(['anime', 'episode']) + ->where('percent_complete', '<', 95) + ->orderByDesc('updated_at') + ->limit(20) + ->get(); + + $achievements = UserAchievement::where('user_id', auth()->id()) + ->with('achievement') + ->orderByDesc('earned_at') + ->get(); + + return view('frontend.profile', compact('items', 'continues', 'achievements')); + } + + public function watchlistToggle(Request $request, Anime $anime) + { + $this->requireAuth(); + + $status = $request->input('status', 'plan'); + if (!array_key_exists($status, Watchlist::STATUSES)) { + $status = 'plan'; + } + + $existing = Watchlist::where('user_id', auth()->id()) + ->where('anime_id', $anime->id) + ->first(); + + if ($existing) { + if ($existing->status === $status) { + $existing->delete(); + $inList = false; + $newStatus = null; + } else { + $existing->update(['status' => $status]); + $inList = true; + $newStatus = $status; + } + } else { + Watchlist::create([ + 'user_id' => auth()->id(), + 'anime_id' => $anime->id, + 'status' => $status, + 'created_at' => now(), + ]); + $inList = true; + $newStatus = $status; + } + + $newlyEarned = AchievementService::check(auth()->user()); + + return response()->json([ + 'in_list' => $inList, + 'status' => $newStatus, + 'status_label' => $newStatus ? (Watchlist::STATUSES[$newStatus] ?? '') : null, + 'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned), + ]); + } + + // ── Watchlist Export ───────────────────────────────────────────────────── + + public function watchlistExport(Request $request) + { + $user = auth()->user(); + + if (!$user->hasPerk('watchlist_export')) { + abort(403, 'Bu özellik için premium üyelik gerekiyor.'); + } + + $format = in_array($request->query('format'), ['csv', 'json']) ? $request->query('format') : 'json'; + + $items = Watchlist::where('user_id', $user->id) + ->with('anime:id,title,mal_score,genres') + ->orderBy('status') + ->orderByDesc('created_at') + ->get() + ->map(fn($w) => [ + 'title' => $w->anime->title ?? '', + 'status' => $w->status, + 'added_at' => $w->created_at?->toDateString(), + 'mal_score' => $w->anime->mal_score ?? null, + ]); + + if ($format === 'csv') { + $csv = "title,status,added_at,mal_score\n"; + foreach ($items as $row) { + $csv .= '"' . str_replace('"', '""', $row['title']) . '",' + . $row['status'] . ',' + . $row['added_at'] . ',' + . $row['mal_score'] . "\n"; + } + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="watchlist.csv"', + ]); + } + + return response()->json($items, 200, [ + 'Content-Disposition' => 'attachment; filename="watchlist.json"', + ]); + } + + // ── Episode Vote ────────────────────────────────────────────────────────── + + public function episodeVote(Request $request, Episode $episode) + { + $this->requireAuth(); + + $vote = $request->input('vote') == 1 ? 1 : -1; + + $existing = EpisodeVote::where('user_id', auth()->id()) + ->where('episode_id', $episode->id) + ->first(); + + if ($existing) { + if ($existing->vote === $vote) { + $existing->delete(); // toggle off + } else { + $existing->update(['vote' => $vote]); + } + } else { + EpisodeVote::create([ + 'user_id' => auth()->id(), + 'episode_id' => $episode->id, + 'vote' => $vote, + 'created_at' => now(), + ]); + } + + $likes = EpisodeVote::where('episode_id', $episode->id)->where('vote', 1)->count(); + $dislikes = EpisodeVote::where('episode_id', $episode->id)->where('vote', -1)->count(); + $myVote = EpisodeVote::where('user_id', auth()->id())->where('episode_id', $episode->id)->value('vote'); + + return response()->json([ + 'likes' => $likes, + 'dislikes' => $dislikes, + 'my_vote' => $myVote, + ]); + } + + // ── Anime Rating ───────────────────────────────────────────────────────── + + public function animeRate(Request $request, Anime $anime) + { + $this->requireAuth(); + + $rating = (int) $request->input('rating'); + if ($rating < 1 || $rating > 10) { + return response()->json(['error' => 'Geçersiz puan'], 422); + } + + AnimeRating::updateOrCreate( + ['user_id' => auth()->id(), 'anime_id' => $anime->id], + ['rating' => $rating] + ); + + $avg = AnimeRating::where('anime_id', $anime->id)->avg('rating'); + $count = AnimeRating::where('anime_id', $anime->id)->count(); + + // Anime tablosunu güncelle (ağırlıklı ortalama) + $anime->update(['rating' => round($avg, 1)]); + + $newlyEarned = AchievementService::check(auth()->user()); + + return response()->json([ + 'avg' => round($avg, 1), + 'count' => $count, + 'my_rating' => $rating, + 'achievements' => array_map(fn($a) => ['title' => $a->title, 'icon' => $a->icon, 'color' => $a->color], $newlyEarned), + ]); + } + + // ── Continue Watching (güncelleme) ─────────────────────────────────────── + + public function continueWatchingUpdate(Request $request) + { + if (!auth()->check()) { + return response()->json(['ok' => false]); + } + + $data = $request->validate([ + 'anime_id' => 'required|integer', + 'episode_id' => 'required|integer', + 'season_number' => 'required|integer', + 'episode_number' => 'required|integer', + 'seconds' => 'required|integer|min:0', + 'total' => 'nullable|integer|min:0', + 'percent' => 'nullable|integer|min:0|max:100', + ]); + + $userId = auth()->id(); + + ContinueWatching::updateOrCreate( + ['user_id' => $userId, 'anime_id' => $data['anime_id']], + [ + 'episode_id' => $data['episode_id'], + 'season_number' => $data['season_number'], + 'episode_number' => $data['episode_number'], + 'seconds_watched' => $data['seconds'], + 'total_seconds' => $data['total'] ?? 0, + 'percent_complete'=> $data['percent'] ?? 0, + 'updated_at' => now(), + ] + ); + + // stream_history perki yoksa en eski kayıtları silerek 30 limiti uygula + if (!auth()->user()->hasPerk('stream_history')) { + $count = ContinueWatching::where('user_id', $userId)->count(); + if ($count > 30) { + $idsToDelete = ContinueWatching::where('user_id', $userId) + ->orderBy('updated_at') + ->limit($count - 30) + ->pluck('id'); + ContinueWatching::whereIn('id', $idsToDelete)->delete(); + } + } + + // Başarım kontrolü (her 5 bölümde bir — performans için) + if ($data['seconds'] % 300 < 35) { + AchievementService::check(auth()->user()); + } + + return response()->json(['ok' => true]); + } + + // ── Anime İsteği ───────────────────────────────────────────────────────── + + public function requestIndex() + { + $requests = AnimeRequest::withCount('votes') + ->whereIn('status', ['pending', 'approved', 'added']) + ->orderByDesc('vote_count') + ->orderByDesc('created_at') + ->paginate(20); + + $myRequests = auth()->check() + ? AnimeRequest::where('user_id', auth()->id())->orderByDesc('id')->limit(5)->get() + : collect(); + + $votedIds = []; + if (auth()->check()) { + $votedIds = AnimeRequestVote::where('user_id', auth()->id()) + ->pluck('anime_request_id')->toArray(); + } + + return view('frontend.anime-request', compact('requests', 'myRequests', 'votedIds')); + } + + public function requestStore(Request $request) + { + $this->requireAuth(); + + $data = $request->validate([ + 'title' => 'required|string|max:200', + 'original_title' => 'nullable|string|max:200', + 'note' => 'nullable|string|max:1000', + ]); + + // Benzer istek var mı? + $existing = AnimeRequest::whereRaw('LOWER(title) = ?', [strtolower($data['title'])])->first(); + if ($existing) { + // Oy ekle + $voted = AnimeRequestVote::where('anime_request_id', $existing->id) + ->where('user_id', auth()->id()) + ->exists(); + if (!$voted) { + AnimeRequestVote::create(['anime_request_id' => $existing->id, 'user_id' => auth()->id(), 'created_at' => now()]); + $existing->increment('vote_count'); + } + return response()->json(['ok' => true, 'merged' => true, 'request_id' => $existing->id, 'vote_count' => $existing->fresh()->vote_count]); + } + + $req = AnimeRequest::create([ + 'user_id' => auth()->id(), + 'title' => $data['title'], + 'original_title' => $data['original_title'] ?? null, + 'note' => $data['note'] ?? null, + 'status' => 'pending', + 'vote_count' => 1, + ]); + + AnimeRequestVote::create(['anime_request_id' => $req->id, 'user_id' => auth()->id(), 'created_at' => now()]); + + AchievementService::check(auth()->user()); + + return response()->json(['ok' => true, 'merged' => false, 'request_id' => $req->id, 'vote_count' => 1]); + } + + public function requestVote(AnimeRequest $animeRequest) + { + $this->requireAuth(); + + $voted = AnimeRequestVote::where('anime_request_id', $animeRequest->id) + ->where('user_id', auth()->id()) + ->exists(); + + if ($voted) { + AnimeRequestVote::where('anime_request_id', $animeRequest->id) + ->where('user_id', auth()->id()) + ->delete(); + $animeRequest->decrement('vote_count'); + $isVoted = false; + } else { + AnimeRequestVote::create(['anime_request_id' => $animeRequest->id, 'user_id' => auth()->id(), 'created_at' => now()]); + $animeRequest->increment('vote_count'); + $isVoted = true; + } + + return response()->json(['ok' => true, 'voted' => $isVoted, 'vote_count' => $animeRequest->fresh()->vote_count]); + } + + // ── Anime Takip ────────────────────────────────────────────────────────── + + public function followToggle(Anime $anime) + { + $this->requireAuth(); + $userId = auth()->id(); + + $existing = AnimeFollow::where('user_id', $userId)->where('anime_id', $anime->id)->first(); + + if ($existing) { + $existing->delete(); + $following = false; + } else { + AnimeFollow::create(['user_id' => $userId, 'anime_id' => $anime->id]); + $following = true; + } + + $count = AnimeFollow::where('anime_id', $anime->id)->count(); + + return response()->json(['following' => $following, 'count' => $count]); + } + + // ── Bildirimler ─────────────────────────────────────────────────────────── + + public function notificationsIndex() + { + $this->requireAuth(); + + $notifications = UserNotification::where('user_id', auth()->id()) + ->orderByDesc('created_at') + ->paginate(30); + + // Görüntülenince hepsini okundu yap + UserNotification::where('user_id', auth()->id()) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return view('frontend.notifications', compact('notifications')); + } + + public function notificationsCount() + { + if (!auth()->check()) { + return response()->json(['count' => 0]); + } + $count = UserNotification::where('user_id', auth()->id())->whereNull('read_at')->count(); + return response()->json(['count' => $count]); + } + + // ── Bölüm Notları ───────────────────────────────────────────────────────── + + public function noteStore(Request $request, Episode $episode) + { + $this->requireAuth(); + + $data = $request->validate([ + 'content' => 'required|string|max:500', + 'timestamp_at' => 'nullable|integer|min:0', + ]); + + $note = EpisodeNote::create([ + 'user_id' => auth()->id(), + 'episode_id' => $episode->id, + 'anime_id' => $episode->anime_id, + 'content' => $data['content'], + 'timestamp_at' => $data['timestamp_at'] ?? null, + ]); + + return response()->json([ + 'ok' => true, + 'note' => [ + 'id' => $note->id, + 'content' => $note->content, + 'timestamp_label' => $note->timestamp_label, + 'timestamp_at' => $note->timestamp_at, + 'created_at' => $note->created_at->format('d.m.Y H:i'), + ], + ]); + } + + public function noteDelete(EpisodeNote $note) + { + $this->requireAuth(); + + if ($note->user_id !== auth()->id()) { + abort(403); + } + + $note->delete(); + + return response()->json(['ok' => true]); + } + + public function episodeNotesList(Episode $episode) + { + $this->requireAuth(); + + $notes = EpisodeNote::where('user_id', auth()->id()) + ->where('episode_id', $episode->id) + ->orderBy('timestamp_at') + ->orderBy('created_at') + ->get() + ->map(fn($n) => [ + 'id' => $n->id, + 'content' => $n->content, + 'timestamp_label' => $n->timestamp_label, + 'timestamp_at' => $n->timestamp_at, + 'created_at' => $n->created_at->format('d.m.Y H:i'), + ]); + + return response()->json(['notes' => $notes]); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + private function requireAuth() + { + if (!auth()->check()) { + abort(401); + } + } +} diff --git a/app/Http/Controllers/Frontend/VoiceCallController.php b/app/Http/Controllers/Frontend/VoiceCallController.php new file mode 100644 index 0000000..411ee1f --- /dev/null +++ b/app/Http/Controllers/Frontend/VoiceCallController.php @@ -0,0 +1,134 @@ +validate(['callee_id' => 'required|integer|exists:users,id']); + $caller = Auth::user(); + $callee = User::findOrFail($request->callee_id); + + if ($caller->id === $callee->id) { + return response()->json(['error' => 'Kendinizi arayamazsınız.'], 422); + } + + // End any previous active calls + VoiceCall::where('caller_id', $caller->id) + ->whereIn('status', ['ringing', 'active']) + ->update(['status' => 'ended', 'ended_at' => now()]); + + $channelName = 'vc_' . Str::random(20); + $call = VoiceCall::create([ + 'caller_id' => $caller->id, + 'callee_id' => $callee->id, + 'channel_name' => $channelName, + 'status' => 'ringing', + ]); + + $callerToken = AgoraTokenService::generateToken($channelName, $caller->id); + $calleeToken = AgoraTokenService::generateToken($channelName, $callee->id); + + return response()->json([ + 'call_id' => $call->id, + 'channel_name' => $channelName, + 'token' => $callerToken, + 'callee' => [ + 'id' => $callee->id, + 'name' => $callee->name, + 'avatar' => $callee->avatar ? \App\Support\MediaUrl::fromStoragePath($callee->avatar) : null, + ], + 'agora_app_id' => env('AGORA_APP_ID', ''), + ]); + } + + public function answer(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id, 403); + abort_unless($call->status === 'ringing', 422, 'Call is no longer ringing.'); + + $call->update(['status' => 'active', 'answered_at' => now()]); + + $token = AgoraTokenService::generateToken($call->channel_name, $user->id); + + return response()->json([ + 'channel_name' => $call->channel_name, + 'token' => $token, + 'agora_app_id' => env('AGORA_APP_ID', ''), + 'caller' => [ + 'id' => $call->caller->id, + 'name' => $call->caller->name, + 'avatar' => $call->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($call->caller->avatar) : null, + ], + ]); + } + + public function decline(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403); + abort_unless($call->status === 'ringing', 422); + + $call->update(['status' => 'declined', 'ended_at' => now()]); + + return response()->json(['ok' => true]); + } + + public function end(VoiceCall $call) + { + $user = Auth::user(); + abort_unless($call->callee_id === $user->id || $call->caller_id === $user->id, 403); + + $call->update(['status' => 'ended', 'ended_at' => now()]); + + return response()->json(['ok' => true]); + } + + public function poll(Request $request) + { + $user = Auth::user(); + + // Check for incoming ringing call + $incoming = VoiceCall::where('callee_id', $user->id) + ->where('status', 'ringing') + ->with('caller') + ->latest() + ->first(); + + if ($incoming) { + return response()->json([ + 'type' => 'incoming', + 'call_id' => $incoming->id, + 'caller' => [ + 'id' => $incoming->caller->id, + 'name' => $incoming->caller->name, + 'avatar' => $incoming->caller->avatar ? \App\Support\MediaUrl::fromStoragePath($incoming->caller->avatar) : null, + ], + ]); + } + + // Check if an active call we're in has been ended by the other side + $call_id = $request->query('call_id'); + if ($call_id) { + $call = VoiceCall::find($call_id); + if ($call && in_array($user->id, [$call->caller_id, $call->callee_id])) { + return response()->json([ + 'type' => 'status', + 'status' => $call->status, + ]); + } + } + + return response()->json(['type' => 'none']); + } +} diff --git a/app/Http/Controllers/MediaController.php b/app/Http/Controllers/MediaController.php new file mode 100644 index 0000000..9c4638f --- /dev/null +++ b/app/Http/Controllers/MediaController.php @@ -0,0 +1,24 @@ +exists($path), 404); + + return response()->file($disk->path($path), [ + 'Cache-Control' => 'public, max-age=31536000', + ]); + } +} diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php new file mode 100644 index 0000000..258338d --- /dev/null +++ b/app/Http/Controllers/SitemapController.php @@ -0,0 +1,97 @@ +view('sitemap_index', compact('domain')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function main() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $genres = Genre::where('is_active', true)->select('slug', 'updated_at')->get(); + + $staticPages = [ + ['loc' => '/', 'priority' => '1.0', 'changefreq' => 'daily'], + ['loc' => '/search', 'priority' => '0.8', 'changefreq' => 'daily'], + ['loc' => '/anime-request','priority' => '0.5', 'changefreq' => 'weekly'], + ['loc' => '/blog', 'priority' => '0.8', 'changefreq' => 'daily'], + ]; + + return response() + ->view('sitemaps.main', compact('domain', 'genres', 'staticPages')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function animes() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $animes = Anime::where('is_published', true) + ->select('slug', 'updated_at', 'rating', 'cover_image', 'title') + ->orderByDesc('rating') + ->get() + ->each(function ($anime) use ($domain) { + $anime->cover_image_url = $anime->cover_image + ? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image) + : null; + }); + + return response() + ->view('sitemaps.animes', compact('domain', 'animes')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function blog() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $posts = BlogPost::published() + ->select('slug', 'updated_at', 'published_at', 'cover_image', 'title', 'excerpt') + ->orderByDesc('published_at') + ->get(); + + return response() + ->view('sitemaps.blog', compact('domain', 'posts')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } + + public function videos() + { + $domain = rtrim(Setting::get('seo_canonical_domain', config('app.url')), '/'); + + $animes = Anime::where('is_published', true) + ->with(['episodes' => fn($q) => $q->with('season:id,season_number')->orderBy('episode_number')->limit(1)]) + ->select('id', 'slug', 'title', 'description', 'cover_image', 'updated_at', 'rating') + ->orderByDesc('rating') + ->limit(200) + ->get() + ->each(function ($anime) use ($domain) { + $anime->cover_image_url = $anime->cover_image + ? (str_starts_with($anime->cover_image, 'http') ? $anime->cover_image : $domain . '/storage/' . $anime->cover_image) + : null; + // İlk bölümün izleme URL'si — player_loc için (loc'tan farklı, gerçek player sayfası) + $firstEp = $anime->episodes->first(); + $sNum = $firstEp?->season?->season_number ?? 1; + $eNum = $firstEp?->episode_number ?? 1; + $anime->first_ep_watch_url = $domain . '/watch/' . $anime->slug . '/' . $sNum . '/' . $eNum; + }); + + return response() + ->view('sitemaps.videos', compact('domain', 'animes')) + ->header('Content-Type', 'application/xml; charset=utf-8'); + } +} diff --git a/app/Http/Middleware/AdminAccessMiddleware.php b/app/Http/Middleware/AdminAccessMiddleware.php new file mode 100644 index 0000000..a8fdae5 --- /dev/null +++ b/app/Http/Middleware/AdminAccessMiddleware.php @@ -0,0 +1,22 @@ +check()) { + return redirect()->route('admin.login'); + } + + if (!auth()->user()->isModerator()) { + abort(403, 'Bu alana erişim yetkiniz yok.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..9504fe0 --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,43 @@ +middleware(['admin']) → admin ONLY (moderators denied) + * ->middleware(['admin:animes.edit']) → admin, OR moderator WITH that permission + */ + public function handle(Request $request, Closure $next, string $permission = null) + { + if (!auth()->check()) { + return redirect()->route('admin.login'); + } + + $user = auth()->user(); + + // Admins always pass + if ($user->isAdmin()) { + return $next($request); + } + + // Must be at least a moderator + if ($user->role !== 'moderator') { + abort(403, 'Bu alana erişim yetkiniz yok.'); + } + + // Moderators always need a specific permission — no blanket access + if (!$permission || !$user->can_mod($permission)) { + if ($request->expectsJson()) { + return response()->json(['error' => 'Bu işlem için yetkiniz yok.'], 403); + } + return back()->with('error', 'Bu sayfaya erişim için gerekli izne sahip değilsiniz.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/BotDetector.php b/app/Http/Middleware/BotDetector.php new file mode 100644 index 0000000..2a2d9b3 --- /dev/null +++ b/app/Http/Middleware/BotDetector.php @@ -0,0 +1,218 @@ +ip(); + $ua = strtolower($request->userAgent() ?? ''); + $path = $request->path(); + + // Skip paths + foreach (self::SKIP_PATHS as $skip) { + if (str_starts_with('/' . $path, $skip)) { + return $next($request); + } + } + + // Güvenilir IP (Google vb.) — tüm kontrolleri atla + foreach (self::TRUSTED_IP_PREFIXES as $prefix) { + if (str_starts_with($ip, $prefix)) { + return $next($request); + } + } + + // Manuel engelli IP kontrolü + if ($this->isBlockedIp($ip)) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'ip_blocked', 'blocked_ip'); + return response('Erişim engellendi.', 403); + } + + // UA boşsa bot olarak işaretle + if (empty($ua)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'noua'); + $this->logBot($ip, '', '/' . $path, $request->method(), 'allowed', 'no_ua'); + return $next($request); + } + + // Kötü bot mu? + foreach (self::BAD_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'blocked', $pattern); + return response('', 403); + } + } + + // İyi bot mu? + foreach (self::GOOD_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'good'); + // İyi botlar için çok agresif rate limit (dakikada 60) + if ($this->isRateLimited($ip, 60, 'good_bot')) { + return response('', 429); + } + return $next($request); + } + } + + // Generic araç mı? + foreach (self::GENERIC_BOTS as $pattern) { + if (str_contains($ua, $pattern)) { + $request->attributes->set('is_bot', true); + $request->attributes->set('bot_type', 'generic'); + if ($this->isRateLimited($ip, 10, 'generic')) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', $pattern); + // 30+ istek → otomatik engelle + $count = Cache::get("bot_count_{$ip}", 0); + if ($count > 30) { + $this->autoBlock($ip, 'Otomatik: dakikada 30+ generic bot isteği'); + } + return response('', 429); + } + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'allowed', $pattern); + return $next($request); + } + } + + // Normal kullanıcı — genel rate limit (dakikada 120 istek) + if ($this->isRateLimited($ip, 120, 'human')) { + $this->logBot($ip, $request->userAgent(), '/' . $path, $request->method(), 'rate_limited', 'human_flood'); + $count = Cache::get("bot_count_{$ip}", 0); + if ($count > 200) { + $this->autoBlock($ip, 'Otomatik: dakikada 200+ istek flood'); + } + return response('', 429); + } + + $request->attributes->set('is_bot', false); + return $next($request); + } + + private function isBlockedIp(string $ip): bool + { + return Cache::remember("blocked_ip_{$ip}", 300, function () use ($ip) { + try { + return DB::table('blocked_ips') + ->where('ip', $ip) + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->exists(); + } catch (\Exception) { + return false; + } + }); + } + + private function isRateLimited(string $ip, int $maxPerMinute, string $type): bool + { + $key = "rl_{$type}_{$ip}"; + $count = Cache::get($key, 0); + + if ($count === 0) { + Cache::put($key, 1, 60); + } else { + Cache::increment($key); + } + + // Bot count ayrı izle + Cache::put("bot_count_{$ip}", Cache::get("bot_count_{$ip}", 0) + 1, 60); + + return $count >= $maxPerMinute; + } + + private function autoBlock(string $ip, string $reason): void + { + try { + DB::table('blocked_ips')->insertOrIgnore([ + 'ip' => $ip, + 'reason' => $reason, + 'auto_blocked' => 1, + 'blocked_at' => now(), + 'expires_at' => now()->addHours(24), + ]); + Cache::forget("blocked_ip_{$ip}"); + } catch (\Exception) {} + } + + private function logBot(string $ip, ?string $ua, string $path, string $method, string $action, string $botName): void + { + try { + DB::table('analytics_bot_logs')->insert([ + 'ip' => $ip, + 'user_agent' => mb_substr($ua ?? '', 0, 500), + 'path' => mb_substr($path, 0, 500), + 'method' => $method, + 'action' => $action, + 'bot_name' => mb_substr($botName, 0, 100), + 'created_at' => now(), + ]); + } catch (\Exception) {} + } +} diff --git a/app/Http/Middleware/ImportApiMiddleware.php b/app/Http/Middleware/ImportApiMiddleware.php new file mode 100644 index 0000000..3303a25 --- /dev/null +++ b/app/Http/Middleware/ImportApiMiddleware.php @@ -0,0 +1,21 @@ +header('X-Import-Key') ?? $request->query('api_key'); + + if (!$apiKey || $provided !== $apiKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurePlayer.php b/app/Http/Middleware/SecurePlayer.php new file mode 100644 index 0000000..62a3161 --- /dev/null +++ b/app/Http/Middleware/SecurePlayer.php @@ -0,0 +1,24 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return $response; + } +} diff --git a/app/Http/Middleware/SeoRedirectMiddleware.php b/app/Http/Middleware/SeoRedirectMiddleware.php new file mode 100644 index 0000000..a0b4a70 --- /dev/null +++ b/app/Http/Middleware/SeoRedirectMiddleware.php @@ -0,0 +1,33 @@ +isMethod('GET')) { + try { + $path = '/' . ltrim($request->path(), '/'); + $redirect = cache()->remember('seo_redirect_' . md5($path), 300, function () use ($path) { + return SeoRedirect::where('from_path', $path)->where('is_active', true)->first(); + }); + + if ($redirect) { + SeoRedirect::where('id', $redirect->id)->increment('hits'); + cache()->forget('seo_redirect_' . md5($path)); + return redirect($redirect->to_path, $redirect->type); + } + } catch (\Throwable $e) { + // DB/cache hatası — redirect yerine normal akışa devam et, site çökmesin + \Illuminate\Support\Facades\Log::error('SeoRedirectMiddleware DB error: ' . $e->getMessage()); + } + } + + return $next($request); + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..23c9e49 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,25 @@ +hasMany(UserAchievement::class); + } +} diff --git a/app/Models/ActivationCode.php b/app/Models/ActivationCode.php new file mode 100644 index 0000000..dfac5b8 --- /dev/null +++ b/app/Models/ActivationCode.php @@ -0,0 +1,59 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } + + public function usedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'used_by'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isUsed(): bool + { + return ! is_null($this->used_at); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function isValid(): bool + { + return ! $this->isUsed() && ! $this->isExpired(); + } + + public static function generateCode(): string + { + do { + $hex = strtoupper(bin2hex(random_bytes(6))); + $code = implode('-', str_split($hex, 4)); + } while (self::where('code', $code)->exists()); + + return $code; + } +} diff --git a/app/Models/Ad.php b/app/Models/Ad.php new file mode 100644 index 0000000..72709ba --- /dev/null +++ b/app/Models/Ad.php @@ -0,0 +1,78 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + /** Aktif + zamanlaması uygun reklamlar */ + public function scopeLive(Builder $q): Builder + { + return $q->where('is_active', true) + ->where(fn($s) => $s->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn($s) => $s->whereNull('ends_at')->orWhere('ends_at', '>=', now())); + } + + /** + * Medya URL'si — yüklenen dosya veya dış URL. + * Yüklenen dosyalar /media/{path} route'undan servis edilir (MediaController); + * public/storage symlink'ine bağımlı değil — kapaklar/avatarlarla aynı yol. + */ + public function getMediaUrlAttribute(): ?string + { + if ($this->file_path) return MediaUrl::fromStoragePath($this->file_path); + if ($this->external_url) return $this->external_url; + return null; + } + + /** CTR yüzdesi */ + public function getCtrAttribute(): float + { + return $this->impressions > 0 + ? round($this->clicks / $this->impressions * 100, 2) + : 0.0; + } + + /** Ağırlıklı rastgele seçim — pre-roll video reklam */ + public static function pickVideo(): ?self + { + return self::weightedPick( + self::live()->where('type', 'video')->where('placement', 'preroll')->get() + ); + } + + /** Ağırlıklı rastgele seçim — banner (placement bazlı) */ + public static function pickBanner(string $placement): ?self + { + return self::weightedPick( + self::live()->where('type', 'banner')->where('placement', $placement)->get() + ); + } + + private static function weightedPick($ads): ?self + { + if ($ads->isEmpty()) return null; + $total = max(1, $ads->sum('weight')); + $roll = random_int(1, $total); + foreach ($ads as $ad) { + $roll -= max(1, $ad->weight); + if ($roll <= 0) return $ad; + } + return $ads->first(); + } +} diff --git a/app/Models/Analytics/AiQuery.php b/app/Models/Analytics/AiQuery.php new file mode 100644 index 0000000..9699eca --- /dev/null +++ b/app/Models/Analytics/AiQuery.php @@ -0,0 +1,23 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Analytics/BotLog.php b/app/Models/Analytics/BotLog.php new file mode 100644 index 0000000..7ee6b0c --- /dev/null +++ b/app/Models/Analytics/BotLog.php @@ -0,0 +1,18 @@ + 'datetime']; +} diff --git a/app/Models/Analytics/PageView.php b/app/Models/Analytics/PageView.php new file mode 100644 index 0000000..d09d2ac --- /dev/null +++ b/app/Models/Analytics/PageView.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/Analytics/VisitorSession.php b/app/Models/Analytics/VisitorSession.php new file mode 100644 index 0000000..9d2b986 --- /dev/null +++ b/app/Models/Analytics/VisitorSession.php @@ -0,0 +1,28 @@ + 'boolean', + 'started_at' => 'datetime', + 'last_seen_at'=> 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Analytics/WatchEvent.php b/app/Models/Analytics/WatchEvent.php new file mode 100644 index 0000000..226e90b --- /dev/null +++ b/app/Models/Analytics/WatchEvent.php @@ -0,0 +1,30 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/Anime.php b/app/Models/Anime.php new file mode 100644 index 0000000..09e6aa7 --- /dev/null +++ b/app/Models/Anime.php @@ -0,0 +1,103 @@ + 'boolean', + 'is_published' => 'boolean', + 'is_dubbed' => 'boolean', + 'is_trending' => 'boolean', + 'rating' => 'float', + 'trending_order' => 'integer', + ]; + + protected static function boot() + { + parent::boot(); + static::creating(function ($anime) { + if (empty($anime->slug)) { + $base = Str::slug($anime->title ?: 'anime'); + $slug = $base; + $i = 2; + while (static::where('slug', $slug)->exists()) { + $slug = $base . '-' . $i++; + } + $anime->slug = $slug; + } + }); + static::saving(function ($anime) { + if (empty($anime->slug)) { + $base = Str::slug($anime->title ?: 'anime'); + $slug = $base; + $i = 2; + while (static::where('slug', $slug)->whereKeyNot($anime->id ?? 0)->exists()) { + $slug = $base . '-' . $i++; + } + $anime->slug = $slug; + } + }); + } + + /** Güvenli detail URL — slug null olsa bile çökmez. */ + public function getDetailUrlAttribute(): string + { + return $this->slug ? route('anime.show', $this->slug) : '#'; + } + + public function genres() + { + return $this->belongsToMany(Genre::class, 'anime_genre'); + } + + public function seasons() + { + return $this->hasMany(Season::class)->orderBy('season_number'); + } + + public function episodes() + { + return $this->hasMany(Episode::class); + } + + public function importJobs() + { + return $this->hasMany(ImportJob::class); + } + + public function permissions() + { + return $this->morphMany(ContentPermission::class, 'content', 'content_type', 'content_id'); + } + + /** Cover veya banner URL'sini döndürür (storage veya dış URL) */ + private function imageUrl(?string $path): ?string + { + return MediaUrl::fromStoragePath($path); + } + + public function getCoverUrlAttribute(): ?string { return $this->imageUrl($this->cover_image); } + public function getBannerUrlAttribute(): ?string { return $this->imageUrl($this->banner_image); } + + public function getPermission(string $key): string + { + $override = $this->permissions()->where('permission_key', $key)->first(); + if ($override) return $override->required_membership; + + $global = PermissionSetting::where('key', $key)->first(); + return $global ? $global->required_membership : 'free'; + } +} diff --git a/app/Models/AnimeFollow.php b/app/Models/AnimeFollow.php new file mode 100644 index 0000000..db959af --- /dev/null +++ b/app/Models/AnimeFollow.php @@ -0,0 +1,15 @@ +belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/AnimeRating.php b/app/Models/AnimeRating.php new file mode 100644 index 0000000..f7c8b69 --- /dev/null +++ b/app/Models/AnimeRating.php @@ -0,0 +1,13 @@ +belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Models/AnimeRequest.php b/app/Models/AnimeRequest.php new file mode 100644 index 0000000..10019d7 --- /dev/null +++ b/app/Models/AnimeRequest.php @@ -0,0 +1,31 @@ + ['label' => 'Bekliyor', 'color' => '#f0883e'], + 'approved' => ['label' => 'Onaylandı', 'color' => '#3fb950'], + 'rejected' => ['label' => 'Reddedildi', 'color' => '#f85149'], + 'added' => ['label' => 'Eklendi', 'color' => '#79c0ff'], + ]; + + public function user() { return $this->belongsTo(User::class); } + public function votes() { return $this->hasMany(AnimeRequestVote::class); } + + public function hasVotedBy(?User $user, string $ip): bool + { + if ($user) { + return $this->votes()->where('user_id', $user->id)->exists(); + } + return $this->votes()->where('ip', $ip)->exists(); + } +} diff --git a/app/Models/AnimeRequestVote.php b/app/Models/AnimeRequestVote.php new file mode 100644 index 0000000..bdd427b --- /dev/null +++ b/app/Models/AnimeRequestVote.php @@ -0,0 +1,14 @@ + 'datetime']; +} diff --git a/app/Models/AnimeSwipe.php b/app/Models/AnimeSwipe.php new file mode 100644 index 0000000..10d31a5 --- /dev/null +++ b/app/Models/AnimeSwipe.php @@ -0,0 +1,15 @@ + 'datetime']; + + public function anime() { return $this->belongsTo(Anime::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Banner.php b/app/Models/Banner.php new file mode 100644 index 0000000..21a773d --- /dev/null +++ b/app/Models/Banner.php @@ -0,0 +1,17 @@ + 'boolean']; + + public function getImageUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->image); + } +} diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php new file mode 100644 index 0000000..601443e --- /dev/null +++ b/app/Models/BlogPost.php @@ -0,0 +1,56 @@ + 'array', + 'faq' => 'array', + 'ai_generated' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function getCoverUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->cover_image); + } + + public function anime(): BelongsTo + { + return $this->belongsTo(Anime::class); + } + + public function scopePublished($q) + { + return $q->where('status', 'published')->whereNotNull('published_at'); + } + + public function getReadableTimeAttribute(): string + { + return $this->reading_time . ' dk okuma'; + } + + public static function generateSlug(string $title): string + { + $slug = Str::slug($title, '-', 'tr'); + $base = $slug; + $i = 1; + while (static::where('slug', $slug)->exists()) { + $slug = $base . '-' . $i++; + } + return $slug; + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..94bfcd5 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,46 @@ + 'boolean']; + + public function likes() + { + return $this->hasMany(CommentLike::class); + } + + public function isLikedBy(?int $userId): bool + { + if (!$userId) return false; + return $this->likes()->where('user_id', $userId)->exists(); + } + + public function commentable() + { + return $this->morphTo(); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function parent() + { + return $this->belongsTo(Comment::class, 'parent_id'); + } + + public function replies() + { + return $this->hasMany(Comment::class, 'parent_id'); + } +} diff --git a/app/Models/CommentLike.php b/app/Models/CommentLike.php new file mode 100644 index 0000000..9b69858 --- /dev/null +++ b/app/Models/CommentLike.php @@ -0,0 +1,20 @@ +belongsTo(Comment::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ContentPermission.php b/app/Models/ContentPermission.php new file mode 100644 index 0000000..2d99f18 --- /dev/null +++ b/app/Models/ContentPermission.php @@ -0,0 +1,10 @@ + 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php new file mode 100644 index 0000000..bd0d6e7 --- /dev/null +++ b/app/Models/Conversation.php @@ -0,0 +1,45 @@ +belongsToMany(User::class, 'conversation_participants') + ->withPivot('last_read_at'); + } + + public function messages() + { + return $this->hasMany(Message::class)->orderBy('created_at'); + } + + public function lastMessage() + { + return $this->hasOne(Message::class)->latestOfMany('created_at'); + } + + public function unreadCountFor(int $userId): int + { + $pivot = $this->participants->firstWhere('id', $userId)?->pivot; + $lastRead = $pivot?->last_read_at; + + $q = $this->messages()->where('user_id', '!=', $userId); + if ($lastRead) { + $q->where('created_at', '>', $lastRead); + } + return $q->count(); + } + + // Find existing DM between two users or return null + public static function between(int $a, int $b): ?self + { + return self::whereHas('participants', fn($q) => $q->where('user_id', $a)) + ->whereHas('participants', fn($q) => $q->where('user_id', $b)) + ->whereHas('participants', fn($q) => $q->havingRaw('COUNT(*) = 2'), null, null, fn($q) => $q->select(\DB::raw('COUNT(*)'))) + ->first(); + } +} diff --git a/app/Models/Episode.php b/app/Models/Episode.php new file mode 100644 index 0000000..8a96145 --- /dev/null +++ b/app/Models/Episode.php @@ -0,0 +1,78 @@ + 'boolean', + 'available_dubs' => 'array', + ]; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function season() + { + return $this->belongsTo(Season::class); + } + + public function subtitles() + { + return $this->hasMany(Subtitle::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function permissions() + { + return $this->morphMany(ContentPermission::class, 'content', 'content_type', 'content_id'); + } + + public function getPermission(string $key): string + { + $override = ContentPermission::where('content_type', 'episode') + ->where('content_id', $this->id) + ->where('permission_key', $key) + ->first(); + if ($override) return $override->required_membership; + + // Anime-level permission + $animeOverride = ContentPermission::where('content_type', 'anime') + ->where('content_id', $this->anime_id) + ->where('permission_key', $key) + ->first(); + if ($animeOverride) return $animeOverride->required_membership; + + $global = PermissionSetting::where('key', $key)->first(); + return $global ? $global->required_membership : 'free'; + } + + public function getDurationFormattedAttribute(): string + { + if (!$this->duration) return '-'; + $minutes = intdiv($this->duration, 60); + $seconds = $this->duration % 60; + return sprintf('%d:%02d', $minutes, $seconds); + } + + public function getThumbnailUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->thumbnail); + } +} diff --git a/app/Models/EpisodeNote.php b/app/Models/EpisodeNote.php new file mode 100644 index 0000000..ba4eac4 --- /dev/null +++ b/app/Models/EpisodeNote.php @@ -0,0 +1,21 @@ +belongsTo(User::class); } + public function episode() { return $this->belongsTo(Episode::class); } + public function anime() { return $this->belongsTo(Anime::class); } + + public function getTimestampLabelAttribute(): string + { + if (!$this->timestamp_at) return ''; + $s = $this->timestamp_at; + return sprintf('%d:%02d', intdiv($s, 60), $s % 60); + } +} diff --git a/app/Models/EpisodePrediction.php b/app/Models/EpisodePrediction.php new file mode 100644 index 0000000..0de1d5a --- /dev/null +++ b/app/Models/EpisodePrediction.php @@ -0,0 +1,16 @@ + 'boolean']; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } + public function votes() { return $this->hasMany(PredictionVote::class, 'prediction_id'); } +} diff --git a/app/Models/EpisodeTimestampComment.php b/app/Models/EpisodeTimestampComment.php new file mode 100644 index 0000000..f81a16f --- /dev/null +++ b/app/Models/EpisodeTimestampComment.php @@ -0,0 +1,22 @@ + 'boolean', + 'timestamp_sec' => 'integer', + ]; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/EpisodeVote.php b/app/Models/EpisodeVote.php new file mode 100644 index 0000000..f1b871f --- /dev/null +++ b/app/Models/EpisodeVote.php @@ -0,0 +1,17 @@ + 'datetime', 'vote' => 'integer']; + + public function user() { return $this->belongsTo(User::class); } + public function episode() { return $this->belongsTo(Episode::class); } +} diff --git a/app/Models/FirstWatchSession.php b/app/Models/FirstWatchSession.php new file mode 100644 index 0000000..8f49f1e --- /dev/null +++ b/app/Models/FirstWatchSession.php @@ -0,0 +1,17 @@ + 'boolean', 'last_seen' => 'datetime']; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Genre.php b/app/Models/Genre.php new file mode 100644 index 0000000..58254c2 --- /dev/null +++ b/app/Models/Genre.php @@ -0,0 +1,27 @@ + 'boolean']; + + protected static function boot() + { + parent::boot(); + static::creating(function ($genre) { + if (empty($genre->slug)) { + $genre->slug = Str::slug($genre->name); + } + }); + } + + public function animes() + { + return $this->belongsToMany(Anime::class, 'anime_genre'); + } +} diff --git a/app/Models/ImportJob.php b/app/Models/ImportJob.php new file mode 100644 index 0000000..0f3a2ba --- /dev/null +++ b/app/Models/ImportJob.php @@ -0,0 +1,86 @@ + 'array', + ]; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function getProgressPercentAttribute(): int + { + if ($this->total_episodes === 0) return 0; + return (int) round($this->done_episodes / $this->total_episodes * 100); + } + + public function getStatusColorAttribute(): string + { + return match($this->status) { + 'pending' => 'secondary', + 'fetching' => 'info', + 'downloading' => 'primary', + 'uploading' => 'warning', + 'done' => 'success', + 'failed' => 'danger', + default => 'secondary', + }; + } + + public function getStatusLabelAttribute(): string + { + return match($this->status) { + 'pending' => 'Bekliyor', + 'fetching' => 'Bölümler Alınıyor', + 'downloading' => 'İndiriliyor', + 'uploading' => 'Yükleniyor', + 'done' => 'Tamamlandı', + 'failed' => 'Hata', + default => $this->status, + }; + } + + /** Toplam bölüm sayısını season_ranges'ten hesapla */ + public function buildEpisodeList(): array + { + if (!$this->season_ranges) return []; + $episodes = []; + foreach ($this->season_ranges as $range) { + $season = (int) $range['season']; + $from = (int) $range['from']; + $to = (int) $range['to']; + for ($ep = $from; $ep <= $to; $ep++) { + $episodes[] = ['season' => $season, 'episode' => $ep]; + } + } + return $episodes; + } + + public function getTotalFromRangesAttribute(): int + { + $total = 0; + foreach (($this->season_ranges ?? []) as $r) { + $total += max(0, (int)$r['to'] - (int)$r['from'] + 1); + } + return $total; + } +} diff --git a/app/Models/MembershipPlan.php b/app/Models/MembershipPlan.php new file mode 100644 index 0000000..cca9d22 --- /dev/null +++ b/app/Models/MembershipPlan.php @@ -0,0 +1,35 @@ + 'array', + 'perks' => 'array', + 'is_active' => 'boolean', + 'is_public' => 'boolean', + 'visible_until' => 'datetime', + 'price' => 'float', + 'trial_days' => 'integer', + ]; + + /** Belirli bir perk'in bu planda aktif olup olmadığını döner */ + public function hasPerk(string $key): bool + { + return !empty(($this->perks ?? [])[$key]); + } + + public function subscriptions() + { + return $this->hasMany(Subscription::class, 'plan_id'); + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..75b4d92 --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,15 @@ + 'datetime']; + + public function user() { return $this->belongsTo(User::class); } + public function conversation() { return $this->belongsTo(Conversation::class); } +} diff --git a/app/Models/ModeratorPermission.php b/app/Models/ModeratorPermission.php new file mode 100644 index 0000000..8c1ba32 --- /dev/null +++ b/app/Models/ModeratorPermission.php @@ -0,0 +1,73 @@ + [ + 'animes.view' => 'Anime listesini görüntüle', + 'animes.create' => 'Yeni anime ekle', + 'animes.edit' => 'Anime düzenle', + 'animes.delete' => 'Anime sil', + 'animes.publish' => 'Anime yayınla / gizle', + ], + 'Bölüm Yönetimi' => [ + 'episodes.view' => 'Bölümleri görüntüle', + 'episodes.create' => 'Bölüm ekle', + 'episodes.edit' => 'Bölüm düzenle', + 'episodes.delete' => 'Bölüm sil', + ], + 'Kullanıcı Yönetimi' => [ + 'users.view' => 'Kullanıcıları görüntüle', + 'users.edit' => 'Kullanıcı bilgilerini düzenle', + 'users.ban' => 'Kullanıcı banla / ban kaldır', + 'users.premium' => 'Premium ver / al', + ], + 'Yorum Yönetimi' => [ + 'comments.view' => 'Yorumları görüntüle', + 'comments.approve' => 'Yorum onayla / reddet', + 'comments.delete' => 'Yorum sil', + 'comments.pin' => 'Yorum sabitle', + ], + 'İçerik Yönetimi' => [ + 'genres.manage' => 'Türleri yönet', + 'banners.manage' => 'Bannerleri yönet', + 'requests.manage' => 'Anime isteklerini yönet', + 'tribunal.manage' => 'Mahkeme yönet', + 'import.manage' => 'Anime import et', + ], + 'Analitik & Raporlar' => [ + 'analytics.view' => 'Analitikleri görüntüle', + 'analytics.bots' => 'Bot analitiğini görüntüle', + 'analytics.block' => 'IP engelle / engel kaldır', + 'analytics.users' => 'Kullanıcı analitiği & aktivite', + ], + 'Bildirimler' => [ + 'notifications.send' => 'Push bildirim gönder', + 'notifications.view' => 'Bildirim geçmişini görüntüle', + ], + ]; + + public static function allKeys(): array + { + return collect(self::$groups)->flatMap(fn($g) => array_keys($g))->values()->all(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function grantedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'granted_by'); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..c32eaaf --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'paid_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function plan() + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } +} diff --git a/app/Models/PermissionSetting.php b/app/Models/PermissionSetting.php new file mode 100644 index 0000000..044839c --- /dev/null +++ b/app/Models/PermissionSetting.php @@ -0,0 +1,10 @@ +belongsTo(EpisodePrediction::class, 'prediction_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Season.php b/app/Models/Season.php new file mode 100644 index 0000000..ae03620 --- /dev/null +++ b/app/Models/Season.php @@ -0,0 +1,31 @@ + 'boolean']; + + public function anime() + { + return $this->belongsTo(Anime::class); + } + + public function episodes() + { + return $this->hasMany(Episode::class)->orderBy('episode_number'); + } + + public function getCoverUrlAttribute(): ?string + { + return MediaUrl::fromStoragePath($this->cover_image); + } +} diff --git a/app/Models/SeoKeyword.php b/app/Models/SeoKeyword.php new file mode 100644 index 0000000..cfef35b --- /dev/null +++ b/app/Models/SeoKeyword.php @@ -0,0 +1,14 @@ + 'boolean']; +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..d40ff59 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,20 @@ +value('value') ?? $default; + } + + public static function set(string $key, $value, string $group = 'general'): void + { + static::updateOrCreate(['key' => $key], ['value' => $value, 'group' => $group]); + } +} diff --git a/app/Models/SpoilerBox.php b/app/Models/SpoilerBox.php new file mode 100644 index 0000000..4e84ba6 --- /dev/null +++ b/app/Models/SpoilerBox.php @@ -0,0 +1,18 @@ + 'boolean', + ]; + + public function episode() { return $this->belongsTo(Episode::class); } + public function user() { return $this->belongsTo(User::class); } + public function boxLikes(){ return $this->hasMany(SpoilerBoxLike::class, 'box_id'); } +} diff --git a/app/Models/SpoilerBoxLike.php b/app/Models/SpoilerBoxLike.php new file mode 100644 index 0000000..77fcb5a --- /dev/null +++ b/app/Models/SpoilerBoxLike.php @@ -0,0 +1,14 @@ +belongsTo(SpoilerBox::class, 'box_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..17cc517 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,28 @@ + 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function plan() + { + return $this->belongsTo(MembershipPlan::class, 'plan_id'); + } +} diff --git a/app/Models/Subtitle.php b/app/Models/Subtitle.php new file mode 100644 index 0000000..105af3c --- /dev/null +++ b/app/Models/Subtitle.php @@ -0,0 +1,15 @@ +belongsTo(Episode::class); + } +} diff --git a/app/Models/TimeCapsule.php b/app/Models/TimeCapsule.php new file mode 100644 index 0000000..1615e9f --- /dev/null +++ b/app/Models/TimeCapsule.php @@ -0,0 +1,28 @@ + 'datetime', + 'opened_at' => 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } + + public function isUnlocked(): bool + { + return now()->gte($this->unlock_at); + } + + public function isOpened(): bool + { + return !is_null($this->opened_at); + } +} diff --git a/app/Models/Tribunal.php b/app/Models/Tribunal.php new file mode 100644 index 0000000..fff7402 --- /dev/null +++ b/app/Models/Tribunal.php @@ -0,0 +1,37 @@ + 'datetime', + 'extra_sides' => 'array', + ]; + + // Tüm tarafları ['a'=>'Haklıydı', 'b'=>'Haksızdı', 'c'=>'...'] formatında döndür + public function allSides(): array + { + $sides = ['a' => $this->side_a, 'b' => $this->side_b]; + foreach (($this->extra_sides ?? []) as $i => $label) { + $sides[chr(99 + $i)] = $label; // c, d, e, ... + } + return $sides; + } + + public function anime() { return $this->belongsTo(Anime::class); } + public function episode() { return $this->belongsTo(Episode::class); } + public function creator() { return $this->belongsTo(User::class, 'created_by'); } + public function votes() { return $this->hasMany(TribunalVote::class); } + public function arguments() { return $this->hasMany(TribunalArgument::class); } + + public function voteCountA() { return $this->votes()->where('side', 'a')->count(); } + public function voteCountB() { return $this->votes()->where('side', 'b')->count(); } +} diff --git a/app/Models/TribunalArgument.php b/app/Models/TribunalArgument.php new file mode 100644 index 0000000..3983c44 --- /dev/null +++ b/app/Models/TribunalArgument.php @@ -0,0 +1,14 @@ +belongsTo(Tribunal::class); } + public function user() { return $this->belongsTo(User::class); } + public function argVotes() { return $this->hasMany(TribunalArgumentVote::class, 'argument_id'); } +} diff --git a/app/Models/TribunalArgumentVote.php b/app/Models/TribunalArgumentVote.php new file mode 100644 index 0000000..1f85fa1 --- /dev/null +++ b/app/Models/TribunalArgumentVote.php @@ -0,0 +1,14 @@ +belongsTo(TribunalArgument::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/TribunalVote.php b/app/Models/TribunalVote.php new file mode 100644 index 0000000..c659ad3 --- /dev/null +++ b/app/Models/TribunalVote.php @@ -0,0 +1,14 @@ +belongsTo(Tribunal::class); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..7d2a986 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,264 @@ + 'datetime', + 'premium_expires_at' => 'datetime', + 'banned_at' => 'datetime', + 'is_banned' => 'boolean', + 'show_watchlist' => 'boolean', + 'show_activity' => 'boolean', + 'animated_banner' => 'boolean', + 'password' => 'hashed', + ]; + } + + /** + * /u/{username} gibi URL'lerde username veya ID ile çözümleme. + * custom_profile_url perki olan kullanıcılar /u/kullanici-adi şeklinde erişilebilir. + */ + public function resolveRouteBinding($value, $field = null): ?self + { + if (is_numeric($value)) { + return static::find($value); + } + return static::where('username', $value) + ->whereNotNull('username') + ->first(); + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function isModerator(): bool + { + return in_array($this->role, ['admin', 'moderator']); + } + + public function moderatorPermissions() + { + return $this->hasMany(ModeratorPermission::class); + } + + public function activityLogs() + { + return $this->hasMany(UserActivityLog::class); + } + + /** Returns cached permission set for this user. Admins have all permissions. */ + public function can_mod(string $permission): bool + { + if ($this->isAdmin()) return true; + if ($this->role !== 'moderator') return false; + + $key = "mod_perms_{$this->id}"; + $perms = cache()->remember($key, 300, fn() => + ModeratorPermission::where('user_id', $this->id)->pluck('permission')->all() + ); + return in_array($permission, $perms); + } + + /** Flush cached permissions (call after saving changes). */ + public function flushPermCache(): void + { + cache()->forget("mod_perms_{$this->id}"); + } + + /** isPremium() sonucunu istek başına önbellekle — sayfa başına onlarca kez çağrılıyor */ + private ?bool $_isPremiumCache = null; + + public function isPremium(): bool + { + if ($this->_isPremiumCache !== null) { + return $this->_isPremiumCache; + } + + // 1. yol: membership alanı 'premium' ve süresi dolmamış + $viaMembership = $this->membership === 'premium' + && ($this->premium_expires_at === null || $this->premium_expires_at->isFuture()); + if ($viaMembership) { + return $this->_isPremiumCache = true; + } + + // 2. yol: aktif abonelik var ama membership alanı senkronize değil + // (2 farklı premium yolu — biri güncellenmezse kullanıcı yine premium sayılır) + try { + $viaSubscription = $this->subscriptions() + ->where('status', 'active') + ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->exists(); + } catch (\Throwable) { + $viaSubscription = false; + } + + return $this->_isPremiumCache = $viaSubscription; + } + + /** + * Kullanıcının aktif planında belirli bir perk var mı? + * Ücretsiz kullanıcılarda her zaman false döner. + */ + public function hasPerk(string $key): bool + { + if (!$this->isPremium()) return false; + + // Ücretsiz mod: tüm perkler herkese açık + if (self::freeModeActive()) return true; + + $sub = $this->subscriptions() + ->where('status', 'active') + ->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->latest() + ->with('plan') + ->first(); + + if (!$sub?->plan) return false; + + $perks = $sub->plan->perks ?? []; + return !empty($perks[$key]); + } + + /** premium_free_mode ayarını 5dk cache'leyerek okur */ + public static function freeModeActive(): bool + { + return cache()->remember('premium_free_mode', 300, fn() => + \App\Models\Setting::get('premium_free_mode', '0') + ) === '1'; + } + + /** + * Kullanıcının aktif avatar URL'si: GIF avatar varsa ve hasPerk('gif_avatar') ise döner. + */ + public function effectiveAvatar(): ?string + { + if ($this->gif_avatar && $this->hasPerk('gif_avatar')) { + return $this->gif_avatar; + } + return $this->avatar; + } + + public function subscriptions() + { + return $this->hasMany(Subscription::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + // ── Social ──────────────────────────────────────────────────────────────── + + public function followers() + { + return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id') + ->withPivot('created_at'); + } + + public function following() + { + return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id') + ->withPivot('created_at'); + } + + public function isFollowing(int $userId): bool + { + return \App\Models\UserFollow::where('follower_id', $this->id) + ->where('following_id', $userId) + ->exists(); + } + + public function conversations() + { + return $this->belongsToMany(Conversation::class, 'conversation_participants') + ->withPivot('last_read_at'); + } + + public function totalUnreadMessages(): int + { + return $this->conversations() + ->with(['messages' => fn($q) => $q->where('user_id', '!=', $this->id)]) + ->get() + ->sum(fn($c) => $c->unreadCountFor($this->id)); + } + + // Anime zevk uyum skoru (0-100) + public function compatibilityWith(User $other): int + { + $myIds = \App\Models\Watchlist::where('user_id', $this->id)->pluck('anime_id'); + $theirIds = \App\Models\Watchlist::where('user_id', $other->id)->pluck('anime_id'); + + if ($myIds->isEmpty() || $theirIds->isEmpty()) return 0; + + $mySet = $myIds->unique()->values(); + $theirSet = $theirIds->unique()->values(); + $intersection = $mySet->intersect($theirSet)->count(); + $union = $mySet->merge($theirSet)->unique()->count(); + + $jaccard = $union > 0 ? $intersection / $union : 0; + + // Rating similarity bonus + $myRatings = \DB::table('anime_ratings')->where('user_id', $this->id)->pluck('rating', 'anime_id'); + $theirRatings = \DB::table('anime_ratings')->where('user_id', $other->id)->pluck('rating', 'anime_id'); + $commonAnimes = $myRatings->keys()->intersect($theirRatings->keys()); + + $ratingScore = 0; + if ($commonAnimes->count() > 0) { + $diffs = $commonAnimes->map(fn($id) => abs($myRatings[$id] - $theirRatings[$id]) / 10); + $ratingScore = 1 - $diffs->avg(); + } + + $score = $jaccard * 0.6 + $ratingScore * 0.4; + return (int) round(min($score * 100, 100)); + } + + /** + * İzleme saatine göre rank bilgisi döner. + * watch_rank perki yoksa null döner. + */ + public function watchRank(): ?array + { + if (!$this->hasPerk('watch_rank')) return null; + + $totalSeconds = \App\Models\ContinueWatching::where('user_id', $this->id) + ->sum('seconds_watched'); + $hours = $totalSeconds / 3600; + + return match(true) { + $hours >= 500 => ['label' => 'Efsane', 'color' => '#ff2d7d', 'icon' => 'bi-trophy-fill'], + $hours >= 250 => ['label' => 'Usta', 'color' => '#ffd700', 'icon' => 'bi-star-fill'], + $hours >= 100 => ['label' => 'Bağımlı', 'color' => '#b84dff', 'icon' => 'bi-heart-fill'], + $hours >= 50 => ['label' => 'Hayran', 'color' => '#00f5ff', 'icon' => 'bi-eye-fill'], + $hours >= 20 => ['label' => 'İzleyici', 'color' => '#00d4a4', 'icon' => 'bi-play-circle-fill'], + default => ['label' => 'Acemi', 'color' => '#9ca3af', 'icon' => 'bi-controller'], + }; + } +} diff --git a/app/Models/UserAchievement.php b/app/Models/UserAchievement.php new file mode 100644 index 0000000..76efe6c --- /dev/null +++ b/app/Models/UserAchievement.php @@ -0,0 +1,17 @@ + 'datetime']; + + public function user() { return $this->belongsTo(User::class); } + public function achievement() { return $this->belongsTo(Achievement::class); } +} diff --git a/app/Models/UserActivityLog.php b/app/Models/UserActivityLog.php new file mode 100644 index 0000000..49b4ccd --- /dev/null +++ b/app/Models/UserActivityLog.php @@ -0,0 +1,45 @@ + 'array', 'is_bot' => 'boolean']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // Human-readable action labels + public static array $actionLabels = [ + 'login' => 'Giriş', + 'logout' => 'Çıkış', + 'register' => 'Kayıt', + 'pageview' => 'Sayfa Görüntüleme', + 'anime_view' => 'Anime Görüntüleme', + 'episode_watch' => 'Bölüm İzleme', + 'comment_create' => 'Yorum', + 'watchlist_add' => 'Listeye Ekle', + 'watchlist_remove'=> 'Listeden Çıkar', + 'rating' => 'Puan Verdi', + 'follow' => 'Takip', + 'search' => 'Arama', + 'download' => 'İndirme', + 'capsule_create' => 'Kapsül Oluşturdu', + 'tribunal_vote' => 'Mahkeme Oyu', + 'prediction_vote' => 'Tahmin Oyu', + 'nico_comment' => 'Nico Yorum', + 'password_change' => 'Şifre Değiştirdi', + 'profile_update' => 'Profil Güncelledi', + ]; +} diff --git a/app/Models/UserFollow.php b/app/Models/UserFollow.php new file mode 100644 index 0000000..8dcb519 --- /dev/null +++ b/app/Models/UserFollow.php @@ -0,0 +1,14 @@ +belongsTo(User::class, 'follower_id'); } + public function following() { return $this->belongsTo(User::class, 'following_id'); } +} diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php new file mode 100644 index 0000000..5ec1138 --- /dev/null +++ b/app/Models/UserNotification.php @@ -0,0 +1,25 @@ + 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function user() { return $this->belongsTo(User::class); } + + public function getIsReadAttribute(): bool + { + return $this->read_at !== null; + } +} diff --git a/app/Models/VideoSource.php b/app/Models/VideoSource.php new file mode 100644 index 0000000..c9d58f3 --- /dev/null +++ b/app/Models/VideoSource.php @@ -0,0 +1,25 @@ + 'boolean', + 'is_hevc' => 'boolean', + 'hevc_checked_at' => 'datetime', + ]; + + public function episode() + { + return $this->belongsTo(Episode::class); + } +} diff --git a/app/Models/VoiceCall.php b/app/Models/VoiceCall.php new file mode 100644 index 0000000..02eb043 --- /dev/null +++ b/app/Models/VoiceCall.php @@ -0,0 +1,33 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + + public function caller(): BelongsTo + { + return $this->belongsTo(User::class, 'caller_id'); + } + + public function callee(): BelongsTo + { + return $this->belongsTo(User::class, 'callee_id'); + } + + public function isActive(): bool + { + return in_array($this->status, ['ringing', 'active']); + } +} diff --git a/app/Models/WatchParty.php b/app/Models/WatchParty.php new file mode 100644 index 0000000..bf8642a --- /dev/null +++ b/app/Models/WatchParty.php @@ -0,0 +1,40 @@ + 'boolean', + 'is_private' => 'boolean', + 'current_sec'=> 'integer', + 'synced_at' => 'datetime', + ]; + + public function host() { return $this->belongsTo(User::class, 'host_user_id'); } + public function episode() { return $this->belongsTo(Episode::class); } + public function members() { return $this->hasMany(WatchPartyMember::class, 'party_id'); } + + public function activeMembers() + { + return $this->members()->where('last_ping', '>=', now()->subSeconds(30)); + } + + public static function generateCode(): string + { + do { + $code = strtoupper(Str::random(6)); + } while (self::where('room_code', $code)->exists()); + + return $code; + } +} diff --git a/app/Models/WatchPartyMember.php b/app/Models/WatchPartyMember.php new file mode 100644 index 0000000..36917f8 --- /dev/null +++ b/app/Models/WatchPartyMember.php @@ -0,0 +1,17 @@ + 'datetime', 'last_ping' => 'datetime']; + + public function party() { return $this->belongsTo(WatchParty::class, 'party_id'); } + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Watchlist.php b/app/Models/Watchlist.php new file mode 100644 index 0000000..ac9c137 --- /dev/null +++ b/app/Models/Watchlist.php @@ -0,0 +1,22 @@ + 'datetime', 'updated_at' => 'datetime']; + + const STATUSES = [ + 'plan' => 'İzlenecek', + 'watching' => 'İzleniyor', + 'completed' => 'Tamamlandı', + 'dropped' => 'Bırakıldı', + ]; + + public function user() { return $this->belongsTo(User::class); } + public function anime() { return $this->belongsTo(Anime::class); } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..7ca6cf9 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,49 @@ + \App\Models\Episode::class, + 'anime' => \App\Models\Anime::class, + ]); + + \Event::listen(SocialiteWasCalled::class, \SocialiteProviders\Discord\DiscordExtendSocialite::class); + + $this->loadSmtpFromDb(); + } + + private function loadSmtpFromDb(): void + { + try { + if (!\Schema::hasTable('settings')) return; + + $keys = ['mail_host','mail_port','mail_username','mail_password', + 'mail_from_address','mail_from_name','mail_encryption']; + $rows = \App\Models\Setting::whereIn('key', $keys)->pluck('value', 'key'); + + if ($rows->isEmpty() || !$rows->get('mail_host')) return; + + Config::set('mail.mailers.smtp.host', $rows->get('mail_host', '')); + Config::set('mail.mailers.smtp.port', $rows->get('mail_port', 587)); + Config::set('mail.mailers.smtp.username', $rows->get('mail_username', '')); + Config::set('mail.mailers.smtp.password', $rows->get('mail_password', '')); + Config::set('mail.mailers.smtp.encryption', $rows->get('mail_encryption', 'tls')); + Config::set('mail.from.address', $rows->get('mail_from_address', '')); + Config::set('mail.from.name', $rows->get('mail_from_name', config('app.name'))); + Config::set('mail.default', 'smtp'); + } catch (\Throwable) { + // DB henüz hazır değilse sessizce geç + } + } +} diff --git a/app/Services/AchievementService.php b/app/Services/AchievementService.php new file mode 100644 index 0000000..46088ea --- /dev/null +++ b/app/Services/AchievementService.php @@ -0,0 +1,63 @@ +id)->pluck('achievement_id')->toArray(); + + $newlyEarned = []; + + foreach ($allAchievements as $ach) { + if (in_array($ach->id, $earned)) continue; + + $met = match ($ach->condition_type) { + 'episodes_watched' => self::episodesWatched($user) >= $ach->condition_value, + 'hours_watched' => self::hoursWatched($user) >= $ach->condition_value, + 'watchlist_count' => Watchlist::where('user_id', $user->id)->count() >= $ach->condition_value, + 'anime_rated' => DB::table('anime_ratings')->where('user_id', $user->id)->count() >= $ach->condition_value, + 'request_sent' => DB::table('anime_requests')->where('user_id', $user->id)->count() >= $ach->condition_value, + 'first_login' => true, + default => false, + }; + + if ($met) { + UserAchievement::firstOrCreate([ + 'user_id' => $user->id, + 'achievement_id' => $ach->id, + ], ['earned_at' => now()]); + $newlyEarned[] = $ach; + } + } + + return $newlyEarned; + } + + private static function episodesWatched(User $user): int + { + return ContinueWatching::where('user_id', $user->id) + ->where('percent_complete', '>=', 70) + ->count(); + } + + private static function hoursWatched(User $user): float + { + return round( + ContinueWatching::where('user_id', $user->id)->sum('seconds_watched') / 3600, 1 + ); + } +} diff --git a/app/Services/AgoraTokenService.php b/app/Services/AgoraTokenService.php new file mode 100644 index 0000000..ea91784 --- /dev/null +++ b/app/Services/AgoraTokenService.php @@ -0,0 +1,75 @@ + $expireTimestamp, + self::PRIVILEGE_PUBLISH_AUDIO_STREAM => $expireTimestamp, + self::PRIVILEGE_PUBLISH_VIDEO_STREAM => 0, + self::PRIVILEGE_PUBLISH_DATA_STREAM => $expireTimestamp, + ]; + + // Pack message + $message = self::packUint16(1); // version: 1 (AccessToken) + $message .= self::packUint32($currentTimestamp); + $message .= self::packUint32($nonce); + $message .= self::packString($channelName); + $message .= self::packUint32($uid); + $message .= self::packPrivileges($privileges); + + // HMAC-SHA256 signature + $signature = hash_hmac('sha256', $appId . $currentTimestamp . $nonce . $channelName . $uid . self::packPrivileges($privileges), $appCertificate, true); + + $content = self::packString($signature) . $message; + + return self::VERSION . $appId . base64_encode($content); + } + + private static function packUint16(int $v): string + { + return pack('n', $v); + } + + private static function packUint32(int $v): string + { + return pack('N', $v); + } + + private static function packString(string $v): string + { + return pack('n', strlen($v)) . $v; + } + + private static function packPrivileges(array $privileges): string + { + ksort($privileges); + $packed = pack('n', count($privileges)); + foreach ($privileges as $key => $value) { + $packed .= pack('n', $key) . pack('N', $value); + } + return $packed; + } +} diff --git a/app/Services/AniListService.php b/app/Services/AniListService.php new file mode 100644 index 0000000..d4b1a28 --- /dev/null +++ b/app/Services/AniListService.php @@ -0,0 +1,152 @@ +withHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json']) + ->post(self::ENDPOINT, ['query' => $gql, 'variables' => $variables]); + + if (!$res->ok()) return null; + if (!empty($res->json('errors'))) return null; + + return $res->json('data.Media'); + } catch (\Throwable $e) { + Log::debug('AniList query failed', ['err' => $e->getMessage()]); + return null; + } + } + + public function fetchByMalId(int $malId): ?array + { + return Cache::remember("anilist_mal_{$malId}", self::CACHE_TTL, function () use ($malId) { + return $this->query( + ['malId' => $malId], + 'query($malId:Int){Media(idMal:$malId,type:ANIME){coverImage{extraLarge}bannerImage}}' + ); + }); + } + + public function fetchByTitle(string $title): ?array + { + return Cache::remember('anilist_title_' . md5($title), self::CACHE_TTL, function () use ($title) { + return $this->query( + ['search' => $title], + 'query($search:String){Media(search:$search,type:ANIME){coverImage{extraLarge}bannerImage}}' + ); + }); + } + + /** + * Resmi indir, yeniden boyutlandır, WebP olarak storage'a kaydet. + * Başarılıysa storage-relative yolu döner (örn. anime/covers/123.webp). + */ + /** + * Resmi indir, max genişliğe orantılı küçült (asla büyütme), WebP kaydet. + * Orijinalden küçükse olduğu gibi bırakır. + */ + private function downloadAndResize(string $url, string $storagePath, int $maxW): ?string + { + try { + $response = Http::timeout(20)->withHeaders([ + 'User-Agent' => 'Mozilla/5.0', + 'Referer' => 'https://anilist.co/', + ])->get($url); + + if (!$response->ok()) return null; + + $raw = $response->body(); + $src = @imagecreatefromstring($raw); + if (!$src) return null; + + $srcW = imagesx($src); + $srcH = imagesy($src); + + if ($srcW > $maxW) { + // Orantılı küçült + $newW = $maxW; + $newH = (int) round($srcH * ($maxW / $srcW)); + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $srcW, $srcH); + imagedestroy($src); + } else { + // Zaten küçük — olduğu gibi kullan + $dst = $src; + } + + $absPath = storage_path('app/public/' . $storagePath); + @mkdir(dirname($absPath), 0755, true); + + $ok = imagewebp($dst, $absPath, self::WEBP_QUALITY); + imagedestroy($dst); + + return $ok ? $storagePath : null; + } catch (\Throwable $e) { + Log::debug('AniList image download failed', ['url' => $url, 'err' => $e->getMessage()]); + return null; + } + } + + /** + * Anime'nin boş kapak/banner alanlarını AniList'ten doldur. + * Resimleri indirir, boyutlandırır, WebP olarak storage'a kaydeder. + * Dolu alanların üzerine yazmaz. + */ + public function fillImages(Anime $anime): bool + { + $needCover = empty($anime->cover_image); + $needBanner = empty($anime->banner_image); + if (!$needCover && !$needBanner) return false; + + $data = null; + if ($anime->mal_id) { + $data = $this->fetchByMalId((int) $anime->mal_id); + } + if (!$data) { + $data = $this->fetchByTitle($anime->title); + } + if (!$data) return false; + + $updates = []; + + if ($needCover && !empty($data['coverImage']['extraLarge'])) { + $path = $this->downloadAndResize( + $data['coverImage']['extraLarge'], + "anime/covers/{$anime->id}.webp", + self::COVER_MAX_W + ); + if ($path) $updates['cover_image'] = $path; + } + + if ($needBanner && !empty($data['bannerImage'])) { + $path = $this->downloadAndResize( + $data['bannerImage'], + "anime/banners/{$anime->id}.webp", + self::BANNER_MAX_W + ); + if ($path) $updates['banner_image'] = $path; + } + + if (empty($updates)) return false; + + $anime->update($updates); + return true; + } +} diff --git a/app/Services/AniSkipService.php b/app/Services/AniSkipService.php new file mode 100644 index 0000000..b7ed727 --- /dev/null +++ b/app/Services/AniSkipService.php @@ -0,0 +1,58 @@ +get($url); + + if (!$res->ok() || empty($res->json('results'))) { + Cache::put($key, null, self::CACHE_MISS); + return null; + } + + $result = []; + foreach ($res->json('results') as $item) { + $type = $item['skip_type'] ?? null; + $interval = $item['interval'] ?? null; + if (!$type || !$interval) continue; + $result[$type] = [ + 'start' => round((float)($interval['start_time'] ?? $interval['startTime'] ?? 0), 2), + 'end' => round((float)($interval['end_time'] ?? $interval['endTime'] ?? 0), 2), + ]; + } + + $data = empty($result) ? null : $result; + Cache::put($key, $data, $data ? self::CACHE_HIT : self::CACHE_MISS); + return $data; + + } catch (\Throwable) { + return null; + } + } + + public function searchByTitle(string $title, ?string $titleEn = null, ?string $titleJp = null): ?string + { + $jikan = new JikanService(); + return $jikan->searchMalId($title, $titleEn, $titleJp); + } +} diff --git a/app/Services/BunnyCdnSigner.php b/app/Services/BunnyCdnSigner.php new file mode 100644 index 0000000..9866765 --- /dev/null +++ b/app/Services/BunnyCdnSigner.php @@ -0,0 +1,62 @@ + $zone, 'apiKey' => $apiKey, 'pullUrl' => $pullUrl]; + } + + /** + * Pull URL'den dosya yolunu çıkar, CDN'den sil. + * Altyazı ve MP4 gibi tekil dosyalar için. + */ + public static function deleteFile(?string $url): void + { + if (!$url) return; + $creds = self::creds(); + if (!$creds) return; + + if (!str_starts_with($url, $creds['pullUrl'])) return; + $path = ltrim(substr($url, strlen($creds['pullUrl'])), '/'); + if (!$path) return; + + self::delete($creds, $path); + } + + /** + * Video URL'sindeki anime klasörünü (anime_XXXXX/) tamamen sil. + * Anime silindiğinde tüm sezon/bölüm dosyaları tek seferde temizlenir. + */ + public static function deleteAnimeFolder(?string $anyVideoUrl): void + { + if (!$anyVideoUrl) return; + $creds = self::creds(); + if (!$creds) return; + + if (!str_starts_with($anyVideoUrl, $creds['pullUrl'])) return; + $path = ltrim(substr($anyVideoUrl, strlen($creds['pullUrl'])), '/'); + $folder = explode('/', $path)[0] ?? ''; + if (!$folder) return; + + // Trailing slash = klasör silme + self::delete($creds, $folder . '/'); + } + + private static function delete(array $creds, string $remotePath): void + { + $url = "https://storage.bunnycdn.com/{$creds['zone']}/{$remotePath}"; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => ["AccessKey: {$creds['apiKey']}"], + ]); + curl_exec($ch); + curl_close($ch); + } +} diff --git a/app/Services/DeepSeekService.php b/app/Services/DeepSeekService.php new file mode 100644 index 0000000..a04f041 --- /dev/null +++ b/app/Services/DeepSeekService.php @@ -0,0 +1,636 @@ +apiKey = Setting::get('deepseek_api_key', ''); + } + + public function isConfigured(): bool + { + return !empty($this->apiKey); + } + + /** + * Anime için Türkçe özet/açıklama üret. + */ + public function generateAnimeDescription(string $title, string $titleJp = '', string $genres = ''): ?string + { + $prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime için Türkçe, akıcı ve bilgilendirici bir özet/açıklama yaz (3-5 cümle, 120-220 kelime arası). Spoiler verme, merak uyandır.\n\n" + . "Anime adı: {$title}" . ($titleJp ? " ({$titleJp})" : '') . "\n" + . ($genres ? "Türler: {$genres}\n" : '') + . "\nSadece açıklama metnini yaz, başka hiçbir şey ekleme."; + + return $this->call($prompt); + } + + /** + * Bölüm için Türkçe açıklama üret. + */ + public function generateEpisodeDescription(string $animeTitle, int $episodeNumber, string $episodeTitle = ''): ?string + { + $prompt = "Sen bir anime veritabanı editörüsün. Aşağıdaki anime bölümü için kısa, akıcı ve spoiler içermeyen Türkçe bir açıklama yaz (2-4 cümle, 80-160 kelime arası).\n\n" + . "Anime: {$animeTitle}\n" + . "Bölüm: {$episodeNumber}. Bölüm" . ($episodeTitle ? " — {$episodeTitle}" : '') . "\n\n" + . "Sadece açıklama metnini yaz, başka hiçbir şey ekleme."; + + return $this->call($prompt); + } + + /** + * Anime için tüm meta verileri JSON olarak döndür. + * Dönen alanlar: description, release_year, studio, type, status, rating, title_en, title_jp, genres[] + */ + public function generateAnimeMeta(string $title, string $titleJp = ''): ?array + { + $prompt = <<callJson($prompt); + return $raw; + } + + public function checkSpoiler(string $text): ?array + { + $result = $this->moderateComment($text); + return ['is_spoiler' => $result['is_spoiler'], 'score' => $result['spoiler_score']]; + } + + /** + * Yorum moderasyonu: spoiler + küfür/hakaret kontrolü. + * Döner: ['is_spoiler'=>bool, 'spoiler_score'=>int, 'is_rude'=>bool, 'rude_score'=>int] + */ + public function moderateComment(string $text): array + { + $default = ['is_spoiler' => false, 'spoiler_score' => 0, 'is_rude' => false, 'rude_score' => 0]; + + $prompt = "Aşağıdaki metin bir anime platformuna yazılmış kullanıcı yorumudur. İki şeyi kontrol et:\n" + . "1) Anime bölümüne ait SPOILER içeriyor mu? (olay örgüsü açıklama, karakter ölümü, sürpriz sahne ifşası vb.)\n" + . "2) KABA/HAKARET içeriyor mu? (küfür, nefret söylemi, ağır hakaret, cinsel içerik)\n\n" + . "Sadece JSON döndür:\n" + . "{\"is_spoiler\": false, \"spoiler_score\": 10, \"is_rude\": false, \"rude_score\": 5}\n" + . "score değerleri 0-100 arası olasılık.\n\n" + . "Metin: " . mb_substr($text, 0, 400); + + $raw = $this->callJson($prompt, 80); + if (!$raw) return $default; + + return [ + 'is_spoiler' => (bool)($raw['is_spoiler'] ?? false), + 'spoiler_score' => (int)($raw['spoiler_score'] ?? $raw['score'] ?? 0), + 'is_rude' => (bool)($raw['is_rude'] ?? false), + 'rude_score' => (int)($raw['rude_score'] ?? 0), + ]; + } + + public string $lastError = ''; + + private function callJson(string $prompt, int $maxTokens = 600): ?array + { + if (!$this->isConfigured()) { + $this->lastError = 'API anahtarı ayarlanmamış'; + return null; + } + + try { + $response = Http::withToken($this->apiKey) + ->timeout(90) + ->post('https://api.deepseek.com/chat/completions', [ + 'model' => 'deepseek-chat', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'max_tokens' => $maxTokens, + 'temperature' => 0.3, + 'response_format' => ['type' => 'json_object'], + ]); + + if (!$response->successful()) { + $this->lastError = 'HTTP ' . $response->status() . ': ' . $response->json('error.message', $response->body()); + \Log::error('DeepSeek API hatası', ['status' => $response->status(), 'body' => $response->body()]); + return null; + } + + $content = trim($response->json('choices.0.message.content', '')); + if (!$content) { + $this->lastError = 'API boş yanıt döndürdü'; + return null; + } + + $content = preg_replace('/^```json\s*/i', '', $content); + $content = preg_replace('/\s*```$/i', '', $content); + + $data = json_decode($content, true); + if (!is_array($data)) { + $this->lastError = 'JSON parse hatası: ' . substr($content, 0, 200); + return null; + } + return $data; + } catch (\Exception $e) { + $this->lastError = $e->getMessage(); + \Log::error('DeepSeek exception', ['message' => $e->getMessage()]); + return null; + } + } + + // ── Frontend AI methods ────────────────────────────────────────────────── + + /** + * Anime kataloğunu AI context string olarak döndür (1 saat önbellek). + */ + public function getAnimeContext(): string + { + return \Illuminate\Support\Facades\Cache::remember('ai_anime_context', 3600, function () { + $animes = \App\Models\Anime::where('is_published', true) + ->with('genres:id,name') + ->get(['id', 'title', 'type', 'status', 'rating', 'release_year']); + + return $animes->map(function ($a) { + $genres = $a->genres->pluck('name')->join(', '); + $type = $a->type === 'movie' ? 'Film' : 'Dizi'; + return "ID:{$a->id}|{$a->title}|{$type}|{$a->release_year}|{$a->rating}" + . ($genres ? "|{$genres}" : ''); + })->join("\n"); + }); + } + + /** + * Çok turlu sohbet (sistem mesajı + anime kataloğu ile). + * $messages = [['role'=>'user','content'=>'...'], ...] + */ + public function chat(array $messages, string $animeContext = ''): ?string + { + $sys = "Sen Animexe'nin AI anime asistanısın. Animexe, Türkçe altyazılı/dublajlı ücretsiz anime izleme platformudur (animexe.com).\n\n"; + + $sys .= "== PLATFORM BİLGİLERİ (kullanıcı sorarsa bunları kullan) ==\n" + . "- Kayıt: Ücretsiz, e-posta ile. Kayıt olmadan bazı içerikler kısıtlı.\n" + . "- Premium üyelik: Aylık ücretli. Avantajları: reklamsız izleme, 1080p HD, erken bölüm erişimi.\n" + . "- Altyazı/Dublaj: Türkçe altyazı ve Türkçe dublaj seçenekleri mevcuttur. Player'da seçilebilir.\n" + . "- Takip/Favori: Anime sayfasında kalp veya 'Takip' butonuna tıkla. Yeni bölüm bildirimi gelir.\n" + . "- İzleme geçmişi: Otomatik kaydedilir. Profil > Geçmiş kısmından görebilirsin.\n" + . "- Arama: Üst menüdeki arama kutusuna anime adını yaz.\n" + . "- Anime isteği: 'Anime İste' sayfasından eksik animeleri talep edebilirsin.\n" + . "- Mobil: Tarayıcıdan tam destek. Android uygulaması da mevcut.\n" + . "- Dil seçimi: Player'da ses ve altyazı dili değiştirilebilir.\n" + . "- Yorumlar: Her bölümün altında yorum yapılabilir, spoiler işaretlenebilir.\n\n"; + + if ($animeContext) { + $sys .= "== PLATFORM KATALOĞU (ID|Başlık|Tip|Yıl|Puan|Türler) ==\n{$animeContext}\n\n"; + } + + $sys .= "== KURALLAR ==\n" + . "- Türkçe, samimi, kısa ve net cevap ver. Emoji kullanabilirsin.\n" + . "- Sadece platformdaki animeleri öner (katalogdan ID'si olan).\n" + . "- Spoiler verme. Merak uyandır.\n" + . "- Anime önerirken cevabının en sonuna şu formatı ekle (başka yere koyma): [SUGGEST:id1,id2,id3]\n" + . " Örnek: 'Attack on Titan harika! [SUGGEST:42]' — max 5 anime ID.\n" + . "- Eğer anime önermiyorsan [SUGGEST:...] satırını HİÇ EKLEME.\n" + . "- Site hakkında soruları yukarıdaki platform bilgilerini kullanarak cevapla.\n"; + + $apiMessages = array_merge( + [['role' => 'system', 'content' => $sys]], + array_slice($messages, -12) + ); + + return $this->callMessages($apiMessages, 700); + } + + /** + * Kullanıcı tercihlerine göre 6 anime öner. + * Döner: [['id'=>1,'reason'=>'...'], ...] + */ + public function recommend(string $preferences, array $animes): ?array + { + $list = implode("\n", array_map(function ($a) { + $genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : ''; + return "ID:{$a['id']}|{$a['title']}|" . ($a['type'] === 'movie' ? 'Film' : 'Dizi') + . "|{$a['release_year']}|{$a['rating']}" . ($genres ? "|{$genres}" : ''); + }, array_slice($animes, 0, 250))); + + $prompt = "Anime öneri sistemi: Kullanıcı tercihlerine göre listeden EN İYİ 6 animeyi seç.\n\n" + . "Tercihler:\n{$preferences}\n\n" + . "Animeler:\n{$list}\n\n" + . "Yanıt: {\"recommendations\":[{\"id\":1,\"reason\":\"Kısa Türkçe neden (max 12 kelime)\"}]}\n" + . "SADECE JSON."; + + $result = $this->callJson($prompt, 500); + if (!is_array($result)) return null; + if (isset($result['recommendations']) && is_array($result['recommendations'])) { + return $result['recommendations']; + } + if (isset($result[0]['id'])) return $result; + return null; + } + + /** + * Doğal dil sorgusu ile anime ara. + * Döner: [id1, id2, ...] + */ + public function naturalSearch(string $query, array $animes): ?array + { + $list = implode("\n", array_map(function ($a) { + $genres = isset($a['genres']) ? implode(', ', array_column($a['genres'], 'name')) : ''; + return "ID:{$a['id']}|{$a['title']}" . ($genres ? "|{$genres}" : ''); + }, array_slice($animes, 0, 300))); + + $prompt = "Kullanıcı sorgusu: \"{$query}\"\n\nAnimeler:\n{$list}\n\n" + . "En uygun max 12 animeyi bul: {\"ids\":[1,5,12]}\nSADECE JSON."; + + $result = $this->callJson($prompt, 150); + if (!is_array($result)) return null; + if (isset($result['ids']) && is_array($result['ids'])) return array_map('intval', $result['ids']); + return null; + } + + /** + * Bölüm hakkında spoilersız AI analizi. + */ + public function episodeInfo(string $animeTitle, int $episodeNumber, string $episodeTitle = '', string $description = ''): ?string + { + $prompt = "Sen bir anime uzmanısın. Aşağıdaki bölüm hakkında Türkçe, kısa ve ilgi çekici bir analiz yaz (3-4 cümle). " + . "Spoiler içerme. Bölümün atmosferini, önemini ve izleyiciyi neden heyecanlandırabileceğini anlat.\n\n" + . "Anime: {$animeTitle}\n" + . "Bölüm: {$episodeNumber}." . ($episodeTitle ? " — {$episodeTitle}" : '') . "\n" + . ($description ? "Açıklama: {$description}\n" : '') + . "\nSadece analiz metnini yaz."; + + return $this->call($prompt, 350); + } + + // ── Blog Generation ────────────────────────────────────────────────────── + + /** + * Bir anime için SEO blog yazısı üret. + * Döner: ['title','slug','excerpt','content','focus_keyword','meta_description','faq','linked_slugs'] + */ + public function generateBlogPost(\App\Models\Anime $anime, array $relatedAnimes = []): ?array + { + $genreList = $anime->genres->pluck('name')->join(', '); + $type = $anime->type === 'movie' ? 'anime film' : 'anime dizi'; + $year = $anime->release_year ?? ''; + $status = match($anime->status ?? '') { + 'ongoing' => 'devam ediyor', + 'completed' => 'tamamlandı', + 'upcoming' => 'yakında çıkacak', + default => '', + }; + + $relatedList = ''; + if (!empty($relatedAnimes)) { + $relatedList = "\nİlgili animeler (içerik içinde bunlara link ver, format: [LINK:slug]Anime Adı[/LINK]):\n"; + foreach (array_slice($relatedAnimes, 0, 5) as $r) { + $relatedList .= "- {$r['slug']}: {$r['title']}\n"; + } + } + + $prompt = <<title} +- Tür: {$type} +- Yıl: {$year} +- Türler: {$genreList} +- Durum: {$status} +- Açıklama: {$anime->description} +{$relatedList} + +Blog yazısı gereksinimleri: +1. 400-600 kelime, sade ve akıcı Türkçe +2. HTML formatında yaz:

,

,