From 73d8441787c4df802985a1688fad1578552dac3d Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Mon, 20 Oct 2025 23:40:55 +0200 Subject: [PATCH] i dont know --- .gitignore | 1 + app/Controllers/ActorController.php | 23 +- app/Controllers/AdminBaseController.php | 70 +++ app/Controllers/AdminController.php | 21 +- app/Controllers/AdultController.php | 6 +- app/Controllers/DashboardController.php | 32 +- app/Controllers/ImageController.php | 53 ++ app/Controllers/MediaSourceController.php | 219 +++++++++ app/Controllers/SettingsController.php | 158 ++++++ app/Controllers/SyncController.php | 294 ++++++++++++ app/Controllers/TvShowController.php | 69 ++- .../Middleware/MediaVisibilityMiddleware.php | 68 +++ app/Models/AdultVideo.php | 15 + app/Services/JellyfinSyncService.php | 2 +- app/Services/LocalSyncService.php | 313 ++++++++++++ app/Services/StashSyncService.php | 2 +- app/Services/SyncServiceInterface.php | 23 + app/Services/XbvrSyncService.php | 2 +- app/Utils/ImageDownloader.php | 16 +- app/helpers.php | 26 +- public/index.php | 31 +- resources/views/actor/index.twig | 4 +- resources/views/actor/show.twig | 4 +- resources/views/admin/index.twig | 21 +- resources/views/admin/layout.twig | 338 +++++++++++++ resources/views/admin/settings.twig | 368 ++++++++++++++ resources/views/admin/sources.twig | 205 ++++++++ resources/views/admin/sync.twig | 453 ++++++++++++++++++ resources/views/adult/index.twig | 2 +- resources/views/layouts/app.twig | 10 + resources/views/movies/index.twig | 8 +- resources/views/tvshows/index.twig | 234 ++++++++- routes/web.php | 57 ++- 33 files changed, 3079 insertions(+), 69 deletions(-) create mode 100644 app/Controllers/AdminBaseController.php create mode 100644 app/Controllers/ImageController.php create mode 100644 app/Controllers/MediaSourceController.php create mode 100644 app/Controllers/SettingsController.php create mode 100644 app/Controllers/SyncController.php create mode 100644 app/Http/Middleware/MediaVisibilityMiddleware.php create mode 100644 app/Services/LocalSyncService.php create mode 100644 app/Services/SyncServiceInterface.php create mode 100644 resources/views/admin/layout.twig create mode 100644 resources/views/admin/settings.twig create mode 100644 resources/views/admin/sources.twig create mode 100644 resources/views/admin/sync.twig diff --git a/.gitignore b/.gitignore index 0575642..815777d 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,4 @@ composer.lock /public/public/images/adult_videos /public/public/images/backdrops /public/public/images/posters +/storage/images diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php index e4997a5..c59ff73 100644 --- a/app/Controllers/ActorController.php +++ b/app/Controllers/ActorController.php @@ -78,6 +78,28 @@ class ActorController extends Controller $stmt->execute(['actor_id' => $actorId]); $tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + + + foreach ($scenes as &$scene) { + if (!empty($scene['metadata'])) { + $metadata = json_decode($scene['metadata'], true); + + // Use local cover path if available, otherwise fall back to original URL + if (!empty($metadata['local_cover_path'])) { + $scene['poster_url'] = $metadata['local_cover_path']; + } elseif (!empty($metadata['cover_url'])) { + $scene['poster_url'] = $metadata['cover_url']; + } + + // Add actors data if available + if (!empty($metadata['actors'])) { + $scene['actors'] = $metadata['actors']; + } + } + } + + return $this->view->render($response, 'actor/show.twig', [ 'title' => $actor['name'], 'actor' => $actor, @@ -86,7 +108,6 @@ class ActorController extends Controller 'tv_shows' => $tvShows ]); } - public function index(Request $request, Response $response, $args) { // Get all actors with their media counts from all types diff --git a/app/Controllers/AdminBaseController.php b/app/Controllers/AdminBaseController.php new file mode 100644 index 0000000..52d19b9 --- /dev/null +++ b/app/Controllers/AdminBaseController.php @@ -0,0 +1,70 @@ +pdo = $pdo; + $this->view = $view; + } + + /** + * Render a template + */ + protected function render(Response $response, string $template, array $data = []): Response + { + // Add common admin data + $data['auth'] = [ + 'check' => isset($_SESSION['user_id']), + 'user' => [ + 'username' => $_SESSION['username'] ?? 'Admin', + 'is_admin' => $_SESSION['is_admin'] ?? false + ] + ]; + + // Add current route for active menu highlighting + $route = $this->getCurrentRoute(); + if ($route) { + $data['current_route'] = $route; + } + + return $this->view->render($response, $template, $data); + } + + /** + * Get current route name + */ + protected function getCurrentRoute(): ?string + { + $route = $_SERVER['REQUEST_URI'] ?? '/'; + $basePath = '/admin/'; + + if (strpos($route, $basePath) === 0) { + $route = substr($route, strlen($basePath)); + $parts = explode('/', $route); + return $parts[0] ?: 'index'; + } + + return null; + } + + /** + * Return JSON response + */ + protected function json(Response $response, $data, int $status = 200): Response + { + $response->getBody()->write(json_encode($data)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($status); + } +} diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index aa3f235..896dcf8 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -15,13 +15,13 @@ use App\Services\ExophaseSyncService; use PDO; use Slim\Views\Twig; -class AdminController extends Controller +class AdminController extends AdminBaseController { - private PDO $pdo; + protected PDO $pdo; public function __construct(PDO $pdo, Twig $view) { - parent::__construct($view); + parent::__construct($pdo, $view); $this->pdo = $pdo; } @@ -33,7 +33,7 @@ class AdminController extends Controller $syncLogModel = new SyncLog($this->pdo); $recentSyncs = SyncLog::getRecent($this->pdo, 10); - return $this->view->render($response, 'admin/index.twig', [ + return $this->render($response, 'admin/index.twig', [ 'title' => 'Admin Dashboard', 'sources' => $sources, 'recent_syncs' => $recentSyncs @@ -119,14 +119,23 @@ class AdminController extends Controller return min(100, round(($processed / $total) * 100, 2)); } + public function settings(Request $request, Response $response, $args) + { + return $this->render($response, 'admin/settings.twig', [ + 'title' => 'Admin Settings', + 'current_route' => 'settings' + ]); + } + public function sources(Request $request, Response $response, $args) { $sourceModel = new Source($this->pdo); $sources = $sourceModel->findAll(); - return $this->view->render($response, 'admin/sources.twig', [ + return $this->render($response, 'admin/sources.twig', [ 'title' => 'Source Management', - 'sources' => $sources + 'sources' => $sources, + 'current_route' => 'sources' ]); } diff --git a/app/Controllers/AdultController.php b/app/Controllers/AdultController.php index 8ffb526..85b3bb9 100644 --- a/app/Controllers/AdultController.php +++ b/app/Controllers/AdultController.php @@ -41,7 +41,7 @@ class AdultController extends Controller // Use local cover path if available, otherwise fall back to original URL if (!empty($metadata['local_cover_path'])) { - $video['poster_url'] = '/public/images/'.$metadata['local_cover_path']; + $video['poster_url'] = $metadata['local_cover_path']; } elseif (!empty($metadata['cover_url'])) { $video['poster_url'] = $metadata['cover_url']; } @@ -103,13 +103,13 @@ class AdultController extends Controller // Add local image paths and other metadata to the video data for template compatibility if (!empty($metadata['local_cover_path'])) { - $adultVideo['poster_url'] = '/public/images/'.$metadata['local_cover_path']; + $adultVideo['poster_url'] = '/images/'.$metadata['local_cover_path']; } elseif (!empty($metadata['cover_url'])) { $adultVideo['poster_url'] = $metadata['cover_url']; } if (!empty($metadata['local_screenshot_path'])) { - $adultVideo['screenshot_url'] = '/public/images/'.$metadata['local_screenshot_path']; + $adultVideo['screenshot_url'] = '/images/'.$metadata['local_screenshot_path']; } // Add actors data if available diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index cf02b11..8470c74 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -7,22 +7,22 @@ use Psr\Http\Message\ServerRequestInterface as Request; use App\Models\Game; use App\Models\Movie; use App\Models\TvShow; -use App\Models\MusicArtist; +use App\Models\AdultVideo; use App\Models\SyncLog; use Slim\Views\Twig; class DashboardController extends Controller { - public function __construct(Twig $view) + private \PDO $pdo; + public function __construct(\PDO $pdo, Twig $view) { parent::__construct($view); + $this->pdo = $pdo; } public function index(Request $request, Response $response, $args) { - $pdo = $this->view->getEnvironment()->getGlobals()['pdo'] ?? null; - - if (!$pdo) { + if (!$this->pdo) { return $this->view->render($response, 'dashboard/index.twig', [ 'title' => 'Dashboard', 'stats' => [ @@ -38,22 +38,24 @@ class DashboardController extends Controller } // Get statistics from models - $gameStats = Game::getStats($pdo); - $movieStats = Movie::getStats($pdo); - $tvShowStats = TvShow::getStats($pdo); - $musicStats = MusicArtist::getStats($pdo); - $syncStats = SyncLog::getStats($pdo); + $gameStats = Game::getStats($this->pdo); + $movieStats = Movie::getStats($this->pdo); + $tvShowStats = TvShow::getStats($this->pdo); + $adultStats = AdultVideo::getStats($this->pdo); + // $musicStats = MusicArtist::getStats($this->pdo); + //$syncStats = SyncLog::getStats($this->pdo); // Get recent activity - $recentGames = Game::getRecent($pdo, 5); - $recentMovies = Movie::getRecent($pdo, 5); - $recentSyncs = SyncLog::getRecent($pdo, 5); + $recentGames = Game::getRecent($this->pdo, 5); + $recentMovies = Movie::getRecent($this->pdo, 5); + $recentSyncs = SyncLog::getRecent($this->pdo, 5); // Calculate total media count $totalMedia = ($gameStats['total_games'] ?? 0) + ($movieStats['total_movies'] ?? 0) + ($tvShowStats['total_shows'] ?? 0) + - ($musicStats['total_artists'] ?? 0); + ($musicStats['total_artists'] ?? 0)+ + ($adultStats['total_adult_videos'] ?? 0); $stats = [ 'total_media' => $totalMedia, @@ -63,11 +65,13 @@ class DashboardController extends Controller 'total_episodes' => $tvShowStats['total_episodes'] ?? 0, 'total_music' => $musicStats['total_artists'] ?? 0, 'total_playtime' => $gameStats['total_playtime'] ?? 0, + 'total_adult_videos' => $adultStats['total_adult_videos'] ?? 0, 'watched_movies' => $movieStats['watched_movies'] ?? 0, 'favorite_games' => $gameStats['favorite_games'] ?? 0, 'favorite_movies' => $movieStats['favorite_movies'] ?? 0, 'favorite_shows' => $tvShowStats['favorite_shows'] ?? 0, 'favorite_music' => $musicStats['favorite_artists'] ?? 0, + 'favorite_adult_videos' => $adultStats['favorite_adult_videos'] ?? 0, ]; return $this->view->render($response, 'dashboard/index.twig', [ diff --git a/app/Controllers/ImageController.php b/app/Controllers/ImageController.php new file mode 100644 index 0000000..34fd896 --- /dev/null +++ b/app/Controllers/ImageController.php @@ -0,0 +1,53 @@ +withStatus(404, 'Image not found'); + } + + // Get file extension and set appropriate content type + $extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); + $contentTypes = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + ]; + + $contentType = $contentTypes[$extension] ?? 'application/octet-stream'; + + // Read and serve the file + $fileContent = file_get_contents($fullPath); + + $response = $response->withHeader('Content-Type', $contentType); + $response = $response->withHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour + $response->getBody()->write($fileContent); + + return $response; + } +} diff --git a/app/Controllers/MediaSourceController.php b/app/Controllers/MediaSourceController.php new file mode 100644 index 0000000..c892e28 --- /dev/null +++ b/app/Controllers/MediaSourceController.php @@ -0,0 +1,219 @@ +source = new Source($pdo); + $this->syncLog = new SyncLog($pdo); + } + + // List all sources + public function index(Request $request, Response $response, array $args): Response + { + $sources = $this->source->all(); + + return $this->render($response, 'admin/sources.twig', [ + 'sources' => $sources, + 'current_route' => 'sources' + ]); + } + + // Show create form + public function create(Request $request, Response $response, array $args): Response + { + return $this->render($response, 'admin/sources/create.twig', [ + 'current_route' => 'sources' + ]); + } + + // Store new source + public function store(Request $request, Response $response, array $args): Response + { + $data = $request->getParsedBody(); + + // Basic validation + if (empty($data['name']) || empty($data['type']) || empty($data['path'])) { + $this->flash->addMessage('error', 'Name, type, and path are required'); + return $response->withHeader('Location', '/admin/sources/create')->withStatus(302); + } + + try { + $sourceData = [ + 'name' => $data['name'], + 'type' => $data['type'], + 'path' => $data['path'], + 'username' => $data['username'] ?? null, + 'password' => !empty($data['password']) ? password_hash($data['password'], PASSWORD_DEFAULT) : null, + 'is_active' => isset($data['is_active']) ? 1 : 0, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s') + ]; + + $this->source->create($sourceData); + $this->flash->addMessage('success', 'Source created successfully'); + return $response->withHeader('Location', '/admin/sources')->withStatus(302); + + } catch (\Exception $e) { + $this->flash->addMessage('error', 'Error creating source: ' . $e->getMessage()); + return $response->withHeader('Location', '/admin/sources/create')->withStatus(302); + } + } + + // Show edit form + public function edit(Request $request, Response $response, array $args): Response + { + $id = $args['id']; + $source = $this->source->find($id); + + if (!$source) { + $this->flash->addMessage('error', 'Source not found'); + return $response->withHeader('Location', '/admin/sources')->withStatus(302); + } + + return $this->render($response, 'admin/sources/edit.twig', [ + 'source' => $source, + 'current_route' => 'sources' + ]); + } + + // Update source + public function update(Request $request, Response $response, array $args): Response + { + $id = $args['id']; + $data = $request->getParsedBody(); + + try { + $sourceData = [ + 'name' => $data['name'], + 'type' => $data['type'], + 'path' => $data['path'], + 'username' => $data['username'] ?? null, + 'is_active' => isset($data['is_active']) ? 1 : 0, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + // Only update password if provided + if (!empty($data['password'])) { + $sourceData['password'] = password_hash($data['password'], PASSWORD_DEFAULT); + } + + $this->source->update($id, $sourceData); + $this->flash->addMessage('success', 'Source updated successfully'); + return $response->withHeader('Location', '/admin/sources')->withStatus(302); + + } catch (\Exception $e) { + $this->flash->addMessage('error', 'Error updating source: ' . $e->getMessage()); + return $response->withHeader('Location', '/admin/sources/' . $id . '/edit')->withStatus(302); + } + } + + // Delete source + public function destroy(Request $request, Response $response, array $args): Response + { + $id = $args['id']; + + try { + $this->source->delete($id); + $this->flash->addMessage('success', 'Source deleted successfully'); + } catch (\Exception $e) { + $this->flash->addMessage('error', 'Error deleting source: ' . $e->getMessage()); + } + + return $response->withHeader('Location', '/admin/sources')->withStatus(302); + } + + // Start sync for a source + public function startSync(Request $request, Response $response, array $args): Response + { + $sourceId = $args['id']; + $source = $this->source->find($sourceId); + + if (!$source) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Source not found' + ], 404); + } + + try { + // Create a sync log entry + $logId = $this->syncLog->create([ + 'source_id' => $sourceId, + 'type' => 'full', + 'status' => 'pending', + 'started_at' => date('Y-m-d H:i:s'), + 'created_at' => date('Y-m-d H:i:s') + ]); + + // Start sync in background (you'll need to implement this) + $this->startBackgroundSync($sourceId, $logId); + + return $this->json($response, [ + 'success' => true, + 'message' => 'Sync started', + 'log_id' => $logId + ]); + + } catch (\Exception $e) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Error starting sync: ' . $e->getMessage() + ], 500); + } + } + + // Get sync status + public function syncStatus(Request $request, Response $response, array $args): Response + { + $logId = $args['log_id'] ?? null; + + if (!$logId) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Log ID is required' + ], 400); + } + + $log = $this->syncLog->find($logId); + + if (!$log) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Sync log not found' + ], 404); + } + + return $this->json($response, [ + 'success' => true, + 'status' => $log['status'], + 'progress' => $log['progress'] ?? 0, + 'message' => $log['message'] ?? '' + ]); + } + + // Start background sync process + private function startBackgroundSync($sourceId, $logId) + { + // This is a simplified example - you'll need to implement this based on your needs + $command = sprintf( + 'php %s/console.php sync:source %d --log=%d > /dev/null 2>&1 &', + dirname(__DIR__, 3), + $sourceId, + $logId + ); + + exec($command); + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php new file mode 100644 index 0000000..c1f7165 --- /dev/null +++ b/app/Controllers/SettingsController.php @@ -0,0 +1,158 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $settings = $this->getSettings(); + $sources = $this->getSources(); + + return $this->view->render($response, 'admin/settings.twig', [ + 'title' => 'Admin Settings', + 'settings' => $settings, + 'sources' => $sources + ]); + } + + public function save(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + + // Save general settings + $this->saveGeneralSettings($data); + + // Save source-specific settings + if (isset($data['sources']) && is_array($data['sources'])) { + $this->saveSourceSettings($data['sources']); + } + + return $this->view->render($response, 'admin/settings.twig', [ + 'title' => 'Admin Settings', + 'settings' => $this->getSettings(), + 'sources' => $this->getSources(), + 'success' => 'Settings saved successfully!' + ]); + } + + private function getSettings(): array + { + // Get general application settings + $settings = []; + + // Get media type visibility settings + $stmt = $this->pdo->prepare("SELECT setting_key, setting_value FROM settings WHERE setting_key LIKE 'media_visibility_%'"); + $stmt->execute(); + $mediaVisibilitySettings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + + $settings['media_visibility'] = [ + 'games' => $mediaVisibilitySettings['media_visibility_games'] ?? 'authenticated', // Default: authenticated users only + 'movies' => $mediaVisibilitySettings['media_visibility_movies'] ?? 'authenticated', + 'tvshows' => $mediaVisibilitySettings['media_visibility_tvshows'] ?? 'authenticated', + 'music' => $mediaVisibilitySettings['media_visibility_music'] ?? 'authenticated', + 'adult' => $mediaVisibilitySettings['media_visibility_adult'] ?? 'authenticated', // Adult content requires auth by default + 'actors' => $mediaVisibilitySettings['media_visibility_actors'] ?? 'authenticated' + ]; + + // You can extend this to include more settings like: + // - Sync intervals + // - Default sync types + // - Notification preferences + // - Theme settings + // - etc. + + return $settings; + } + + private function getSources(): array + { + $stmt = $this->pdo->prepare("SELECT * FROM sources ORDER BY display_name"); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function saveGeneralSettings(array $data): void + { + // Save general settings to a settings table or config file + // For now, we'll store them in a simple settings table + + foreach ($data as $key => $value) { + if (strpos($key, 'setting_') === 0) { + $settingKey = substr($key, 8); // Remove 'setting_' prefix + + // Check if setting exists + $stmt = $this->pdo->prepare("SELECT id FROM settings WHERE setting_key = :key LIMIT 1"); + $stmt->execute(['key' => $settingKey]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + // Update existing setting + $stmt = $this->pdo->prepare("UPDATE settings SET setting_value = :value WHERE setting_key = :key"); + $stmt->execute(['key' => $settingKey, 'value' => $value]); + } else { + // Insert new setting + $stmt = $this->pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)"); + $stmt->execute(['key' => $settingKey, 'value' => $value]); + } + } + } + + // Save media visibility settings + if (isset($data['media_visibility']) && is_array($data['media_visibility'])) { + foreach ($data['media_visibility'] as $mediaType => $visibility) { + $settingKey = "media_visibility_{$mediaType}"; + + // Check if setting exists + $stmt = $this->pdo->prepare("SELECT id FROM settings WHERE setting_key = :key LIMIT 1"); + $stmt->execute(['key' => $settingKey]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + // Update existing setting + $stmt = $this->pdo->prepare("UPDATE settings SET setting_value = :value WHERE setting_key = :key"); + $stmt->execute(['key' => $settingKey, 'value' => $visibility]); + } else { + // Insert new setting + $stmt = $this->pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)"); + $stmt->execute(['key' => $settingKey, 'value' => $visibility]); + } + } + } + } + + private function saveSourceSettings(array $sources): void + { + foreach ($sources as $sourceId => $sourceData) { + // Update source configuration + $config = isset($sourceData['config']) ? json_encode($sourceData['config']) : '{}'; + + $stmt = $this->pdo->prepare(" + UPDATE sources + SET api_url = :api_url, api_key = :api_key, config = :config, is_active = :is_active + WHERE id = :id + "); + + $stmt->execute([ + 'id' => $sourceId, + 'api_url' => $sourceData['api_url'] ?? '', + 'api_key' => $sourceData['api_key'] ?? '', + 'config' => $config, + 'is_active' => isset($sourceData['is_active']) ? 1 : 0 + ]); + } + } +} diff --git a/app/Controllers/SyncController.php b/app/Controllers/SyncController.php new file mode 100644 index 0000000..1671fe8 --- /dev/null +++ b/app/Controllers/SyncController.php @@ -0,0 +1,294 @@ +source = new Source($pdo); + $this->syncLog = new SyncLog($pdo); + + // Initialize sync services + $this->syncServices = [ + 'jellyfin' => new JellyfinSyncService($pdo), + 'local' => new LocalSyncService($pdo), + 'samba' => new SambaSyncService($pdo), + 'nfs' => new NfsSyncService($pdo) + ]; + } + + // Show sync dashboard + public function index(Request $request, Response $response, array $args): Response + { + // Get recent sync logs + $recentLogs = $this->syncLog->orderBy('created_at', 'desc')->limit(10)->get(); + + // Get sync statistics + $stats = [ + 'total_syncs' => $this->syncLog->count(), + 'successful_syncs' => $this->syncLog->where('status', 'completed')->count(), + 'failed_syncs' => $this->syncLog->where('status', 'failed')->count(), + 'pending_syncs' => $this->syncLog->where('status', 'pending')->count(), + ]; + + return $this->render($response, 'admin/sync/index.twig', [ + 'recent_logs' => $recentLogs, + 'stats' => $stats, + 'current_route' => 'sync' + ]); + } + + // Start a new sync + public function start(Request $request, Response $response, array $args): Response + { + $data = $request->getParsedBody(); + $type = $data['type'] ?? 'full'; // full, scan, update + $sourceId = $data['source_id'] ?? null; + + try { + // Create a new sync log + $logData = [ + 'source_id' => $sourceId, + 'sync_type' => $type, + 'status' => 'pending', + 'started_at' => date('Y-m-d H:i:s'), + 'created_at' => date('Y-m-d H:i:s') + ]; + + $logId = $this->syncLog->create($logData); + + // Start sync in background + $this->startBackgroundSync($sourceId, $logId, $type); + + return $this->json($response, [ + 'success' => true, + 'message' => 'Sync started', + 'log_id' => $logId + ]); + + } catch (\Exception $e) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Error starting sync: ' . $e->getMessage() + ], 500); + } + } + + // Get sync status + public function status(Request $request, Response $response, array $args): Response + { + $logId = $args['log_id'] ?? null; + + if (!$logId) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Log ID is required' + ], 400); + } + + $log = $this->syncLog->find($logId); + + if (!$log) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Sync log not found' + ], 404); + } + + // Get detailed status from the sync service if available + $status = [ + 'status' => $log['status'], + 'progress' => (int)($log['progress'] ?? 0), + 'message' => $log['message'] ?? '', + 'started_at' => $log['started_at'], + 'completed_at' => $log['completed_at'] ?? null, + 'total_items' => (int)($log['total_items'] ?? 0), + 'processed_items' => (int)($log['processed_items'] ?? 0), + 'new_items' => (int)($log['new_items'] ?? 0), + 'updated_items' => (int)($log['updated_items'] ?? 0), + 'deleted_items' => (int)($log['deleted_items'] ?? 0) + ]; + + return $this->json($response, [ + 'success' => true, + 'log' => $status + ]); + } + + // Cancel a running sync + public function cancel(Request $request, Response $response, array $args): Response + { + $logId = $args['log_id'] ?? null; + + if (!$logId) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Log ID is required' + ], 400); + } + + try { + // Update the log status to cancelled + $this->syncLog->update($logId, [ + 'status' => 'cancelled', + 'completed_at' => date('Y-m-d H:i:s'), + 'message' => 'Sync cancelled by user' + ]); + + // TODO: Send a signal to the running process to cancel + + return $this->json($response, [ + 'success' => true, + 'message' => 'Sync cancelled' + ]); + + } catch (\Exception $e) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Error cancelling sync: ' . $e->getMessage() + ], 500); + } + } + + // Clear sync logs + public function clearLogs(Request $request, Response $response, array $args): Response + { + $type = $request->getParsedBody()['type'] ?? 'completed'; // completed, all + + try { + if ($type === 'all') { + $this->syncLog->query('TRUNCATE TABLE sync_logs'); + } else { + $this->syncLog->where('status', 'completed')->delete(); + $this->syncLog->where('status', 'failed')->delete(); + $this->syncLog->where('status', 'cancelled')->delete(); + } + + return $this->json($response, [ + 'success' => true, + 'message' => 'Logs cleared successfully' + ]); + + } catch (\Exception $e) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Error clearing logs: ' . $e->getMessage() + ], 500); + } + } + + // Start background sync process + private function startBackgroundSync($sourceId, $logId, $type = 'full') + { + // This is a simplified example - you'll need to implement this based on your needs + $command = sprintf( + 'php %s/console.php sync:start --log=%d --type=%s %s > /dev/null 2>&1 &', + dirname(__DIR__, 3), // Path to your project root + $logId, + escapeshellarg($type), + $sourceId ? '--source=' . $sourceId : '' + ); + + exec($command); + } + + // Process sync (called from CLI) + public function processSync($sourceId, $logId, $type = 'full') + { + try { + $log = $this->syncLog->find($logId); + + if (!$log) { + throw new \Exception('Sync log not found'); + } + + // Update log status to started + $this->syncLog->update($logId, [ + 'status' => 'in_progress', + 'started_at' => date('Y-m-d H:i:s'), + 'message' => 'Sync started' + ]); + + $source = null; + if ($sourceId) { + $source = $this->source->find($sourceId); + if (!$source) { + throw new \Exception('Source not found'); + } + } + + // Get the appropriate sync service + $service = $this->getSyncService($source ? $source['type'] : 'local'); + + // Start sync + $result = $service->sync($source, $type, function($progress, $message) use ($logId) { + // Update progress callback + $this->updateSyncProgress($logId, $progress, $message); + }); + + // Update log with final status + $this->syncLog->update($logId, [ + 'status' => $result['success'] ? 'completed' : 'failed', + 'completed_at' => date('Y-m-d H:i:s'), + 'message' => $result['message'] ?? 'Sync completed', + 'total_items' => $result['total_items'] ?? 0, + 'processed_items' => $result['processed_items'] ?? 0, + 'new_items' => $result['new_items'] ?? 0, + 'updated_items' => $result['updated_items'] ?? 0, + 'deleted_items' => $result['deleted_items'] ?? 0, + 'errors' => !empty($result['errors']) ? json_encode($result['errors']) : null + ]); + + return $result; + + } catch (\Exception $e) { + // Update log with error + if (isset($logId) && $this->syncLog) { + $this->syncLog->update($logId, [ + 'status' => 'failed', + 'completed_at' => date('Y-m-d H:i:s'), + 'message' => 'Error: ' . $e->getMessage() + ]); + } + + throw $e; + } + } + + // Update sync progress + private function updateSyncProgress($logId, $progress, $message = '') + { + $this->syncLog->update($logId, [ + 'progress' => $progress, + 'message' => $message, + 'updated_at' => date('Y-m-d H:i:s') + ]); + } + + // Get the appropriate sync service + private function getSyncService($type) + { + $type = strtolower($type); + + if (!isset($this->syncServices[$type])) { + throw new \Exception("Unsupported source type: $type"); + } + + return $this->syncServices[$type]; + } +} diff --git a/app/Controllers/TvShowController.php b/app/Controllers/TvShowController.php index 91d9b6d..8847311 100644 --- a/app/Controllers/TvShowController.php +++ b/app/Controllers/TvShowController.php @@ -41,7 +41,11 @@ class TvShowController extends Controller $totalPages = ceil($totalCount / $perPage); $hasNextPage = $page < $totalPages; $hasPrevPage = $page > 1; - +/* + echo '
';
+        print_r($tvshows);
+        die();
+*/
         return $this->view->render($response, 'tvshows/index.twig', [
             'title' => 'TV Shows',
             'tvshows' => $tvshows,
@@ -107,4 +111,67 @@ class TvShowController extends Controller
             'seasons' => $seasons
         ]);
     }
+
+    public function delete(Request $request, Response $response, $args)
+    {
+        $tvShowId = (int) $args['id'];
+
+        // Get TV show details to access metadata
+        $stmt = $this->pdo->prepare("
+            SELECT t.*
+            FROM tv_shows t
+            WHERE t.id = :id
+        ");
+        $stmt->execute(['id' => $tvShowId]);
+        $tvShow = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        if (!$tvShow) {
+            return $response->withStatus(404);
+        }
+
+        // Decode metadata to find image paths
+        $metadata = json_decode($tvShow['metadata'], true);
+
+        // Delete associated images
+        $imagesDeleted = [];
+        if (!empty($metadata['local_poster_path'])) {
+            $posterPath = __DIR__ . '/../storage/images/' . $metadata['local_poster_path'];
+            if (file_exists($posterPath)) {
+                unlink($posterPath);
+                $imagesDeleted[] = $metadata['local_poster_path'];
+            }
+        }
+
+        if (!empty($metadata['local_backdrop_path'])) {
+            $backdropPath = __DIR__ . '/../storage/images/' . $metadata['local_backdrop_path'];
+            if (file_exists($backdropPath)) {
+                unlink($backdropPath);
+                $imagesDeleted[] = $metadata['local_backdrop_path'];
+            }
+        }
+
+        // Delete actor relationships
+        $stmt = $this->pdo->prepare("
+            DELETE FROM actor_tv_show
+            WHERE tv_show_id = :tv_show_id
+        ");
+        $stmt->execute(['tv_show_id' => $tvShowId]);
+
+        // Delete the TV show record
+        $stmt = $this->pdo->prepare("DELETE FROM tv_shows WHERE id = :id");
+        $result = $stmt->execute(['id' => $tvShowId]);
+
+        if ($result) {
+            return $response->withJson([
+                'success' => true,
+                'message' => 'TV show deleted successfully',
+                'images_deleted' => $imagesDeleted
+            ]);
+        } else {
+            return $response->withJson([
+                'success' => false,
+                'message' => 'Failed to delete TV show'
+            ], 500);
+        }
+    }
 }
diff --git a/app/Http/Middleware/MediaVisibilityMiddleware.php b/app/Http/Middleware/MediaVisibilityMiddleware.php
new file mode 100644
index 0000000..cec74f7
--- /dev/null
+++ b/app/Http/Middleware/MediaVisibilityMiddleware.php
@@ -0,0 +1,68 @@
+getUri()->getPath();
+
+        // Map routes to media types
+        $mediaRoutes = [
+            '/media/games' => 'games',
+            '/media/movies' => 'movies',
+            '/media/tv-shows' => 'tvshows',
+            '/media/music' => 'music',
+            '/media/adult' => 'adult',
+            '/media/actors' => 'actors'
+        ];
+
+        foreach ($mediaRoutes as $route => $mediaType) {
+            if (strpos($path, $route) === 0) {
+                // Check if this media type is visible to the current user
+                if (!$this->isMediaTypeVisible($mediaType)) {
+                    // Redirect to login or show 404 based on configuration
+                    if (!is_logged_in()) {
+                        return $handler->handle($request)->withStatus(401)->withHeader('Location', '/login');
+                    } else {
+                        return $handler->handle($request)->withStatus(404);
+                    }
+                }
+                break;
+            }
+        }
+
+        return $handler->handle($request);
+    }
+
+    private function isMediaTypeVisible(string $mediaType): bool
+    {
+        // Get database connection
+        $pdo = \App\Database\Database::getInstance();
+
+        // Get media visibility setting
+        $stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1");
+        $stmt->execute(['key' => "media_visibility_{$mediaType}"]);
+        $visibility = $stmt->fetchColumn() ?: 'authenticated'; // Default to authenticated only
+
+        // Check user authentication status
+        $isLoggedIn = is_logged_in();
+
+        switch ($visibility) {
+            case 'public':
+                return true; // Visible to everyone
+            case 'authenticated':
+                return $isLoggedIn; // Visible only to authenticated users
+            case 'hidden':
+                return false; // Hidden from all users
+            default:
+                return $isLoggedIn; // Default to authenticated only
+        }
+    }
+}
diff --git a/app/Models/AdultVideo.php b/app/Models/AdultVideo.php
index 2a9699e..0e0e0da 100644
--- a/app/Models/AdultVideo.php
+++ b/app/Models/AdultVideo.php
@@ -166,4 +166,19 @@ class AdultVideo extends Model
             'cast' => $castString
         ]);
     }
