diff --git a/README.md b/README.md index fd27c0a..ae57c3b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ php artisan tmdb:fetch-export tv_series ### Populate the list of movies/tv_series to fetch ``` -php artisan tmdb:fetch-export-items movie} +php artisan tmdb:fetch-export-items movie php artisan tmdb:fetch-export-items show ``` diff --git a/app/Console/Commands/ItemProcessing/FetchItemDataCommand.php b/app/Console/Commands/ItemProcessing/FetchItemDataCommand.php index 618e690..c37ec22 100644 --- a/app/Console/Commands/ItemProcessing/FetchItemDataCommand.php +++ b/app/Console/Commands/ItemProcessing/FetchItemDataCommand.php @@ -58,7 +58,12 @@ class FetchItemDataCommand extends Command { switch ($type){ case ItemToFetch::TYPE_MOVIE: - return MovieProcessingService::getBatch($batch, $job); + $items = MovieProcessingService::getBatch($batch, $job); + + if ($items->count() == 0){ + $items = MovieProcessingService::getMissingMoviesBatch($batch, $job); + } + return $items; case ItemToFetch::TYPE_TV_SERIES: return ShowProcessingService::getBatch($batch, $job, $tmdbId); } diff --git a/app/Console/Commands/TmdbFetch/FetchExportItemsCommand.php b/app/Console/Commands/TmdbFetch/FetchExportItemsCommand.php index b27f876..37e42cc 100644 --- a/app/Console/Commands/TmdbFetch/FetchExportItemsCommand.php +++ b/app/Console/Commands/TmdbFetch/FetchExportItemsCommand.php @@ -52,7 +52,7 @@ class FetchExportItemsCommand extends Command if (null === $decodedLine) { continue; } - + echo '.'; $this->processItem($decodedLine, $type); } } diff --git a/app/Http/Middleware/CheckPermission.php b/app/Http/Middleware/CheckPermission.php new file mode 100644 index 0000000..835625b --- /dev/null +++ b/app/Http/Middleware/CheckPermission.php @@ -0,0 +1,31 @@ +user()) { + abort(403, 'Unauthorized action.'); + } + + foreach ($permissions as $permission) { + if ($request->user()->hasPermission($permission)) { + return $next($request); + } + } + + abort(403, 'Unauthorized action.'); + } +} diff --git a/app/Http/Middleware/CheckRole.php b/app/Http/Middleware/CheckRole.php new file mode 100644 index 0000000..ab0e999 --- /dev/null +++ b/app/Http/Middleware/CheckRole.php @@ -0,0 +1,31 @@ +user()) { + abort(403, 'Unauthorized action.'); + } + + foreach ($roles as $role) { + if ($request->user()->hasRole($role)) { + return $next($request); + } + } + + abort(403, 'Unauthorized action.'); + } +} diff --git a/app/Models/Movie.php b/app/Models/Movie.php index 791ea50..6249979 100644 --- a/app/Models/Movie.php +++ b/app/Models/Movie.php @@ -71,9 +71,9 @@ class Movie extends Model public function director() { - return $this->belongsToMany(Person::class, 'movie_cast', 'movie_id', 'person_id') - ->where('movie_cast.job', 'Director') - ->orWhere('movie_cast.known_for_department', 'Directing') + return $this->belongsToMany(Person::class, 'movie_crew', 'movie_id', 'person_id') + ->where('movie_crew.job', 'Director') + ->orWhere('movie_crew.known_for_department', 'Directing') ; } diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 0000000..a6c81f9 --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,24 @@ +belongsToMany(Role::class, 'permission_role'); + } + + public function users() + { + return $this->belongsToMany(User::class, 'permission_user'); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..80fc4f5 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,38 @@ +belongsToMany(User::class, 'role_user'); + } + + public function permissions() + { + return $this->belongsToMany(Permission::class, 'permission_role'); + } + + public function hasPermission($permission) + { + if (is_string($permission)) { + return $this->permissions->contains('slug', $permission); + } + + return $this->permissions->contains('id', $permission->id); + } + + public function givePermissionTo($permission) + { + return $this->permissions()->save($permission); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2506d2d..2c88820 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -52,4 +52,69 @@ class User extends Authenticatable { return $this->belongsToMany(MediaList::class, 'user_media_lists', 'user_id', 'media_list_id'); } + + public function roles() + { + return $this->belongsToMany(Role::class, 'role_user'); + } + + public function permissions() + { + return $this->belongsToMany(Permission::class, 'permission_user'); + } + + public function hasRole($role) + { + if (is_string($role)) { + return $this->roles->contains('slug', $role); + } + + return $this->roles->contains('id', $role->id); + } + + public function hasPermission($permission) + { + if (is_string($permission)) { + // Check direct permissions + if ($this->permissions->contains('slug', $permission)) { + return true; + } + + // Check role permissions + foreach ($this->roles as $role) { + if ($role->hasPermission($permission)) { + return true; + } + } + + return false; + } + + return $this->permissions->contains('id', $permission->id); + } + + public function hasAnyRole($roles) + { + if (is_string($roles)) { + return $this->roles->contains('slug', $roles); + } + + foreach ($roles as $role) { + if ($this->hasRole($role)) { + return true; + } + } + + return false; + } + + public function assignRole($role) + { + return $this->roles()->save($role); + } + + public function givePermissionTo($permission) + { + return $this->permissions()->save($permission); + } } diff --git a/app/Service/MediaProcessing/MovieProcessingService.php b/app/Service/MediaProcessing/MovieProcessingService.php index 09d3ea1..1f15750 100644 --- a/app/Service/MediaProcessing/MovieProcessingService.php +++ b/app/Service/MediaProcessing/MovieProcessingService.php @@ -58,6 +58,20 @@ class MovieProcessingService implements ProcessingInterface ->get(); } + public static function getMissingMoviesBatch(int $batch, string $job) + { + return ItemToFetchJobs::select('item_to_fetch_jobs.*') + ->leftJoin('movies', function ($join) { + $join->on('movies.tmdb_id', '=', 'item_to_fetch_jobs.tmdb_id'); + }) + ->whereRaw("FIND_IN_SET('".$job."', item_to_fetch_jobs.jobs)") + ->where('item_to_fetch_jobs.type', 'movie') + ->whereNull('movies.id') + ->whereNotNull('item_to_fetch_jobs.tmdb_id') + ->take($batch) + ->get(); + } + public function process($item, $job) { $methodName = "get".ucfirst($job); @@ -66,7 +80,7 @@ class MovieProcessingService implements ProcessingInterface // @todo maybe raise an exception here return; } - echo "Processing {$methodName} ".$item->tmdb_id." (".$item->id.") \n"; + echo "Processing {$methodName} ".$item->tmdb_id." (".$item->tmdb_id.") \n"; $this->$methodName($item); // maybe we should sleep for a while so we do not spam the tmdb api @@ -78,7 +92,7 @@ class MovieProcessingService implements ProcessingInterface $tmdbMovie = $this->moviesApi->getMovie($item->tmdb_id); if (isset($tmdbMovie['status_code']) && $tmdbMovie['status_code'] == 34) { - echo "[ApiError-Could not be found]\n]"; + echo "[ApiError-Could not be found]\n"; return; } @@ -204,12 +218,7 @@ class MovieProcessingService implements ProcessingInterface $imageExists = Image::create($image); } - if ($movie->whereHas('images', function ($query) use ($imageExists) { - $query->where('type', $imageExists->type) - ->where('file_path', $imageExists->file_path); - })->exists()) { - return; - } else { + if (!$movie->images()->where('image_id', $imageExists->id)->exists()) { $movie->images()->attach($imageExists); } } @@ -238,11 +247,7 @@ class MovieProcessingService implements ProcessingInterface ]); } - if ($movie->whereHas('keywords', function ($query) use ($existingKeyword) { - $query->where('tmdb_id', $existingKeyword->tmdb_id); - })->exists()) { - return; - } else { + if (!$movie->keywords()->where('keyword_id', $existingKeyword->id)->exists()) { $movie->keywords()->attach($existingKeyword); } } @@ -276,11 +281,7 @@ class MovieProcessingService implements ProcessingInterface ]); } - if ($movie->whereHas('releaseDates', function ($query) use ($movie) { - $query->where('movie_id', $movie->id); - })->exists()) { - return; - } else { + if (!$movie->releaseDates()->where('release_date_id', $existingReleaseDate->id)->exists()) { $movie->releaseDates()->attach($existingReleaseDate); } } @@ -452,9 +453,7 @@ class MovieProcessingService implements ProcessingInterface ]); } - if (!$movie->whereHas('watchProviders', function ($query) use ($movie) { - $query->where('movie_id', $movie->id); - })->exists()) { + if (!$movie->watchProviders()->where('watch_provider_id', $existingWatchProvider->id)->where('locale', $locale)->exists()) { $movie->watchProviders()->attach($existingWatchProvider, [ 'locale' => $locale, 'tmdb_url' => $tmdbWatchProvider['link'], @@ -494,11 +493,7 @@ class MovieProcessingService implements ProcessingInterface ]); } - if ($movie->whereHas('videos', function ($query) use ($movie) { - $query->where('movie_id', $movie->id); - })->exists()) { - return; - } else { + if (!$movie->videos()->where('video_id', $videoExists->id)->exists()) { $movie->videos()->attach($videoExists); } } @@ -718,8 +713,6 @@ class MovieProcessingService implements ProcessingInterface private function processMovieCollection(int $movieId, $belongsToCollection) { - dd($belongsToCollection); - $movieCollection = MovieCollection::where('tmdb_id', $belongsToCollection['id'])->first(); if (!$movieCollection) { @@ -795,6 +788,7 @@ class MovieProcessingService implements ProcessingInterface if (empty($remainingJobs)) { $itemToFetchJobs->delete(); + return; } // $itemToFetchJobs->remaining_jobs = $remainingJobs; @@ -833,7 +827,7 @@ class MovieProcessingService implements ProcessingInterface $personData = array_merge($personData, [ 'biography' => $tmdbPerson['biography'], - 'birtday' => $tmdbPerson['birthday'], + 'birthday' => $tmdbPerson['birthday'], 'deathday' => $tmdbPerson['deathday'], 'homepage' => $tmdbPerson['homepage'], 'imdb_id' => $tmdbPerson['imdb_id'], @@ -883,7 +877,7 @@ class MovieProcessingService implements ProcessingInterface if (!empty($tmdbPerson)) { $personData = array_merge($personData, [ 'biography' => $tmdbPerson['biography'], - 'birtday' => $tmdbPerson['birthday'], + 'birthday' => $tmdbPerson['birthday'], 'deathday' => $tmdbPerson['deathday'], 'homepage' => $tmdbPerson['homepage'], 'imdb_id' => $tmdbPerson['imdb_id'], diff --git a/bootstrap/app.php b/bootstrap/app.php index 7b162da..4d14444 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,7 +11,10 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - // + $middleware->alias([ + 'role' => \App\Http\Middleware\CheckRole::class, + 'permission' => \App\Http\Middleware\CheckPermission::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/database/migrations/2026_03_07_104816_create_roles_and_permissions_tables.php b/database/migrations/2026_03_07_104816_create_roles_and_permissions_tables.php new file mode 100644 index 0000000..7c30b2d --- /dev/null +++ b/database/migrations/2026_03_07_104816_create_roles_and_permissions_tables.php @@ -0,0 +1,74 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + // Create permissions table + Schema::create('permissions', function (Blueprint $table) { + $table->id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + // Create role_user pivot table + Schema::create('role_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('role_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['user_id', 'role_id']); + }); + + // Create permission_role pivot table + Schema::create('permission_role', function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id')->constrained()->onDelete('cascade'); + $table->foreignId('permission_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['role_id', 'permission_id']); + }); + + // Create permission_user pivot table (for direct user permissions) + Schema::create('permission_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('permission_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['user_id', 'permission_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('permission_user'); + Schema::dropIfExists('permission_role'); + Schema::dropIfExists('role_user'); + Schema::dropIfExists('permissions'); + Schema::dropIfExists('roles'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..672a903 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,9 +2,11 @@ namespace Database\Seeders; +use App\Models\MediaList; use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Str; class DatabaseSeeder extends Seeder { @@ -17,7 +19,26 @@ class DatabaseSeeder extends Seeder User::factory()->create([ 'name' => 'Test User', + 'slug' => 'test-user', 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $user = User::find(1); + + $this->createMediaList('Watchlist', $user); + $this->createMediaList('Collection', $user); + $this->createMediaList('Watched', $user); + $this->createMediaList('Liked', $user); + $this->createMediaList('Favourite', $user); + } + + private function createMediaList(string $name, $user) + { + return MediaList::create([ + 'name' => $name, + 'slug' => Str::slug($name), + 'user_id' => $user->id, ]); } } diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php new file mode 100644 index 0000000..d309b81 --- /dev/null +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -0,0 +1,84 @@ + 'View Movies', 'slug' => 'view-movies', 'description' => 'Can view movies'], + ['name' => 'Create Movies', 'slug' => 'create-movies', 'description' => 'Can create new movies'], + ['name' => 'Edit Movies', 'slug' => 'edit-movies', 'description' => 'Can edit existing movies'], + ['name' => 'Delete Movies', 'slug' => 'delete-movies', 'description' => 'Can delete movies'], + ['name' => 'Manage Users', 'slug' => 'manage-users', 'description' => 'Can manage users'], + ['name' => 'Manage Roles', 'slug' => 'manage-roles', 'description' => 'Can manage roles and permissions'], + ['name' => 'Moderate Content', 'slug' => 'moderate-content', 'description' => 'Can moderate user content'], + ]; + + foreach ($permissions as $permission) { + Permission::firstOrCreate(['slug' => $permission['slug']], $permission); + } + + // Create roles + $adminRole = Role::firstOrCreate( + ['slug' => 'admin'], + ['name' => 'Administrator', 'description' => 'Full access to all features'] + ); + + $moderatorRole = Role::firstOrCreate( + ['slug' => 'moderator'], + ['name' => 'Moderator', 'description' => 'Can moderate content and manage movies'] + ); + + $userRole = Role::firstOrCreate( + ['slug' => 'user'], + ['name' => 'User', 'description' => 'Regular user with basic access'] + ); + + // Assign permissions to admin (all permissions) + $allPermissions = Permission::all(); + foreach ($allPermissions as $permission) { + if (!$adminRole->hasPermission($permission)) { + $adminRole->permissions()->attach($permission->id); + } + } + + // Assign permissions to moderator + $moderatorPermissions = Permission::whereIn('slug', [ + 'view-movies', + 'create-movies', + 'edit-movies', + 'delete-movies', + 'moderate-content', + ])->get(); + + foreach ($moderatorPermissions as $permission) { + if (!$moderatorRole->hasPermission($permission)) { + $moderatorRole->permissions()->attach($permission->id); + } + } + + // Assign permissions to user + $userPermissions = Permission::whereIn('slug', [ + 'view-movies', + ])->get(); + + foreach ($userPermissions as $permission) { + if (!$userRole->hasPermission($permission)) { + $userRole->permissions()->attach($permission->id); + } + } + + $this->command->info('Roles and permissions seeded successfully!'); + } +} diff --git a/resources/sass/app.scss b/resources/sass/app.scss index 570cb42..9ade0c8 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -764,6 +764,52 @@ $fa-font-path: "/fonts"; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28224, 224, 224, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } } + + // Star rating + .jq-star{ + display: inline-block; + svg{ + max-width: 20px; + max-height: 20px; + } + } + + // Movie page + .movie-page{ + .card{ + .card-header{ + h1{ + display: inline-block; + font-size: 1.7em; + } + } + } + .tagline{ + text-transform: uppercase; + } + .cast-list{ + .department{ + .persons{ + .person{ + img{ + max-width: 45px; + margin-right: 5px; + } + margin-bottom: 30px; + } + } + } + } + } + + // Right sidebar - add missing padding + .right-sidebar { + .trending-movies { + .movie-item { + padding: 15px; + } + } + } } body.light-mode{ @@ -1343,6 +1389,7 @@ body.light-mode{ overflow: hidden; margin-bottom: 1.5rem; border: 1px solid #e0e0e0; + background: white; transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { @@ -1352,6 +1399,7 @@ body.light-mode{ .card-header { background: #f8f9fa; + color: #495057; padding: 15px; font-weight: 600; border-bottom: 1px solid #e0e0e0; @@ -1359,6 +1407,7 @@ body.light-mode{ h5 { display: flex; align-items: center; + color: #495057; .iconify { margin-right: 8px; @@ -1369,13 +1418,15 @@ body.light-mode{ .card-body { padding: 15px; + color: #495057; } } .movie-info-list { .list-group-item { - padding: 12px 15px; + background-color: white; border-color: #e0e0e0; + padding: 12px 15px; .info-label { color: #6c757d; @@ -1421,8 +1472,6 @@ body.light-mode{ } .movie-info { - flex: 1; - h6 { margin-bottom: 5px; font-size: 0.95rem; @@ -1434,7 +1483,6 @@ body.light-mode{ color: #6c757d; display: flex; align-items: center; - margin-bottom: 10px; .iconify { margin-right: 5px; @@ -1471,7 +1519,6 @@ body.light-mode{ a { color: #495057; - text-decoration: none; display: flex; align-items: center; padding: 12px 15px; @@ -1486,6 +1533,7 @@ body.light-mode{ &:hover { color: #0d6efd; background-color: #f8f9fa; + text-decoration: none; } } }