This commit is contained in:
2025-09-18 15:31:27 +02:00
commit 2abba0c2b7
406 changed files with 31879 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\PopularDaily;
use App\Models\PopularWeekly;
use App\Models\PopularMonthly;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command;
class AutoStats extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:auto-stats';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear Outdated Popular Stats';
/**
* Execute the console command.
*/
public function handle()
{
PopularDaily::where('created_at', '<=', Carbon::now()->subMinutes(1440))->forceDelete();
PopularWeekly::where('created_at', '<=', Carbon::now()->subMinutes(10080))->forceDelete();
PopularMonthly::where('created_at', '<=', Carbon::now()->subMinutes(43200))->forceDelete();
$this->comment('Automated Purge Stats Complete');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Models\Episode;
use App\Models\Hentai;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
class GenerateSitemap extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'sitemap:generate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate the sitemap.';
/**
* Execute the console command.
*/
public function handle()
{
$latestEpisode = Episode::latest()->first();
$latestUpdate = Carbon::create($latestEpisode->created_at);
$sitemap = Sitemap::create()
->add(Url::create('/')
->setLastModificationDate($latestUpdate))
->add(Url::create('/stats')
->setLastModificationDate(Carbon::now()))
->add(Url::create('/search')
->setLastModificationDate(Carbon::now()))
->add(Url::create('/contact')
->setLastModificationDate(Carbon::create('2023', '8', '1')))
->add(Episode::all())
->add(Hentai::all());
$sitemap->writeToFile(public_path('sitemap.xml'));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\Downloads;
use App\Jobs\GetFileSizeFromCDN;
use Illuminate\Console\Command;
class GetFileSize extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:get-file-size';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get missing filesize from API (Job)';
/**
* Execute the console command.
*/
public function handle()
{
foreach(Downloads::whereNull('size')->get() as $download) {
GetFileSizeFromCDN::dispatch($download->id);
}
$this->comment('Added all missing sizes to Job Queue!');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserDownload;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command;
class ResetUserDownloads extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:reset-user-downloads';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Resets the daily limit of user downloads';
/**
* Execute the console command.
*/
public function handle()
{
User::query()->update([
'downloads_left' => config('hstream.free_downloads_count'),
]);
// Clear old downloads which have expired
UserDownload::where('created_at', '<=', Carbon::now()->subHour(6))
->forceDelete();
}
}

27
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}

139
app/Helpers/CacheHelper.php Normal file
View File

@@ -0,0 +1,139 @@
<?php
namespace App\Helpers;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use App\Models\PopularDaily;
use Conner\Tagging\Model\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
class CacheHelper
{
public static function getRecentlyReleased(bool $guest)
{
$guestString = $guest ? 'guest' : 'authed';
return Cache::remember("recently_released_".$guestString, now()->addMinutes(60), function () use ($guest) {
return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('release_date', 'desc')
->limit(16)
->get();
});
}
public static function getRecentlyUploaded(bool $guest)
{
$guestString = $guest ? 'guest' : 'authed';
return Cache::remember("recently_uploaded".$guestString, now()->addMinutes(5), function () use ($guest) {
return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('created_at', 'desc')
->limit(16)
->get();
});
}
public static function getTotalViewCount()
{
return Cache::remember("total_view_count", now()->addMinutes(60), function () {
return Episode::sum('view_count');
});
}
public static function getTotalEpisodeCount()
{
return Cache::remember("total_episode_count", now()->addMinutes(60), function () {
return Episode::count();
});
}
public static function getTotalHentaiCount()
{
return Cache::remember("total_hentai_count", now()->addMinutes(60), function () {
return Hentai::count();
});
}
public static function getTotalMonthlyViews()
{
return Cache::remember("total_monthly_view_count", now()->addMinutes(60), function () {
return PopularMonthly::count();
});
}
public static function getPopularAllTime(bool $guest)
{
$guestString = $guest ? 'guest' : 'authed';
return Cache::remember("top_hentai_alltime".$guestString, now()->addMinutes(360), function () use ($guest) {
return Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy('view_count','desc')
->limit(16)
->get();
});
}
public static function getPopularMonthly()
{
return Cache::remember("top_hentai_monthly", now()->addMinutes(360), function () {
return PopularMonthly::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery')
->orderBy('total', 'desc')
->limit(16)
->get();
});
}
public static function getPopularWeekly()
{
return Cache::remember("top_hentai_weekly", now()->addMinutes(360), function () {
return PopularWeekly::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery')
->with('episode.studio')
->orderBy('total', 'desc')
->limit(16)
->get();
});
}
public static function getPopularDaily()
{
return Cache::remember("top_hentai_daily", now()->addMinutes(30), function () {
return PopularDaily::groupBy('episode_id')
->select('episode_id', DB::raw('count(*) as total'))
->with('episode.gallery')
->orderBy('total', 'desc')
->limit(16)
->get();
});
}
public static function getMostLikes()
{
return Cache::remember("top_likes", now()->addMinutes(30), function () {
return DB::table('markable_likes')->groupBy('markable_id')->select('markable_id', DB::raw('count(*) as total'))->orderBy('total', 'desc')->limit(16)->get();
});
}
public static function getAllTags()
{
return Cache::remember("all_tags", now()->addMinutes(10080), function () {
return Tag::where('count', '>', 0)->orderBy('slug', 'ASC')->get();
});
}
public static function getLatestComments()
{
return Cache::remember("latest_comments", now()->addMinutes(60), function () {
return DB::table('comments')->latest()->take(10)->get();
});
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Alert;
use Illuminate\Http\Request;
class AlertController extends Controller
{
/**
* Display alert index page
*/
public function index(): \Illuminate\View\View
{
return view('admin.alert.index');
}
/**
* Create Alert.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'message' => 'required|string|max:255',
'type' => 'required|integer|min:0|digits_between:0,1',
]);
Alert::create([
'text' => $request->input('message'),
'type' => $request->input('type'),
]);
cache()->forget('alerts');
return redirect()->back();
}
/**
* Delete Alert.
*/
public function delete(int $alert_id): \Illuminate\Http\RedirectResponse
{
Alert::where('id', $alert_id)->forceDelete();
cache()->forget('alerts');
return redirect()->back();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Contact;
class ContactController extends Controller
{
/**
* Display Contact Page.
*/
public function index(): \Illuminate\View\View
{
$contacts = Contact::orderBy('created_at', 'DESC')->get();
return view('admin.contact.index', [
'contacts' => $contacts
]);
}
/**
* Delete Contact.
*/
public function delete(int $contact_id): \Illuminate\Http\RedirectResponse
{
Contact::where('id', $contact_id)->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Episode;
use App\Jobs\DiscordReleaseNotification;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Http\Request;
class EpisodeController extends Controller
{
protected EpisodeService $episodeService;
protected GalleryService $galleryService;
protected DownloadService $downloadService;
public function __construct(
EpisodeService $episodeService,
GalleryService $galleryService,
DownloadService $downloadService
) {
$this->episodeService = $episodeService;
$this->galleryService = $galleryService;
$this->downloadService = $downloadService;
}
/**
* Add Episode to existing series
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
// Create Episode
$episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode);
$this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1);
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
// Discord Alert
DiscordReleaseNotification::dispatch($episode->slug, 'release');
cache()->flush();
return to_route('hentai.index', [
'title' => $episode->slug
]);
}
/**
* Edit Episode
*/
public function update(Request $request): \Illuminate\Http\RedirectResponse
{
$episode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
$oldinterpolated = $episode->interpolated;
$oldInterpolatedUHD = $episode->interpolated_uhd;
$episode = $this->episodeService->updateEpisode($request, $studio, $episode->id);
$this->episodeService->createOrUpdateCover($request, $episode, $episode->hentai->slug, 1);
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
$this->galleryService->createOrUpdateGallery($request, $episode->hentai, $episode, $episode->episode, true);
// Discord Alert
if ($oldinterpolated !== (int) $episode->interpolated) {
DiscordReleaseNotification::dispatch($episode->slug, 'update');
}
if ($oldInterpolatedUHD !== (int) $episode->interpolated_uhd) {
DiscordReleaseNotification::dispatch($episode->slug, 'updateUHD');
}
cache()->flush();
return to_route('hentai.index', [
'title' => $episode->slug
]);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Hentai;
use App\Jobs\DiscordReleaseNotification;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Http\Request;
class ReleaseController extends Controller
{
protected EpisodeService $episodeService;
protected GalleryService $galleryService;
protected DownloadService $downloadService;
public function __construct(
EpisodeService $episodeService,
GalleryService $galleryService,
DownloadService $downloadService
) {
$this->episodeService = $episodeService;
$this->galleryService = $galleryService;
$this->downloadService = $downloadService;
}
/**
* Display release page
*/
public function index(): \Illuminate\View\View
{
return view('admin.release.create');
}
/**
* Upload New Hentai with One or Multipe Episodes
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
// Create new Hentai or find existing one
$slug = $this->episodeService->generateSlug($request->input('title'));
$hentai = Hentai::where('slug', $slug)->first();
// If hentai exists and was created today, return to home
if ($hentai?->created_at->isToday()) {
return to_route('home.index');
}
// If hentai does not exist, create a new instance
$hentai = Hentai::firstOrCreate(
['slug' => $slug],
['description' => $request->input('description1')]
);
// Studio
$studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value);
// Create Episode(s)
$releasedEpisodes = [];
for ($i = 1; $i <= $request->input('episodes'); $i++) {
$episode = $this->episodeService->createEpisode($request, $hentai, $i, $studio);
$this->episodeService->createOrUpdateCover($request, $episode, $slug, $i);
$this->downloadService->createOrUpdateDownloads($request, $episode, $i);
$this->galleryService->createOrUpdateGallery($request, $hentai, $episode, $i);
$releasedEpisodes[] = $episode->slug;
}
foreach ($releasedEpisodes as $slug) {
// Dispatch Discord Alert
DiscordReleaseNotification::dispatch($slug, 'release');
}
cache()->flush();
return to_route('home.index');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteBackground;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder;
class SiteBackgroundController extends Controller
{
/**
* Display admin index page
*/
public function index(): \Illuminate\View\View
{
return view('admin.background.index', [
'images' => SiteBackground::all(),
]);
}
/**
* Create new site backgrounds
*/
public function create(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validate([
'images' => 'required',
'date_start' => 'required',
'date_end' => 'required',
]);
foreach($request->file('images') as $file) {
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
try {
$bg = SiteBackground::create(array_merge(
$request->only(['date_start', 'date_end']),
[
'default' => (bool) $request->input('default', false)
]
));
$resolutions = [1440, 1080, 720, 640];
foreach($resolutions as $resolution) {
// /images/background/1-2560p.webp
$targetPath = "/images/background/{$bg->id}-{$resolution}p.webp";
Image::read($file->getRealPath())
->scaleDown(height: $resolution)
->encode(new WebpEncoder())
->save(public_path($targetPath));
}
} catch (\Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
return redirect()->back();
}
// Committing the database transaction.
DB::commit();
}
cache()->forget('background');
return redirect()->back();
}
public function update(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validate([
'id' => 'required|exists:site_backgrounds,id',
'date_start' => 'required',
'date_end' => 'required',
]);
SiteBackground::where('id', $request->input('id'))->update(array_merge(
$request->only(['date_start', 'date_end']),
[
'default' => (bool) $request->input('default', false)
]
));
cache()->forget('background');
return redirect()->back();
}
/**
* Delete backround
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$id = $request->input('id');
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
$bg = SiteBackground::where('id', $id)->firstOrFail();
$bg->forceDelete();
$resolutions = [1440, 1080, 720, 640];
try {
foreach($resolutions as $resolution) {
$targetPath = "/images/background/{$id}-{$resolution}p.webp";
File::delete(public_path($targetPath));
}
} catch (\Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
return redirect()->back();
}
// Committing the database transaction.
DB::commit();
cache()->forget('background');
return redirect()->back();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Episode;
use App\Models\EpisodeSubtitle;
use App\Models\Subtitle;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class SubtitleController extends Controller
{
/**
* Add new Subtitle.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$subtitle = Subtitle::create([
'name' => $request->name,
'slug' => $request->slug,
]);
// Add to Episode
EpisodeSubtitle::create([
'episode_id' => $request->episode_id,
'subtitle_id' => $subtitle->id,
]);
return redirect()->back();
}
/**
* Update Episode Subtitles.
*/
public function update(Request $request): \Illuminate\Http\RedirectResponse
{
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
// Clear everything
foreach($episode->subtitles as $sub) {
$sub->forceDelete();
}
if (! $request->input('subtitles')) {
return redirect()->back();
}
// Re-Add
foreach (json_decode($request->input('subtitles')) as $sub) {
$subtitle = Subtitle::where('name', $sub->value)->firstOrFail();
// Add to Episode
EpisodeSubtitle::create([
'episode_id' => $episode->id,
'subtitle_id' => $subtitle->id,
]);
}
return redirect()->back();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Torrents;
use Illuminate\Http\Request;
class TorrentController extends Controller
{
/**
* Display Add Torrent Page.
*/
public function index(int $hentai_id): \Illuminate\View\View
{
return view('admin.add-torrent', [
'hentai_id' => $hentai_id
]);
}
/**
* Add Torrent.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'hentai_id' => 'required|exists:hentais,id',
'torrent_url' => 'required|string|max:256',
'torrent_episodes' => 'required|string|max:8',
]);
Torrents::create([
'hentai_id' => $request->hentai_id,
'torrent_url' => $request->torrent_url,
'episodes' => $request->torrent_episodes,
]);
return to_route('download.search');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class UserController extends Controller
{
/**
* Display Users Page.
*/
public function index(): \Illuminate\View\View
{
return view('admin.users.index');
}
/**
* Update user (ban/unban).
*/
public function update(Request $request)
{
$validated = $request->validate([
'id' => 'required|exists:users,id',
'action' => 'required',
]);
$user = User::findOrFail($validated['id']);
switch ($validated['action']) {
case 'ban':
$user->update(['is_banned' => 1]);
alert()->success('Banned', 'User has been banned.');
break;
case 'unban':
$user->update(['is_banned' => 0]);
alert()->success('Unbanned', 'User has been unbanned.');
break;
default:
alert()->error('Error','Invalid action provided');
}
return redirect()->back();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\CacheHelper;
use App\Models\Episode;
use App\Models\Studios;
use App\Models\Subtitle;
use App\Http\Controllers\Controller;
class AdminApiController extends Controller
{
/**
* Get Tags (API).
*/
public function getTags()
{
$tags = CacheHelper::getAllTags();
$tagWhiteList = [];
foreach ($tags as $tag) {
$tagWhiteList[] = $tag->name;
}
return response()->json(['message' => 'success', 'tags' => $tagWhiteList], 200);
}
/**
* Get Studios (API).
*/
public function getStudios()
{
$studios = Studios::orderBy('name', 'ASC')->get();
$studioList = [];
foreach ($studios as $studio) {
$studioList[] = $studio->name;
}
return response()->json(['message' => 'success', 'studios' => $studioList], 200);
}
/**
* Get Subtitles (API).
*/
public function getSubtitles(int $episode_id)
{
$subs = Subtitle::all();
$subsWhiteList = [];
foreach ($subs as $sub) {
$subsWhiteList[] = $sub->name;
}
$episode = Episode::where('id', $episode_id)->firstOrFail();
$episodetags = [];
foreach ($episode->subtitles as $tag) {
$episodetags[] = $tag->subtitle->name;
}
return response()->json(['message' => 'success', 'subs' => $subsWhiteList, 'episodesubs' => $episodetags], 200);
}
/**
* Get Episode Tags (API).
*/
public function getEpisodeTags(int $episode_id)
{
$tags = CacheHelper::getAllTags();
$tagWhiteList = [];
foreach ($tags as $tag) {
$tagWhiteList[] = $tag->name;
}
$episode = Episode::where('id', $episode_id)->firstOrFail();
$episodetags = [];
foreach ($episode->tags as $tag) {
$episodetags[] = $tag->name;
}
return response()->json(['message' => 'success', 'tags' => $tagWhiteList, 'episodetags' => $episodetags], 200);
}
/**
* Get Episode Studio (API).
*/
public function getEpisodeStudio(int $episode_id)
{
$studios = Studios::orderBy('name', 'ASC')->get();
$studioList = [];
foreach ($studios as $studio) {
$studioList[] = $studio->name;
}
$episode = Episode::where('id', $episode_id)->firstOrFail();
$episodestudio = [$episode->studio->name];
return response()->json(['message' => 'success', 'studios' => $studioList, 'episodestudios' => $episodestudio], 200);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Downloads;
use App\Models\Episode;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class DownloadApiController extends Controller
{
/**
* Get Download URL for users who are not logged in.
*/
public function getDownload(Request $request)
{
$validated = $request->validate([
'episode_id' => 'required',
'captcha' => 'required|captcha'
]);
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
// Increase download count, as we assume the user
// downloads after submitting the captcha
$download = Downloads::find($episode->getDownloadByType('FHD')->id);
$oldCount = $download->count;
$download->count++;
$download->save();
return response()->json([
'message' => 'success',
'download_url' => $download->url,
'download_count' => $oldCount,
], 200);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Episode;
use Illuminate\Http\Request;
class StreamApiController extends Controller
{
/**
* Get Data used by the Video Player.
*/
public function getStream(Request $request)
{
$validated = $request->validate([
'episode_id' => 'required',
]);
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
$subtitles = $episode->subtitles
->mapWithKeys(fn($sub) => [$sub->subtitle->slug => $sub->subtitle->name])
->toArray();
return response()->json([
'title' => $episode->title.' - '.$episode->episode,
'poster' => $episode->gallery()->first()->image_url,
'interpolated' => $episode->interpolated,
'interpolated_uhd' => $episode->interpolated_uhd,
'stream_url' => $episode->url,
'stream_domains' => config('hstream.stream_domain'),
'asia_stream_domains' => config('hstream.asia_stream_domain'),
'extra_subtitles' => $subtitles
], 200);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\CacheHelper;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Conner\Tagging\Model\Tag;
class UserApiController extends Controller
{
/**
* Get Tags (API).
*/
public function getBlacklist(Request $request)
{
$user = $request->user();
$tagWhiteList = [];
$tagBlackList = [];
// All Tags
foreach (CacheHelper::getAllTags() as $tag) {
$tagWhiteList[] = $tag->name;
}
// User Tags
if ($user->tag_blacklist) {
foreach ($user->tag_blacklist as $tag) {
$t = Tag::where('slug', $tag)->first();
$tagBlackList[] = $t->name;
}
}
return response()->json([
'message' => 'success',
'tags' => $tagWhiteList,
'usertags' => $tagBlackList
], 200);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME);
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(RouteServiceProvider::HOME)
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(RouteServiceProvider::HOME.'?verified=1');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\Contact;
use Illuminate\Http\Request;
class ContactController extends Controller
{
/**
* Display Contact Page.
*/
public function index(): \Illuminate\View\View
{
return view('contact.form');
}
/**
* Store Contact Submission.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'name' => 'required|max:30',
'email' => 'required|max:50',
'message' => 'required|max:1000',
'subject' => 'required|max:50',
'captcha' => 'required|captcha',
]);
$contact = new Contact();
$contact->name = $request->input('name');
$contact->email = $request->input('email');
$contact->message = $request->input('message');
$contact->subject = $request->input('subject');
$contact->save();
return back()->with('status', 'contact-submitted');
}
public function reloadCaptcha(): \Illuminate\Http\JsonResponse
{
return response()->json(['captcha'=> captcha_img()]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Helpers\CacheHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
class HomeController extends Controller
{
/**
* Display Home Page.
*/
public function index(): \Illuminate\View\View
{
$guest = Auth::guest();
$guestString = $guest ? 'guest' : 'authed';
$mostLikes = \cache()->remember('mostLikes'.$guestString, 300, fn () =>
Episode::with('gallery')
->when($guest, fn ($query) => $query->withoutTags(['loli', 'shota']))
->whereIn('id', function($query) {
$mostLikesIds = CacheHelper::getMostLikes()->pluck('markable_id')->toArray();
$query->selectRaw('id')
->from('episodes')
->whereIn('id', $mostLikesIds)
->orderByRaw("FIELD(id, " . implode(',', $mostLikesIds) . ")");
})
->get()
);
return view('home.index', [
'recentlyReleased' => CacheHelper::getRecentlyReleased($guest),
'recentlyUploaded' => CacheHelper::getRecentlyUploaded($guest),
'popularAllTime' => CacheHelper::getPopularAllTime($guest),
'popularMonthly' => CacheHelper::getPopularMonthly(),
'popularWeekly' => CacheHelper::getPopularWeekly(),
'popularDaily' => CacheHelper::getPopularDaily(),
'mostLikes' => $mostLikes,
'latestComments' => CacheHelper::getLatestComments(),
]);
}
/**
* Display Banned Page.
*/
public function banned(): \Illuminate\View\View
{
return view('auth.banned');
}
/**
* Display Search Page.
*/
public function search(): \Illuminate\View\View
{
return view('search.index');
}
/**
* Display Download Search Page.
*/
public function downloadSearch(): \Illuminate\View\View
{
return view('search.download');
}
/**
* Redirect POST Data to GET with Query String.
*/
public function searchRedirect(Request $request): \Illuminate\Http\RedirectResponse
{
return redirect()->route('hentai.search', [
'search' => $request->input('live-search'),
]);
}
/**
* Display Stats Page.
*/
public function stats(): \Illuminate\View\View
{
return view('home.stats', [
'viewCount' => CacheHelper::getTotalViewCount(),
'episodeCount' => CacheHelper::getTotalEpisodeCount(),
'hentaiCount' => CacheHelper::getTotalHentaiCount(),
'monthlyCount' => CacheHelper::getTotalMonthlyViews()
]);
}
/**
* Manually set website language
*/
public function updateLanguage(Request $request): \Illuminate\Http\RedirectResponse
{
if(! in_array($request->language, config('lang-detector.languages'))) {
return redirect()->back();
}
Cookie::queue(Cookie::forever('locale', $request->language));
return redirect()->back();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class NotificationController extends Controller
{
/**
* Display the user's notification page.
*/
public function index(Request $request): \Illuminate\View\View
{
return view('profile.notifications', [
'user' => $request->user(),
'notifications' => $request->user()->unreadNotifications,
]);
}
/**
* Delete Notifcation
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validate([
'id' => 'required|exists:notifications,id',
]);
$notification = $request->user()
->notifications()
->where('id', $request->input('id'))
->firstOrFail();
$notification->forceDelete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Services\PlaylistService;
use Illuminate\Http\Request;
use RealRashid\SweetAlert\Facades\Alert;
class PlaylistController extends Controller
{
protected PlaylistService $playlistService;
public function __construct(PlaylistService $playlistService)
{
$this->playlistService = $playlistService;
}
/**
* Display the public playlists page.
*/
public function index(): \Illuminate\View\View
{
return view('playlist.index');
}
/**
* Display public playlist.
*/
public function show($playlist_id): \Illuminate\View\View
{
if (!is_numeric($playlist_id)) {
abort(404);
}
$playlist = Playlist::where('is_private', 0)->where('id', $playlist_id)->firstOrFail();
return view('playlist.list', [
'playlist' => $playlist,
]);
}
/**
* Display the user's playlists page.
*/
public function playlists(Request $request): \Illuminate\View\View
{
$title = 'Delete Playlist!';
$text = "Are you sure you want to delete?";
confirmDelete($title, $text);
return view('profile.playlists', [
'user' => $request->user(),
'playlists' => $request->user()->playlists,
]);
}
/**
* Display user's playlist.
*/
public function showPlaylist(Request $request, $playlist_id): \Illuminate\View\View
{
if (!is_numeric($playlist_id)) {
abort(404);
}
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)->where('id', $playlist_id)->firstOrFail();
return view('playlist.list', [
'playlist' => $playlist,
]);
}
/**
* Create user playlist (Form).
*/
public function createPlaylist(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'name' => 'required|max:30',
]);
$playlist = new Playlist();
$playlist->user_id = $request->user()->id;
$playlist->name = $request->input('name');
$playlist->is_private = $request->input('visiblity') === 'private';
$playlist->save();
return to_route('profile.playlists');
}
/**
* Delete user playlist.
*/
public function deletePlaylist(Request $request, $playlist_id): \Illuminate\Http\RedirectResponse
{
if (!is_numeric($playlist_id)) {
abort(404);
}
$user = $request->user();
$playlist = Playlist::where('user_id', $user->id)->where('id', $playlist_id)->firstOrFail();
// Delete Playlist Episodes
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
// Delete Playlist
$playlist->forceDelete();
return to_route('profile.playlists');
}
/**
* Delete episode from playlist.
*/
public function deleteEpisodeFromPlaylist(Request $request): \Illuminate\Http\JsonResponse
{
if (!is_numeric($request->input('playlist')) || !is_numeric($request->input('episode'))) {
return response()->json([
'message' => 'not-numeric',
'user' => $request->user(),
], 404);
}
$playlist = Playlist::where('user_id', $request->user()->id)->where('id', (int) $request->input('playlist'))->firstOrFail();
PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', (int) $request->input('episode'))->forceDelete();
$this->playlistService->reorderPositions($playlist);
return response()->json([
'message' => 'success',
'user' => $request->user(),
], 200);
}
/**
* Add to user playlist (API).
*/
public function addPlaylistApi(Request $request): \Illuminate\Http\JsonResponse
{
$user = $request->user();
$validated = $request->validate([
'playlist' => 'required|max:30',
'episode_id' => 'required'
]);
$playlist = Playlist::where('user_id', $user->id)->where('id', $request->input('playlist'))->firstOrFail();
$episode = Episode::where('id', $request->input('episode_id'))->firstOrFail();
// Check if already in playlist
$exists = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->exists();
if ($exists) {
return response()->json([
'message' => 'already-added'
], 200);
}
// Position of entry
$position = $playlist->episodes->count() + 1;
PlaylistEpisode::create([
'playlist_id' => $playlist->id,
'episode_id' => $episode->id,
'position' => $position,
]);
return response()->json([
'message' => 'success'
], 200);
}
/**
* Create user playlist (API).
*/
public function createPlaylistApi(Request $request): \Illuminate\Http\JsonResponse
{
$validated = $request->validate([
'name' => 'required|max:30',
]);
$playlist = new Playlist();
$playlist->user_id = $request->user()->id;
$playlist->name = $request->input('name');
$playlist->is_private = $request->input('visiblity') === 'private';
$playlist->save();
return response()->json([
'message' => 'success',
'playlist_id' => $playlist->id
], 200);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Conner\Tagging\Model\Tag;
class ProfileController extends Controller
{
/**
* Display the user page.
*/
public function index(Request $request): \Illuminate\View\View
{
return view('profile.index', [
'user' => $request->user(),
]);
}
/**
* Display the user's settings form.
*/
public function settings(Request $request): \Illuminate\View\View
{
$example = Episode::where('title', 'Succubus Yondara Gibo ga Kita!?')->first();
return view('profile.settings', [
'user' => $request->user(),
'example' => $example,
]);
}
/**
* Display the user's watched page.
*/
public function watched(Request $request): \Illuminate\View\View
{
return view('profile.watched', [
'user' => $request->user(),
]);
}
/**
* Display the user's comments page.
*/
public function comments(Request $request): \Illuminate\View\View
{
return view('profile.comments', [
'user' => $request->user(),
]);
}
/**
* Display the user's likes page.
*/
public function likes(Request $request): \Illuminate\View\View
{
return view('profile.likes', [
'user' => $request->user(),
]);
}
/**
* Update user settings.
*/
public function saveSettings(Request $request): \Illuminate\Http\RedirectResponse
{
$user = $request->user();
$user->search_design = $request->input('searchDesign') == 'thumbnail';
$user->home_top_design = $request->input('topDesign') == 'thumbnail';
$user->home_middle_design = $request->input('middleDesign') == 'thumbnail';
$user->save();
return Redirect::route('profile.settings')->with('status', 'design-updated');
}
/**
* Update user tag blacklist.
*/
public function saveBlacklist(Request $request): \Illuminate\Http\RedirectResponse
{
$user = $request->user();
$tags = json_decode($request->input('tags'));
if (!$tags) {
$user->tag_blacklist = null;
$user->save();
return Redirect::route('profile.settings')->with('status', 'blacklist-updated');
}
$blacklist = [];
foreach ($tags as $tag) {
$t = Tag::where('slug', Str::slug($tag->value))->firstOrFail();
$blacklist[] = $t->slug;
}
$user->tag_blacklist = $blacklist;
$user->save();
return Redirect::route('profile.settings')->with('status', 'blacklist-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Models\Episode;
use App\Models\Gallery;
use App\Models\Hentai;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Watched;
use App\Helpers\CacheHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use hisorange\BrowserDetect\Facade as Browser;
class StreamController extends Controller
{
/**
* Display Stream Page.
*/
public function index(Request $request, string $title): \Illuminate\View\View
{
$titleParts = explode('-', $title);
if (! is_numeric($titleParts[array_key_last($titleParts)])) {
$hentai = Hentai::with('episodes')->where('slug', $title)->firstOrFail();
if (Auth::guest() && $hentai->isLoliOrShota()) {
return view('auth.please-login');
}
return view('series.index', [
'hentai' => $hentai,
'popularWeekly' => CacheHelper::getPopularWeekly(),
]);
}
$episode = Episode::where('slug', $title)->firstOrFail();
$gallery = Gallery::where('episode_id', $episode->id)->get();
$moreEpisodes = Episode::with(['gallery', 'studio'])->where('hentai_id', $episode->hentai_id)->whereNot('id', $episode->id)->get();
$studioEpisodes = Episode::with(['gallery', 'studio'])->inRandomOrder()->where('studios_id', $episode->studios_id)->whereNot('id', $episode->id)->limit(6)->get();
// Only allow access to problematic stuff when logged in
if (Auth::guest() && $episode->isLoliOrShota()) {
return view('auth.please-login');
}
// Increment View Count
$episode->incrementViewCount();
// Increment Popular Count
$episode->incrementPopularCount();
if (!Auth::guest()) {
$user = Auth::user();
// Add to user watched list
$time = Carbon::now()->subHour(1);
$alreadyWatched = Watched::where('user_id', $user->id)->where('episode_id', $episode->id)->where('created_at', '>=', $time)->exists();
if (!$alreadyWatched) {
Watched::create(['user_id' => $user->id, 'episode_id' => $episode->id]);
cache()->forget('user' . $user->id . 'watched' . $episode->id);
}
}
// Mobile Detection
$isMobile = Browser::isMobile();
// Playlist
if ($request->has('playlist')) {
// Get and check if playlist exists
$playlist = Playlist::where('id', $request->input('playlist'))->firstOrFail();
// Check if episode is in playlist
$inPlaylist = PlaylistEpisode::where('playlist_id', $playlist->id)->where('episode_id', $episode->id)->firstOrFail();
// Get Playlist Episodes and order them
$playlistEpisodes = $playlist->episodes()->orderBy('position')->get();
// Check if authorized
if ($playlist->is_private && (Auth::guest() || (!Auth::guest() && Auth::user()->id != $playlist->user_id))) {
abort(404);
}
return view('stream.index', [
'episode' => $episode,
'moreEpisodes' => $moreEpisodes,
'studioEpisodes' => $studioEpisodes,
'gallery' => $gallery,
'playlist' => $playlist,
'playlistEpisodes' => $playlistEpisodes,
'popularWeekly' => CacheHelper::getPopularWeekly(),
'isMobile' => $isMobile,
]);
}
return view('stream.index', [
'episode' => $episode,
'moreEpisodes' => $moreEpisodes,
'studioEpisodes' => $studioEpisodes,
'gallery' => $gallery,
'popularWeekly' => CacheHelper::getPopularWeekly(),
'isMobile' => $isMobile,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Models\Watched;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class UserController extends Controller
{
/**
* Display User Page.
*/
public function index(string $username): \Illuminate\View\View
{
$user = User::where('username', $username)
->select('id', 'username', 'global_name', 'avatar', 'created_at', 'is_patreon')
->firstOrFail();
return view('user.index', [
'user' => $user,
]);
}
/**
* Delete User.
*/
public function delete(Request $request): \Illuminate\Http\RedirectResponse
{
$user = User::where('id', $request->user()->id)->firstOrFail();
// Delete Playlist
$playlists = Playlist::where('user_id', $user->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $user->id)->update(['commenter_id' => 1]);
$user->forceDelete();
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
cache()->flush();
return redirect('/');
}
}

70
app/Http/Kernel.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\IsBanned::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'auth.admin' => \App\Http\Middleware\IsAdmin::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,44 @@
<?php namespace app\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\Guard;
class IsAdmin {
/**
* The Guard implementation.
*
* @var Guard
*/
protected $auth;
/**
* Create a new filter instance.
*
* @param Guard $auth
* @return void
*/
public function __construct(Guard $auth)
{
$this->auth = $auth;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if( ! $this->auth->user()->is_admin)
{
session()->flash('error_msg','This resource is restricted to Administrators!');
return redirect()->route('home.index');
}
return $next($request);
}
}

View File

@@ -0,0 +1,29 @@
<?php namespace app\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
use Illuminate\Contracts\Auth\Guard;
class IsBanned {
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if(auth()->check() && auth()->user()->is_banned == 1)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home.banned');
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'name' => ['string', 'max:255'],
'email' => ['email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Spatie\DiscordAlerts\Facades\DiscordAlert;
class DiscordReleaseNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private $slug;
private $messageType;
/**
* Create a new job instance.
*/
public function __construct($slug, $messageType)
{
$this->slug = $slug;
$this->messageType = $messageType;
}
/**
* Execute the job.
*/
public function handle(): void
{
// Wait 2s to avoid Discord API Rate limit
sleep(2);
if ($this->messageType == 'release') {
DiscordAlert::message("<@&868457842250764289> (´• ω •`)ノ New **4k** Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
}
if ($this->messageType == 'update') {
DiscordAlert::to('update')->message("<@&1283518462584426598> (´• ω •`)ノ Added **48fps** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
}
if ($this->messageType == 'updateUHD') {
DiscordAlert::to('update')->message("<@&1326860920902778963> (´• ω •`)ノ Added **48fps 4k** to Release! Check it out here: https://hstream.moe/hentai/".$this->slug);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Jobs;
use App\Models\Downloads;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GetFileSizeFromCDN implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private $downloadId;
/**
* Create a new job instance.
*/
public function __construct(int $id)
{
$this->downloadId = $id;
}
/**
* Execute the job.
*/
public function handle(): void
{
// Retrieve the download record, return if not found
$download = Downloads::find($this->downloadId);
if (!$download) {
Log::error("Download not found for ID: {$this->downloadId}");
return;
}
// Generate encrypted file path and expiration
$endpoint = config('hstream.download_domain_4k')[0];
$serverPath = $download->type === 'UHD' ? 'hentai/' : 'hentai-1080p/';
$file = Crypt::encryptString($serverPath.$download->url);
$expire = Crypt::encryptString(Carbon::now()->addHours(6)->toDateTimeString());
try {
// Send HTTP request to the endpoint
$response = Http::get($endpoint . '/getSize/' . $file . '/' . $expire);
// Check if response is successful
if ($response->successful()) {
$size = $response->json()['size'];
// Update the download record with the retrieved size
$download->size = $size;
$download->save();
Log::info("Updated size for download ID: {$this->downloadId}");
} else {
Log::error("Failed to retrieve size for download ID: {$this->downloadId}, HTTP status: {$response->status()}");
}
} catch (RequestException $e) {
Log::error("HTTP request failed for download ID: {$this->downloadId}, error: " . $e->getMessage());
} catch (\Exception $e) {
Log::error("An error occurred for download ID: {$this->downloadId}, error: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\User;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
class AdminUserSearch extends Component
{
use WithPagination;
#[Url(history: true)]
public $search = '';
#[Url(history: true)]
public $filtered = ['true'];
#[Url(history: true)]
public $patreon = [];
#[Url(history: true)]
public $banned = [];
public function render()
{
$users = User::when($this->filtered !== [], fn ($query) => $query->where('id', '>=', 10000))
->when($this->patreon !== [], fn ($query) => $query->where('is_patreon', 1))
->when($this->banned !== [], fn ($query) => $query->where('is_banned', 1))
->when($this->search !== '', fn ($query) => $query->where(function($query) {
$query->where('username', 'like', '%'.$this->search.'%')
->orWhere('global_name', 'like', '%'.$this->search.'%');
}))
->paginate(20);
return view('livewire.admin-user-search', [
'users' => $users
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use App\Models\SiteBackground;
use Illuminate\Support\Carbon;
class BackgroundImages extends Component
{
use WithPagination;
#[Url(history: true)]
public $filter = 'all';
public function render()
{
$now = Carbon::now();
$images = SiteBackground::when($this->filter === 'active', fn ($query) =>
$query->whereDate('date_start', '<=', $now)->whereDate('date_end', '>=', $now)
)
->when($this->filter === 'inactive', fn ($query) =>
$query->whereDate('date_start', '>', $now)->orWhereDate('date_end', '<', $now)
)
->paginate(10);
return view('livewire.background-images', [
'images' => $images
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\Downloads;
use Livewire\Component;
class DownloadButton extends Component
{
public $downloadUrl;
public $downloadId;
public $downloadCount;
public $episodeNumber;
public $fillNumbers;
public $fileSize;
public $background = 'bg-rose-600';
public function clicked($downloadId)
{
$download = Downloads::find($downloadId);
if (!$download) {
return;
}
$download->count++;
$download->save();
$this->downloadCount = $download->count;
cache()->forget("episode_{$download->episode->id}_download_{$download->type}");
}
public function render()
{
return view('livewire.download-button');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Livewire;
use App\Models\Hentai;
use Livewire\Component;
use Livewire\WithPagination;
class Downloads extends Component
{
use WithPagination;
public $search;
public $order = 'az';
public $withTorrents;
protected $queryString = [
'search' => ['except' => '', 'as' => 's'],
'withTorrents' => ['withTorrents' => '', 'as' => 'withTorrents'],
'order' => ['except' => '', 'as' => 'order'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$orderby = 'slug';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'slug';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'slug';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$hentai = Hentai::with('episodes')
->when($this->search != '', fn ($query) => $query->whereHas('episodes', fn ($q) => $q->where('title', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%')))
->when($this->withTorrents != '', fn ($query) => $query->whereHas('torrents'))
->orderBy($orderby, $orderdirection)
->paginate(10);
return view('livewire.downloads', [
'hentai' => $hentai,
'query' => $this->search,
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use App\Models\User;
use App\Models\UserDownload;
use Livewire\Component;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class DownloadsFree extends Component
{
public $episodeId = 0;
public $downloadsLeft = 5;
public $granted = 0;
public $override = false;
public $interpolated = false;
public function mount(Episode $episode, $interpolated = false)
{
$this->episodeId = $episode->id;
$this->interpolated = $interpolated;
$user = Auth::user()->id;
if (Auth::check()) {
$this->downloadsLeft = (int) User::where('id', $user)->firstOrFail()->downloads_left;
}
if ($this->downloadsLeft === 0) {
$this->granted = 3;
}
if ($episode->created_at >= Carbon::now()->subDays(7)) {
$this->granted = 4;
}
$alreadyDownloaded = UserDownload::where('user_id', $user)
->where('episode_id', $this->episodeId)
->where('interpolated', $this->interpolated)
->first();
if ($alreadyDownloaded) {
// Check timestamp
if (Carbon::parse($alreadyDownloaded->created_at)->addHours(6) <= Carbon::now()) {
// Already expired
$alreadyDownloaded->forceDelete();
return;
}
$this->override = true;
}
}
public function generate()
{
$user = User::where('id', Auth::user()->id)->firstOrFail();
if ($user->downloads_left <= 0) {
// Daily limit reached
$this->granted = 3;
return;
}
$user->downloads_left -= 1;
$user->save();
$this->downloadsLeft -= 1;
$this->granted = 1;
UserDownload::create([
'user_id' => $user->id,
'episode_id' => $this->episodeId,
'interpolated' => $this->interpolated,
]);
}
public function render()
{
return view('livewire.downloads-free');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use App\Models\User;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Maize\Markable\Models\Like;
class LikeButton extends Component
{
public $episodeId = 0;
public $likeCount = 0;
public $liked = false;
public function mount(Episode $episode)
{
$this->episodeId = $episode->id;
$this->likeCount = $episode->likes->count();
if (Auth::check()) {
$this->liked = Like::has($episode, User::where('id', Auth::user()->id)->firstOrFail());
}
}
public function update()
{
$episode = Episode::where('id', $this->episodeId)->firstOrFail();
$this->likeCount = $episode->likes->count();
if (Auth::check()) {
$this->liked = Like::has($episode, User::where('id', Auth::user()->id)->firstOrFail());
}
}
public function like()
{
if (! Auth::check()) {
return;
}
$episode = Episode::where('id', $this->episodeId)->firstOrFail();
Like::toggle($episode, User::where('id', Auth::user()->id)->firstOrFail());
Cache::forget('episodeLikes'.$this->episodeId);
if ($this->liked) {
$this->liked = false;
$this->likeCount--;
return;
}
$this->liked = true;
$this->likeCount++;
}
public function render()
{
return view('livewire.like-button');
}
}

155
app/Livewire/LiveSearch.php Normal file
View File

@@ -0,0 +1,155 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
class LiveSearch extends Component
{
use WithPagination;
#[Url(history: true)]
public $search = '';
#[Url(history: true)]
public $order = 'recently-uploaded';
#[Url(history: true)]
public $tags = [];
public $tagsCopy = [];
#[Url(history: true)]
public $studios = [];
public $studiosCopy = [];
#[Url(history: true)]
public $blacklist = [];
public $blacklistCopy = [];
#[Url(history: true)]
public $hideWatched = [];
#[Url(history: true)]
public $view = 'thumbnail';
public $pagination = 25;
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingHideWatched(): void
{
$this->resetPage();
}
public function updatedView(): void
{
$this->pagination = $this->view == 'thumbnail' ? 25 : 24;
$this->resetPage();
}
public function revertFilters(): void
{
$this->tags = $this->tagsCopy;
$this->studios = $this->studiosCopy;
$this->blacklist = $this->blacklistCopy;
}
public function applyFilters(): void
{
$this->tagsCopy = $this->tags;
$this->studiosCopy = $this->studios;
$this->blacklistCopy = $this->blacklist;
$this->resetPage();
}
public function mount()
{
// User blacklist
if (Auth::check() && empty($this->blacklist) && !empty(auth()->user()->tag_blacklist)) {
$this->blacklist = auth()->user()->tag_blacklist;
}
if (Auth::check()) {
$this->view = auth()->user()->search_design ? 'thumbnail' : 'poster';
$this->pagination = $this->view == 'thumbnail' ? 25 : 24;
}
}
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'title';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'title';
$orderdirection = 'desc';
break;
case 'recently-released':
$orderby = 'release_date';
$orderdirection = 'desc';
break;
case 'oldest-uploads':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
case 'oldest-releases':
$orderby = 'release_date';
$orderdirection = 'asc';
break;
case 'view-count':
$orderby = 'view_count';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$user_id = Auth::check() ? auth()->user()->id : 0;
$episodes = Episode::with('gallery')->when($this->search != '', fn ($query) => $query->where(function($query) { $query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%'); }))
->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags))
->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist))
->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); }))
->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) {
$query->where('user_id', $user_id);
}))
->when(Auth::guest(), fn ($query) => $query->withoutTags(['loli', 'shota']))
->orderBy($orderby, $orderdirection)
->paginate($this->pagination);
$searchIsJpn = false;
if (! empty($this->search)) {
if (strlen($this->search) != strlen(utf8_decode($this->search))) {
$searchIsJpn = true;
}
}
// Dispatch Event to trigger JS reload for thumbnails
$this->dispatch('contentChanged');
return view('livewire.live-search', [
'episodes' => $episodes,
'tagcount' => is_array($this->tags) ? count($this->tags) : 0,
'studiocount' => is_array($this->studios) ? count($this->studios) : 0,
'blacklistcount' => is_array($this->blacklist) ? count($this->blacklist) : 0,
'query' => $this->search,
'selectedtags' => $this->tags,
'selectedstudios' => $this->studios,
'selectedblacklist' => $this->blacklist,
'searchIsJpn' => $searchIsJpn,
'view' => $this->view,
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use App\Models\Gallery;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
class NavLiveSearch extends Component
{
public $navSearch;
protected $queryString = [
'navSearch' => ['except' => '', 'as' => 's'],
];
public function render()
{
$episodes = [];
$randomimage = null;
if ($this->navSearch != '') {
$episodes = Episode::with('gallery')->where('title', 'like', '%'.$this->navSearch.'%')
->orWhere('title_jpn', 'like', '%'.$this->navSearch.'%')
->when(Auth::guest(), fn ($query) => $query->withoutTags(['loli', 'shota']))
->take(10)
->get();
$randomimage = Gallery::all()
->random(1)
->first();
}
return view('livewire.nav-live-search', [
'episodes' => $episodes,
'randomimage' => $randomimage,
'query' => $this->navSearch,
'hide' => empty($this->navSearch),
]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Livewire;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use App\Services\PlaylistService;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Collection;
class PlaylistOverview extends Component
{
use WithPagination;
protected PlaylistService $playlistService;
#[Url(history: true)]
public $search;
public int $pagination = 25;
public Playlist $playlist;
public Collection $playlistEpisodes;
public function boot(PlaylistService $playlistService)
{
$this->playlistService = $playlistService;
}
public function mount($playlist_id)
{
$this->playlist = Playlist::with(['episodes.episode'])->findOrFail($playlist_id);
// Set position if null
$this->playlist->episodes->each(function ($item, $index) {
if ($item->position === null) {
$item->position = $index + 1;
$item->save();
}
});
$this->refreshEpisodes();
}
public function refreshEpisodes()
{
$this->playlistEpisodes = $this->playlist->episodes()->orderBy('position')->with('episode')->get();
}
public function moveUp($episodeId)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return;
}
$episode = PlaylistEpisode::find($episodeId);
$above = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '<', $episode->position)
->orderBy('position', 'desc')
->first();
if ($above) {
$this->playlistService->swapPositions($episode, $above);
}
$this->refreshEpisodes();
}
public function moveDown($episodeId)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return;
}
$episode = PlaylistEpisode::find($episodeId);
$below = PlaylistEpisode::where('playlist_id', $episode->playlist_id)
->where('position', '>', $episode->position)
->orderBy('position')
->first();
if ($below) {
$this->playlistService->swapPositions($episode, $below);
}
$this->refreshEpisodes();
}
public function remove($episodeId)
{
if (! Auth::check()) {
return;
}
if (Auth::user()->id !== $this->playlist->user->id) {
return;
}
PlaylistEpisode::find($episodeId)?->delete();
$this->playlistService->reorderPositions($this->playlist);
$this->refreshEpisodes();
}
public function render()
{
return view('livewire.playlist-overview', [
'query' => $this->search,
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Livewire;
use App\Models\Playlist;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
class Playlists extends Component
{
use WithPagination;
#[Url(history: true)]
public $search;
#[Url(history: true)]
public $order = 'episode-count';
public $pagination = 12;
public function render()
{
$orderby = 'episodes_count';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'name';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'name';
$orderdirection = 'desc';
break;
case 'episode-count':
$orderby = 'episodes_count';
$orderdirection = 'desc';
break;
case 'newest':
$orderby = 'created_at';
$orderdirection = 'desc';
break;
case 'oldest':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
default:
$orderby = 'episodes_count';
$orderdirection = 'desc';
}
$playlists = Playlist::where('is_private', 0)
->withCount('episodes')
->having('episodes_count', '>', 1)
->when($this->search != '', fn($query) => $query->where('name', 'like', '%' . $this->search . '%'))
->orderBy($orderby, $orderdirection)
->paginate($this->pagination);
return view('livewire.playlists', [
'playlists' => $playlists
]);
}
}

152
app/Livewire/UserLikes.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Facades\Auth;
class UserLikes extends Component
{
use WithPagination;
#[Url(history: true)]
public $search;
#[Url(history: true)]
public $order = 'recently-uploaded';
#[Url(history: true)]
public $tags = [];
public $tagsCopy = [];
#[Url(history: true)]
public $studios = [];
public $studiosCopy = [];
#[Url(history: true)]
public $blacklist = [];
public $blacklistCopy = [];
#[Url(history: true)]
public $hideWatched = [];
#[Url(history: true)]
public string $view = 'thumbnail';
public int $pagination = 25;
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingHideWatched(): void
{
$this->resetPage();
}
public function revertFilters(): void
{
$this->tags = $this->tagsCopy;
$this->studios = $this->studiosCopy;
$this->blacklist = $this->blacklistCopy;
}
public function applyFilters(): void
{
$this->tagsCopy = $this->tags;
$this->studiosCopy = $this->studios;
$this->blacklistCopy = $this->blacklist;
$this->resetPage();
}
public function updatedView(): void
{
$this->pagination = $this->view == 'thumbnail' ? 25 : 24;
$this->resetPage();
}
public function mount()
{
// User blacklist
if (Auth::check() && empty($this->blacklist) && !empty(auth()->user()->tag_blacklist)) {
$this->blacklist = auth()->user()->tag_blacklist;
}
if (Auth::check()) {
$this->view = auth()->user()->search_design ? 'thumbnail' : 'poster';
$this->pagination = $this->view == 'thumbnail' ? 25 : 24;
}
}
public function render()
{
$orderby = 'created_at';
$orderdirection = 'desc';
switch ($this->order) {
case 'az':
$orderby = 'title';
$orderdirection = 'asc';
break;
case 'za':
$orderby = 'title';
$orderdirection = 'desc';
break;
case 'recently-released':
$orderby = 'release_date';
$orderdirection = 'desc';
break;
case 'oldest-uploads':
$orderby = 'created_at';
$orderdirection = 'asc';
break;
case 'oldest-releases':
$orderby = 'release_date';
$orderdirection = 'asc';
break;
case 'view-count':
$orderby = 'view_count';
$orderdirection = 'desc';
break;
default:
$orderby = 'created_at';
$orderdirection = 'desc';
}
$user_id = Auth::check() ? auth()->user()->id : 0;
$episodes = Episode::whereHasLike(auth()->user())
->when($this->search !== '', fn ($query) => $query->where(function($query) { $query->where('title', 'like', '%'.$this->search.'%')->orWhere('title_search', 'like', '%'.$this->search.'%')->orWhere('title_jpn', 'like', '%'.$this->search.'%'); }))
->when($this->tags !== [], fn ($query) => $query->withAllTags($this->tags))
->when($this->blacklist !== [], fn ($query) => $query->withoutTags($this->blacklist))
->when($this->studios !== [], fn ($query) => $query->whereHas('studio', function ($query) { $query->whereIn('slug', $this->studios); }))
->when($this->hideWatched !== [] && Auth::check(), fn ($query) => $query->whereDoesntHave('watched', function ($query) use ($user_id) {
$query->where('user_id', $user_id);
}))
->orderBy($orderby, $orderdirection)
->paginate($this->pagination);
$searchIsJpn = false;
if (! empty($this->search)) {
if (strlen($this->search) != strlen(utf8_decode($this->search))) {
$searchIsJpn = true;
}
}
return view('livewire.user-likes', [
'episodes' => $episodes,
'tagcount' => is_array($this->tags) ? count($this->tags) : 0,
'studiocount' => is_array($this->studios) ? count($this->studios) : 0,
'blacklistcount' => is_array($this->blacklist) ? count($this->blacklist) : 0,
'query' => $this->search,
'selectedtags' => $this->tags,
'selectedstudios' => $this->studios,
'selectedblacklist' => $this->blacklist,
'searchIsJpn' => $searchIsJpn,
'view' => $this->view,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Livewire;
use App\Models\Episode;
use Livewire\Component;
class ViewCount extends Component
{
public $episodeId = 0;
public $viewCount = 0;
public function mount(Episode $episode)
{
$this->episodeId = $episode->id;
$this->viewCount = $episode->view_count;
}
public function update()
{
$this->viewCount = Episode::where('id', $this->episodeId)->firstOrFail()->view_count;
}
public function render()
{
return view('livewire.view-count');
}
}

34
app/Livewire/Watched.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Livewire;
use App\Models\Watched as UserWatched;
use App\Models\User;
use Illuminate\Support\Carbon;
use Livewire\Component;
use Livewire\WithPagination;
class Watched extends Component
{
use WithPagination;
public $userId;
public function mount($user)
{
$this->userId = $user ? $user->id : auth()->user()->id;
}
public function render()
{
$watched = UserWatched::where('user_id', $this->userId)->orderBy('created_at', 'desc')->paginate(25);
$watchedGrouped = $watched->groupBy(function ($val) {
return Carbon::parse($val->created_at)->format('h');
});
return view('livewire.watched', [
'watched' => $watched,
'watchedGrouped' => $watchedGrouped,
]);
}
}

18
app/Models/Alert.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Alert extends Model
{
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'text',
'type',
];
}

21
app/Models/Contact.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Contact extends Model
{
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'id',
'name',
'email',
'subject',
'message',
];
}

52
app/Models/Downloads.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Downloads extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'episode_id',
'type',
'url',
];
/**
* Belongs to an episode
*/
public function episode()
{
return $this->belongsTo(Episode::class);
}
/**
* Convert bytes to human readable format
*/
private static function bytesToHuman($bytes)
{
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
for ($i = 0; $bytes > 1024; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Returns the human readable form of the file size
*/
public function getFileSize(): ?string
{
return $this->size === null ? null : self::bytesToHuman($this->size);
}
}

215
app/Models/Episode.php Normal file
View File

@@ -0,0 +1,215 @@
<?php
namespace App\Models;
use App\Models\Downloads;
use App\Models\PopularMonthly;
use App\Models\PopularWeekly;
use App\Models\PopularDaily;
use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable;
use Maize\Markable\Markable;
use Maize\Markable\Models\Like;
use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Episode extends Model implements Sitemapable
{
use Commentable, Markable, Taggable;
use HasFactory;
protected static $marks = [
Like::class
];
/**
* Get the studio for the Hentai.
*/
public function studio(): BelongsTo
{
return $this->belongsTo(Studios::class, 'studios_id');
}
/**
* Get the hentai for the episode.
*/
public function hentai(): BelongsTo
{
return $this->belongsTo(Hentai::class, 'hentai_id');
}
/**
* Get the subtitles for the episode.
*/
public function subtitles(): HasMany
{
return $this->hasMany(EpisodeSubtitle::class);
}
/**
* Has a Gallery.
*/
public function gallery(): HasMany
{
return $this->hasMany(Gallery::class);
}
/**
* Has many Downloads.
*/
public function downloads(): HasMany
{
return $this->hasMany(Downloads::class);
}
/**
* Increment View Count.
*/
public function incrementViewCount(): bool
{
$this->view_count++;
return $this->save();
}
/**
* Increment Popular Count.
*/
public function incrementPopularCount(): void
{
PopularDaily::create(['episode_id' => $this->id]);
PopularWeekly::create(['episode_id' => $this->id]);
PopularMonthly::create(['episode_id' => $this->id]);
}
/**
* Get cached view count
*/
public function viewCount(): int
{
return cache()->remember('episodeViews' . $this->id, 300, fn() => $this->view_count);
}
/**
* Get view count in a human readable way
*/
public function viewCountFormatted(): string
{
if ($this->viewCount() < 1000) {
return $this->viewCount();
}
$units = ['k', 'M'];
$index = floor(log($this->viewCount(), 1000));
$shortNumber = $this->viewCount() / pow(1000, $index);
return round($shortNumber, 0) . $units[$index - 1];
}
/**
* Get cached like count
*/
public function likeCount(): int
{
return cache()->remember('episodeLikes' . $this->id, 300, fn() => $this->likes->count());
}
/**
* Get cached comment count
*/
public function commentCount(): int
{
return cache()->remember('episodeComments' . $this->id, 300, fn() => $this->comments->count());
}
public function getProblematicTags(): string
{
$problematicTags = ['Gore', 'Scat', 'Horror'];
$problematicResults = '';
foreach ($problematicTags as $pTag) {
if (! $this->tags->contains('name', $pTag)) {
continue;
}
if (! empty($problematicResults)) {
$problematicResults .= ' + ';
}
$problematicResults .= $pTag;
}
return $problematicResults;
}
/**
* Check if episode contains loli / shota tag
*/
public function isLoliOrShota(): bool
{
$problematicTags = ['Loli', 'Shota'];
return Cache::remember(
"episode:{$this->id}:has_problematic_tags",
now()->addMinutes(1440),
fn () => $this->tags->pluck('name')->intersect($problematicTags)->isNotEmpty()
);
}
/**
* If episode has machine translated subtitles
*/
public function hasAutoTrans(): bool
{
return cache()->remember('mt' . $this->id, 900, fn() => $this->subtitles()->exists());
}
public function is48Fps(): bool
{
return cache()->remember('48fps' . $this->id, 900, fn() => $this->interpolated);
}
public function isUHD48Fps(): bool
{
return cache()->remember('48fpsUHD' . $this->id, 900, fn() => $this->interpolated_uhd);
}
public function getResolution(): string
{
if ($this->isUHD48Fps()) {
return '4k | 4k 48fps | FHD 48fps';
}
return $this->is48Fps() ? '4k | FHD 48fps' : '4k';
}
public function userWatched(int $user_id): bool
{
return cache()->remember('user' . $user_id . 'watched' . $this->id, 300, fn() => Watched::where('user_id', $user_id)->where('episode_id', $this->id)->exists());
}
public function watched(): HasMany
{
return $this->hasMany(Watched::class);
}
public function getDownloadByType(string $type): Downloads | null
{
$cacheKey = "episode_{$this->id}_download_{$type}";
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($type) {
return $this->downloads()->where('type', $type)->first();
});
}
public function toSitemapTag(): Url | string | array
{
return Url::create(route('hentai.index', $this->slug))
->setLastModificationDate(Carbon::create($this->created_at));
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EpisodeSubtitle extends Model
{
use HasFactory;
/**
* Indicates If The Model Should Be Timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'episode_id',
'subtitle_id',
];
/**
* Get the according subtitle.
*/
public function subtitle()
{
return $this->belongsTo(Subtitle::class, 'subtitle_id');
}
}

18
app/Models/Gallery.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Gallery extends Model
{
public $table = 'gallery';
/**
* Belongs To Episode.
*/
public function episode()
{
return $this->belongsTo(Episode::class);
}
}

72
app/Models/Hentai.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Conner\Tagging\Taggable;
use Laravelista\Comments\Commentable;
class Hentai extends Model implements Sitemapable
{
use Commentable, Taggable;
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'slug',
'description',
];
public function episodes()
{
return $this->hasMany(Episode::class, 'hentai_id');
}
public function torrents()
{
return $this->hasMany(Torrents::class, 'hentai_id');
}
public function title(): String
{
return $this->episodes->first()->title;
}
/**
* Has a Gallery.
*/
public function gallery()
{
return $this->hasMany(Gallery::class);
}
/**
* Check if hentai contains loli / shota tag
*/
public function isLoliOrShota(): bool
{
$problematicTags = ['Loli', 'Shota'];
return Cache::remember(
"episode:{$this->id}:has_problematic_tags",
now()->addMinutes(1440),
fn () => $this->episodes[0]->tags->pluck('name')->intersect($problematicTags)->isNotEmpty()
);
}
public function toSitemapTag(): Url | string | array
{
return Url::create(route('hentai.index', $this->slug))
->setLastModificationDate(Carbon::create($this->created_at));
}
}

25
app/Models/Playlist.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
class Playlist extends Model
{
/**
* Belongs To A User.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Has Many Episodes.
*/
public function episodes(): HasMany
{
return $this->hasMany(PlaylistEpisode::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
class PlaylistEpisode extends Model
{
/**
* Indicates If The Model Should Be Timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'playlist_id',
'episode_id',
'position',
];
/**
* Belongs To A Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class);
}
/**
* Belongs To A Playlist.
*/
public function playlist(): BelongsTo
{
return $this->belongsTo(Playlist::class);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PopularDaily extends Model
{
public $table = 'popular_daily';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [ 'episode_id' ];
/**
* Get the Episode.
*/
public function episode()
{
return $this->belongsTo(Episode::class, 'episode_id');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PopularMonthly extends Model
{
public $table = 'popular_monthly';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [ 'episode_id' ];
/**
* Get the Episode.
*/
public function episode()
{
return $this->belongsTo(Episode::class, 'episode_id');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PopularWeekly extends Model
{
public $table = 'popular_weekly';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [ 'episode_id' ];
/**
* Get the Episode.
*/
public function episode()
{
return $this->belongsTo(Episode::class, 'episode_id');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class SiteBackground extends Model
{
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'date_start',
'date_end',
'default'
];
/**
* Returns the current IDs of active wallpaper
*/
public function getImages(): ? \Illuminate\Support\Collection
{
$now = Carbon::now();
$byDates = $this->whereDate('date_start', '<=', $now)->whereDate('date_end', '>=', $now)->get()->pluck('id');
$default = $this->where('default', true)->get()->pluck('id');
return $byDates->isEmpty() ? $default : $byDates;
}
}

30
app/Models/Studios.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
class Studios extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'slug',
];
/**
* Get the tags for the Hentai.
*/
public function hentais(): HasMany
{
return $this->hasMany(Hentai::class);
}
}

28
app/Models/Subtitle.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Subtitle extends Model
{
use HasFactory;
/**
* Indicates If The Model Should Be Timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'slug',
];
}

22
app/Models/Torrents.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Torrents extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'hentai_id',
'torrent_url',
'episodes',
];
}

108
app/Models/User.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Jakyeru\Larascord\Traits\InteractsWithDiscord;
use Laravelista\Comments\Commenter;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Facades\DB;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, InteractsWithDiscord, Commenter;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'id',
'username',
'global_name',
'discriminator',
'email',
'avatar',
'verified',
'banner',
'banner_color',
'accent_color',
'locale',
'mfa_enabled',
'premium_type',
'public_flags',
'roles',
'is_banned',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array
*/
protected $hidden = [
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'id' => 'integer',
'username' => 'string',
'global_name' => 'string',
'discriminator' => 'string',
'email' => 'string',
'avatar' => 'string',
'verified' => 'boolean',
'banner' => 'string',
'banner_color' => 'string',
'accent_color' => 'string',
'locale' => 'string',
'mfa_enabled' => 'boolean',
'premium_type' => 'integer',
'public_flags' => 'integer',
'roles' => 'json',
'tag_blacklist' => 'array',
];
/**
* Has Many Playlists.
*/
public function playlists(): HasMany
{
return $this->hasMany(Playlist::class);
}
/**
* Has Many Watched Episodes.
*/
public function watched(): HasMany
{
return $this->hasMany(Watched::class);
}
/**
* Has Many Watched Episodes.
*/
public function likes(): int
{
return DB::table('markable_likes')->where('user_id', $this->id)->count();
}
/**
* Has Many Comments.
*/
public function commentCount(): int
{
return DB::table('comments')->where('commenter_id', $this->id)->count();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
class UserDownload extends Model
{
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'user_id',
'episode_id',
'interpolated',
];
/**
* Belongs To A User.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Belongs To A Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class);
}
}

34
app/Models/Watched.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Model;
class Watched extends Model
{
public $table = 'watched';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = ['episode_id', 'user_id'];
/**
* Get the Episode.
*/
public function episode(): BelongsTo
{
return $this->belongsTo(Episode::class, 'episode_id');
}
/**
* Get the User.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class CommentNotification extends Notification
{
use Queueable;
protected $type;
protected $message;
protected $url;
/**
* Create a new notification instance.
*/
public function __construct($type, $message, $url)
{
$this->type = $type;
$this->message = $message;
$this->url = $url;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase($notifiable)
{
return [
'type' => $this->type,
'message' => $this->message,
'url' => $this->url,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Laravelista\Comments;
use Laravelista\Comments\Comment;
class CommentPolicy
{
/**
* Can user create the comment
*
* @param $user
* @return bool
*/
public function create($user) : bool
{
return true;
}
/**
* Can user delete the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function delete($user, Comment $comment) : bool
{
return ($user->getKey() == $comment->commenter_id) || $user->is_admin;
}
/**
* Can user update the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function update($user, Comment $comment) : bool
{
return $user->getKey() == $comment->commenter_id;
}
/**
* Can user reply to the comment
*
* @param $user
* @param Comment $comment
* @return bool
*/
public function reply($user, Comment $comment) : bool
{
return $user->getKey() != $comment->commenter_id;
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Laravelista\Comments;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use App\Notifications\CommentNotification;
use App\Models\User;
use App\Models\Episode;
class CommentService
{
/**
* Handles creating a new comment for given model.
* @return mixed the configured comment-model
*/
public function store(Request $request)
{
// If guest commenting is turned off, authorize this action.
if (Config::get('comments.guest_commenting') == false) {
Gate::authorize('create-comment', Comment::class);
}
// Define guest rules if user is not logged in.
if (!Auth::check()) {
$guest_rules = [
'guest_name' => 'required|string|max:255',
'guest_email' => 'required|string|email|max:255',
];
}
// Merge guest rules, if any, with normal validation rules.
Validator::make($request->all(), array_merge($guest_rules ?? [], [
'commentable_type' => 'required|string',
'commentable_id' => 'required|string|min:1',
'message' => 'required|string'
]))->validate();
$model = $request->commentable_type::findOrFail($request->commentable_id);
$commentClass = Config::get('comments.model');
$comment = new $commentClass;
if (!Auth::check()) {
$comment->guest_name = $request->guest_name;
$comment->guest_email = $request->guest_email;
} else {
$comment->commenter()->associate(Auth::user());
}
$comment->commentable()->associate($model);
$comment->comment = $request->message;
$comment->approved = !Config::get('comments.approval_required');
$comment->save();
return $comment;
}
/**
* Handles updating the message of the comment.
* @return mixed the configured comment-model
*/
public function update(Request $request, Comment $comment)
{
Gate::authorize('edit-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$comment->update([
'comment' => $request->message
]);
return $comment;
}
/**
* Handles deleting a comment.
* @return mixed the configured comment-model
*/
public function destroy(Comment $comment): void
{
Gate::authorize('delete-comment', $comment);
if (Config::get('comments.soft_deletes') == true) {
$comment->delete();
} else {
$comment->forceDelete();
}
}
/**
* Handles creating a reply "comment" to a comment.
* @return mixed the configured comment-model
*/
public function reply(Request $request, Comment $comment)
{
Gate::authorize('reply-to-comment', $comment);
Validator::make($request->all(), [
'message' => 'required|string'
])->validate();
$commentClass = Config::get('comments.model');
$reply = new $commentClass;
$reply->commenter()->associate(Auth::user());
$reply->commentable()->associate($comment->commentable);
$reply->parent()->associate($comment);
$reply->comment = $request->message;
$reply->approved = !Config::get('comments.approval_required');
$reply->save();
// Notify
if ($comment->commentable_type == 'App\Models\Episode') {
$episode = Episode::where('id', $comment->commentable_id)->firstOrFail();
$url = '/hentai/' . $episode->slug . '#comment-' . $reply->id;
$user = Auth::user();
$username = $user->global_name ?? $user->username;
$parentCommentUser = User::where('id', $comment->commenter_id)->firstOrFail();
$parentCommentUser->notify(
new CommentNotification(
"{$username} replied to your comment.",
Str::limit($reply->comment, 50),
$url
)
);
}
return $reply;
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace Jakyeru\Larascord\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Jakyeru\Larascord\Http\Requests\StoreUserRequest;
use Jakyeru\Larascord\Services\DiscordService;
use RealRashid\SweetAlert\Facades\Alert;
class DiscordController extends Controller
{
/**
* Handles the Discord OAuth2 login.
*/
public function handle(StoreUserRequest $request): RedirectResponse | JsonResponse
{
// Making sure the "guilds" scope was added to .env if there are any guilds specified in "larascord.guilds".
if (count(config('larascord.guilds'))) {
if (!in_array('guilds', explode('&', config('larascord.scopes')))) {
return $this->throwError('missing_guilds_scope');
}
}
// Getting the accessToken from the Discord API.
try {
$accessToken = (new DiscordService())->getAccessTokenFromCode($request->get('code'));
} catch (\Exception $e) {
return $this->throwError('invalid_code', $e);
}
// Get the user from the Discord API.
try {
$user = (new DiscordService())->getCurrentUser($accessToken);
$user->setAccessToken($accessToken);
} catch (\Exception $e) {
return $this->throwError('authorization_failed', $e);
}
// Making sure the user has an email if the email scope is set.
if (in_array('email', explode('&', config('larascord.scopes')))) {
if (empty($user->email)) {
return $this->throwError('missing_email');
}
}
if (auth()->check()) {
// Making sure the current logged-in user's ID is matching the ID retrieved from the Discord API.
if (auth()->id() !== (int)$user->id) {
auth()->logout();
return $this->throwError('invalid_user');
}
// Confirming the session in case the user was redirected from the password.confirm middleware.
$request->session()->put('auth.password_confirmed_at', time());
}
// Trying to create or update the user in the database.
// Initiating a database transaction in case something goes wrong.
DB::beginTransaction();
try {
$user = (new DiscordService())->createOrUpdateUser($user);
$user->accessToken()->updateOrCreate([], $accessToken->toArray());
} catch (\Exception $e) {
DB::rollBack();
return $this->throwError('database_error', $e);
}
// Verifying if the user is soft-deleted.
if (Schema::hasColumn('users', 'deleted_at')) {
if ($user->trashed()) {
DB::rollBack();
return $this->throwError('user_deleted');
}
}
// Patreon check
try {
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) {
DB::rollBack();
return $this->throwError('missing_guilds_members_read_scope');
}
$guildMember = (new DiscordService())->getGuildMember($accessToken, config('discord.guild_id'));
$patreonroles = config('discord.patreon_roles');
$user->is_patreon = false;
if ((new DiscordService())->hasRoleInGuild($guildMember, $patreonroles)) {
$user->is_patreon = true;
}
$user->save();
} catch (\Exception $e) {
// Clearly not a patreon
$user->is_patreon = false;
$user->save();
}
// Committing the database transaction.
DB::commit();
// Authenticating the user if the user is not logged in.
if (!auth()->check()) {
auth()->login($user, config('larascord.remember_me', false));
}
// Redirecting the user to the intended page or to the home page.
return redirect()->intended(RouteServiceProvider::HOME);
}
/**
* Handles the throwing of an error.
*/
private function throwError(string $message, \Exception $exception = NULL): RedirectResponse | JsonResponse
{
if (app()->hasDebugModeEnabled()) {
return response()->json([
'larascord_message' => config('larascord.error_messages.' . $message),
'message' => $exception?->getMessage(),
'code' => $exception?->getCode()
]);
} else {
if (config('larascord.error_messages.' . $message . '.redirect')) {
Alert::error('Error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
return redirect(config('larascord.error_messages.' . $message . '.redirect'))->with('error', config('larascord.error_messages.' . $message . '.message', 'An error occurred while trying to log you in.'));
} else {
return redirect('/')->with('error', config('larascord.error_messages.' . $message, 'An error occurred while trying to log you in.'));
}
}
}
/**
* Handles the deletion of the user.
*/
public function destroy(): RedirectResponse | JsonResponse
{
// Revoking the OAuth2 access token.
try {
(new DiscordService())->revokeAccessToken(auth()->user()->accessToken()->first()->refresh_token);
} catch (\Exception $e) {
return $this->throwError('revoke_token_failed', $e);
}
// Deleting the user from the database.
auth()->user()->delete();
// Showing the success message.
if (config('larascord.success_messages.user_deleted.redirect')) {
return redirect(config('larascord.success_messages.user_deleted.redirect'))->with('success', config('larascord.success_messages.user_deleted.message', 'Your account has been deleted.'));
} else {
return redirect('/')->with('success', config('larascord.success_messages.user_deleted', 'Your account has been deleted.'));
}
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace Jakyeru\Larascord\Services;
use App\Models\User;
use App\Models\OldUser;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Jakyeru\Larascord\Types\AccessToken;
use Jakyeru\Larascord\Types\GuildMember;
use Illuminate\Support\Facades\DB;
class DiscordService
{
/**
* The Discord OAuth2 token URL.
*/
protected string $tokenURL = "https://discord.com/api/oauth2/token";
/**
* The Discord API base URL.
*/
protected string $baseApi = "https://discord.com/api";
/**
* The required data for the token request.
*/
protected array $tokenData = [
"client_id" => NULL,
"client_secret" => NULL,
"grant_type" => "authorization_code",
"code" => NULL,
"redirect_uri" => NULL,
"scope" => null
];
/**
* UserService constructor.
*/
public function __construct()
{
$this->tokenData['client_id'] = config('larascord.client_id');
$this->tokenData['client_secret'] = config('larascord.client_secret');
$this->tokenData['grant_type'] = config('larascord.grant_type');
$this->tokenData['redirect_uri'] = config('larascord.redirect_uri');
$this->tokenData['scope'] = config('larascord.scopes');
}
/**
* Handles the Discord OAuth2 callback and returns the access token.
*
* @throws RequestException
*/
public function getAccessTokenFromCode(string $code): AccessToken
{
$this->tokenData['code'] = $code;
$response = Http::asForm()->post($this->tokenURL, $this->tokenData);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Get access token from refresh token.
*
* @throws RequestException
*/
public function refreshAccessToken(string $refreshToken): AccessToken
{
$response = Http::asForm()->post($this->tokenURL, [
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
$response->throw();
return new AccessToken(json_decode($response->body()));
}
/**
* Authenticates the user with the access token and returns the user data.
*
* @throws RequestException
*/
public function getCurrentUser(AccessToken $accessToken): \Jakyeru\Larascord\Types\User
{
$response = Http::withToken($accessToken->access_token)->get($this->baseApi . '/users/@me');
$response->throw();
return new \Jakyeru\Larascord\Types\User(json_decode($response->body()));
}
/**
* Get the user's guilds.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserGuilds(AccessToken $accessToken, bool $withCounts = false): array
{
if (!$accessToken->hasScope('guilds')) throw new Exception(config('larascord.error_messages.missing_guilds_scope.message'));
$endpoint = '/users/@me/guilds';
if ($withCounts) {
$endpoint .= '?with_counts=true';
}
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . $endpoint);
$response->throw();
return array_map(function ($guild) {
return new \Jakyeru\Larascord\Types\Guild($guild);
}, json_decode($response->body()));
}
/**
* Get the Guild Member object for a user.
*
* @throws RequestException
* @throws Exception
*/
public function getGuildMember(AccessToken $accessToken, string $guildId): GuildMember
{
if (!$accessToken->hasScopes(['guilds', 'guilds.members.read'])) throw new Exception(config('larascord.error_messages.missing_guilds_members_read_scope.message'));
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/guilds/' . $guildId . '/member');
$response->throw();
return new GuildMember(json_decode($response->body()));
}
/**
* Get the User's connections.
*
* @throws RequestException
* @throws Exception
*/
public function getCurrentUserConnections(AccessToken $accessToken): array
{
if (!$accessToken->hasScope('connections')) throw new Exception('The "connections" scope is required.');
$response = Http::withToken($accessToken->access_token, $accessToken->token_type)->get($this->baseApi . '/users/@me/connections');
$response->throw();
return array_map(function ($connection) {
return new \Jakyeru\Larascord\Types\Connection($connection);
}, json_decode($response->body()));
}
/**
* Join a guild.
*
* @throws RequestException
* @throws Exception
*/
public function joinGuild(AccessToken $accessToken, User $user, string $guildId, array $options = []): GuildMember
{
if (!config('larascord.access_token')) throw new Exception(config('larascord.error_messages.missing_access_token.message'));
if (!$accessToken->hasScope('guilds.join')) throw new Exception('The "guilds" and "guilds.join" scopes are required.');
$response = Http::withToken(config('larascord.access_token'), 'Bot')->put($this->baseApi . '/guilds/' . $guildId . '/members/' . $user->id, array_merge([
'access_token' => $accessToken->access_token,
], $options));
$response->throw();
if ($response->status() === 204) return throw new Exception('User is already in the guild.');
return new GuildMember(json_decode($response->body()));
}
/**
* Create or update a user in the database.
*
* @throws Exception
*/
public function createOrUpdateUser(\Jakyeru\Larascord\Types\User $user): User
{
if (!$user->getAccessToken()) {
throw new Exception('User access token is missing.');
}
$forgottenUser = User::where('email', '=', $user->email)->where('id', '!=', $user->id)->first();
if ($forgottenUser) {
// This case should never happen (TM) - The discord id changed
// The user probably re-created their discord account with the same email
// Delete Playlist
$playlists = Playlist::where('user_id', $forgottenUser->id)->get();
foreach($playlists as $playlist) {
PlaylistEpisode::where('playlist_id', $playlist->id)->forceDelete();
$playlist->forceDelete();
}
// Update comments to deleted user
DB::table('comments')->where('commenter_id', '=', $forgottenUser->id)->update(['commenter_id' => 1]);
$forgottenUser->forceDelete();
}
return User::updateOrCreate(
[
'id' => $user->id,
],
$user->toArray(),
);
}
/**
* Verify if the user is in the specified guild(s).
*/
public function isUserInGuilds(array $guilds): bool
{
// Verify if the user is in all the specified guilds if strict mode is enabled.
if (config('larascord.guilds_strict')) {
return empty(array_diff(config('larascord.guilds'), array_column($guilds, 'id')));
}
// Verify if the user is in any of the specified guilds if strict mode is disabled.
return !empty(array_intersect(config('larascord.guilds'), array_column($guilds, 'id')));
}
/**
* Verify if the user has the specified role(s) in the specified guild.
*/
public function hasRoleInGuild(GuildMember $guildMember, array $roles): bool
{
// Verify if the user has any of the specified roles.
return !empty(array_intersect($roles, $guildMember->roles));
}
/**
* Updates the user's roles in the database.
*/
public function updateUserRoles(User $user, GuildMember $guildMember, int $guildId): void
{
// Updating the user's roles in the database.
$updatedRoles = $user->roles;
$updatedRoles[$guildId] = $guildMember->roles;
$user->roles = $updatedRoles;
$user->save();
}
/**
* Revoke the user's access token.
*
* @throws RequestException
*/
public function revokeAccessToken(string $accessToken): object
{
$response = Http::asForm()->post($this->tokenURL . '/revoke', [
'token' => $accessToken,
'client_id' => config('larascord.client_id'),
'client_secret' => config('larascord.client_secret'),
]);
$response->throw();
return json_decode($response->body());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
//
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
Registered::class => [
SendEmailVerificationNotification::class,
],
];
/**
* Register any events for your application.
*/
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*/
public function shouldDiscoverEvents(): bool
{
return false;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
/**
* The path to your application's "home" route.
*
* Typically, users are redirected here after authentication.
*
* @var string
*/
public const HOME = '/';
/**
* Define your route model bindings, pattern filters, and other route configuration.
*/
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Services;
use App\Models\Downloads;
use App\Models\Episode;
use App\Jobs\GetFileSizeFromCDN;
use Illuminate\Http\Request;
class DownloadService
{
public function createOrUpdateDownloads(Request $request, Episode $episode, int $index): void
{
$downloadTypes = [
'episodedlurl' => 'FHD',
'episodedlurlinterpolated' => 'FHDi',
'episodedlurl4k' => 'UHD',
'downloadUHDi' => 'UHDi',
];
foreach ($downloadTypes as $inputField => $type) {
$fieldName = $inputField.$index;
if ($request->filled($fieldName)) {
$download = Downloads::updateOrCreate([
'episode_id' => $episode->id,
'type' => $type,
], [
'url' => $request->input($fieldName),
]);
// Dispatch Job to get File Size from CDN
GetFileSizeFromCDN::dispatch($download->id);
}
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\Studios;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder;
class EpisodeService
{
public function generateSlug(string $title): string
{
$slug = Str::slug($title);
$slugParts = explode('-', $slug);
$lastPart = $slugParts[array_key_last($slugParts)];
if (is_numeric($lastPart) && $lastPart < 1000) {
$slugParts[array_key_last($slugParts)] = 's'.$lastPart;
return implode('-', $slugParts);
}
return $slug;
}
public function createEpisode(
Request $request,
Hentai $hentai,
int $episodeNumber,
Studios $studio = null,
Episode $referenceEpisode = null
): Episode
{
$episode = new Episode();
$episode->title = $referenceEpisode->title ?? $request->input('title');
$episode->title_search = preg_replace("/[^A-Za-z0-9 ]/", '', $episode->title);
$episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn');
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
$episode->hentai_id = $hentai->id;
$episode->studios_id = $referenceEpisode->studio->id ?? $studio->id;
$episode->episode = $episodeNumber;
$episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}");
$episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
$episode->view_count = 0;
$episode->interpolated = true;
$episode->is_dvd_aspect = false;
$episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d');
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
$episode->save();
// Tagging
$tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags'));
foreach ($tags as $t) {
$episode->tag($referenceEpisode ? $t->name : $t->value);
}
return $episode;
}
public function updateEpisode(Request $request, Studios $studio, int $episodeId): Episode
{
$episode = Episode::where('id', $episodeId)->firstOrFail();
$episode->studios_id = $studio->id;
$episode->description = $request->input('description');
$episode->url = $request->input('baseurl');
$episode->release_date = Carbon::parse($request->input('releasedate'))->format('Y-m-d');
$episode->interpolated = $request->input('interpolated') == 'yes';
$episode->interpolated_uhd = $request->input('downloadUHDi1') ? true : false;
$episode->is_dvd_aspect = $request->input('dvd') == 'yes';
$episode->save();
// Tagging
$tags = json_decode($request->input('tags'));
$newtags = [];
foreach ($tags as $t) {
$newtags[] = $t->value;
}
$episode->retag($newtags);
return $episode;
}
public function getOrCreateStudio(string $studioName): Studios
{
return Studios::firstOrCreate(
['name' => $studioName],
['slug' => Str::slug($studioName)]
);
}
public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void
{
if (! $request->hasFile("episodecover{$episodeNumber}")) {
return;
}
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
}
// Encode and save cover image
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath())
->cover(268, 394)
->encode(new WebpEncoder())
->save(Storage::disk('public')->path($episode->cover_url));
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Episode;
use App\Models\Gallery;
use App\Models\Hentai;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
use Intervention\Image\Encoders\WebpEncoder;
class GalleryService
{
public function createOrUpdateGallery(Request $request, Hentai $hentai, Episode $episode, int $episodeNumber, bool $override = false): void
{
$galleryInputNumber = $override ? 1 : $episodeNumber;
if($request->hasFile('episodegallery'.$galleryInputNumber)) {
$this->deleteOldGallery($episode);
$this->createGalleryFolder($hentai);
$counter = 0;
foreach($request->file('episodegallery'.$galleryInputNumber) as $file) {
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
$this->saveGalleryImage($gallery, $file);
$counter += 1;
}
}
}
private function createGalleryFolder(Hentai $hentai): void
{
// Create Folder for Image Upload
if (! Storage::disk('public')->exists("/images/hentai/{$hentai->slug}")) {
Storage::disk('public')->makeDirectory("/images/hentai/{$hentai->slug}");
}
}
private function createGallery(Hentai $hentai, Episode $episode, int $episodeNumber, int $counter): Gallery
{
$gallery = new Gallery();
$gallery->hentai_id = $hentai->id;
$gallery->episode_id = $episode->id;
$gallery->image_url = "/images/hentai/{$hentai->slug}/gallery-ep-{$episodeNumber}-{$counter}.webp";
$gallery->thumbnail_url = "/images/hentai/{$hentai->slug}/gallery-ep-{$episodeNumber}-{$counter}-thumbnail.webp";
$gallery->save();
return $gallery;
}
private function saveGalleryImage(Gallery $gallery, $sourceImage): void
{
Image::read($sourceImage->getRealPath())
->cover(1920, 1080)
->encode(new WebpEncoder())
->save(Storage::disk('public')->path($gallery->image_url));
Image::read($sourceImage->getRealPath())
->cover(960, 540)
->encode(new WebpEncoder())
->save(Storage::disk('public')->path($gallery->thumbnail_url));
}
private function deleteOldGallery(Episode $episode): void
{
$oldGallery = Gallery::where('episode_id', $episode->id)->get();
foreach ($oldGallery as $oldImage) {
Storage::disk('public')->delete($oldImage->image_url);
Storage::disk('public')->delete($oldImage->thumbnail_url);
$oldImage->forceDelete();
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use App\Models\Playlist;
use App\Models\PlaylistEpisode;
class PlaylistService
{
public function reorderPositions(Playlist $playlist): void
{
$episodes = PlaylistEpisode::where('playlist_id', $playlist->id)
->orderBy('position')
->get();
foreach ($episodes as $index => $episode) {
$episode->position = $index + 1;
$episode->save();
}
}
public function swapPositions(PlaylistEpisode $a, PlaylistEpisode $b): void
{
$temp = $a->position;
$a->position = $b->position;
$b->position = $temp;
$a->save();
$b->save();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}