+    
+    /**
+     * Get TV show statistics
+     */
+    public static function getStats(\PDO $pdo): array
+    {
+        $stmt = $pdo->query("
+            SELECT
+                COUNT(*) as total_adult_videos,
+                COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos,
+                AVG(rating) as avg_rating
+            FROM adult_videos
+        ");
+        return $stmt->fetch(\PDO::FETCH_ASSOC);
+    }
 }
diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php
index c9b371a..524d95c 100644
--- a/app/Services/JellyfinSyncService.php
+++ b/app/Services/JellyfinSyncService.php
@@ -790,7 +790,7 @@ class JellyfinSyncService extends BaseSyncService
 
         try {
             // Create images directory structure if it doesn't exist
-            $imagesDir = "public/images/{$type}";
+            $imagesDir = __DIR__ . "/../../storage/images/{$type}";
             if (!is_dir($imagesDir)) {
                 if (!mkdir($imagesDir, 0755, true)) {
                     $this->logProgress("Warning: Could not create images directory: {$imagesDir}");
diff --git a/app/Services/LocalSyncService.php b/app/Services/LocalSyncService.php
new file mode 100644
index 0000000..a795b09
--- /dev/null
+++ b/app/Services/LocalSyncService.php
@@ -0,0 +1,313 @@
+ ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg'],
+        'tv_shows' => ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg'],
+        'music' => ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a'],
+        'games' => ['iso', 'rom', 'nsp', 'xci', 'rvz', 'ciso', 'gcm', 'wbfs'],
+        'books' => ['epub', 'mobi', 'pdf', 'azw', 'azw3', 'djvu'],
+        'pictures' => ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'raw']
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public function sync($source, string $type = 'full', callable $progressCallback = null): array
+    {
+        $this->source = $source;
+        $this->sourceId = $source['id'] ?? null;
+        
+        if (!$this->sourceId) {
+            throw new Exception('Source ID is required for sync');
+        }
+        
+        try {
+            $this->logProgress("Starting {$type} sync for local source: " . ($source['name'] ?? 'Unknown'));
+            
+            $path = $source['path'] ?? null;
+            if (empty($path) || !is_dir($path)) {
+                throw new Exception("Invalid or inaccessible source path: {$path}");
+            }
+            
+            // Determine the media type from the source configuration
+            $mediaType = $this->determineMediaType($source);
+            
+            // Get all files from the source directory recursively
+            $files = $this->scanDirectory($path, $mediaType);
+            $this->logProgress(sprintf('Found %d files to process', count($files)));
+            
+            // Get existing media items from the database
+            $existingItems = $this->getExistingMediaItems($mediaType);
+            $this->logProgress(sprintf('Found %d existing items in database', count($existingItems)));
+            
+            $result = [
+                'total_items' => count($files),
+                'processed_items' => 0,
+                'new_items' => 0,
+                'updated_items' => 0,
+                'deleted_items' => 0,
+                'errors' => []
+            ];
+            
+            // Process each file
+            foreach ($files as $filePath => $fileInfo) {
+                try {
+                    $relativePath = $this->getRelativePath($path, $filePath);
+                    $fileKey = $this->generateFileKey($relativePath, $fileInfo);
+                    
+                    if (isset($existingItems[$fileKey])) {
+                        // Update existing item if needed
+                        $item = $existingItems[$fileKey];
+                        $updated = $this->updateMediaItem($item, $filePath, $fileInfo, $mediaType);
+                        
+                        if ($updated) {
+                            $result['updated_items']++;
+                            $this->logProgress("Updated: {$relativePath}");
+                        }
+                        
+                        // Remove from existing items to track deletions
+                        unset($existingItems[$fileKey]);
+                    } else {
+                        // Add new item
+                        $this->createMediaItem($filePath, $fileInfo, $mediaType, $relativePath);
+                        $result['new_items']++;
+                        $this->logProgress("Added: {$relativePath}");
+                    }
+                    
+                    $result['processed_items']++;
+                    
+                    // Update progress
+                    if ($progressCallback) {
+                        $progress = (int)(($result['processed_items'] / $result['total_items']) * 100);
+                        $progressCallback($progress, "Processing: {$relativePath}");
+                    }
+                    
+                } catch (\Exception $e) {
+                    $errorMsg = "Error processing {$filePath}: " . $e->getMessage();
+                    $this->logProgress($errorMsg, 'ERROR');
+                    $result['errors'][] = $errorMsg;
+                }
+            }
+            
+            // Handle deleted files
+            if ($type === 'full' && !empty($existingItems)) {
+                foreach ($existingItems as $item) {
+                    try {
+                        $this->deleteMediaItem($item, $mediaType);
+                        $result['deleted_items']++;
+                        $this->logProgress("Deleted: {$item['file_path']}");
+                    } catch (\Exception $e) {
+                        $errorMsg = "Error deleting {$item['file_path']}: " . $e->getMessage();
+                        $this->logProgress($errorMsg, 'ERROR');
+                        $result['errors'][] = $errorMsg;
+                    }
+                }
+            }
+            
+            $this->logProgress("Sync completed successfully");
+            
+            return array_merge($result, [
+                'success' => true,
+                'message' => 'Sync completed successfully',
+            ]);
+            
+        } catch (\Exception $e) {
+            $errorMsg = 'Sync failed: ' . $e->getMessage();
+            $this->logProgress($errorMsg, 'ERROR');
+            
+            return [
+                'success' => false,
+                'message' => $errorMsg,
+                'errors' => [$errorMsg]
+            ];
+        }
+    }
+    
+    /**
+     * @inheritDoc
+     */
+    public function getSupportedTypes(): array
+    {
+        return ['local', 'file'];
+    }
+    
+    /**
+     * Determine the media type from the source configuration
+     */
+    protected function determineMediaType(array $source): string
+    {
+        // First check if media_type is explicitly set in the source config
+        if (!empty($source['media_type'])) {
+            return strtolower($source['media_type']);
+        }
+        
+        // Otherwise, try to guess from the path or name
+        $path = strtolower($source['path'] ?? '');
+        $name = strtolower($source['name'] ?? '');
+        
+        foreach (array_keys($this->supportedExtensions) as $type) {
+            if (strpos($path, $type) !== false || strpos($name, $type) !== false) {
+                return $type;
+            }
+        }
+        
+        // Default to 'other' if we can't determine the type
+        return 'other';
+    }
+    
+    /**
+     * Scan a directory recursively for media files
+     */
+    protected function scanDirectory(string $path, string $mediaType): array
+    {
+        $files = [];
+        $extensions = $this->supportedExtensions[$mediaType] ?? [];
+        
+        $iterator = new RecursiveIteratorIterator(
+            new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
+            RecursiveIteratorIterator::SELF_FIRST
+        );
+        
+        /** @var SplFileInfo $file */
+        foreach ($iterator as $file) {
+            if ($file->isFile()) {
+                $ext = strtolower($file->getExtension());
+                
+                // If we have specific extensions for this media type, filter by them
+                if (!empty($extensions) && !in_array($ext, $extensions)) {
+                    continue;
+                }
+                
+                $files[$file->getPathname()] = [
+                    'size' => $file->getSize(),
+                    'modified' => $file->getMTime(),
+                    'extension' => $ext,
+                    'path' => $file->getPathname(),
+                    'filename' => $file->getFilename()
+                ];
+            }
+        }
+        
+        return $files;
+    }
+    
+    /**
+     * Get existing media items from the database
+     */
+    protected function getExistingMediaItems(string $mediaType): array
+    {
+        $mediaItem = new MediaItem($this->pdo);
+        $items = $mediaItem->where('source_id', $this->sourceId)
+                          ->where('media_type', $mediaType)
+                          ->get();
+        
+        $result = [];
+        foreach ($items as $item) {
+            $result[$item['file_key']] = $item;
+        }
+        
+        return $result;
+    }
+    
+    /**
+     * Generate a unique key for a file
+     */
+    protected function generateFileKey(string $relativePath, array $fileInfo): string
+    {
+        return md5($relativePath . $fileInfo['size'] . $fileInfo['modified']);
+    }
+    
+    /**
+     * Get the relative path from the base path
+     */
+    protected function getRelativePath(string $basePath, string $filePath): string
+    {
+        return ltrim(str_replace('\\', '/', substr($filePath, strlen($basePath))), '/');
+    }
+    
+    /**
+     * Create a new media item in the database
+     */
+    protected function createMediaItem(string $filePath, array $fileInfo, string $mediaType, string $relativePath): void
+    {
+        $mediaItem = new MediaItem($this->pdo);
+        
+        $data = [
+            'source_id' => $this->sourceId,
+            'media_type' => $mediaType,
+            'file_path' => $relativePath,
+            'file_name' => $fileInfo['filename'],
+            'file_size' => $fileInfo['size'],
+            'file_modified' => date('Y-m-d H:i:s', $fileInfo['modified']),
+            'file_extension' => $fileInfo['extension'],
+            'file_key' => $this->generateFileKey($relativePath, $fileInfo),
+            'metadata' => json_encode([
+                'original_path' => $filePath,
+                'imported_at' => date('Y-m-d H:i:s')
+            ]),
+            'created_at' => date('Y-m-d H:i:s'),
+            'updated_at' => date('Y-m-d H:i:s')
+        ];
+        
+        $mediaItem->create($data);
+    }
+    
+    /**
+     * Update an existing media item if needed
+     */
+    protected function updateMediaItem(array $item, string $filePath, array $fileInfo, string $mediaType): bool
+    {
+        $needsUpdate = false;
+        $updates = [];
+        
+        // Check if file has been modified
+        if ($item['file_size'] != $fileInfo['size'] || 
+            $item['file_modified'] != date('Y-m-d H:i:s', $fileInfo['modified'])) {
+            
+            $updates = [
+                'file_size' => $fileInfo['size'],
+                'file_modified' => date('Y-m-d H:i:s', $fileInfo['modified']),
+                'updated_at' => date('Y-m-d H:i:s')
+            ];
+            $needsUpdate = true;
+        }
+        
+        // Update metadata if needed
+        $metadata = json_decode($item['metadata'] ?? '{}', true);
+        $metadata['last_checked'] = date('Y-m-d H:i:s');
+        $updates['metadata'] = json_encode($metadata);
+        
+        if ($needsUpdate) {
+            $mediaItem = new MediaItem($this->pdo);
+            $mediaItem->update($item['id'], $updates);
+            return true;
+        }
+        
+        return false;
+    }
+    
+    /**
+     * Delete a media item from the database
+     */
+    protected function deleteMediaItem(array $item, string $mediaType): void
+    {
+        $mediaItem = new MediaItem($this->pdo);
+        $mediaItem->delete($item['id']);
+    }
+}
diff --git a/app/Services/StashSyncService.php b/app/Services/StashSyncService.php
index 69ae9c4..eb7d58e 100644
--- a/app/Services/StashSyncService.php
+++ b/app/Services/StashSyncService.php
@@ -36,7 +36,7 @@ class StashSyncService extends BaseSyncService
             'verify' => false // Disable SSL verification for problematic servers
         ]);
 
-        $this->imageDownloader = new ImageDownloader('public/images', $this->apiKey);
+        $this->imageDownloader = new ImageDownloader(__DIR__ . '/../../storage/images', $this->apiKey);
     }
 
     protected function executeSync(string $syncType): void
diff --git a/app/Services/SyncServiceInterface.php b/app/Services/SyncServiceInterface.php
new file mode 100644
index 0000000..ff883c5
--- /dev/null
+++ b/app/Services/SyncServiceInterface.php
@@ -0,0 +1,23 @@
+imageDownloader = new ImageDownloader('public/images');
+        $this->imageDownloader = new ImageDownloader(__DIR__ . '/../../storage/images');
     }
 
     protected function executeSync(string $syncType): void
diff --git a/app/Utils/ImageDownloader.php b/app/Utils/ImageDownloader.php
index d74d8da..26373ba 100644
--- a/app/Utils/ImageDownloader.php
+++ b/app/Utils/ImageDownloader.php
@@ -10,7 +10,7 @@ class ImageDownloader
     private Client $httpClient;
     private string $basePath;
 
-    public function __construct(string $basePath = 'public/images', ?string $apiKey = null)
+    public function __construct(string $basePath = 'storage/images', ?string $apiKey = null)
     {
         $headers = [
             'User-Agent' => 'MediaCollector/1.0'
@@ -25,7 +25,13 @@ class ImageDownloader
             'headers' => $headers,
             'verify' => false // Disable SSL verification for problematic servers
         ]);
-        $this->basePath = rtrim($basePath, '/');
+
+        // Convert relative path to absolute path
+        if (strpos($basePath, '/') !== 0) {
+            $this->basePath = __DIR__ . '/../' . $basePath;
+        } else {
+            $this->basePath = $basePath;
+        }
     }
 
     /**
@@ -115,6 +121,8 @@ class ImageDownloader
                 return true;
             }
         }
+
+        return false; // Not a valid image type
     }
 
     public function saveImage(string $imageData, string $filename, string $subfolder = ''): ?string
@@ -167,9 +175,9 @@ class ImageDownloader
             return null;
         }
 
-        // Remove the public/ prefix to get the web-accessible path
+        // Remove the absolute basePath prefix to get the relative path
         $relativePath = str_replace($this->basePath . '/', '', $localPath);
 
-        return '/' . $relativePath;
+        return '/images/' . $relativePath;
     }
 }
diff --git a/app/helpers.php b/app/helpers.php
index ea48236..de3d659 100644
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -164,9 +164,29 @@ function array_to_object(array $array): object
 }
 
 /**
- * Convert object to array recursively
+ * Check if a media type is visible to the current user
  */
-function object_to_array(object $object): array
+function is_media_type_visible(string $mediaType): bool
 {
-    return json_decode(json_encode($object), true);
+    // Get database connection
+    $pdo = \App\Database\Database::getInstance();
+
+    // Get media visibility setting
+    $stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1");
+    $stmt->execute(['key' => "media_visibility_{$mediaType}"]);
+    $visibility = $stmt->fetchColumn() ?: 'authenticated'; // Default to authenticated only
+
+    // Check user authentication status
+    $isLoggedIn = is_logged_in();
+
+    switch ($visibility) {
+        case 'public':
+            return true; // Visible to everyone
+        case 'authenticated':
+            return $isLoggedIn; // Visible only to authenticated users
+        case 'hidden':
+            return false; // Hidden from all users
+        default:
+            return $isLoggedIn; // Default to authenticated only
+    }
 }
diff --git a/public/index.php b/public/index.php
index 490ca00..36e539f 100644
--- a/public/index.php
+++ b/public/index.php
@@ -55,6 +55,11 @@ $container->set('view', function () use ($container) {
             $_SERVER['HTTP_HOST'] ?? 'localhost'
         );
     }));
+    
+    // Add media visibility function
+    $twig->getEnvironment()->addFunction(new TwigFunction('is_media_type_visible', function ($mediaType) {
+        return is_media_type_visible($mediaType);
+    }));
 
     // Placeholder path_for function - will be updated after routes are registered
     $twig->getEnvironment()->addFunction(new TwigFunction('path_for', function ($name, $data = [], $queryParams = []) {
@@ -84,6 +89,12 @@ $container->set('view', function () use ($container) {
             case 'admin.index':
                 $basePath = '/admin';
                 break;
+            case 'admin.settings':
+                $basePath = '/admin/settings';
+                break;
+            case 'admin.sources':
+                $basePath = '/admin/sources';
+                break;
             case 'admin.sync':
                 $basePath = '/admin/sync/' . ($data['id'] ?? '');
                 break;
@@ -144,6 +155,9 @@ $container->set('view', function () use ($container) {
         $authService = $container->get(\App\Services\AuthService::class);
         return $authService->generateCSRFToken();
     }));
+
+
+    
     $twig->getEnvironment()->addFilter(new TwigFilter('format_duration', function ($minutes) {
         if (!$minutes || $minutes == 0) {
             return '0m';
@@ -199,7 +213,7 @@ $container->set(\App\Controllers\GameController::class, function ($c) {
 });
 
 $container->set(\App\Controllers\DashboardController::class, function ($c) {
-    return new \App\Controllers\DashboardController($c->get('view'));
+    return new \App\Controllers\DashboardController($c->get(PDO::class), $c->get('view'));
 });
 
 $container->set(\App\Controllers\MovieController::class, function ($c) {
@@ -226,14 +240,25 @@ $container->set(\App\Controllers\SearchController::class, function ($c) {
     return new \App\Controllers\SearchController($c->get(PDO::class), $c->get('view'));
 });
 
+$container->set(\App\Controllers\ImageController::class, function ($c) {
+    return new \App\Controllers\ImageController($c->get('view'));
+});
+
+$container->set(\App\Controllers\SettingsController::class, function ($c) {
+    return new \App\Controllers\SettingsController($c->get(PDO::class), $c->get('view'));
+});
 
 // Register middleware
 $container->set(\App\Http\Middleware\AuthMiddleware::class, function ($c) {
     return new \App\Http\Middleware\AuthMiddleware($c->get(\App\Services\AuthService::class));
 });
 
-$container->set(\App\Http\Middleware\AdminMiddleware::class, function ($c) {
-    return new \App\Http\Middleware\AdminMiddleware($c->get(\App\Services\AuthService::class));
+$container->set(\App\Controllers\MediaSourceController::class, function ($c) {
+    return new \App\Controllers\MediaSourceController($c->get(PDO::class), $c->get('view'));
+});
+
+$container->set(\App\Http\Middleware\MediaVisibilityMiddleware::class, function ($c) {
+    return new \App\Http\Middleware\MediaVisibilityMiddleware();
 });
 
 // Create App with DI Container
diff --git a/resources/views/actor/index.twig b/resources/views/actor/index.twig
index c355789..7037d43 100644
--- a/resources/views/actor/index.twig
+++ b/resources/views/actor/index.twig
@@ -12,12 +12,12 @@
     {% if actors %}
     
{% for actor in actors %} -
+
{% if actor.thumbnail_path %} - {{ actor.name }} + {{ actor.name }} {% else %}
diff --git a/resources/views/actor/show.twig b/resources/views/actor/show.twig index 7c9b866..75af231 100644 --- a/resources/views/actor/show.twig +++ b/resources/views/actor/show.twig @@ -19,7 +19,7 @@
{% if actor.thumbnail_path %} - {{ actor.name }} + {{ actor.name }} {% else %}
@@ -75,7 +75,7 @@
{% if scene.poster_url %} - {{ scene.title }} + {{ scene.title }} {% else %}
diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig index a17ee0d..cc57f86 100644 --- a/resources/views/admin/index.twig +++ b/resources/views/admin/index.twig @@ -1,6 +1,25 @@ -{% extends 'layouts/app.twig' %} +{% extends 'admin/layout.twig' %} {% block content %} + + + +

Admin Dashboard

Manage your media sources and synchronization

diff --git a/resources/views/admin/layout.twig b/resources/views/admin/layout.twig new file mode 100644 index 0000000..72dff0b --- /dev/null +++ b/resources/views/admin/layout.twig @@ -0,0 +1,338 @@ + + + + + + {% block title %}Admin Panel - MediaLib{% endblock %} + + + + + + + + + + + {% block styles %}{% endblock %} + + + + + + +
+ + + + + +
+ {% block content %}{% endblock %} +
+ +
+ + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/resources/views/admin/settings.twig b/resources/views/admin/settings.twig new file mode 100644 index 0000000..cd3da59 --- /dev/null +++ b/resources/views/admin/settings.twig @@ -0,0 +1,368 @@ +{% extends 'admin/layout.twig' %} + +{% block content %} + + + +
+

Admin Settings

+

Configure your media sources and application settings

+
+ + {% if success %} + + {% endif %} + + +
+
+

General Settings

+

Configure general application settings

+
+ +
+
+
+
+ + +
How often to check for new content (5-1440 minutes)
+
+ +
+ + +
Maximum number of items to process in a single sync operation
+
+
+ +
+
+
+ + +
+
Send notifications when sync operations complete
+
+ +
+
+ + +
+
Automatically remove sync logs older than 30 days
+
+
+ +
+ +
+
+
+
+ + +
+
+

Source Configuration

+

Configure your media sources and their sync settings

+
+ +
+
+ {% for source in sources %} +
+

{{ source.display_name }}

+ +
+
+ + +
Base URL for the {{ source.name }} API
+
+ +
+ + +
API key for authenticating with {{ source.name }}
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + {% if source.name == 'jellyfin' %} +
+
+ + +
Comma-separated list of Jellyfin library IDs to sync
+
+ +
+ + +
Jellyfin user ID for accessing content
+
+
+ {% endif %} + + {% if source.name == 'xbvr' %} +
+
+ + +
+ +
+ + +
+
+ {% endif %} + + {% if source.name == 'stash' %} +
+
+ + +
Comma-separated list of tags to include (empty for all)
+
+ +
+ + +
Comma-separated list of tags to exclude
+
+
+ {% endif %} + +
+
+
+ + +
+
+
+
+ {% endfor %} + +
+ +
+
+
+
+ + +
+
+

Media Type Visibility

+

Control which media types are visible to non-authenticated users

+
+ +
+
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
Adult content should typically require authentication
+
+ +
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+

Security Settings

+

Configure security and access settings

+
+ +
+
+
+
+ + +
User sessions will expire after this many minutes of inactivity
+
+ +
+ + +
Number of failed login attempts before account lockout
+
+
+ +
+
+
+ + +
+
Force all connections to use HTTPS
+
+ +
+
+ + +
+
Log all admin actions for security auditing
+
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/resources/views/admin/sources.twig b/resources/views/admin/sources.twig new file mode 100644 index 0000000..840b347 --- /dev/null +++ b/resources/views/admin/sources.twig @@ -0,0 +1,205 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}Media Sources - Admin{% endblock %} + +{% block content %} +
+

Media Sources

+ +
+ + {% if sources is not empty %} +
+
+
+ + + + + + + + + + + + + {% for source in sources %} + + + + + + + + + {% endfor %} + +
NameTypePath/URLStatusLast SyncActions
{{ source.name }} + + {{ source.type|upper }} + + {{ source.path }} + + {{ source.is_active ? 'Active' : 'Inactive' }} + + {{ source.last_sync ? source.last_sync|date('Y-m-d H:i:s') : 'Never' }} +
+ + + + + +
+
+
+
+
+ {% else %} +
+
+ +

No Media Sources Found

+

Add your first media source to get started.

+ +
+
+ {% endif %} + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/resources/views/admin/sync.twig b/resources/views/admin/sync.twig new file mode 100644 index 0000000..701100b --- /dev/null +++ b/resources/views/admin/sync.twig @@ -0,0 +1,453 @@ +{% extends 'admin/layout.twig' %} + +{% block title %}Sync Media - Admin{% endblock %} + +{% block content %} +
+

Sync Media

+
+ +
+
+ +
+
+
+
+
Sync Status
+ +
+
+
+
+ +

No sync activity yet

+
+
+
+
+
+ +
+
+
+
Sync Statistics
+
+
+
+
+
+ + + + + + +
+
0%
+
+
+
+
Idle
+

Last sync: Never

+
+ +
+ +
+
+ Movies + 0 +
+
+
+
+
+ +
+
+ TV Shows + 0 +
+
+
+
+
+ +
+
+ Music + 0 +
+
+
+
+
+
+
+ +
+
+
Quick Actions
+
+
+ + + + +
+
+
+
+{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/resources/views/adult/index.twig b/resources/views/adult/index.twig index 24c5297..fff684f 100644 --- a/resources/views/adult/index.twig +++ b/resources/views/adult/index.twig @@ -186,7 +186,7 @@
{% for movie in movies %} -
+
diff --git a/resources/views/layouts/app.twig b/resources/views/layouts/app.twig index 7bb3412..abc5cb8 100644 --- a/resources/views/layouts/app.twig +++ b/resources/views/layouts/app.twig @@ -42,18 +42,27 @@ + {% if is_media_type_visible('games') %} + {% endif %} + {% if is_media_type_visible('movies') %} + {% endif %} + {% if is_media_type_visible('tvshows') %} + {% endif %} + {% if is_media_type_visible('music') %} + {% endif %} + {% if is_media_type_visible('adult') %}
  • Performers
  • + {% endif %}
    diff --git a/resources/views/movies/index.twig b/resources/views/movies/index.twig index 006e984..7a97a68 100644 --- a/resources/views/movies/index.twig +++ b/resources/views/movies/index.twig @@ -98,7 +98,7 @@
    {% if movie.poster_url %} - {{ movie.title }} + {{ movie.title }} {% else %}
    @@ -152,7 +152,7 @@
    {% if movie.poster_url %}
    - {{ movie.title }} + {{ movie.title }}
    {% else %}
    @@ -180,13 +180,13 @@
    {% for movie in movies %} -
    +
    {% if movie.poster_url %} - {{ movie.title }} + {{ movie.title }} {% else %}
    diff --git a/resources/views/tvshows/index.twig b/resources/views/tvshows/index.twig index a1239ca..3f9338c 100644 --- a/resources/views/tvshows/index.twig +++ b/resources/views/tvshows/index.twig @@ -7,7 +7,6 @@

    TV Shows

    - TV Shows collection coming soon
    @@ -23,13 +22,13 @@ value="{{ search }}" placeholder="Search TV shows..." class="form-control ps-5" - disabled + >
    - @@ -39,8 +38,6 @@ {% for mode in view_modes %}
    - + + {% if tvshows is empty %}
    - + -

    TV Shows Coming Soon

    -

    TV show collection and management features are currently in development.

    +

    + {% if search %} + No TV shows found matching "{{ search }}" + {% else %} + No TV shows found + {% endif %} +

    +

    + {% if search %} + Try adjusting your search terms or browse all TV shows. + {% else %} + Start syncing your TV show libraries to see your TV shows here. + {% endif %} +

    + {% if search %} + + View all TV shows + + {% endif %}
    + {% else %} + + {% if view_mode == 'list' %} + +
    +
      + {% for movie in tvshows %} +
    • +
      +
      + {% if movie.poster_url %} + {{ movie.title }} + {% else %} +
      + + + +
      + {% endif %} +
      +

      + + {{ movie.title }} + +

      +
      + {% if movie.release_date %} + {{ movie.release_date|date('Y') }} + {% endif %} + {% if movie.rating %} + ⭐ {{ movie.rating }}/10 + {% endif %} + {% if movie.runtime_minutes %} + {{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m + {% endif %} + {{ movie.source_name }} +
      +
      +
      +
      + {% if movie.watched %} + + Watched + + {% endif %} + {% if movie.is_favorite %} + + Favorite + + {% endif %} +
      +
      +
    • + {% endfor %} +
    +
    + + {% elseif view_mode == 'covers' %} + +
    + {% for movie in tvshows %} +
    +
    + {% if movie.poster_url %} +
    + {{ movie.title }} +
    + {% else %} +
    + + + +
    + {% endif %} +
    +
    + + {{ movie.title }} + +
    + {% if movie.release_date %} +

    {{ movie.release_date|date('Y') }}

    + {% endif %} +
    +
    +
    + {% endfor %} +
    + + {% else %} + +
    + {% for movie in tvshows %} +
    +
    +
    +
    +
    + {% if movie.poster_url %} + {{ movie.title }} + {% else %} +
    + + + +
    + {% endif %} +
    +
    +
    + + {{ movie.title }} + +
    +
    + {% if movie.release_date %} + {{ movie.release_date|date('Y') }} + {% endif %} + {% if movie.rating %} + ⭐ {{ movie.rating }}/10 + {% endif %} +
    + {% if movie.source_name %} +

    + {{ movie.source_name }} +

    + {% endif %} +
    +
    + {% if movie.overview %} +
    +

    + {{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %} +

    +
    + {% endif %} +
    + {% if movie.runtime_minutes %} + {{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m + {% endif %} +
    + {% if movie.watched %} + + Watched + + {% endif %} + {% if movie.is_favorite %} + + Favorite + + {% endif %} +
    +
    +
    +
    +
    + {% endfor %} +
    + {% endif %} + + + {% if pagination.total_pages > 1 %} +
    +
    + + + per page +
    + +
    + {% if pagination.has_prev %} + + Previous + + {% endif %} + +
    + {% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %} + + {{ page_num }} + + {% endfor %} +
    + + {% if pagination.has_next %} + + Next + + {% endif %} +
    +
    + {% endif %} + {% endif %}
    {% endblock %} diff --git a/routes/web.php b/routes/web.php index 136fefb..bc2de90 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,7 +10,7 @@ use App\Controllers\GameController; use App\Controllers\AdultController; use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AdminMiddleware; -use App\Controllers\ActorController; +use App\Controllers\ImageController; // Authentication routes (no middleware required) $app->get('/login', AuthController::class . ':showLogin')->setName('auth.login'); @@ -20,45 +20,78 @@ $app->get('/logout', AuthController::class . ':logout')->setName('auth.logout'); // Protected routes (require authentication) $app->group('', function (RouteCollectorProxy $group) { - $group->get('/', DashboardController::class . ':index')->setName('home'); + // Image serving (no auth required for public images) + $group->get('/images/{path:.+}', 'App\Controllers\ImageController:serve')->setName('images.serve'); // Global Search $group->get('/search', 'App\Controllers\SearchController:index')->setName('search.index'); + $group->get('/', 'App\Controllers\DashboardController:index')->setName('dashboard.index'); // Media Routes $group->group('/media', function (RouteCollectorProxy $mediaGroup) { // Games $mediaGroup->get('/games', GameController::class . ':index')->setName('games.index'); $mediaGroup->get('/games/{game_key}', GameController::class . ':show')->setName('games.show'); + $mediaGroup->delete('/games/{game_key}', GameController::class . ':delete')->setName('games.delete'); // Movies $mediaGroup->get('/movies', 'App\Controllers\MovieController:index')->setName('movies.index'); $mediaGroup->get('/movies/{id:\d+}', 'App\Controllers\MovieController:show')->setName('movies.show'); + $mediaGroup->delete('/movies/{id:\d+}', 'App\Controllers\MovieController:delete')->setName('movies.delete'); // TV Shows $mediaGroup->get('/tv-shows', 'App\Controllers\TvShowController:index')->setName('tvshows.index'); $mediaGroup->get('/tv-shows/{id:\d+}', 'App\Controllers\TvShowController:show')->setName('tvshows.show'); + $mediaGroup->delete('/tv-shows/{id:\d+}', 'App\Controllers\TvShowController:delete')->setName('tvshows.delete'); // Music $mediaGroup->get('/music', 'App\Controllers\MusicController:index')->setName('music.index'); $mediaGroup->get('/music/{id:\d+}', 'App\Controllers\MusicController:show')->setName('music.show'); + $mediaGroup->delete('/music/{id:\d+}', 'App\Controllers\MusicController:delete')->setName('music.delete'); // Adult Videos $mediaGroup->get('/adult', AdultController::class . ':index')->setName('adult.index'); $mediaGroup->get('/adult/{id:\d+}', AdultController::class . ':show')->setName('adult.show'); + $mediaGroup->delete('/adult/{id:\d+}', AdultController::class . ':delete')->setName('adult.delete'); // Adult Performers (Actors) - $mediaGroup->get('/actors', ActorController::class . ':index')->setName('actors.index'); - $mediaGroup->get('/actors/{id:\d+}', ActorController::class . ':show')->setName('actors.show'); + $mediaGroup->get('/actors', 'App\Controllers\ActorController:index')->setName('actors.index'); + $mediaGroup->get('/actors/{id:\d+}', 'App\Controllers\ActorController:show')->setName('actors.show'); }); -})->add(AuthMiddleware::class); +})->add(AuthMiddleware::class)->add('App\Http\Middleware\MediaVisibilityMiddleware'); -// Admin routes (require authentication + admin role) -$app->group('/admin', function (RouteCollectorProxy $group) { - $group->get('', AdminController::class . ':index')->setName('admin.index'); - $group->post('/sync/{id:\d+}', AdminController::class . ':syncSource')->setName('admin.sync'); - $group->get('/sync/status/{id:\d+}', AdminController::class . ':syncStatus')->setName('admin.sync.status'); - $group->get('/sources', AdminController::class . ':sources')->setName('admin.sources'); -})->add(AdminMiddleware::class); + // Admin routes (require admin role) + $app->group('/admin', function (RouteCollectorProxy $adminGroup) { + // Dashboard + $adminGroup->get('', AdminController::class . ':index')->setName('admin.index'); + $adminGroup->get('/settings', AdminController::class . ':settings')->setName('admin.settings'); + + + // Media Sources + $adminGroup->group('/sources', function (RouteCollectorProxy $sourcesGroup) { + $sourcesGroup->get('', 'App\Controllers\MediaSourceController:index')->setName('admin.sources.index'); + $sourcesGroup->get('/create', 'App\Controllers\MediaSourceController:create')->setName('admin.sources.create'); + $sourcesGroup->post('', 'App\Controllers\MediaSourceController:store')->setName('admin.sources.store'); + $sourcesGroup->get('/{id:\d+}/edit', 'App\Controllers\MediaSourceController:edit')->setName('admin.sources.edit'); + $sourcesGroup->post('/{id:\d+}', 'App\Controllers\MediaSourceController:update')->setName('admin.sources.update'); + $sourcesGroup->delete('/{id:\d+}', 'App\Controllers\MediaSourceController:destroy')->setName('admin.sources.destroy'); + + // Source sync operations + $sourcesGroup->post('/{id:\d+}/sync', 'App\Controllers\MediaSourceController:startSync')->setName('admin.sources.sync'); + $sourcesGroup->get('/sync/status/{log_id}', 'App\Controllers\MediaSourceController:syncStatus')->setName('admin.sources.sync.status'); + }); + + // Sync Management + $adminGroup->group('/sync', function (RouteCollectorProxy $syncGroup) { + $syncGroup->get('', 'App\Controllers\SyncController:index')->setName('admin.sync.index'); + $syncGroup->post('', 'App\Controllers\SyncController:start')->setName('admin.sync.start'); + $syncGroup->post('/{id:\d+}', AdminController::class . ':syncSource')->setName('admin.sync'); + $syncGroup->get('/status/{id:\d+}', AdminController::class . ':syncStatus')->setName('admin.sync.status'); + $syncGroup->get('/status/{log_id}', 'App\Controllers\SyncController:status')->setName('admin.sync.status'); + $syncGroup->post('/{log_id}/cancel', 'App\Controllers\SyncController:cancel')->setName('admin.sync.cancel'); + $syncGroup->post('/clear-logs', 'App\Controllers\SyncController:clearLogs')->setName('admin.sync.clearLogs'); + + }); + })->add(AdminMiddleware::class);