From 929ee430017d55bc380a44c03f72f4e8d899f110 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Fri, 17 Oct 2025 13:29:28 +0200 Subject: [PATCH] first commit --- .dockerignore | 17 + .env.example | 29 + .gitignore | 144 ++ Dockerfile | 52 + README.md | 301 +++ app/Controllers/AdminController.php | 129 ++ app/Controllers/AdultController.php | 99 + app/Controllers/AuthController.php | 76 + app/Controllers/Controller.php | 25 + app/Controllers/DashboardController.php | 82 + app/Controllers/GameController.php | 95 + app/Controllers/MovieController.php | 91 + app/Controllers/MusicController.php | 72 + app/Controllers/SearchController.php | 68 + app/Controllers/TvShowController.php | 72 + app/Database/Database.php | 183 ++ app/Http/Middleware/AdminMiddleware.php | 33 + app/Http/Middleware/AuthMiddleware.php | 33 + app/Models/AdultVideo.php | 109 ++ app/Models/Game.php | 298 +++ app/Models/Model.php | 108 ++ app/Models/Movie.php | 165 ++ app/Models/Source.php | 103 ++ app/Models/SyncLog.php | 90 + app/Models/User.php | 92 + app/Services/AdultSyncService.php | 211 +++ app/Services/AuthService.php | 121 ++ app/Services/BaseSyncService.php | 141 ++ app/Services/ExophaseSyncService.php | 174 ++ app/Services/JellyfinSyncService.php | 315 ++++ app/Services/StashSyncService.php | 486 +++++ app/Services/SteamSyncService.php | 166 ++ app/Services/XbvrSyncService.php | 257 +++ app/Utils/ImageDownloader.php | 94 + app/helpers.php | 172 ++ check_db.php | 47 + check_sources.php | 31 + check_user.php | 39 + composer.json | 27 + config/database.php | 19 + ...2023_10_15_000001_create_sources_table.php | 34 + .../2023_10_15_000002_create_actors_table.php | 27 + ..._10_15_000002_create_media_types_table.php | 31 + .../2023_10_15_000003_create_games_table.php | 39 + .../2023_10_15_000004_create_movies_table.php | 42 + ...023_10_15_000005_create_tv_shows_table.php | 41 + ..._10_15_000006_create_tv_episodes_table.php | 39 + ...0_15_000007_create_music_artists_table.php | 35 + ...10_15_000008_create_music_albums_table.php | 36 + ...10_15_000009_create_music_tracks_table.php | 37 + ...23_10_15_000010_create_sync_logs_table.php | 35 + .../2023_10_15_000011_create_users_table.php | 31 + ..._10_15_000012_add_game_grouping_fields.php | 36 + ...10_15_000013_create_adult_videos_table.php | 46 + docker-compose.override.yml | 36 + docker-compose.prod.yml | 13 + docker-compose.yml | 49 + docker-start.sh | 35 + docker/nginx.conf | 55 + nginx.conf | 110 ++ package-lock.json | 1607 +++++++++++++++++ package.json | 22 + resources/js/app.js | 20 + resources/scss/app.css | 2 + resources/views/admin/index.twig | 241 +++ resources/views/adult/index.twig | 307 ++++ resources/views/adult/show.twig | 183 ++ resources/views/auth/login.twig | 78 + resources/views/dashboard/index.twig | 330 ++++ resources/views/games/index.twig | 282 +++ resources/views/games/show.twig | 212 +++ resources/views/layouts/app.twig | 96 + resources/views/movies/index.twig | 301 +++ resources/views/movies/show.twig | 181 ++ resources/views/music/index.twig | 70 + resources/views/music/show.twig | 32 + resources/views/search/index.twig | 110 ++ resources/views/tvshows/index.twig | 70 + resources/views/tvshows/show.twig | 32 + routes/web.php | 58 + setup.php | 84 + setup_adult_source.php | 154 ++ test_auth.php | 70 + test_jellyfin.php | 115 ++ vite.config.js | 31 + 85 files changed, 10361 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/Controllers/AdminController.php create mode 100644 app/Controllers/AdultController.php create mode 100644 app/Controllers/AuthController.php create mode 100644 app/Controllers/Controller.php create mode 100644 app/Controllers/DashboardController.php create mode 100644 app/Controllers/GameController.php create mode 100644 app/Controllers/MovieController.php create mode 100644 app/Controllers/MusicController.php create mode 100644 app/Controllers/SearchController.php create mode 100644 app/Controllers/TvShowController.php create mode 100644 app/Database/Database.php create mode 100644 app/Http/Middleware/AdminMiddleware.php create mode 100644 app/Http/Middleware/AuthMiddleware.php create mode 100644 app/Models/AdultVideo.php create mode 100644 app/Models/Game.php create mode 100644 app/Models/Model.php create mode 100644 app/Models/Movie.php create mode 100644 app/Models/Source.php create mode 100644 app/Models/SyncLog.php create mode 100644 app/Models/User.php create mode 100644 app/Services/AdultSyncService.php create mode 100644 app/Services/AuthService.php create mode 100644 app/Services/BaseSyncService.php create mode 100644 app/Services/ExophaseSyncService.php create mode 100644 app/Services/JellyfinSyncService.php create mode 100644 app/Services/StashSyncService.php create mode 100644 app/Services/SteamSyncService.php create mode 100644 app/Services/XbvrSyncService.php create mode 100644 app/Utils/ImageDownloader.php create mode 100644 app/helpers.php create mode 100644 check_db.php create mode 100644 check_sources.php create mode 100644 check_user.php create mode 100644 composer.json create mode 100644 config/database.php create mode 100644 database/migrations/2023_10_15_000001_create_sources_table.php create mode 100644 database/migrations/2023_10_15_000002_create_actors_table.php create mode 100644 database/migrations/2023_10_15_000002_create_media_types_table.php create mode 100644 database/migrations/2023_10_15_000003_create_games_table.php create mode 100644 database/migrations/2023_10_15_000004_create_movies_table.php create mode 100644 database/migrations/2023_10_15_000005_create_tv_shows_table.php create mode 100644 database/migrations/2023_10_15_000006_create_tv_episodes_table.php create mode 100644 database/migrations/2023_10_15_000007_create_music_artists_table.php create mode 100644 database/migrations/2023_10_15_000008_create_music_albums_table.php create mode 100644 database/migrations/2023_10_15_000009_create_music_tracks_table.php create mode 100644 database/migrations/2023_10_15_000010_create_sync_logs_table.php create mode 100644 database/migrations/2023_10_15_000011_create_users_table.php create mode 100644 database/migrations/2023_10_15_000012_add_game_grouping_fields.php create mode 100644 database/migrations/2023_10_15_000013_create_adult_videos_table.php create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker-start.sh create mode 100644 docker/nginx.conf create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 resources/js/app.js create mode 100644 resources/scss/app.css create mode 100644 resources/views/admin/index.twig create mode 100644 resources/views/adult/index.twig create mode 100644 resources/views/adult/show.twig create mode 100644 resources/views/auth/login.twig create mode 100644 resources/views/dashboard/index.twig create mode 100644 resources/views/games/index.twig create mode 100644 resources/views/games/show.twig create mode 100644 resources/views/layouts/app.twig create mode 100644 resources/views/movies/index.twig create mode 100644 resources/views/movies/show.twig create mode 100644 resources/views/music/index.twig create mode 100644 resources/views/music/show.twig create mode 100644 resources/views/search/index.twig create mode 100644 resources/views/tvshows/index.twig create mode 100644 resources/views/tvshows/show.twig create mode 100644 routes/web.php create mode 100644 setup.php create mode 100644 setup_adult_source.php create mode 100644 test_auth.php create mode 100644 test_jellyfin.php create mode 100644 vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a8b36c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +README.md +.env.example +docker-compose.yml +Dockerfile +docker/ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vscode/ +.idea/ +*.log +storage/cache/* +storage/logs/* +!storage/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c3ff028 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +APP_NAME="Media Collector" +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 + +# Database Configuration +DB_CONNECTION=mysql +DB_DATABASE=phpmedialib +DB_HOST=192.168.1.102 +DB_PORT=3306 +DB_USERNAME=phpmedialib +DB_PASSWORD=phpmedialib + +# Session +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +# Cache +CACHE_DRIVER=file + +# Admin User Configuration +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=admin123 + +# Gaming Platform APIs +STEAM_API_KEY=your_steam_api_key_here +EXOPHASE_API_KEY=your_exophase_api_key_here +EXOPHASE_API_URL=https://api.exophase.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65f8744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +# Dependencies +/vendor/ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS generated files +Thumbs.db +ehthumbs.db +Desktop.ini + +# Laravel specific +/storage/app/* +/storage/framework/* +/storage/logs/* +/bootstrap/cache/* +/public/storage + +# PHPUnit +.phpunit.result.cache + +# Composer +composer.lock + +# Database +*.sqlite +*.sqlite3 +*.db + +# Application specific - Downloaded images and media +/public/images/adult_videos/* +/public/images/actors/* +!/public/images/adult_videos/.gitkeep +!/public/images/actors/.gitkeep + +# Backup files +*.bak +*.backup + +# Cache +.cache/ + +# Temporary files +*.tmp +*.temp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c62bef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM php:8.2-fpm + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + libzip-dev \ + zip \ + unzip \ + nodejs \ + npm + +# Clear cache +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install PHP extensions +RUN docker-php-ext-install pdo_mysql pdo_sqlite mbstring exif pcntl bcmath gd zip + +# Get latest Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /var/www + +# Copy composer files +COPY composer.json composer.lock* ./ + +# Install PHP dependencies +RUN composer install --no-dev --optimize-autoloader + +# Copy application code +COPY . . + +# Install Node.js dependencies and build assets +RUN npm install && npm run build + +# Create storage directories +RUN mkdir -p storage/logs storage/cache database + +# Set permissions +RUN chown -R www-data:www-data /var/www \ + && chmod -R 755 /var/www/storage /var/www/database + +# Create PHP-FPM configuration +RUN mkdir -p /var/run/php + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0d994a --- /dev/null +++ b/README.md @@ -0,0 +1,301 @@ +# Media Collector + +A modern PHP application to collect and manage media from various sources like Steam, Jellyfin, Stash, and XBVR. + +## Features + +- **Unified Dashboard**: View all your media in one place with comprehensive statistics +- **Multiple Sources**: Connect to Steam, Jellyfin, Stash, and XBVR +- **Media Types**: Manage games, movies, TV shows, and music with detailed tracking +- **Database-Driven**: Complete database schema with migrations and models +- **Rich Analytics**: Track playtime, watch counts, favorites, and sync status +- **User Authentication**: Secure login system with session management +- **Admin Panel**: Comprehensive admin interface for sync management +- **Real-time Sync**: Live progress tracking and status updates +- **Responsive Design**: Works on desktop and mobile devices +- **Modern Stack**: Built with PHP 8.1+, Slim Framework, Vue.js, and Tailwind CSS + +## Authentication & Admin Features + +### User Management +- **Secure Authentication**: Session-based login system with CSRF protection +- **Role-based Access**: Admin and user roles with appropriate permissions +- **Session Management**: Automatic session handling and cleanup + +### Admin Panel (`/admin`) +- **Source Management**: Configure and manage media sources +- **Sync Control**: Start full or incremental syncs for each source +- **Live Status**: Real-time progress tracking and error reporting +- **Sync History**: View past sync activities and results +- **Error Monitoring**: Comprehensive error logging and debugging + +## Database Schema + +The application includes a comprehensive database schema supporting: + +- **Games**: Steam integration, playtime tracking, completion percentages +- **Movies**: Watch status, ratings, TMDb integration +- **TV Shows & Episodes**: Season/episode tracking, watch progress +- **Music**: Artists, albums, tracks with play counts and favorites +- **Sources**: Integration management for external services +- **Sync Logs**: Synchronization tracking and error reporting +- **Users**: Authentication and role management + +## Prerequisites + +- PHP 8.1 or higher +- Composer (for PHP dependencies) +- Node.js 16+ and npm/yarn (for frontend assets) +- SQLite (or MySQL/PostgreSQL) + +## Docker Installation (Recommended) + +For the easiest setup, use Docker Compose: + +### Prerequisites +- [Docker](https://docker.com/get-started) +- [Docker Compose](https://docs.docker.com/compose/install/) + +### Quick Start +1. **Clone and setup**: + ```bash + git clone + cd media-collector + cp .env.example .env + # Edit .env with your API keys + ``` + +2. **Start the application**: + ```bash + # For production + docker-compose up -d + + # For development (with hot-reload) + docker-compose --profile dev up + ``` + +3. **Access the application**: + - Main app: http://localhost:8000 + - Admin panel: http://localhost:8000/admin + +### Docker Commands +```bash +# View logs +docker-compose logs -f + +# Stop containers +docker-compose down + +# Rebuild containers +docker-compose up --build + +# Access PHP container +docker-compose exec app bash + +# Reset database +docker-compose down -v +docker-compose up -d +``` + +### Docker Architecture +- **App Container**: PHP 8.2-FPM with Composer dependencies +- **Nginx Container**: Web server for static files and reverse proxy +- **Database**: SQLite file stored in persistent volume +- **Networks**: Isolated network for container communication + +## Manual Installation (Alternative) + +1. Clone the repository: + ```bash + git clone [repository-url] + cd media-collector + ``` + +2. Install PHP dependencies: + ```bash + composer install + ``` + +3. Install frontend dependencies: + ```bash + npm install + ``` + +4. Copy and configure the environment file: + ```bash + cp .env.example .env + ``` + + Edit `.env` and configure your settings: + ```bash + APP_NAME="Media Collector" + APP_ENV=local + APP_DEBUG=true + APP_URL=http://localhost:8000 + + # Database Configuration + DB_CONNECTION=sqlite + DB_DATABASE=database/database.sqlite + + # Admin User Configuration (required for initial setup) + ADMIN_USERNAME=admin + ADMIN_EMAIL=admin@example.com + ADMIN_PASSWORD=admin123 + + # Gaming Platform APIs + STEAM_API_KEY=your_steam_api_key_here + EXOPHASE_API_KEY=your_exophase_api_key_here + EXOPHASE_API_URL=https://api.exophase.com + + # Services API Keys + JELLYFIN_API_KEY=your_jellyfin_api_key_here + STASH_API_KEY=your_stash_api_key_here + STASH_URL=http://localhost:9999 + XBVR_URL=http://localhost:9998 + ``` + +5. Set up the database (runs migrations and creates admin user): + ```bash + php setup.php + ``` + +6. Build frontend assets: + ```bash + npm run dev + # or for production: + # npm run build + ``` + +7. Start the development server: + ```bash + php -S localhost:8000 -t public + ``` + +8. **Login to Admin Panel**: + - Open http://localhost:8000/login + - Use the admin credentials created during setup + - Access the admin panel at http://localhost:8000/admin + +## Development + +- Frontend development (with hot-reload): + ```bash + npm run dev + ``` + +- Building for production: + ```bash + npm run build + ``` + +- Running tests: + ```bash + composer test + ``` + +## Project Structure + +``` +media-collector/ +├── app/ # Application code +│ ├── Controllers/ # Request handlers +│ │ ├── AuthController.php # Authentication routes +│ │ ├── AdminController.php # Admin panel routes +│ │ ├── Controller.php # Base controller class +│ │ └── DashboardController.php +│ ├── Database/ # Database connection and configuration +│ │ └── Database.php +│ ├── Http/ # HTTP middleware +│ │ └── Middleware/ # Authentication and admin middleware +│ ├── Models/ # Database models and ORM +│ │ ├── Model.php # Base model class +│ │ ├── Game.php # Game model with Steam integration +│ │ ├── Movie.php # Movie model with TMDb integration +│ │ ├── TvShow.php # TV show model +│ │ ├── TvEpisode.php # TV episode model +│ │ ├── MusicArtist.php # Music artist model +│ │ ├── MusicAlbum.php # Music album model +│ │ ├── MusicTrack.php # Music track model +│ │ ├── Source.php # Source integration model +│ │ ├── SyncLog.php # Synchronization log model +│ │ └── User.php # User authentication model +│ ├── Services/ # Business logic services +│ │ ├── AuthService.php # Authentication service +│ │ ├── BaseSyncService.php # Base sync service class +│ │ ├── SteamSyncService.php # Steam API integration +│ │ ├── JellyfinSyncService.php # Jellyfin API integration +│ │ ├── StashSyncService.php # Stash API integration +│ │ └── XbvrSyncService.php # XBVR API integration +├── config/ # Configuration files +│ └── database.php # Database configuration +├── database/ # Database migrations and seeds +│ ├── migrations/ # Database migration files (11 total) +│ └── seeds/ # Database seeders +├── public/ # Web root +│ └── index.php # Application entry point +├── resources/ +│ ├── js/ # JavaScript files +│ │ └── app.js # Main Vue.js application +│ ├── scss/ # Stylesheets +│ │ └── app.scss # Main stylesheet with Tailwind CSS +│ └── views/ # Twig templates +│ ├── layouts/ +│ │ └── app.twig # Base application layout +│ ├── auth/ +│ │ └── login.twig # Login page +│ ├── dashboard/ +│ │ └── index.twig # Dashboard view +│ └── admin/ +│ └── index.twig # Admin dashboard +├── routes/ # Application routes +│ └── web.php # Web routes definition +├── storage/ # Logs, cache, etc. +│ └── framework/ # Framework-specific storage +├── .env # Environment variables +├── composer.json # PHP dependencies +├── package.json # Node.js dependencies +├── setup.php # Database setup script +└── README.md +``` + +## API Integration + +The application supports integration with: + +- **Steam**: Game library, playtime, achievements +- **Jellyfin**: Movies and TV shows with metadata +- **Stash**: Adult content management +- **Exophase Integration**: Cross-platform gaming achievements and playtime tracking + +Configure your API keys in the `.env` file to enable these integrations. + +## Database Models + +Each media type includes rich model functionality: + +- **Statistics**: Total counts, averages, favorites +- **Relationships**: Proper foreign key relationships +- **Methods**: CRUD operations, special queries, formatting +- **Type Casting**: Automatic data type conversion +- **Error Handling**: Comprehensive error reporting + +## Security Features + +- **CSRF Protection**: All forms include CSRF tokens +- **Session Security**: Secure session handling with timeouts +- **Input Validation**: Comprehensive input sanitization +- **Role-based Access**: Admin-only routes and functionality +- **Password Hashing**: Secure password storage + +## Admin Panel Usage + +1. **Login**: Use admin credentials at `/login` +2. **Access Admin**: Navigate to `/admin` for the admin dashboard +3. **Source Management**: Configure your media sources +4. **Sync Operations**: Start full or incremental syncs +5. **Monitor Progress**: View real-time sync status and results +6. **View History**: Check past sync activities and errors + +## License + +This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..7f066ad --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,129 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $sourceModel = new Source($this->pdo); + $sources = $sourceModel->findAll(); + + $syncLogModel = new SyncLog($this->pdo); + $recentSyncs = SyncLog::getRecent($this->pdo, 10); + + return $this->view->render($response, 'admin/index.twig', [ + 'title' => 'Admin Dashboard', + 'sources' => $sources, + 'recent_syncs' => $recentSyncs + ]); + } + + public function syncSource(Request $request, Response $response, $args) + { + $sourceId = $args['id']; + $syncType = $request->getQueryParams()['type'] ?? 'full'; + + $sourceModel = new Source($this->pdo); + $source = $sourceModel->find($sourceId); + + if (!$source) { + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + // Start sync in background (simplified - in production you'd use queues) + $syncLogId = $this->startSync($source, $syncType); + + return $this->json($response, [ + 'success' => true, + 'sync_log_id' => $syncLogId, + 'message' => 'Sync started successfully' + ]); + } + + public function syncStatus(Request $request, Response $response, $args) + { + $syncLogId = $args['id']; + + $syncLogModel = new SyncLog($this->pdo); + $syncLog = $syncLogModel->find($syncLogId); + + if (!$syncLog) { + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + return $this->json($response, [ + 'id' => $syncLog['id'], + 'status' => $syncLog['status'], + 'sync_type' => $syncLog['sync_type'], + 'total_items' => $syncLog['total_items'], + 'processed_items' => $syncLog['processed_items'], + 'new_items' => $syncLog['new_items'], + 'updated_items' => $syncLog['updated_items'], + 'deleted_items' => $syncLog['deleted_items'], + 'started_at' => $syncLog['started_at'], + 'completed_at' => $syncLog['completed_at'], + 'message' => $syncLog['message'], + 'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [] + ]); + } + + public function sources(Request $request, Response $response, $args) + { + $sourceModel = new Source($this->pdo); + $sources = $sourceModel->findAll(); + + return $this->view->render($response, 'admin/sources.twig', [ + 'title' => 'Source Management', + 'sources' => $sources + ]); + } + + private function startSync(array $source, string $syncType): int + { + // Create appropriate sync service based on source type + switch ($source['name']) { + case 'steam': + $syncService = new SteamSyncService($this->pdo, $source); + break; + case 'jellyfin': + $syncService = new JellyfinSyncService($this->pdo, $source); + break; + case 'stash': + $syncService = new StashSyncService($this->pdo, $source); + break; + case 'adult': + $syncService = new AdultSyncService($this->pdo, $source); + break; + case 'exophase': + $syncService = new ExophaseSyncService($this->pdo, $source); + break; + default: + throw new \Exception('Unsupported source type: ' . $source['name']); + } + + // Start sync (this would typically be queued in production) + return $syncService->startSync($syncType); + } +} diff --git a/app/Controllers/AdultController.php b/app/Controllers/AdultController.php new file mode 100644 index 0000000..a9ae961 --- /dev/null +++ b/app/Controllers/AdultController.php @@ -0,0 +1,99 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + + // Get pagination parameters + $page = max(1, (int)($queryParams['page'] ?? 1)); + $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24))); + + // Get search parameters + $search = trim($queryParams['search'] ?? ''); + + // Get view mode + $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + + // Get adult videos with pagination and search + $adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search); + + // Get total count for pagination + $totalCount = AdultVideo::getTotalCount($this->pdo, $search); + + // Calculate pagination info + $totalPages = ceil($totalCount / $perPage); + $hasNextPage = $page < $totalPages; + $hasPrevPage = $page > 1; + + return $this->view->render($response, 'adult/index.twig', [ + 'title' => 'Adult Videos', + 'movies' => $adultVideos, // Keep same variable name for template compatibility + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'total_items' => $totalCount, + 'has_next' => $hasNextPage, + 'has_prev' => $hasPrevPage, + 'next_page' => $page + 1, + 'prev_page' => $page - 1 + ], + 'search' => $search, + 'view_mode' => $viewMode, + 'view_modes' => ['grid', 'list', 'covers'] + ]); + } + + public function show(Request $request, Response $response, $args) + { + $adultVideoId = (int) $args['id']; + + // Get adult video details + $stmt = $this->pdo->prepare(" + SELECT av.*, s.display_name as source_name + FROM adult_videos av + JOIN sources s ON av.source_id = s.id + WHERE av.id = :id + "); + $stmt->execute(['id' => $adultVideoId]); + $adultVideo = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$adultVideo) { + return $response->withStatus(404); + } + + // Decode metadata for display + $metadata = json_decode($adultVideo['metadata'], true); + + return $this->view->render($response, 'adult/show.twig', [ + 'title' => $adultVideo['title'], + 'movie' => $adultVideo, // Keep same variable name for template compatibility + 'metadata' => $metadata + ]); + } + + private function getAdultSourceId(): ?int + { + $stmt = $this->pdo->prepare("SELECT id FROM sources WHERE name = 'adult' LIMIT 1"); + $stmt->execute(); + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + return $result ? (int) $result['id'] : null; + } +} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..dd7c7a8 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,76 @@ +auth = $auth; + } + + public function showLogin(Request $request, Response $response, $args) + { + // If already logged in, redirect to dashboard + if ($this->auth->isLoggedIn()) { + return $response->withStatus(302)->withHeader('Location', '/'); + } + + return $this->view->render($response, 'auth/login.twig', [ + 'title' => 'Login', + 'csrf_token' => $this->auth->generateCSRFToken() + ]); + } + + public function login(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + $username = $data['username'] ?? ''; + $password = $data['password'] ?? ''; + $csrfToken = $data['csrf_token'] ?? ''; + + // Verify CSRF token + if (!$this->auth->verifyCSRFToken($csrfToken)) { + return $this->view->render($response->withStatus(400), 'auth/login.twig', [ + 'title' => 'Login', + 'error' => 'Invalid CSRF token', + 'csrf_token' => $this->auth->generateCSRFToken() + ]); + } + + // Validate input + if (empty($username) || empty($password)) { + return $this->view->render($response->withStatus(400), 'auth/login.twig', [ + 'title' => 'Login', + 'error' => 'Username and password are required', + 'csrf_token' => $this->auth->generateCSRFToken() + ]); + } + + // Attempt login + if ($this->auth->login($username, $password, $_SERVER['REMOTE_ADDR'] ?? null)) { + return $response->withStatus(302)->withHeader('Location', '/'); + } + + // Login failed + return $this->view->render($response->withStatus(401), 'auth/login.twig', [ + 'title' => 'Login', + 'error' => 'Invalid username or password', + 'csrf_token' => $this->auth->generateCSRFToken() + ]); + } + + public function logout(Request $request, Response $response, $args) + { + $this->auth->logout(); + return $response->withStatus(302)->withHeader('Location', '/login'); + } +} diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php new file mode 100644 index 0000000..5f77bbe --- /dev/null +++ b/app/Controllers/Controller.php @@ -0,0 +1,25 @@ +view = $view; + } + + 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/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..cf02b11 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,82 @@ +view->getEnvironment()->getGlobals()['pdo'] ?? null; + + if (!$pdo) { + return $this->view->render($response, 'dashboard/index.twig', [ + 'title' => 'Dashboard', + 'stats' => [ + 'total_media' => 0, + 'total_games' => 0, + 'total_movies' => 0, + 'total_tv_shows' => 0, + 'total_episodes' => 0, + 'total_music' => 0, + ], + 'error' => 'Database connection not available' + ]); + } + + // Get statistics from models + $gameStats = Game::getStats($pdo); + $movieStats = Movie::getStats($pdo); + $tvShowStats = TvShow::getStats($pdo); + $musicStats = MusicArtist::getStats($pdo); + $syncStats = SyncLog::getStats($pdo); + + // Get recent activity + $recentGames = Game::getRecent($pdo, 5); + $recentMovies = Movie::getRecent($pdo, 5); + $recentSyncs = SyncLog::getRecent($pdo, 5); + + // Calculate total media count + $totalMedia = ($gameStats['total_games'] ?? 0) + + ($movieStats['total_movies'] ?? 0) + + ($tvShowStats['total_shows'] ?? 0) + + ($musicStats['total_artists'] ?? 0); + + $stats = [ + 'total_media' => $totalMedia, + 'total_games' => $gameStats['total_games'] ?? 0, + 'total_movies' => $movieStats['total_movies'] ?? 0, + 'total_tv_shows' => $tvShowStats['total_shows'] ?? 0, + 'total_episodes' => $tvShowStats['total_episodes'] ?? 0, + 'total_music' => $musicStats['total_artists'] ?? 0, + 'total_playtime' => $gameStats['total_playtime'] ?? 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, + ]; + + return $this->view->render($response, 'dashboard/index.twig', [ + 'title' => 'Dashboard', + 'stats' => $stats, + 'recent_games' => $recentGames, + 'recent_movies' => $recentMovies, + 'recent_syncs' => $recentSyncs, + 'sync_stats' => $syncStats + ]); + } +} diff --git a/app/Controllers/GameController.php b/app/Controllers/GameController.php new file mode 100644 index 0000000..846b92d --- /dev/null +++ b/app/Controllers/GameController.php @@ -0,0 +1,95 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + + // Get pagination parameters + $page = max(1, (int)($queryParams['page'] ?? 1)); + $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24))); + + // Get search parameters + $search = trim($queryParams['search'] ?? ''); + + // Get view mode + $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + + // Get games with pagination and search + $games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search); + + // Get total count for pagination + $totalCount = Game::getTotalCount($this->pdo, $search); + + // Calculate pagination info + $totalPages = ceil($totalCount / $perPage); + $hasNextPage = $page < $totalPages; + $hasPrevPage = $page > 1; + + return $this->view->render($response, 'games/index.twig', [ + 'title' => 'Games', + 'games' => $games, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'total_items' => $totalCount, + 'has_next' => $hasNextPage, + 'has_prev' => $hasPrevPage, + 'next_page' => $page + 1, + 'prev_page' => $page - 1 + ], + 'search' => $search, + 'view_mode' => $viewMode, + 'view_modes' => ['grid', 'list', 'covers'] + ]); + } + + public function show(Request $request, Response $response, $args) + { + $gameKey = $args['game_key']; + + // Find the main game entry (could be any platform version) + $stmt = $this->pdo->prepare(" + SELECT g.*, s.display_name as source_name + FROM games g + JOIN sources s ON g.source_id = s.id + WHERE g.game_key = :game_key + LIMIT 1 + "); + $stmt->execute(['game_key' => $gameKey]); + $mainGame = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$mainGame) { + return $response->withStatus(404); + } + + // Get all platform versions + $gameModel = new Game($this->pdo); + $gameModel->id = $mainGame['id']; + $gameModel->game_key = $mainGame['game_key']; + $platformVersions = $gameModel->getPlatformVersions(); + + return $this->view->render($response, 'games/show.twig', [ + 'title' => $mainGame['title'], + 'main_game' => $mainGame, + 'platform_versions' => $platformVersions + ]); + } +} diff --git a/app/Controllers/MovieController.php b/app/Controllers/MovieController.php new file mode 100644 index 0000000..209c3a8 --- /dev/null +++ b/app/Controllers/MovieController.php @@ -0,0 +1,91 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + + // Get pagination parameters + $page = max(1, (int)($queryParams['page'] ?? 1)); + $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24))); + + // Get search parameters + $search = trim($queryParams['search'] ?? ''); + + // Get view mode + $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + + // Get movies with pagination and search + $movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search); + + // Get total count for pagination + $totalCount = Movie::getTotalCount($this->pdo, $search); + + // Calculate pagination info + $totalPages = ceil($totalCount / $perPage); + $hasNextPage = $page < $totalPages; + $hasPrevPage = $page > 1; + + return $this->view->render($response, 'movies/index.twig', [ + 'title' => 'Movies', + 'movies' => $movies, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'total_items' => $totalCount, + 'has_next' => $hasNextPage, + 'has_prev' => $hasPrevPage, + 'next_page' => $page + 1, + 'prev_page' => $page - 1 + ], + 'search' => $search, + 'view_mode' => $viewMode, + 'view_modes' => ['grid', 'list', 'covers'] + ]); + } + + public function show(Request $request, Response $response, $args) + { + $movieId = (int) $args['id']; + + // Get movie details + $stmt = $this->pdo->prepare(" + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + WHERE m.id = :id + "); + $stmt->execute(['id' => $movieId]); + $movie = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$movie) { + return $response->withStatus(404); + } + + // Decode metadata for display + $metadata = json_decode($movie['metadata'], true); + + return $this->view->render($response, 'movies/show.twig', [ + 'title' => $movie['title'], + 'movie' => $movie, + 'metadata' => $metadata + ]); + } +} diff --git a/app/Controllers/MusicController.php b/app/Controllers/MusicController.php new file mode 100644 index 0000000..09e7efe --- /dev/null +++ b/app/Controllers/MusicController.php @@ -0,0 +1,72 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + + // Get pagination parameters + $page = max(1, (int)($queryParams['page'] ?? 1)); + $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24))); + + // Get search parameters + $search = trim($queryParams['search'] ?? ''); + + // Get view mode + $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + + // For now, return empty arrays since Music isn't implemented yet + $music = []; + $totalCount = 0; + + // Calculate pagination info + $totalPages = 0; + $hasNextPage = false; + $hasPrevPage = false; + + return $this->view->render($response, 'music/index.twig', [ + 'title' => 'Music', + 'music' => $music, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'total_items' => $totalCount, + 'has_next' => $hasNextPage, + 'has_prev' => $hasPrevPage, + 'next_page' => $page + 1, + 'prev_page' => $page - 1 + ], + 'search' => $search, + 'view_mode' => $viewMode, + 'view_modes' => ['grid', 'list', 'covers'] + ]); + } + + public function show(Request $request, Response $response, $args) + { + $musicId = (int) $args['id']; + + // For now, return a placeholder since Music isn't implemented yet + return $this->view->render($response, 'music/show.twig', [ + 'title' => 'Music Details', + 'music' => ['id' => $musicId, 'title' => 'Coming Soon'], + 'message' => 'Music details page is not yet implemented.' + ]); + } +} diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php new file mode 100644 index 0000000..be54cde --- /dev/null +++ b/app/Controllers/SearchController.php @@ -0,0 +1,68 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + $search = trim($queryParams['q'] ?? ''); + + if (empty($search)) { + return $this->view->render($response, 'search/index.twig', [ + 'title' => 'Search', + 'search' => $search, + 'results' => [] + ]); + } + + // Search across different media types + $results = []; + + // Search movies (including adult videos) + $movieStmt = $this->pdo->prepare(" + SELECT m.*, s.display_name as source_name, 'movie' as type + FROM movies m + JOIN sources s ON m.source_id = s.id + WHERE (m.title LIKE :search OR m.overview LIKE :search) + ORDER BY m.title + LIMIT 20 + "); + $searchParam = "%{$search}%"; + $movieStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR); + $movieStmt->execute(); + $results['movies'] = $movieStmt->fetchAll(\PDO::FETCH_ASSOC); + + // Search games + $gameStmt = $this->pdo->prepare(" + SELECT g.*, s.display_name as source_name, 'game' as type + FROM games g + JOIN sources s ON g.source_id = s.id + WHERE (g.name LIKE :search OR g.description LIKE :search) + ORDER BY g.name + LIMIT 20 + "); + $gameStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR); + $gameStmt->execute(); + $results['games'] = $gameStmt->fetchAll(\PDO::FETCH_ASSOC); + + return $this->view->render($response, 'search/index.twig', [ + 'title' => 'Search Results', + 'search' => $search, + 'results' => $results + ]); + } +} diff --git a/app/Controllers/TvShowController.php b/app/Controllers/TvShowController.php new file mode 100644 index 0000000..62e4819 --- /dev/null +++ b/app/Controllers/TvShowController.php @@ -0,0 +1,72 @@ +pdo = $pdo; + } + + public function index(Request $request, Response $response, $args) + { + $queryParams = $request->getQueryParams(); + + // Get pagination parameters + $page = max(1, (int)($queryParams['page'] ?? 1)); + $perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24))); + + // Get search parameters + $search = trim($queryParams['search'] ?? ''); + + // Get view mode + $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers + + // For now, return empty arrays since TV Shows aren't implemented yet + $tvshows = []; + $totalCount = 0; + + // Calculate pagination info + $totalPages = 0; + $hasNextPage = false; + $hasPrevPage = false; + + return $this->view->render($response, 'tvshows/index.twig', [ + 'title' => 'TV Shows', + 'tvshows' => $tvshows, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + 'total_items' => $totalCount, + 'has_next' => $hasNextPage, + 'has_prev' => $hasPrevPage, + 'next_page' => $page + 1, + 'prev_page' => $page - 1 + ], + 'search' => $search, + 'view_mode' => $viewMode, + 'view_modes' => ['grid', 'list', 'covers'] + ]); + } + + public function show(Request $request, Response $response, $args) + { + $tvShowId = (int) $args['id']; + + // For now, return a placeholder since TV Shows aren't implemented yet + return $this->view->render($response, 'tvshows/show.twig', [ + 'title' => 'TV Show Details', + 'tvshow' => ['id' => $tvShowId, 'title' => 'Coming Soon'], + 'message' => 'TV show details page is not yet implemented.' + ]); + } +} diff --git a/app/Database/Database.php b/app/Database/Database.php new file mode 100644 index 0000000..e4601c1 --- /dev/null +++ b/app/Database/Database.php @@ -0,0 +1,183 @@ +getConnection()->getPdo(); + } + + public static function getCapsule(): Capsule + { + if (self::$capsule === null) { + self::connect(); + } + + return self::$capsule; + } + + private static function connect(): void + { + self::$capsule = new Capsule(); + + self::$capsule->addConnection([ + 'driver' => self::$config['driver'] ?? 'sqlite', + 'host' => self::$config['host'] ?? '127.0.0.1', + 'port' => self::$config['port'] ?? 3306, + 'database' => self::$config['database'] ?? '', + 'username' => self::$config['username'] ?? '', + 'password' => self::$config['password'] ?? '', + 'charset' => self::$config['charset'] ?? 'utf8mb4', + 'prefix' => self::$config['prefix'] ?? '', + 'schema' => self::$config['schema'] ?? 'public', + ]); + + // Set as global for Eloquent + self::$capsule->setAsGlobal(); + + // Boot Eloquent (for schema operations) + self::$capsule->bootEloquent(); + + // Set up facade application + if (!self::$capsule->getContainer()->bound('app')) { + self::$capsule->getContainer()->instance('app', self::$capsule->getContainer()); + } + } + + public static function migrate(): void + { + $capsule = self::getCapsule(); + + // Create migrations table if it doesn't exist + if (!$capsule->schema()->hasTable('migrations')) { + $capsule->schema()->create('migrations', function ($table) { + $table->unsignedInteger('id', true); // Primary key with AUTO_INCREMENT + $table->string('migration'); + $table->integer('batch'); + $table->timestamps(); + }); + } + + // Get list of migration files + $migrationFiles = glob(__DIR__ . '/../../database/migrations/*.php'); + + foreach ($migrationFiles as $file) { + $migrationName = basename($file, '.php'); + + // Check if migration has already been run + $ran = $capsule->table('migrations')->where('migration', $migrationName)->exists(); + + if ($ran) { + echo "Migration {$migrationName} already run. Skipping.\n"; + continue; + } + + // Run the migration + echo "Running migration {$migrationName}\n"; + + // Include the migration file to make the class available + require_once $file; + + $className = self::getMigrationClassName($file); + $migration = new $className(); + $migration->up(); + + // Record the migration + $capsule->table('migrations')->insert([ + 'migration' => $migrationName, + 'batch' => 1 + ]); + } + } + + private static function getMigrationClassName(string $file): string + { + $content = file_get_contents($file); + + // Extract class name from PHP file + if (preg_match('/class\s+(\w+)\s+extends\s+Migration/', $content, $matches)) { + return $matches[1]; + } + + // Fallback: convert filename to class name + $filename = basename($file, '.php'); + return str_replace(' ', '', ucwords(str_replace('_', ' ', $filename))); + } + + public static function seed(): void + { + $pdo = self::getInstance(); + + // Seed media types + $mediaTypes = [ + ['name' => 'games', 'display_name' => 'Games', 'icon' => 'game-controller'], + ['name' => 'movies', 'display_name' => 'Movies', 'icon' => 'film'], + ['name' => 'tv_shows', 'display_name' => 'TV Shows', 'icon' => 'tv'], + ['name' => 'music', 'display_name' => 'Music', 'icon' => 'musical-notes'] + ]; + + foreach ($mediaTypes as $type) { + $pdo->prepare("INSERT IGNORE INTO media_types (name, display_name, icon) VALUES (?, ?, ?)") + ->execute([$type['name'], $type['display_name'], $type['icon']]); + } + + // Seed sources + $sources = [ + ['name' => 'steam', 'display_name' => 'Steam', 'api_url' => null, 'api_key' => null], + ['name' => 'jellyfin', 'display_name' => 'Jellyfin', 'api_url' => null, 'api_key' => null], + ['name' => 'stash', 'display_name' => 'Stash', 'api_url' => null, 'api_key' => null], + ['name' => 'xbvr', 'display_name' => 'XBVR', 'api_url' => null, 'api_key' => null] + ]; + + foreach ($sources as $source) { + $pdo->prepare("INSERT IGNORE INTO sources (name, display_name, api_url, api_key) VALUES (?, ?, ?, ?)") + ->execute([$source['name'], $source['display_name'], $source['api_url'], $source['api_key']]); + } + } + + public static function reset(): void + { + $pdo = self::getInstance(); + + // Drop all tables (in reverse order due to foreign keys) + $tables = [ + 'sync_logs', + 'music_tracks', + 'music_albums', + 'music_artists', + 'tv_episodes', + 'tv_shows', + 'movies', + 'games', + 'sources', + 'media_types', + 'migrations' + ]; + + foreach ($tables as $table) { + try { + $pdo->exec("DROP TABLE IF EXISTS {$table}"); + } catch (PDOException $e) { + // Ignore errors if table doesn't exist + } + } + } +} diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..36456a8 --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,33 @@ +auth = $auth; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Check if user is logged in and is admin + if (!$this->auth->isLoggedIn() || !$this->auth->isAdmin()) { + $response = new \Slim\Psr7\Response(); + return $response->withStatus(403)->withHeader('Content-Type', 'application/json'); + } + + // Add user to request attributes + $request = $request->withAttribute('user', $this->auth->getCurrentUser()); + + return $handler->handle($request); + } +} diff --git a/app/Http/Middleware/AuthMiddleware.php b/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..be8c922 --- /dev/null +++ b/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,33 @@ +auth = $auth; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Check if user is logged in + if (!$this->auth->isLoggedIn()) { + $response = new \Slim\Psr7\Response(); + return $response->withStatus(302)->withHeader('Location', '/login'); + } + + // Add user to request attributes + $request = $request->withAttribute('user', $this->auth->getCurrentUser()); + + return $handler->handle($request); + } +} diff --git a/app/Models/AdultVideo.php b/app/Models/AdultVideo.php new file mode 100644 index 0000000..d823493 --- /dev/null +++ b/app/Models/AdultVideo.php @@ -0,0 +1,109 @@ +prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + if (!empty($search)) { + $stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + public static function getTotalCount(\PDO $pdo, string $search = ''): int + { + $whereClause = ''; + $params = []; + + if (!empty($search)) { + $whereClause = "WHERE (title LIKE :search OR overview LIKE :search)"; + $params['search'] = "%{$search}%"; + } + + $sql = "SELECT COUNT(*) as count FROM adult_videos {$whereClause}"; + + $stmt = $pdo->prepare($sql); + + if (!empty($search)) { + $stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR); + } + + $stmt->execute(); + return (int) $stmt->fetch(\PDO::FETCH_ASSOC)['count']; + } + + public function markAsWatched(): bool + { + $stmt = $this->pdo->prepare("UPDATE adult_videos SET watched = 1, watch_count = watch_count + 1, updated_at = NOW() WHERE id = :id"); + return $stmt->execute(['id' => $this->id]); + } + + public function markAsUnwatched(): bool + { + $stmt = $this->pdo->prepare("UPDATE adult_videos SET watched = 0, updated_at = NOW() WHERE id = :id"); + return $stmt->execute(['id' => $this->id]); + } + + public function toggleFavorite(): bool + { + $stmt = $this->pdo->prepare("UPDATE adult_videos SET is_favorite = !is_favorite, updated_at = NOW() WHERE id = :id"); + return $stmt->execute(['id' => $this->id]); + } + + public function source(): ?Source + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php new file mode 100644 index 0000000..96bc988 --- /dev/null +++ b/app/Models/Game.php @@ -0,0 +1,298 @@ + 'float', + 'playtime_minutes' => 'int', + 'completion_percentage' => 'int', + 'is_installed' => 'bool', + 'is_favorite' => 'bool', + 'release_date' => 'date', + 'last_played_at' => 'datetime', + 'platform_achievements' => 'array', + 'platform_stats' => 'array' + ]; + + public function source() + { + return new Source($this->pdo); + } + + public function markAsPlayed(int $minutes = 60): bool + { + $this->playtime_minutes += $minutes; + $this->last_played_at = date('Y-m-d H:i:s'); + return $this->update($this->id, [ + 'playtime_minutes' => $this->playtime_minutes, + 'last_played_at' => $this->last_played_at + ]); + } + + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + public function toggleInstalled(): bool + { + return $this->update($this->id, [ + 'is_installed' => !$this->is_installed + ]); + } + + public function updateCompletion(int $percentage): bool + { + return $this->update($this->id, [ + 'completion_percentage' => min(100, max(0, $percentage)) + ]); + } + + public function updateRating(float $rating): bool + { + return $this->update($this->id, [ + 'rating' => min(10.0, max(0.0, $rating)) + ]); + } + + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_games, + SUM(playtime_minutes) as total_playtime, + AVG(rating) as avg_rating, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_games, + COUNT(CASE WHEN is_installed = 1 THEN 1 END) as installed_games, + AVG(completion_percentage) as avg_completion + FROM games + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + public static function getRecent(\PDO $pdo, int $limit = 10): array + { + $stmt = $pdo->prepare(" + SELECT g.*, s.display_name as source_name + FROM games g + JOIN sources s ON g.source_id = s.id + WHERE g.last_played_at IS NOT NULL + ORDER BY g.last_played_at DESC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Generate a game key for grouping games across platforms + */ + public static function generateGameKey(string $title, string $platform = null): string + { + // Normalize title for consistent grouping + $normalized = strtolower(trim($title)); + $normalized = preg_replace('/[^\w\s]/', '', $normalized); // Remove special characters + $normalized = preg_replace('/\s+/', ' ', $normalized); // Normalize whitespace + $normalized = trim($normalized); + + // Add platform to make it unique if provided + if ($platform) { + $normalized .= ' ' . strtolower($platform); + } + + return $normalized; + } + + /** + * Find all platform versions of a game + */ + public function getPlatformVersions(): array + { + if (!$this->game_key) { + return []; + } + + $stmt = $this->pdo->prepare(" + SELECT g.*, s.display_name as source_name + FROM games g + JOIN sources s ON g.source_id = s.id + WHERE g.game_key = :game_key + ORDER BY g.platform, g.source_id + "); + $stmt->execute(['game_key' => $this->game_key]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get games grouped by title for display + */ + public static function getGroupedGames(\PDO $pdo, int $limit = 50): array + { + $stmt = $pdo->prepare(" + SELECT + game_key, + title, + COUNT(*) as platform_count, + GROUP_CONCAT(DISTINCT platform ORDER BY platform) as platforms, + GROUP_CONCAT(DISTINCT source_id ORDER BY source_id) as source_ids, + MAX(image_url) as image_url, + MAX(last_played_at) as last_played_at, + SUM(playtime_minutes) as total_playtime, + MAX(completion_percentage) as max_completion, + GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres + FROM games + WHERE game_key IS NOT NULL + GROUP BY game_key, title + ORDER BY last_played_at DESC, total_playtime DESC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + $games = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Enhance each game with platform details + foreach ($games as &$game) { + $game['platforms'] = array_unique(explode(',', $game['platforms'])); + $game['source_ids'] = array_unique(explode(',', $game['source_ids'])); + $game['genres'] = array_unique(array_filter(explode(',', $game['genres']))); + } + + return $games; + } + + /** + * Update platform-specific achievements + */ + public function updatePlatformAchievements(array $achievements): bool + { + return $this->update($this->id, [ + 'platform_achievements' => json_encode($achievements) + ]); + } + + /** + * Update platform-specific statistics + */ + public function updatePlatformStats(array $stats): bool + { + return $this->update($this->id, [ + 'platform_stats' => json_encode($stats) + ]); + } + + /** + * Get platform-specific achievements + */ + public function getPlatformAchievements(): array + { + return $this->platform_achievements ?? []; + } + + /** + * Get platform-specific statistics + */ + public function getPlatformStats(): array + { + return $this->platform_stats ?? []; + } + + /** + * Get total count of games for pagination + */ + public static function getTotalCount(\PDO $pdo, string $search = ''): int + { + $sql = "SELECT COUNT(*) as count FROM games"; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return (int) $stmt->fetch()['count']; + } + + /** + * Get grouped games with pagination and search support + */ + public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array + { + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT + game_key, + title, + COUNT(*) as platform_count, + GROUP_CONCAT(DISTINCT platform ORDER BY platform) as platforms, + GROUP_CONCAT(DISTINCT source_id ORDER BY source_id) as source_ids, + MAX(image_url) as image_url, + MAX(last_played_at) as last_played_at, + SUM(playtime_minutes) as total_playtime, + MAX(completion_percentage) as max_completion, + GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres + FROM games + WHERE game_key IS NOT NULL + "; + + $params = []; + + if (!empty($search)) { + $sql .= " AND title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + foreach ($params as $key => $value) { + $stmt->bindValue(":{$key}", $value); + } + + $stmt->execute(); + $games = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Enhance each game with platform details + foreach ($games as &$game) { + $game['platforms'] = array_unique(explode(',', $game['platforms'])); + $game['source_ids'] = array_unique(explode(',', $game['source_ids'])); + $game['genres'] = array_unique(array_filter(explode(',', $game['genres']))); + } + + return $games; + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..078fe67 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,108 @@ +pdo = $pdo; + } + + public function find(int $id): ?array + { + $stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = :id"); + $stmt->execute(['id' => $id]); + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + public function findAll(array $conditions = []): array + { + $whereClause = $this->buildWhereClause($conditions); + $sql = "SELECT * FROM {$this->table} {$whereClause['sql']}"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($whereClause['params']); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + public function create(array $data): ?int + { + $filteredData = array_intersect_key($data, array_flip($this->fillable)); + $columns = array_keys($filteredData); + $placeholders = array_map(fn($col) => ":$col", $columns); + $sql = "INSERT INTO {$this->table} (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($filteredData); + return (int) $this->pdo->lastInsertId(); + } + + public function update(int $id, array $data): bool + { + $filteredData = array_intersect_key($data, array_flip($this->fillable)); + if (empty($filteredData)) { + return false; + } + + $setClause = array_map(fn($col) => "$col = :$col", array_keys($filteredData)); + $sql = "UPDATE {$this->table} SET " . implode(', ', $setClause) . " WHERE id = :id"; + $filteredData['id'] = $id; + + $stmt = $this->pdo->prepare($sql); + return $stmt->execute($filteredData); + } + + public function delete(int $id): bool + { + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = :id"); + return $stmt->execute(['id' => $id]); + } + + public function count(array $conditions = []): int + { + $whereClause = $this->buildWhereClause($conditions); + $sql = "SELECT COUNT(*) FROM {$this->table} {$whereClause['sql']}"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($whereClause['params']); + return (int) $stmt->fetchColumn(); + } + + protected function buildWhereClause(array $conditions): array + { + if (empty($conditions)) { + return ['sql' => '', 'params' => []]; + } + + $parts = []; + $params = []; + + foreach ($conditions as $column => $value) { + if (is_array($value)) { + $parts[] = "$column IN (" . implode(', ', array_map(fn($k) => ":$column$k", array_keys($value))) . ")"; + foreach ($value as $k => $v) { + $params["$column$k"] = $v; + } + } else { + $parts[] = "$column = :$column"; + $params[$column] = $value; + } + } + + return [ + 'sql' => 'WHERE ' . implode(' AND ', $parts), + 'params' => $params + ]; + } + + public function getTable(): string + { + return $this->table; + } +} diff --git a/app/Models/Movie.php b/app/Models/Movie.php new file mode 100644 index 0000000..4b1821b --- /dev/null +++ b/app/Models/Movie.php @@ -0,0 +1,165 @@ + 'int', + 'rating' => 'float', + 'watched' => 'bool', + 'watch_count' => 'int', + 'is_favorite' => 'bool', + 'release_date' => 'date', + 'last_watched_at' => 'datetime' + ]; + + public function source() + { + return new Source($this->pdo); + } + + public function markAsWatched(): bool + { + $this->watched = true; + $this->watch_count += 1; + $this->last_watched_at = date('Y-m-d H:i:s'); + return $this->update($this->id, [ + 'watched' => $this->watched, + 'watch_count' => $this->watch_count, + 'last_watched_at' => $this->last_watched_at + ]); + } + + public function markAsUnwatched(): bool + { + $this->watched = false; + return $this->update($this->id, [ + 'watched' => $this->watched + ]); + } + + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + public function updateRating(float $rating): bool + { + return $this->update($this->id, [ + 'rating' => min(10.0, max(0.0, $rating)) + ]); + } + + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_movies, + COUNT(CASE WHEN watched = 1 THEN 1 END) as watched_movies, + SUM(watch_count) as total_watches, + AVG(rating) as avg_rating, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_movies, + SUM(runtime_minutes) as total_runtime + FROM movies + "); + return $stmt->fetch(PDO::FETCH_ASSOC); + } + + public static function getRecent(\PDO $pdo, int $limit = 10): array + { + $stmt = $pdo->prepare(" + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + WHERE m.last_watched_at IS NOT NULL + ORDER BY m.last_watched_at DESC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public static function getTotalCount(\PDO $pdo, string $search = ''): int + { + $sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id"; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE m.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return (int) $stmt->fetch()['count']; + } + + public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array + { + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + "; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE m.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $sql .= " ORDER BY m.title ASC LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + foreach ($params as $key => $value) { + $stmt->bindValue(":{$key}", $value); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + public static function getAll(\PDO $pdo, int $limit = 100): array + { + $stmt = $pdo->prepare(" + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + ORDER BY m.title ASC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } +} diff --git a/app/Models/Source.php b/app/Models/Source.php new file mode 100644 index 0000000..18c6a22 --- /dev/null +++ b/app/Models/Source.php @@ -0,0 +1,103 @@ +pdo->prepare("SELECT * FROM games WHERE source_id = :source_id"); + $stmt->execute(['source_id' => $this->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function movies(): array + { + $stmt = $this->pdo->prepare("SELECT * FROM movies WHERE source_id = :source_id"); + $stmt->execute(['source_id' => $this->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function tvShows(): array + { + $stmt = $this->pdo->prepare("SELECT * FROM tv_shows WHERE source_id = :source_id"); + $stmt->execute(['source_id' => $this->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function musicArtists(): array + { + $stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE source_id = :source_id"); + $stmt->execute(['source_id' => $this->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function getSyncLogs(): array + { + $stmt = $this->pdo->prepare("SELECT * FROM sync_logs WHERE source_id = :source_id ORDER BY created_at DESC"); + $stmt->execute(['source_id' => $this->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function createSyncLog(string $syncType, string $status): int + { + $data = [ + 'source_id' => $this->id, + 'sync_type' => $syncType, + 'status' => $status, + 'total_items' => 0, + 'processed_items' => 0, + 'new_items' => 0, + 'updated_items' => 0, + 'deleted_items' => 0, + 'started_at' => date('Y-m-d H:i:s') + ]; + + $columns = array_keys($data); + $placeholders = array_map(fn($col) => ":$col", $columns); + $sql = "INSERT INTO sync_logs (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($data); + + return (int) $this->pdo->lastInsertId(); + } + + public function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool + { + $data = [ + 'status' => $status, + 'processed_items' => $stats['processed_items'] ?? 0, + 'new_items' => $stats['new_items'] ?? 0, + 'updated_items' => $stats['updated_items'] ?? 0, + 'deleted_items' => $stats['deleted_items'] ?? 0, + 'completed_at' => date('Y-m-d H:i:s') + ]; + + if (!empty($stats['errors'])) { + $data['errors'] = json_encode($stats['errors']); + } + + if (!empty($stats['message'])) { + $data['message'] = $stats['message']; + } + + $setClause = array_map(fn($col) => "$col = :$col", array_keys($data)); + $sql = "UPDATE sync_logs SET " . implode(', ', $setClause) . " WHERE id = :id"; + $data['id'] = $syncLogId; + + $stmt = $this->pdo->prepare($sql); + return $stmt->execute($data); + } +} diff --git a/app/Models/SyncLog.php b/app/Models/SyncLog.php new file mode 100644 index 0000000..3d5fd4d --- /dev/null +++ b/app/Models/SyncLog.php @@ -0,0 +1,90 @@ + 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime' + ]; + + public static function getRecent(\PDO $pdo, int $limit = 10): array + { + $stmt = $pdo->prepare(" + SELECT sl.*, s.display_name as source_name + FROM sync_logs sl + JOIN sources s ON sl.source_id = s.id + ORDER BY sl.created_at DESC + LIMIT :limit + "); + $stmt->execute(['limit' => $limit]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + public function source() + { + return new Source($this->pdo); + } + + public function markAsStarted(): void + { + $this->update($this->id, [ + 'status' => 'started', + 'started_at' => date('Y-m-d H:i:s'), + 'message' => null, + 'errors' => null + ]); + } + + public function markAsCompleted(array $stats = []): void + { + $data = [ + 'status' => 'completed', + 'completed_at' => date('Y-m-d H:i:s') + ]; + + if (!empty($stats)) { + $data = array_merge($data, $stats); + } + + $this->update($this->id, $data); + } + + public function markAsFailed(string $errorMessage, array $errors = []): void + { + $this->update($this->id, [ + 'status' => 'failed', + 'completed_at' => date('Y-m-d H:i:s'), + 'message' => $errorMessage, + 'errors' => !empty($errors) ? json_encode($errors) : null + ]); + } + + public function updateProgress(int $processed, int $new = 0, int $updated = 0, int $deleted = 0): void + { + $this->update($this->id, [ + 'processed_items' => $processed, + 'new_items' => $new, + 'updated_items' => $updated, + 'deleted_items' => $deleted + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..2c23111 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,92 @@ + 'bool', + 'last_login_at' => 'datetime' + ]; + + public function setPassword(string $password): void + { + $this->password = password_hash($password, PASSWORD_DEFAULT); + } + + public function verifyPassword(string $password): bool + { + return password_verify($password, $this->password); + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function updateLastLogin(string $ip = null): bool + { + return $this->update($this->id, [ + 'last_login_at' => date('Y-m-d H:i:s'), + 'login_ip' => $ip + ]); + } + + public static function findByUsername(\PDO $pdo, string $username): ?array + { + $stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND is_active = 1"); + $stmt->execute(['username' => $username]); + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + public static function findByEmail(\PDO $pdo, string $email): ?array + { + $stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND is_active = 1"); + $stmt->execute(['email' => $email]); + return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + public static function createAdmin(\PDO $pdo, string $username, string $email, string $password): int + { + $data = [ + 'username' => $username, + 'email' => $email, + 'role' => 'admin', + 'is_active' => true + ]; + + $userModel = new self($pdo); + $userModel->setPassword($password); + $data['password'] = $userModel->password; + + return $userModel->create($data); + } + + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_users, + COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_users, + COUNT(CASE WHEN last_login_at IS NOT NULL THEN 1 END) as active_users + FROM users + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } +} diff --git a/app/Services/AdultSyncService.php b/app/Services/AdultSyncService.php new file mode 100644 index 0000000..7e2ae2e --- /dev/null +++ b/app/Services/AdultSyncService.php @@ -0,0 +1,211 @@ +xbvrSource = $this->findSourceByName('xbvr'); + $this->stashSource = $this->findSourceByName('stash'); + + $this->httpClient = new Client([ + 'timeout' => 60, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'Content-Type' => 'application/json' + ] + ]); + } + + private function findSourceByName(string $name): ?array + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE name = :name AND is_active = 1 LIMIT 1"); + $stmt->execute(['name' => $name]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + protected function executeSync(string $syncType): void + { + if (!$this->xbvrSource && !$this->stashSource) { + throw new Exception('No active XBVR or Stash sources found'); + } + + $this->logProgress('Starting adult content library sync...'); + + // Sync XBVR content + if ($this->xbvrSource) { + $this->syncXbvrContent(); + } + + // Sync Stash content + if ($this->stashSource) { + $this->syncStashContent(); + } + + $this->logProgress("Processed {$this->processedCount} adult content items"); + } + + private function syncXbvrContent(): void + { + if (!$this->xbvrSource) return; + + try { + $xbvrService = new XbvrSyncService($this->pdo, $this->xbvrSource); + $xbvrService->startSync('full'); + + $this->logProgress("XBVR content synced directly to adult videos"); + } catch (Exception $e) { + $this->logProgress("Error syncing XBVR content: " . $e->getMessage()); + } + } + + private function syncStashContent(): void + { + if (!$this->stashSource) return; + + try { + $stashService = new StashSyncService($this->pdo, $this->stashSource); + $stashService->startSync('full'); + + $this->logProgress("Stash content synced directly to adult videos"); + } catch (Exception $e) { + $this->logProgress("Error syncing Stash content: " . $e->getMessage()); + } + } + + private function migrateXbvrToAdultVideos(): void + { + // Get all movies from XBVR source + $stmt = $this->pdo->prepare(" + SELECT m.* FROM movies m + WHERE m.source_id = :xbvr_source_id + "); + $stmt->execute(['xbvr_source_id' => $this->xbvrSource['id']]); + $xbvrMovies = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($xbvrMovies as $movie) { + // Check if adult video already exists + $existingStmt = $this->pdo->prepare(" + SELECT id FROM adult_videos + WHERE source_id = :adult_source_id AND external_id = :external_id + LIMIT 1 + "); + $existingStmt->execute([ + 'adult_source_id' => $this->source['id'], + 'external_id' => $movie['id'] + ]); + + if (!$existingStmt->fetch(PDO::FETCH_ASSOC)) { + // Create adult video from XBVR movie + $adultVideoData = [ + 'title' => $movie['title'], + 'overview' => $movie['overview'], + 'poster_url' => $movie['poster_url'], + 'backdrop_url' => $movie['backdrop_url'], + 'rating' => $movie['rating'], + 'runtime_minutes' => $movie['runtime_minutes'], + 'release_date' => $movie['release_date'], + 'director' => $movie['director'], + 'writer' => $movie['writer'], + 'cast' => $movie['cast'], + 'genre' => $movie['genre'], + 'metadata' => $movie['metadata'], + 'watched' => $movie['watched'], + 'watch_count' => $movie['watch_count'], + 'is_favorite' => $movie['is_favorite'], + 'source_id' => $this->source['id'], + 'external_id' => $movie['id'], + 'created_at' => $movie['created_at'], + 'updated_at' => $movie['updated_at'] + ]; + + $columns = array_keys($adultVideoData); + $placeholders = array_map(fn($col) => ":$col", $columns); + $sql = "INSERT INTO adult_videos (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + + $insertStmt = $this->pdo->prepare($sql); + $insertStmt->execute($adultVideoData); + + $this->processedCount++; + } + } + + $this->logProgress("Processed {$this->processedCount} XBVR items"); + } + + private function migrateStashToAdultVideos(): void + { + // Get all movies from Stash source + $stmt = $this->pdo->prepare(" + SELECT m.* FROM movies m + WHERE m.source_id = :stash_source_id + "); + $stmt->execute(['stash_source_id' => $this->stashSource['id']]); + $stashMovies = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($stashMovies as $movie) { + // Check if adult video already exists + $existingStmt = $this->pdo->prepare(" + SELECT id FROM adult_videos + WHERE source_id = :adult_source_id AND external_id = :external_id + LIMIT 1 + "); + $existingStmt->execute([ + 'adult_source_id' => $this->source['id'], + 'external_id' => $movie['id'] + ]); + + if (!$existingStmt->fetch(PDO::FETCH_ASSOC)) { + // Create adult video from Stash movie + $adultVideoData = [ + 'title' => $movie['title'], + 'overview' => $movie['overview'], + 'poster_url' => $movie['poster_url'], + 'backdrop_url' => $movie['backdrop_url'], + 'rating' => $movie['rating'], + 'runtime_minutes' => $movie['runtime_minutes'], + 'release_date' => $movie['release_date'], + 'director' => $movie['director'], + 'writer' => $movie['writer'], + 'cast' => $movie['cast'], + 'genre' => $movie['genre'], + 'metadata' => $movie['metadata'], + 'watched' => $movie['watched'], + 'watch_count' => $movie['watch_count'], + 'is_favorite' => $movie['is_favorite'], + 'source_id' => $this->source['id'], + 'external_id' => $movie['id'], + 'created_at' => $movie['created_at'], + 'updated_at' => $movie['updated_at'] + ]; + + $columns = array_keys($adultVideoData); + $placeholders = array_map(fn($col) => ":$col", $columns); + $sql = "INSERT INTO adult_videos (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + + $insertStmt = $this->pdo->prepare($sql); + $insertStmt->execute($adultVideoData); + + $this->processedCount++; + } + } + + $this->logProgress("Processed {$this->processedCount} Stash items"); + } +} diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php new file mode 100644 index 0000000..5fc3977 --- /dev/null +++ b/app/Services/AuthService.php @@ -0,0 +1,121 @@ +pdo = $pdo; + $this->checkSession(); + } + + public function checkSession(): void + { + if (isset($_SESSION['user_id'])) { + $user = User::findByUsername($this->pdo, $_SESSION['username']); + + if (!$user || !$user['is_active']) { + $this->logout(); + } else { + $this->user = $user; + } + } + } + + public function login(string $username, string $password, string $ip = null): bool + { + $user = User::findByUsername($this->pdo, $username); + + if (!$user || !$user['is_active']) { + return false; + } + + // Verify password directly using the hash from database + if (!password_verify($password, $user['password'])) { + return false; + } + + // Set session + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + + // Update last login + $this->updateUserLastLogin($user['id'], $ip); + + $this->user = $user; + return true; + } + + public function logout(): void + { + unset($_SESSION['user_id']); + unset($_SESSION['username']); + unset($_SESSION['role']); + $this->user = null; + } + + public function getCurrentUser(): ?array + { + return $this->user; + } + + public function isLoggedIn(): bool + { + return $this->user !== null; + } + + public function isAdmin(): bool + { + return $this->isLoggedIn() && $this->user['role'] === 'admin'; + } + + public function requireLogin(): void + { + if (!$this->isLoggedIn()) { + header('Location: /login'); + exit; + } + } + + public function requireAdmin(): void + { + $this->requireLogin(); + + if (!$this->isAdmin()) { + http_response_code(403); + echo 'Access denied. Admin privileges required.'; + exit; + } + } + + public function generateCSRFToken(): string + { + if (!isset($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + public function verifyCSRFToken(string $token): bool + { + return isset($_SESSION['csrf_token']) && $_SESSION['csrf_token'] === $token; + } + + private function updateUserLastLogin(int $userId, string $ip = null): void + { + $stmt = $this->pdo->prepare("UPDATE users SET last_login_at = :last_login_at, login_ip = :login_ip WHERE id = :id"); + $stmt->execute([ + 'id' => $userId, + 'last_login_at' => date('Y-m-d H:i:s'), + 'login_ip' => $ip + ]); + } +} diff --git a/app/Services/BaseSyncService.php b/app/Services/BaseSyncService.php new file mode 100644 index 0000000..7b62bc9 --- /dev/null +++ b/app/Services/BaseSyncService.php @@ -0,0 +1,141 @@ +pdo = $pdo; + $this->source = $source; + + if (!isset($source['id']) || empty($source['id'])) { + throw new \Exception('Source ID is required for sync service'); + } + + $this->sourceId = (int) $source['id']; + } + + public function startSync(string $syncType = 'full'): int + { + // Create sync log entry + $this->syncLog = new SyncLog($this->pdo); + $syncLogId = $this->createSyncLog($syncType, 'started'); + + $this->syncLog->id = $syncLogId; + + try { + $this->executeSync($syncType); + + // Update sync log as completed + $this->updateSyncLog($syncLogId, 'completed', [ + 'processed_items' => $this->getProcessedCount(), + 'new_items' => $this->getNewCount(), + 'updated_items' => $this->getUpdatedCount(), + 'deleted_items' => $this->getDeletedCount() + ]); + + } catch (Exception $e) { + // Update sync log as failed + $this->updateSyncLog($syncLogId, 'failed', [ + 'message' => $e->getMessage(), + 'errors' => [$e->getMessage()] + ]); + + throw $e; + } + + return $syncLogId; + } + + private function createSyncLog(string $syncType, string $status): int + { + $data = [ + 'source_id' => $this->sourceId, + 'sync_type' => $syncType, + 'status' => $status, + 'total_items' => 0, + 'processed_items' => 0, + 'new_items' => 0, + 'updated_items' => 0, + 'deleted_items' => 0, + 'started_at' => date('Y-m-d H:i:s') + ]; + + $columns = array_keys($data); + $placeholders = array_map(fn($col) => ":$col", $columns); + $sql = "INSERT INTO sync_logs (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($data); + + return (int) $this->pdo->lastInsertId(); + } + + private function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool + { + $data = [ + 'status' => $status, + 'processed_items' => $stats['processed_items'] ?? 0, + 'new_items' => $stats['new_items'] ?? 0, + 'updated_items' => $stats['updated_items'] ?? 0, + 'deleted_items' => $stats['deleted_items'] ?? 0, + 'completed_at' => date('Y-m-d H:i:s') + ]; + + if (!empty($stats['errors'])) { + $data['errors'] = json_encode($stats['errors']); + } + + if (!empty($stats['message'])) { + $data['message'] = $stats['message']; + } + + $setClause = array_map(fn($col) => "$col = :$col", array_keys($data)); + $sql = "UPDATE sync_logs SET " . implode(', ', $setClause) . " WHERE id = :id"; + $data['id'] = $syncLogId; + + $stmt = $this->pdo->prepare($sql); + return $stmt->execute($data); + } + + abstract protected function executeSync(string $syncType): void; + + protected function getProcessedCount(): int + { + return 0; // Override in subclasses + } + + protected function getNewCount(): int + { + return 0; // Override in subclasses + } + + protected function getUpdatedCount(): int + { + return 0; // Override in subclasses + } + + protected function getDeletedCount(): int + { + return 0; // Override in subclasses + } + + protected function logProgress(string $message): void + { + // Update sync log with progress message + if ($this->syncLog) { + $this->updateSyncLog($this->syncLog->id, 'running', [ + 'message' => $message + ]); + } + } +} diff --git a/app/Services/ExophaseSyncService.php b/app/Services/ExophaseSyncService.php new file mode 100644 index 0000000..9f334d9 --- /dev/null +++ b/app/Services/ExophaseSyncService.php @@ -0,0 +1,174 @@ +httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'Authorization' => 'Bearer ' . $source['api_key'] + ] + ]); + $this->apiKey = $source['api_key']; + $this->baseUrl = rtrim($source['api_url'] ?? 'https://api.exophase.com', '/'); + } + + protected function executeSync(string $syncType): void + { + if (empty($this->apiKey)) { + throw new Exception('Exophase API key not configured'); + } + + $this->logProgress('Starting Exophase gaming data sync...'); + + // Sync games from all supported platforms + $this->syncGames(); + + $this->logProgress("Processed {$this->processedCount} Exophase gaming items"); + } + + private function syncGames(): void + { + try { + $games = $this->getExophaseGames(); + + foreach ($games as $gameData) { + $this->syncGame($gameData); + $this->processedCount++; + } + } catch (Exception $e) { + $this->logProgress('Error syncing games: ' . $e->getMessage()); + } + } + + private function getExophaseGames(): array + { + try { + // Exophase API endpoint for user's games + $response = $this->httpClient->get("{$this->baseUrl}/v1/user/games"); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['games'])) { + throw new Exception('No games found in Exophase'); + } + + return $data['games']; + } catch (Exception $e) { + throw new Exception('Failed to fetch Exophase games: ' . $e->getMessage()); + } + } + + private function syncGame(array $gameData): void + { + $gameModel = new Game($this->pdo); + + // Check if game already exists + $existingGame = $gameModel->findAll([ + 'source_id' => $this->sourceId + ]); + + // Find existing game by platform-specific ID or title + foreach ($existingGame as $game) { + $metadata = json_decode($game['metadata'], true); + if (isset($metadata['exophase_game_id']) && $metadata['exophase_game_id'] === $gameData['id']) { + $existingGame = [$game]; + break; + } + } + + $gameData = [ + 'title' => $gameData['title'] ?: 'Untitled Game', + 'game_key' => Game::generateGameKey($gameData['title'], $gameData['platform'] ?? null), + 'platform' => $this->mapPlatform($gameData['platform'] ?? 'unknown'), + 'playtime_minutes' => $gameData['playtime_minutes'] ?? 0, + 'completion_percentage' => $gameData['completion_percentage'] ?? 0, + 'source_id' => $this->sourceId, + 'last_played_at' => isset($gameData['last_played']) ? date('Y-m-d H:i:s', strtotime($gameData['last_played'])) : null, + 'metadata' => json_encode([ + 'exophase_game_id' => $gameData['id'], + 'exophase_platform' => $gameData['platform'] ?? null, + 'achievements_earned' => $gameData['achievements_earned'] ?? 0, + 'achievements_total' => $gameData['achievements_total'] ?? 0, + 'trophies_earned' => $gameData['trophies_earned'] ?? 0, + 'trophies_total' => $gameData['trophies_total'] ?? 0, + 'gamerscore_earned' => $gameData['gamerscore_earned'] ?? 0, + 'gamerscore_total' => $gameData['gamerscore_total'] ?? 0, + 'last_achievement' => $gameData['last_achievement'] ?? null, + 'first_achievement' => $gameData['first_achievement'] ?? null, + 'rating' => $gameData['rating'] ?? null, + 'genre' => $gameData['genre'] ?? null, + 'developer' => $gameData['developer'] ?? null, + 'publisher' => $gameData['publisher'] ?? null, + 'release_date' => $gameData['release_date'] ?? null + ]) + ]; + + if (empty($existingGame)) { + $gameModel->create($gameData); + $this->newCount++; + } else { + $gameModel->update($existingGame[0]['id'], $gameData); + $this->updatedCount++; + } + } + + private function mapPlatform(string $platform): string + { + $platformMap = [ + 'steam' => 'PC', + 'psn' => 'PlayStation', + 'ps4' => 'PlayStation 4', + 'ps5' => 'PlayStation 5', + 'xbox' => 'Xbox', + 'xbox360' => 'Xbox 360', + 'xboxone' => 'Xbox One', + 'xboxseries' => 'Xbox Series X/S', + 'nintendo' => 'Nintendo', + 'switch' => 'Nintendo Switch', + 'epic' => 'Epic Games', + 'gog' => 'GOG', + 'origin' => 'Origin', + 'uplay' => 'Ubisoft Connect', + 'battlenet' => 'Battle.net' + ]; + + return $platformMap[$platform] ?? ucfirst($platform); + } + + protected function getProcessedCount(): int + { + return $this->processedCount; + } + + protected function getNewCount(): int + { + return $this->newCount; + } + + protected function getUpdatedCount(): int + { + return $this->updatedCount; + } + + protected function getDeletedCount(): int + { + return 0; // Exophase doesn't provide deletion info in this context + } +} diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php new file mode 100644 index 0000000..4058139 --- /dev/null +++ b/app/Services/JellyfinSyncService.php @@ -0,0 +1,315 @@ +httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'X-MediaBrowser-Token' => $source['api_key'] + ] + ]); + $this->apiKey = $source['api_key']; + $this->baseUrl = rtrim($source['api_url'], '/'); + } + + protected function executeSync(string $syncType): void + { + if (empty($this->apiKey) || empty($this->baseUrl)) { + throw new Exception('Jellyfin API key and URL not configured'); + } + + $this->logProgress('Starting Jellyfin library sync...'); + $this->logProgress("Jellyfin URL: {$this->baseUrl}"); + $this->logProgress("API Key: " . (empty($this->apiKey) ? 'NOT SET' : 'SET')); + + try { + $userId = $this->getUserId(); + $this->logProgress("User ID: {$userId}"); + } catch (Exception $e) { + $this->logProgress('Error getting user ID: ' . $e->getMessage()); + throw $e; + } + + // Sync movies + try { + $this->logProgress('Fetching movies from Jellyfin...'); + $movies = $this->getJellyfinItems('Movie'); + $this->logProgress("Found " . count($movies) . " movies in Jellyfin"); + + if (empty($movies)) { + $this->logProgress('No movies found in Jellyfin library'); + $this->logProgress("Processed {$this->processedCount} items"); + return; + } + + foreach ($movies as $movieData) { + $this->syncMovie($movieData); + $this->processedCount++; + } + + $this->logProgress("Successfully processed {$this->processedCount} movies"); + + } catch (Exception $e) { + $this->logProgress('Error syncing movies: ' . $e->getMessage()); + throw $e; + } + + // TODO: Sync TV shows and episodes when TvShow model is implemented + // $this->syncTvShows(); + + $this->logProgress("Processed {$this->processedCount} items"); + } + + private function syncMovies(): void + { + try { + $movies = $this->getJellyfinItems('Movie'); + + foreach ($movies as $movieData) { + $this->syncMovie($movieData); + $this->processedCount++; + } + } catch (Exception $e) { + $this->logProgress('Error syncing movies: ' . $e->getMessage()); + } + } + + private function syncTvShows(): void + { + try { + $tvShows = $this->getJellyfinItems('Series'); + + foreach ($tvShows as $showData) { + $this->syncTvShow($showData); + $this->processedCount++; + } + } catch (Exception $e) { + $this->logProgress('Error syncing TV shows: ' . $e->getMessage()); + } + } + + private function getJellyfinItems(string $type): array + { + try { + $url = "{$this->baseUrl}/Users/{$this->getUserId()}/Items"; + $this->logProgress("Fetching {$type} from: {$url}"); + + $response = $this->httpClient->get($url, [ + 'query' => [ + 'IncludeItemTypes' => $type, + 'Recursive' => 'true', + 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,Genres,Studios,RunTimeTicks' + ] + ]); + + $httpCode = $response->getStatusCode(); + $this->logProgress("HTTP Response Code: {$httpCode}"); + + if ($httpCode !== 200) { + throw new Exception("Jellyfin API returned HTTP {$httpCode}"); + } + + $data = json_decode($response->getBody(), true); + $itemCount = count($data['Items'] ?? []); + $this->logProgress("Successfully fetched {$itemCount} {$type} items"); + + return $data['Items'] ?? []; + } catch (Exception $e) { + $this->logProgress('Failed to fetch Jellyfin items: ' . $e->getMessage()); + throw new Exception('Failed to fetch Jellyfin items: ' . $e->getMessage()); + } + } + + private function getUserId(): string + { + try { + $url = "{$this->baseUrl}/Users"; + $this->logProgress("Getting user ID from: {$url}"); + + $response = $this->httpClient->get($url); + $httpCode = $response->getStatusCode(); + + if ($httpCode !== 200) { + throw new Exception("Jellyfin Users API returned HTTP {$httpCode}"); + } + + $data = json_decode($response->getBody(), true); + + if (empty($data) || !isset($data[0]['Id'])) { + throw new Exception('No users found in Jellyfin or invalid response format'); + } + + $userId = $data[0]['Id']; + $this->logProgress("Using Jellyfin user ID: {$userId}"); + + return $userId; + } catch (Exception $e) { + $this->logProgress('Failed to get Jellyfin user ID: ' . $e->getMessage()); + throw new Exception('Failed to get Jellyfin user ID: ' . $e->getMessage()); + } + } + + private function syncMovie(array $movieData): void + { + $movieModel = new Movie($this->pdo); + + // Check if movie already exists + $existingMovie = $movieModel->findAll([ + 'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null, + 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, + 'source_id' => $this->source['id'] + ]); + + $movieData = [ + 'title' => $movieData['Name'], + 'overview' => $movieData['Overview'] ?? null, + 'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null, + 'runtime_minutes' => $movieData['RunTimeTicks'] ? intval($movieData['RunTimeTicks'] / (10000000 * 60)) : null, + 'rating' => $movieData['CommunityRating'] ?? null, + 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, + 'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null, + 'poster_url' => $this->getImageUrl($movieData['Id'], 'Primary'), + 'backdrop_url' => $this->getImageUrl($movieData['Id'], 'Backdrop'), + 'source_id' => $this->source['id'], + 'metadata' => json_encode([ + 'jellyfin_id' => $movieData['Id'], + 'genres' => $movieData['Genres'] ?? [], + 'studios' => $movieData['Studios'] ?? [] + ]) + ]; + + if (empty($existingMovie)) { + $movieModel->create($movieData); + $this->newCount++; + } else { + $movieModel->update($existingMovie[0]['id'], $movieData); + $this->updatedCount++; + } + } + + // TODO: Implement when TvShow model is created + // private function syncTvShow(array $showData): void + // { + // $showModel = new TvShow($this->pdo); + + // // Check if show already exists + // $existingShow = $showModel->findAll([ + // 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, + // 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, + // 'source_id' => $this->source->id + // ]); + + // $showData = [ + // 'title' => $showData['Name'], + // 'overview' => $showData['Overview'] ?? null, + // 'first_air_date' => $showData['PremiereDate'] ? date('Y-m-d', strtotime($showData['PremiereDate'])) : null, + // 'rating' => $showData['CommunityRating'] ?? null, + // 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, + // 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, + // 'poster_url' => $this->getImageUrl($showData['Id'], 'Primary'), + // 'backdrop_url' => $this->getImageUrl($showData['Id'], 'Backdrop'), + // 'source_id' => $this->source->id, + // 'metadata' => json_encode([ + // 'jellyfin_id' => $showData['Id'], + // 'genres' => $showData['Genres'] ?? [] + // ]) + // ]; + + // if (empty($existingShow)) { + // $showId = $showModel->create($showData); + // $this->newCount++; + // } else { + // $showId = $existingShow[0]['id']; + // $showModel->update($showId, $showData); + // $this->updatedCount++; + // } + + // // Sync episodes for this show + // $this->syncEpisodes($showId, $showData['Id']); + // } + + // TODO: Implement when TvEpisode model is created + // private function syncEpisodes(int $showId, string $jellyfinShowId): void + // { + // try { + // $episodes = $this->getShowEpisodes($jellyfinShowId); + + // foreach ($episodes as $episodeData) { + // $this->syncEpisode($showId, $episodeData); + // } + // } catch (Exception $e) { + // $this->logProgress('Error syncing episodes for show ' . $jellyfinShowId . ': ' . $e->getMessage()); + // } + // } + + // TODO: Implement when TvEpisode model is created + // private function syncEpisode(int $showId, array $episodeData): void + // { + // $episodeModel = new TvEpisode($this->pdo); + + // $episodeData = [ + // 'title' => $episodeData['Name'], + // 'overview' => $episodeData['Overview'] ?? null, + // 'season_number' => $episodeData['ParentIndexNumber'] ?? 1, + // 'episode_number' => $episodeData['IndexNumber'] ?? 1, + // 'air_date' => $episodeData['PremiereDate'] ? date('Y-m-d', strtotime($episodeData['PremiereDate'])) : null, + // 'runtime_minutes' => $episodeData['RunTimeTicks'] ? intval($episodeData['RunTimeTicks'] / (10000000 * 60)) : null, + // 'rating' => $episodeData['CommunityRating'] ?? null, + // 'tv_show_id' => $showId, + // 'source_id' => $this->source->id, + // 'metadata' => json_encode([ + // 'jellyfin_id' => $episodeData['Id'] + // ]) + // ]; + + // $episodeModel->create($episodeData); + // } + + private function getImageUrl(string $itemId, string $type): ?string + { + if (empty($itemId)) { + return null; + } + + return "{$this->baseUrl}/Items/{$itemId}/Images/{$type}?maxWidth=400"; + } + + protected function getProcessedCount(): int + { + return $this->processedCount; + } + + protected function getNewCount(): int + { + return $this->newCount; + } + + protected function getUpdatedCount(): int + { + return $this->updatedCount; + } + + protected function getDeletedCount(): int + { + return 0; // Jellyfin doesn't provide deletion info in this context + } +} diff --git a/app/Services/StashSyncService.php b/app/Services/StashSyncService.php new file mode 100644 index 0000000..3e65ee3 --- /dev/null +++ b/app/Services/StashSyncService.php @@ -0,0 +1,486 @@ +httpClient = new Client([ + 'timeout' => 60, // Stash can be slow + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'Content-Type' => 'application/json' + ] + ]); + $this->apiKey = $source['api_key']; + $this->baseUrl = rtrim($source['api_url'], '/'); + $this->imageDownloader = new ImageDownloader(); + } + + protected function executeSync(string $syncType): void + { + if (empty($this->apiKey) || empty($this->baseUrl)) { + throw new Exception('Stash API key and URL not configured'); + } + + $this->logProgress('Starting Stash library sync...'); + + // Sync scenes (movies) + $this->syncScenes(); + + // Sync movies (if Stash has movie support) + $this->syncMovies(); + + $this->logProgress("Processed {$this->processedCount} Stash items"); + } + + private function syncScenes(): void + { + try { + $this->logProgress('Fetching Stash scenes...'); + + // Use pagination to handle large libraries + $page = 0; + $perPage = 50; // Smaller batch size for reliability + + do { + $scenes = $this->getStashScenes($page * $perPage, $perPage); + $this->logProgress("Processing page {$page} with " . count($scenes) . " scenes..."); + + foreach ($scenes as $sceneData) { + $this->syncScene($sceneData); + $this->processedCount++; + } + + $page++; + } while (count($scenes) === $perPage); // Continue if we got a full page + + $this->logProgress("Completed syncing Stash scenes"); + } catch (Exception $e) { + $this->logProgress('Error syncing scenes: ' . $e->getMessage()); + throw $e; + } + } + + private function getStashScenes(int $offset = 0, int $limit = 50): array + { + try { + $query = ' + query FindScenes($filter: FindFilterType) { + findScenes(filter: $filter) { + scenes { + id + title + details + url + date + rating100 + organized + o_counter + created_at + updated_at + paths { + screenshot + preview + stream + webp + vtt + sprite + funscript + caption + } + files { + size + duration + video_codec + audio_codec + width + height + } + paths { + screenshot + } + performers { + id + name + disambiguation + url + gender + birthdate + ethnicity + country + eye_color + height_cm + measurements + fake_tits + penis_length + circumcised + career_length + tattoos + piercings + alias_list + favorite + ignore_auto_tag + created_at + updated_at + details + death_date + hair_color + weight + image_path + scene_count + } + } + count + } + } + '; + + $variables = [ + 'filter' => [ + 'per_page' => $limit, + 'page' => $offset / $limit + 1, + 'sort' => 'created_at', + 'direction' => 'DESC' + ] + ]; + + $response = $this->httpClient->post("{$this->baseUrl}/graphql", [ + 'json' => [ + 'query' => $query, + 'variables' => $variables + ], + 'timeout' => 30 + ]); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['data']['findScenes']['scenes'])) { + $this->logProgress('No scenes data in response'); + return []; + } + + return $data['data']['findScenes']['scenes']; + } catch (Exception $e) { + $this->logProgress('Failed to fetch Stash scenes: ' . $e->getMessage()); + throw new Exception('Failed to fetch Stash scenes: ' . $e->getMessage()); + } + } + + private function syncMovies(): void + { + try { + $movies = $this->getStashMovies(); + + foreach ($movies as $movieData) { + $this->syncMovie($movieData); + $this->processedCount++; + } + } catch (Exception $e) { + $this->logProgress('Error syncing movies: ' . $e->getMessage()); + } + } + + private function getStashMovies(): array + { + try { + $query = ' + query FindMovies($filter: FindFilterType) { + findMovies(filter: $filter) { + movies { + id + name + aliases + duration + date + rating100 + director + synopsis + url + created_at + updated_at + front_image_path + back_image_path + } + count + } + } + '; + + $variables = [ + 'filter' => [ + 'per_page' => 100, + 'sort' => 'created_at', + 'direction' => 'DESC' + ] + ]; + + $response = $this->httpClient->post("{$this->baseUrl}/graphql", [ + 'json' => [ + 'query' => $query, + 'variables' => $variables + ] + ]); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['data']['findMovies']['movies'])) { + return []; // No movies found + } + + return $data['data']['findMovies']['movies']; + } catch (Exception $e) { + // Return empty array if movies can't be fetched + return []; + } + } + + private function syncScene(array $sceneData): void + { + $adultVideoModel = new AdultVideo($this->pdo); + + // Check if scene already exists by stash_id in metadata + $stmt = $this->pdo->prepare(" + SELECT id, metadata FROM adult_videos + WHERE source_id = :source_id + "); + $stmt->execute(['source_id' => $this->source['id']]); + $existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $existingScene = null; + foreach ($existingScenes as $scene) { + $metadata = json_decode($scene['metadata'], true); + if (isset($metadata['stash_id']) && $metadata['stash_id'] === $sceneData['id']) { + $existingScene = $scene; + break; + } + } + + // Download images locally + $coverFilename = null; + $screenshotFilename = null; + + // Extract image URLs from Stash API response + $coverUrl = null; + $screenshotUrl = null; + + // Stash provides paths.screenshot for screenshot + if (!empty($sceneData['paths']['screenshot'])) { + // Convert relative path to full URL + $screenshotUrl = "{$this->baseUrl}/" . ltrim($sceneData['paths']['screenshot'], '/'); + } + + // For cover, we might need to use a different approach or check if there's a primary image + // For now, we'll use the screenshot as cover if available + if ($screenshotUrl) { + $coverUrl = $screenshotUrl; + } + + if (!empty($coverUrl)) { + $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + } + } + + if (!empty($screenshotUrl)) { + $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + } + } + + // Handle performers/actors + $performers = $sceneData['performers'] ?? []; + $actorNames = []; + $performerImages = []; + foreach ($performers as $performer) { + $actorNames[] = $performer['name']; + $performerImages[$performer['name']] = $performer['image_path'] ?? null; + } + $actors = $this->syncActors($actorNames, $performerImages); + + $sceneData = [ + 'title' => $sceneData['title'] ?: 'Untitled Scene', + 'overview' => $sceneData['details'] ?? null, + 'release_date' => $sceneData['date'] ? date('Y-m-d', strtotime($sceneData['date'])) : null, + 'runtime_minutes' => !empty($sceneData['files'][0]['duration']) ? round($sceneData['files'][0]['duration'] / 60) : null, + 'rating' => $sceneData['rating100'] ? $sceneData['rating100'] / 100 : null, // Convert from 0-100 to 0-10 + 'source_id' => $this->source['id'], + 'external_id' => $sceneData['id'], + 'metadata' => json_encode([ + 'stash_id' => $sceneData['id'], + 'stash_url' => $sceneData['url'] ?? null, + 'organized' => $sceneData['organized'] ?? false, + 'o_counter' => $sceneData['o_counter'] ?? 0, + 'performers' => $performers, + 'actors' => $actors, + 'file_info' => $sceneData['files'][0] ?? null, + 'paths' => $sceneData['paths'] ?? null, + 'cover_url' => $coverUrl, + 'local_cover_path' => $sceneData['local_cover_path'] ?? null, + 'screenshot_url' => $screenshotUrl, + 'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null + ]) + ]; + + if ($existingScene) { + $adultVideoModel->update($existingScene['id'], $sceneData); + $this->updatedCount++; + } else { + $adultVideoModel->create($sceneData); + $this->newCount++; + } + } + + private function syncMovie(array $movieData): void + { + $adultVideoModel = new AdultVideo($this->pdo); + + // Check if movie already exists by stash_movie_id in metadata + $stmt = $this->pdo->prepare(" + SELECT id, metadata FROM adult_videos + WHERE source_id = :source_id + "); + $stmt->execute(['source_id' => $this->source['id']]); + $existingMovies = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $existingMovie = null; + foreach ($existingMovies as $movie) { + $metadata = json_decode($movie['metadata'], true); + if (isset($metadata['stash_movie_id']) && $metadata['stash_movie_id'] === $movieData['id']) { + $existingMovie = $movie; + break; + } + } + + $movieData = [ + 'title' => $movieData['name'] ?: 'Untitled Movie', + 'overview' => $movieData['synopsis'] ?? null, + 'director' => $movieData['director'] ?? null, + 'release_date' => $movieData['date'] ? date('Y-m-d', strtotime($movieData['date'])) : null, + 'runtime_minutes' => $movieData['duration'] ?? null, + 'rating' => $movieData['rating100'] ? $movieData['rating100'] / 100 : null, + 'source_id' => $this->source['id'], + 'external_id' => $movieData['id'], + 'metadata' => json_encode([ + 'stash_movie_id' => $movieData['id'], + 'aliases' => $movieData['aliases'] ?? null, + 'url' => $movieData['url'] ?? null + ]) + ]; + + if ($existingMovie) { + $adultVideoModel->update($existingMovie['id'], $movieData); + $this->updatedCount++; + } else { + $adultVideoModel->create($movieData); + $this->newCount++; + } + } + + private function syncActors(array $actorNames, array $performerImages = []): array + { + $actors = []; + + foreach ($actorNames as $actorName) { + if (empty($actorName)) continue; + + $imagePath = $performerImages[$actorName] ?? null; + $actor = $this->getOrCreateActor($actorName, $imagePath); + if ($actor) { + $actors[] = $actor; + } + } + + return $actors; + } + + private function getOrCreateActor(string $name, ?string $imagePath = null): ?array + { + // Check if actor already exists + $stmt = $this->pdo->prepare(" + SELECT id, name, thumbnail_path FROM actors WHERE name = :name + "); + $stmt->execute(['name' => $name]); + $existingActor = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existingActor) { + return [ + 'id' => $existingActor['id'], + 'name' => $existingActor['name'], + 'thumbnail_path' => $existingActor['thumbnail_path'] + ]; + } + + // Try to download performer image if available + $thumbnailPath = null; + if ($imagePath) { + $imageUrl = "{$this->baseUrl}/" . ltrim($imagePath, '/'); + $thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor'); + $localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors'); + if ($localImagePath) { + $thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath); + } + } + + try { + $stmt = $this->pdo->prepare(" + INSERT INTO actors (name, thumbnail_path, created_at, updated_at) + VALUES (:name, :thumbnail_path, NOW(), NOW()) + "); + $stmt->execute([ + 'name' => $name, + 'thumbnail_path' => $thumbnailPath + ]); + $actorId = $this->pdo->lastInsertId(); + + return [ + 'id' => $actorId, + 'name' => $name, + 'thumbnail_path' => $thumbnailPath + ]; + } catch (Exception $e) { + $this->logProgress("Failed to create actor {$name}: " . $e->getMessage()); + return null; + } + } + + protected function getProcessedCount(): int + { + return $this->processedCount; + } + + protected function getNewCount(): int + { + return $this->newCount; + } + + protected function getUpdatedCount(): int + { + return $this->updatedCount; + } + + protected function getDeletedCount(): int + { + return 0; // Stash doesn't provide deletion info in this context + } +} diff --git a/app/Services/SteamSyncService.php b/app/Services/SteamSyncService.php new file mode 100644 index 0000000..b054ef8 --- /dev/null +++ b/app/Services/SteamSyncService.php @@ -0,0 +1,166 @@ +httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0' + ] + ]); + $this->apiKey = $source['api_key']; + + // Steam ID can be configured in source config or use a default test account + $this->steamId = $source['config']['steam_id'] ?? '76561198000000000'; // Default test Steam ID + } + + protected function executeSync(string $syncType): void + { + if (empty($this->apiKey)) { + throw new Exception('Steam API key not configured'); + } + + $this->logProgress('Starting Steam library sync...'); + + // Get Steam user game library + $games = $this->getSteamLibrary(); + + foreach ($games as $gameData) { + $this->syncGame($gameData); + $this->processedCount++; + } + + $this->logProgress("Processed {$this->processedCount} Steam games"); + } + + private function getSteamLibrary(): array + { + try { + // Steam Web API: GetOwnedGames + $response = $this->httpClient->get('https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/', [ + 'query' => [ + 'key' => $this->apiKey, + 'steamid' => $this->steamId, + 'format' => 'json', + 'include_appinfo' => 'true', + 'include_played_free_games' => 'true' + ] + ]); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['response']['games'])) { + throw new Exception('No games found in Steam library'); + } + + return $data['response']['games']; + } catch (Exception $e) { + throw new Exception('Failed to fetch Steam library: ' . $e->getMessage()); + } + } + + private function syncGame(array $gameData): void + { + $gameModel = new Game($this->pdo); + + // Check if game already exists + $existingGame = $gameModel->findAll([ + 'steam_app_id' => $gameData['appid'], + 'source_id' => $this->source['id'] + ]); + + // Get additional game details from Steam API + $gameDetails = $this->getGameDetails($gameData['appid']); + + $gameData = [ + 'title' => $gameData['name'], + 'game_key' => Game::generateGameKey($gameData['name'], 'steam'), + 'steam_app_id' => $gameData['appid'], + 'playtime_minutes' => intval($gameData['playtime_forever']), + 'platform' => 'PC', + 'source_id' => $this->source['id'], + 'last_played_at' => isset($gameData['rt_time_last_played']) && $gameData['rt_time_last_played'] > 0 + ? date('Y-m-d H:i:s', $gameData['rt_time_last_played']) + : null, + 'metadata' => json_encode([ + 'appid' => $gameData['appid'], + 'playtime_windows' => $gameData['playtime_windows_forever'] ?? 0, + 'playtime_mac' => $gameData['playtime_mac_forever'] ?? 0, + 'playtime_linux' => $gameData['playtime_linux_forever'] ?? 0, + 'img_icon_url' => $gameDetails['img_icon_url'] ?? null, + 'img_logo_url' => $gameDetails['img_logo_url'] ?? null, + 'has_community_visible_stats' => $gameDetails['has_community_visible_stats'] ?? false + ]) + ]; + + if (empty($existingGame)) { + $gameModel->create($gameData); + $this->newCount++; + } else { + $gameModel->update($existingGame[0]['id'], $gameData); + $this->updatedCount++; + } + } + + private function getGameDetails(int $appId): array + { + try { + // Steam Web API: GetAppDetails + $response = $this->httpClient->get('https://store.steampowered.com/api/appdetails/', [ + 'query' => [ + 'appids' => $appId, + 'cc' => 'US', + 'l' => 'english' + ] + ]); + + $data = json_decode($response->getBody(), true); + $appData = $data[$appId] ?? []; + + if (!$appData['success']) { + return []; + } + + return $appData['data'] ?? []; + } catch (Exception $e) { + // Return empty array if details can't be fetched + return []; + } + } + + protected function getProcessedCount(): int + { + return $this->processedCount; + } + + protected function getNewCount(): int + { + return $this->newCount; + } + + protected function getUpdatedCount(): int + { + return $this->updatedCount; + } + + protected function getDeletedCount(): int + { + return 0; // Steam doesn't provide deletion info in this context + } +} diff --git a/app/Services/XbvrSyncService.php b/app/Services/XbvrSyncService.php new file mode 100644 index 0000000..22f5a7c --- /dev/null +++ b/app/Services/XbvrSyncService.php @@ -0,0 +1,257 @@ +httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'X-API-Key' => $source['api_key'] + ] + ]); + $this->apiKey = $source['api_key']; + $this->baseUrl = rtrim($source['api_url'], '/'); + $this->imageDownloader = new ImageDownloader(); + } + + protected function executeSync(string $syncType): void + { + if (empty($this->apiKey) || empty($this->baseUrl)) { + throw new Exception('XBVR API key and URL not configured'); + } + + $this->logProgress('Starting XBVR library sync...'); + + // Sync VR scenes + $this->syncScenes(); + + $this->logProgress("Processed {$this->processedCount} XBVR items"); + } + + private function syncScenes(): void + { + try { + $scenes = $this->getXbvrScenes(); + + foreach ($scenes as $sceneData) { + $this->syncScene($sceneData); + $this->processedCount++; + } + } catch (Exception $e) { + $this->logProgress('Error syncing XBVR scenes: ' . $e->getMessage()); + } + } + + private function getXbvrScenes(): array + { + try { + // XBVR API endpoint for scenes + $response = $this->httpClient->get("{$this->baseUrl}/api/scene"); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['scenes'])) { + throw new Exception('No scenes found in XBVR'); + } + + return $data['scenes']; + } catch (Exception $e) { + throw new Exception('Failed to fetch XBVR scenes: ' . $e->getMessage()); + } + } + + private function syncScene(array $sceneData): void + { + $adultVideoModel = new AdultVideo($this->pdo); + + // Check if scene already exists by xbvr_id in metadata + $stmt = $this->pdo->prepare(" + SELECT id, metadata FROM adult_videos + WHERE source_id = :source_id + "); + $stmt->execute(['source_id' => $this->source['id']]); + $existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $existingScene = null; + foreach ($existingScenes as $scene) { + $metadata = json_decode($scene['metadata'], true); + if (isset($metadata['xbvr_id']) && $metadata['xbvr_id'] === $sceneData['id']) { + $existingScene = $scene; + break; + } + } + + private function syncScene(array $sceneData): void + { + $adultVideoModel = new AdultVideo($this->pdo); + + // Check if scene already exists by xbvr_id in metadata + $stmt = $this->pdo->prepare(" + SELECT id, metadata FROM adult_videos + WHERE source_id = :source_id + "); + $stmt->execute(['source_id' => $this->source['id']]); + $existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $existingScene = null; + foreach ($existingScenes as $scene) { + $metadata = json_decode($scene['metadata'], true); + if (isset($metadata['xbvr_id']) && $metadata['xbvr_id'] === $sceneData['id']) { + $existingScene = $scene; + break; + } + } + + // Download images locally + $coverFilename = null; + $screenshotFilename = null; + + if (!empty($sceneData['cover_url'])) { + $coverFilename = $this->imageDownloader->generateFilename($sceneData['cover_url'], 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($sceneData['cover_url'], $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + } + } + + if (!empty($sceneData['screenshot_url'])) { + $screenshotFilename = $this->imageDownloader->generateFilename($sceneData['screenshot_url'], 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($sceneData['screenshot_url'], $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + } + } + + // Handle actors + $actors = $this->syncActors($sceneData['cast'] ?? []); + + $sceneData = [ + 'title' => $sceneData['title'] ?: 'Untitled VR Scene', + 'overview' => $sceneData['synopsis'] ?? null, + 'release_date' => $sceneData['release_date'] ? date('Y-m-d', strtotime($sceneData['release_date'])) : null, + 'runtime_minutes' => $sceneData['duration'] ?? null, + 'rating' => $sceneData['rating'] ?? null, + 'source_id' => $this->source['id'], + 'external_id' => $sceneData['id'], + 'metadata' => json_encode([ + 'xbvr_id' => $sceneData['id'], + 'xbvr_url' => $sceneData['scene_url'] ?? null, + 'cast' => $sceneData['cast'] ?? [], + 'actors' => $actors, + 'tags' => $sceneData['tags'] ?? [], + 'is_available' => $sceneData['is_available'] ?? true, + 'is_watched' => $sceneData['is_watched'] ?? false, + 'watch_count' => $sceneData['watch_count'] ?? 0, + 'video_length' => $sceneData['video_length'] ?? null, + 'video_width' => $sceneData['video_width'] ?? null, + 'video_height' => $sceneData['video_height'] ?? null, + 'video_codec' => $sceneData['video_codec'] ?? null, + 'file_path' => $sceneData['file_path'] ?? null, + 'cover_url' => $sceneData['cover_url'] ?? null, + 'local_cover_path' => $sceneData['local_cover_path'] ?? null, + 'screenshot_url' => $sceneData['screenshot_url'] ?? null, + 'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null + ]) + ]; + + if ($existingScene) { + $adultVideoModel->update($existingScene['id'], $sceneData); + $this->updatedCount++; + } else { + $adultVideoModel->create($sceneData); + $this->newCount++; + } + } + + private function syncActors(array $cast): array + { + $actors = []; + + foreach ($cast as $actorName) { + if (empty($actorName)) continue; + + $actor = $this->getOrCreateActor($actorName); + if ($actor) { + $actors[] = $actor; + } + } + + return $actors; + } + + private function getOrCreateActor(string $name): ?array + { + // Check if actor already exists + $stmt = $this->pdo->prepare(" + SELECT id, name, thumbnail_path FROM actors WHERE name = :name + "); + $stmt->execute(['name' => $name]); + $existingActor = $stmt->fetch(\PDO::FETCH_ASSOC); + + if ($existingActor) { + return [ + 'id' => $existingActor['id'], + 'name' => $existingActor['name'], + 'thumbnail_path' => $existingActor['thumbnail_path'] + ]; + } + + // For now, we'll create actor without thumbnail + // In a full implementation, you'd fetch actor details from XBVR API + try { + $stmt = $this->pdo->prepare(" + INSERT INTO actors (name, created_at, updated_at) + VALUES (:name, NOW(), NOW()) + "); + $stmt->execute(['name' => $name]); + $actorId = $this->pdo->lastInsertId(); + + return [ + 'id' => $actorId, + 'name' => $name, + 'thumbnail_path' => null + ]; + } catch (Exception $e) { + $this->logProgress("Failed to create actor {$name}: " . $e->getMessage()); + return null; + } + } + + protected function getProcessedCount(): int + { + return $this->processedCount; + } + + protected function getNewCount(): int + { + return $this->newCount; + } + + protected function getUpdatedCount(): int + { + return $this->updatedCount; + } + + protected function getDeletedCount(): int + { + return 0; // XBVR doesn't provide deletion info in this context + } +} diff --git a/app/Utils/ImageDownloader.php b/app/Utils/ImageDownloader.php new file mode 100644 index 0000000..64ebafd --- /dev/null +++ b/app/Utils/ImageDownloader.php @@ -0,0 +1,94 @@ +httpClient = new Client([ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0' + ] + ]); + $this->basePath = rtrim($basePath, '/'); + } + + /** + * Download an image from URL and save it locally + */ + public function downloadImage(string $url, string $filename, string $subfolder = ''): ?string + { + if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { + return null; + } + + try { + $folderPath = $this->basePath; + if (!empty($subfolder)) { + $folderPath .= '/' . trim($subfolder, '/'); + } + + // Create folder if it doesn't exist + if (!is_dir($folderPath)) { + mkdir($folderPath, 0755, true); + } + + $filePath = $folderPath . '/' . $filename; + + // Check if file already exists + if (file_exists($filePath)) { + return $filePath; + } + + $response = $this->httpClient->get($url, ['sink' => $filePath]); + + if ($response->getStatusCode() === 200) { + return $filePath; + } + + return null; + } catch (Exception $e) { + // Log error but don't throw - images are not critical + error_log("Failed to download image {$url}: " . $e->getMessage()); + return null; + } + } + + /** + * Generate a unique filename for an image + */ + public function generateFilename(string $url, string $prefix = ''): string + { + $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION); + if (empty($extension)) { + $extension = 'jpg'; // Default fallback + } + + $hash = substr(md5($url . time()), 0, 8); + + return ($prefix ? $prefix . '_' : '') . $hash . '.' . $extension; + } + + /** + * Get the public URL for a local image + */ + public function getPublicUrl(string $localPath): ?string + { + if (empty($localPath) || !file_exists($localPath)) { + return null; + } + + // Remove the public/ prefix to get the web-accessible path + $relativePath = str_replace($this->basePath . '/', '', $localPath); + + return '/' . $relativePath; + } +} diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 0000000..ea48236 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,172 @@ + '/', + 'games.index' => '/media/games', + 'movies.index' => '/media/movies', + 'tvshows.index' => '/media/tv-shows', + 'music.index' => '/media/music', + 'auth.login' => '/login', + 'auth.logout' => '/logout' + ]; + + $path = $routes[$name] ?? '/'; + + // Replace parameters in path + foreach ($params as $key => $value) { + $path = str_replace('{' . $key . '}', $value, $path); + } + + return $path; +} + +/** + * Get CSRF token for forms + */ +function csrf_token(): string +{ + if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) { + return $_SESSION['auth']->generateCSRFToken(); + } + return ''; +} + +/** + * Get current authenticated user + */ +function current_user(): ?array +{ + if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) { + return $_SESSION['auth']->getCurrentUser(); + } + return null; +} + +/** + * Check if current user is authenticated + */ +function is_logged_in(): bool +{ + if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) { + return $_SESSION['auth']->isLoggedIn(); + } + return false; +} + +/** + * Check if current user is admin + */ +function is_admin(): bool +{ + if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) { + return $_SESSION['auth']->isAdmin(); + } + return false; +} + +/** + * Format bytes to human readable format + */ +function format_bytes(int $bytes, int $precision = 2): string +{ + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= (1 << (10 * $pow)); + + return round($bytes, $precision) . ' ' . $units[$pow]; +} + +/** + * Format duration in seconds to human readable format + */ +function format_duration(int $seconds): string +{ + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + $seconds = $seconds % 60; + + if ($hours > 0) { + return sprintf('%dh %dm', $hours, $minutes); + } elseif ($minutes > 0) { + return sprintf('%dm %ds', $minutes, $seconds); + } else { + return sprintf('%ds', $seconds); + } +} + +/** + * Generate a random string + */ +function generate_random_string(int $length = 32): string +{ + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[random_int(0, $charactersLength - 1)]; + } + + return $randomString; +} + +/** + * Check if file exists and is readable + */ +function file_exists_and_readable(string $path): bool +{ + return file_exists($path) && is_readable($path); +} + +/** + * Get file extension from path + */ +function get_file_extension(string $path): string +{ + return strtolower(pathinfo($path, PATHINFO_EXTENSION)); +} + +/** + * Check if string is valid JSON + */ +function is_json(string $string): bool +{ + json_decode($string); + return json_last_error() === JSON_ERROR_NONE; +} + +/** + * Convert array to object recursively + */ +function array_to_object(array $array): object +{ + return json_decode(json_encode($array), false); +} + +/** + * Convert object to array recursively + */ +function object_to_array(object $object): array +{ + return json_decode(json_encode($object), true); +} diff --git a/check_db.php b/check_db.php new file mode 100644 index 0000000..9540723 --- /dev/null +++ b/check_db.php @@ -0,0 +1,47 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); + echo "✅ Database connection successful\n"; +} catch (Exception $e) { + die('❌ Database connection failed: ' . $e->getMessage()); +} + +// Check current movie count +try { + $stmt = $pdo->query("SELECT COUNT(*) as count FROM movies"); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + echo "📊 Current movies in database: {$result['count']}\n"; + + // Check recent sync logs + $stmt = $pdo->query("SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT 5"); + $syncLogs = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($syncLogs)) { + echo "📋 No sync logs found\n"; + } else { + echo "📋 Recent sync logs:\n"; + foreach ($syncLogs as $log) { + echo " - ID: {$log['id']}, Status: {$log['status']}, Items: {$log['processed_items']}/{$log['total_items']}, Created: {$log['created_at']}\n"; + if ($log['message']) { + echo " Message: {$log['message']}\n"; + } + } + } + +} catch (Exception $e) { + echo "❌ Error checking database: " . $e->getMessage() . "\n"; +} + +echo "\n✨ Database check completed!\n"; diff --git a/check_sources.php b/check_sources.php new file mode 100644 index 0000000..2804376 --- /dev/null +++ b/check_sources.php @@ -0,0 +1,31 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); + echo "✅ Database connection successful\n"; + + // Check sources + $stmt = $pdo->query('SELECT * FROM sources'); + $sources = $stmt->fetchAll(PDO::FETCH_ASSOC); + + echo "📋 Sources in database:\n"; + foreach ($sources as $source) { + echo " - ID: {$source['id']}, Name: {$source['name']}, Display: {$source['display_name']}\n"; + echo " API URL: {$source['api_url']}\n"; + echo " API Key: " . (empty($source['api_key']) ? 'NOT SET' : 'SET') . "\n"; + } + +} catch (Exception $e) { + echo "❌ Error: " . $e->getMessage() . "\n"; +} diff --git a/check_user.php b/check_user.php new file mode 100644 index 0000000..b4f628f --- /dev/null +++ b/check_user.php @@ -0,0 +1,39 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); + + $userModel = new \App\Models\User($pdo); + $existingUser = $userModel::findByUsername($pdo, 'admin'); + + if ($existingUser) { + echo 'Admin user exists: ' . $existingUser['username'] . PHP_EOL; + echo 'Password hash: ' . $existingUser['password'] . PHP_EOL; + echo 'Role: ' . $existingUser['role'] . PHP_EOL; + echo 'Is active: ' . $existingUser['is_active'] . PHP_EOL; + + // Test password verification + $testPassword = 'admin123'; + $userModel->password = $existingUser['password']; + if ($userModel->verifyPassword($testPassword)) { + echo 'Password verification: SUCCESS' . PHP_EOL; + } else { + echo 'Password verification: FAILED' . PHP_EOL; + } + } else { + echo 'Admin user does not exist in database.' . PHP_EOL; + } +} catch (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7836965 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "media-collector/app", + "description": "Media Collection Dashboard", + "type": "project", + "require": { + "php": "^8.1", + "vlucas/phpdotenv": "^5.5", + "guzzlehttp/guzzle": "^7.5", + "slim/slim": "^4.10", + "slim/psr7": "^1.6", + "slim/twig-view": "^3.3", + "php-di/php-di": "^7.0", + "illuminate/database": "^10.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + }, + "files": [ + "app/helpers.php" + ] + }, + "config": { + "optimize-autoloader": true + }, + "minimum-stability": "stable" +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..e4c3a01 --- /dev/null +++ b/config/database.php @@ -0,0 +1,19 @@ + env('DB_CONNECTION', 'sqlite'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', 3306), + 'database' => env('DB_DATABASE', __DIR__ . '/../database/database.sqlite'), + 'username' => env('DB_USERNAME', ''), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'prefix' => env('DB_PREFIX', ''), + 'schema' => env('DB_SCHEMA', 'public'), + 'unix_socket' => env('DB_SOCKET', ''), + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; diff --git a/database/migrations/2023_10_15_000001_create_sources_table.php b/database/migrations/2023_10_15_000001_create_sources_table.php new file mode 100644 index 0000000..f8e8289 --- /dev/null +++ b/database/migrations/2023_10_15_000001_create_sources_table.php @@ -0,0 +1,34 @@ +schema()->create('sources', function (Blueprint $table) { + $table->id(); + $table->string('name'); // steam, jellyfin, stash, xbvr + $table->string('display_name'); + $table->string('api_url')->nullable(); + $table->string('api_key')->nullable(); + $table->json('config')->nullable(); // Additional configuration + $table->boolean('is_active')->default(true); + $table->timestamp('last_sync_at')->nullable(); + $table->timestamps(); + }); + } + + public function down() + { + // Get the capsule instance from Database class + $capsule = Database::getCapsule(); + + $capsule->schema()->dropIfExists('sources'); + } +} diff --git a/database/migrations/2023_10_15_000002_create_actors_table.php b/database/migrations/2023_10_15_000002_create_actors_table.php new file mode 100644 index 0000000..94bd12e --- /dev/null +++ b/database/migrations/2023_10_15_000002_create_actors_table.php @@ -0,0 +1,27 @@ +schema()->create('actors', function ($table) { + $table->id(); + $table->string('name'); + $table->string('thumbnail_path')->nullable(); + $table->json('metadata')->nullable(); // XBVR/Stash specific data + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + //$table->index(['unique_actor_name']); + }); + } + public static function down(PDO $pdo): void + { + $sql = "DROP TABLE IF EXISTS actors;"; + $pdo->exec($sql); + } +} diff --git a/database/migrations/2023_10_15_000002_create_media_types_table.php b/database/migrations/2023_10_15_000002_create_media_types_table.php new file mode 100644 index 0000000..be99621 --- /dev/null +++ b/database/migrations/2023_10_15_000002_create_media_types_table.php @@ -0,0 +1,31 @@ +schema()->create('media_types', function (Blueprint $table) { + $table->id(); + $table->string('name'); // games, movies, tv_shows, music + $table->string('display_name'); + $table->string('icon')->nullable(); // Icon class for UI + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down() + { + // Get the capsule instance from Database class + $capsule = Database::getCapsule(); + + $capsule->schema()->dropIfExists('media_types'); + } +} diff --git a/database/migrations/2023_10_15_000003_create_games_table.php b/database/migrations/2023_10_15_000003_create_games_table.php new file mode 100644 index 0000000..8bbfba3 --- /dev/null +++ b/database/migrations/2023_10_15_000003_create_games_table.php @@ -0,0 +1,39 @@ +schema()->create('games', function ($table) { + $table->id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('genre')->nullable(); + $table->string('developer')->nullable(); + $table->string('publisher')->nullable(); + $table->date('release_date')->nullable(); + $table->string('platform')->nullable(); // PC, PlayStation, Xbox, etc. + $table->string('steam_app_id')->nullable(); // For Steam integration + $table->string('image_url')->nullable(); + $table->string('banner_url')->nullable(); + $table->decimal('rating', 3, 1)->nullable(); // 0.0 to 10.0 + $table->integer('playtime_minutes')->default(0); // Total playtime in minutes + $table->integer('completion_percentage')->default(0); // 0-100 + $table->boolean('is_installed')->default(false); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional game-specific data + $table->foreignId('source_id')->constrained('sources'); + $table->timestamp('last_played_at')->nullable(); + $table->timestamps(); + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('games'); + } +} diff --git a/database/migrations/2023_10_15_000004_create_movies_table.php b/database/migrations/2023_10_15_000004_create_movies_table.php new file mode 100644 index 0000000..be0d06d --- /dev/null +++ b/database/migrations/2023_10_15_000004_create_movies_table.php @@ -0,0 +1,42 @@ +schema()->create('movies', function ($table) { + + $table->id(); + $table->string('title'); + $table->text('overview')->nullable(); + $table->string('director')->nullable(); + $table->string('writer')->nullable(); + $table->string('genre')->nullable(); + $table->string('cast')->nullable(); // Comma-separated actors + $table->date('release_date')->nullable(); + $table->integer('runtime_minutes')->nullable(); // Runtime in minutes + $table->decimal('rating', 3, 1)->nullable(); // IMDb rating 0.0 to 10.0 + $table->string('imdb_id')->nullable(); + $table->string('tmdb_id')->nullable(); // The Movie Database ID + $table->string('poster_url')->nullable(); + $table->string('backdrop_url')->nullable(); + $table->boolean('watched')->default(false); + $table->integer('watch_count')->default(0); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional movie-specific data + $table->foreignId('source_id')->constrained('sources'); + $table->timestamp('last_watched_at')->nullable(); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('movies'); + } +} diff --git a/database/migrations/2023_10_15_000005_create_tv_shows_table.php b/database/migrations/2023_10_15_000005_create_tv_shows_table.php new file mode 100644 index 0000000..d189c96 --- /dev/null +++ b/database/migrations/2023_10_15_000005_create_tv_shows_table.php @@ -0,0 +1,41 @@ +schema()->create('tv_shows', function ($table) { + + $table->id(); + $table->string('title'); + $table->text('overview')->nullable(); + $table->string('creator')->nullable(); + $table->string('genre')->nullable(); + $table->string('cast')->nullable(); // Comma-separated actors + $table->date('first_air_date')->nullable(); + $table->date('last_air_date')->nullable(); + $table->integer('number_of_seasons')->default(0); + $table->integer('number_of_episodes')->default(0); + $table->decimal('rating', 3, 1)->nullable(); // IMDb rating 0.0 to 10.0 + $table->string('imdb_id')->nullable(); + $table->string('tmdb_id')->nullable(); // The Movie Database ID + $table->string('tvdb_id')->nullable(); // TVDB ID + $table->string('poster_url')->nullable(); + $table->string('backdrop_url')->nullable(); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional show-specific data + $table->foreignId('source_id')->constrained('sources'); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('tv_shows'); + } +} diff --git a/database/migrations/2023_10_15_000006_create_tv_episodes_table.php b/database/migrations/2023_10_15_000006_create_tv_episodes_table.php new file mode 100644 index 0000000..545a70b --- /dev/null +++ b/database/migrations/2023_10_15_000006_create_tv_episodes_table.php @@ -0,0 +1,39 @@ +schema()->create('tv_episodes', function ($table) { + + $table->id(); + $table->string('title'); + $table->text('overview')->nullable(); + $table->integer('season_number'); + $table->integer('episode_number'); + $table->date('air_date')->nullable(); + $table->integer('runtime_minutes')->nullable(); + $table->decimal('rating', 3, 1)->nullable(); + $table->string('director')->nullable(); + $table->string('writer')->nullable(); + $table->string('still_url')->nullable(); // Episode still image + $table->boolean('watched')->default(false); + $table->integer('watch_count')->default(0); + $table->json('metadata')->nullable(); // Additional episode-specific data + $table->foreignId('tv_show_id')->constrained('tv_shows')->onDelete('cascade'); + $table->foreignId('source_id')->constrained('sources'); + $table->timestamp('last_watched_at')->nullable(); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('tv_episodes'); + } +} diff --git a/database/migrations/2023_10_15_000007_create_music_artists_table.php b/database/migrations/2023_10_15_000007_create_music_artists_table.php new file mode 100644 index 0000000..ab74930 --- /dev/null +++ b/database/migrations/2023_10_15_000007_create_music_artists_table.php @@ -0,0 +1,35 @@ +schema()->create('music_artists', function ($table) { + + $table->id(); + $table->string('name'); + $table->text('biography')->nullable(); + $table->date('formed_date')->nullable(); + $table->string('genre')->nullable(); + $table->string('country')->nullable(); + $table->string('image_url')->nullable(); + $table->string('banner_url')->nullable(); + $table->string('spotify_id')->nullable(); + $table->string('musicbrainz_id')->nullable(); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional artist-specific data + $table->foreignId('source_id')->constrained('sources'); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('music_artists'); + } +} diff --git a/database/migrations/2023_10_15_000008_create_music_albums_table.php b/database/migrations/2023_10_15_000008_create_music_albums_table.php new file mode 100644 index 0000000..eb5cf37 --- /dev/null +++ b/database/migrations/2023_10_15_000008_create_music_albums_table.php @@ -0,0 +1,36 @@ +schema()->create('music_albums', function ($table) { + + $table->id(); + $table->string('title'); + $table->string('artist_name'); // Denormalized for performance + $table->date('release_date')->nullable(); + $table->string('genre')->nullable(); + $table->integer('track_count')->default(0); + $table->integer('total_duration_seconds')->default(0); + $table->string('cover_url')->nullable(); + $table->string('spotify_id')->nullable(); + $table->string('musicbrainz_id')->nullable(); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional album-specific data + $table->foreignId('artist_id')->constrained('music_artists')->onDelete('cascade'); + $table->foreignId('source_id')->constrained('sources'); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('music_albums'); + } +} diff --git a/database/migrations/2023_10_15_000009_create_music_tracks_table.php b/database/migrations/2023_10_15_000009_create_music_tracks_table.php new file mode 100644 index 0000000..24f5c7a --- /dev/null +++ b/database/migrations/2023_10_15_000009_create_music_tracks_table.php @@ -0,0 +1,37 @@ +schema()->create('music_tracks', function ($table) { + + $table->id(); + $table->string('title'); + $table->string('artist_name'); // Denormalized for performance + $table->string('album_name')->nullable(); // Denormalized for performance + $table->integer('track_number')->nullable(); + $table->integer('duration_seconds')->nullable(); + $table->string('genre')->nullable(); + $table->date('release_date')->nullable(); + $table->integer('play_count')->default(0); + $table->boolean('is_favorite')->default(false); + $table->json('metadata')->nullable(); // Additional track-specific data + $table->foreignId('artist_id')->constrained('music_artists')->onDelete('cascade'); + $table->foreignId('album_id')->nullable()->constrained('music_albums')->onDelete('set null'); + $table->foreignId('source_id')->constrained('sources'); + $table->timestamp('last_played_at')->nullable(); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('music_tracks'); + } +} diff --git a/database/migrations/2023_10_15_000010_create_sync_logs_table.php b/database/migrations/2023_10_15_000010_create_sync_logs_table.php new file mode 100644 index 0000000..a5f5a19 --- /dev/null +++ b/database/migrations/2023_10_15_000010_create_sync_logs_table.php @@ -0,0 +1,35 @@ +schema()->create('sync_logs', function ($table) { + + $table->id(); + $table->foreignId('source_id')->constrained('sources'); + $table->string('sync_type'); // full, incremental + $table->string('status'); // started, completed, failed + $table->integer('total_items')->default(0); + $table->integer('processed_items')->default(0); + $table->integer('new_items')->default(0); + $table->integer('updated_items')->default(0); + $table->integer('deleted_items')->default(0); + $table->json('errors')->nullable(); // Array of error messages + $table->text('message')->nullable(); // Additional details + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('sync_logs'); + } +} diff --git a/database/migrations/2023_10_15_000011_create_users_table.php b/database/migrations/2023_10_15_000011_create_users_table.php new file mode 100644 index 0000000..f3e9970 --- /dev/null +++ b/database/migrations/2023_10_15_000011_create_users_table.php @@ -0,0 +1,31 @@ +schema()->create('users', function ($table) { + + $table->id(); + $table->string('username')->unique(); + $table->string('email')->unique(); + $table->string('password'); + $table->string('role')->default('user'); // user, admin + $table->boolean('is_active')->default(true); + $table->timestamp('last_login_at')->nullable(); + $table->string('login_ip')->nullable(); + $table->rememberToken(); + $table->timestamps(); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('users'); + } +} diff --git a/database/migrations/2023_10_15_000012_add_game_grouping_fields.php b/database/migrations/2023_10_15_000012_add_game_grouping_fields.php new file mode 100644 index 0000000..1fc2110 --- /dev/null +++ b/database/migrations/2023_10_15_000012_add_game_grouping_fields.php @@ -0,0 +1,36 @@ +schema()->table('games', function ($table) { + + // Add game_key for grouping games across platforms + $table->string('game_key')->nullable()->after('title')->index(); + + // Add platform_game_id for platform-specific identifiers + $table->string('platform_game_id')->nullable()->after('platform'); + + // Add platform_achievements for platform-specific achievement data + $table->json('platform_achievements')->nullable()->after('metadata'); + + // Add platform_stats for platform-specific statistics + $table->json('platform_stats')->nullable()->after('platform_achievements'); + + }); + } + + public function down() + { + Database::getCapsule()->schema()->table('games', function ($table) { + + $table->dropColumn(['game_key', 'platform_game_id', 'platform_achievements', 'platform_stats']); + + }); + } +} diff --git a/database/migrations/2023_10_15_000013_create_adult_videos_table.php b/database/migrations/2023_10_15_000013_create_adult_videos_table.php new file mode 100644 index 0000000..14391c5 --- /dev/null +++ b/database/migrations/2023_10_15_000013_create_adult_videos_table.php @@ -0,0 +1,46 @@ +schema()->create('adult_videos', function ($table) { + $table->id(); + $table->string('title'); + $table->text('overview')->nullable(); + $table->string('poster_url')->nullable(); + $table->string('backdrop_url')->nullable(); + $table->decimal('rating', 3, 1)->nullable(); + $table->integer('runtime_minutes')->nullable(); + $table->date('release_date')->nullable(); + $table->string('director')->nullable(); + $table->string('writer')->nullable(); + $table->text('cast')->nullable(); + $table->string('genre')->nullable(); + $table->json('metadata')->nullable(); // XBVR/Stash specific data + $table->boolean('watched')->default(false); + $table->integer('watch_count')->default(0); + $table->boolean('is_favorite')->default(false); + $table->unsignedBigInteger('source_id'); + $table->string('external_id')->nullable(); // XBVR/Stash ID + $table->timestamps(); + + $table->foreign('source_id')->references('id')->on('sources')->onDelete('cascade'); + $table->index(['source_id', 'external_id']); + $table->index('title'); + }); + } + + public function down() + { + // Get the capsule instance from Database class + $capsule = Database::getCapsule(); + + $capsule->schema()->dropIfExists('adult_videos'); + } +} diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..a5f3050 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + app: + volumes: + - .:/var/www:cached + - /var/www/node_modules + - /var/www/vendor + environment: + - APP_ENV=development + command: > + sh -c " + composer install && + npm install && + php setup.php && + php-fpm + " + + nginx: + volumes: + - ./public:/var/www/public:ro + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "8000:80" + + # Watch for frontend changes during development + frontend-watcher: + build: . + container_name: media-collector-watcher + volumes: + - .:/var/www:cached + - /var/www/node_modules + working_dir: /var/www + command: npm run dev + profiles: + - dev diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..09bdbc7 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + app: + environment: + - APP_ENV=production + restart: unless-stopped + + nginx: + restart: unless-stopped + + db: + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f8df50 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: '3.8' + +services: + # PHP Application + app: + build: . + container_name: media-collector-app + volumes: + - ./database:/var/www/database + - ./storage:/var/www/storage + - ./.env:/var/www/.env + environment: + - APP_ENV=production + depends_on: + - db + networks: + - media-collector-network + + # Nginx Web Server + nginx: + image: nginx:alpine + container_name: media-collector-nginx + ports: + - "8000:80" + volumes: + - ./public:/var/www/public:ro + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + networks: + - media-collector-network + + # SQLite Database (file-based, using volume for persistence) + db: + image: alpine:latest + container_name: media-collector-db + volumes: + - ./database:/var/www/database + command: sh -c "touch /var/www/database/database.sqlite && chown -R 1000:1000 /var/www/database" + networks: + - media-collector-network + +networks: + media-collector-network: + driver: bridge + +volumes: + database: + storage: diff --git a/docker-start.sh b/docker-start.sh new file mode 100644 index 0000000..87c1a37 --- /dev/null +++ b/docker-start.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Media Collector Docker Startup Script + +echo "🚀 Starting Media Collector with Docker..." + +# Check if .env file exists +if [ ! -f .env ]; then + echo "⚠️ .env file not found. Copying from .env.example..." + cp .env.example .env + echo "✅ .env file created. Please edit it with your configuration." +fi + +# Check if database file exists +if [ ! -f database/database.sqlite ]; then + echo "📦 Database file not found. It will be created automatically." +fi + +echo "🐳 Building and starting containers..." +docker-compose up --build -d + +echo "" +echo "🎉 Media Collector is now running!" +echo "📱 Access the application at: http://localhost:8000" +echo "🔐 Admin panel at: http://localhost:8000/admin" +echo "" +echo "📋 Useful commands:" +echo " docker-compose logs -f # View logs" +echo " docker-compose down # Stop containers" +echo " docker-compose up -d # Start containers" +echo " docker-compose exec app bash # Access PHP container" +echo "" +echo "🔧 For development with hot-reload:" +echo " docker-compose --profile dev up" +echo "" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..cf569d2 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,55 @@ +server { + listen 80; + server_name localhost; + root /var/www/public; + index index.php index.html index.htm; + + # Handle static files + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Handle PHP files + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Security headers + fastcgi_param HTTPS off; + fastcgi_param HTTP_X_FORWARDED_PROTO $scheme; + fastcgi_param HTTP_X_REAL_IP $remote_addr; + fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Hide PHP version + fastcgi_hide_header X-Powered-By; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..24b58a7 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,110 @@ +server { + listen 80; + server_name localhost; + root /var/www/media-collector/public; + index index.php index.html index.htm; + + # Security: Hide nginx version + server_tokens off; + + # Handle static files with caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Frame-Options "SAMEORIGIN"; + try_files $uri =404; + } + + # Main location block + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PHP processing via FastCGI + location ~ \.php$ { + # Security: Don't pass requests to PHP for non-existent files + try_files $uri =404; + + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + + # FastCGI parameters + include fastcgi_params; + + # Security and proxy headers + fastcgi_param HTTPS off; + fastcgi_param HTTP_X_FORWARDED_PROTO $scheme; + fastcgi_param HTTP_X_REAL_IP $remote_addr; + fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; + + # Performance settings + fastcgi_read_timeout 300; + fastcgi_send_timeout 300; + fastcgi_connect_timeout 300; + + # Buffer settings for large uploads + fastcgi_buffer_size 128k; + fastcgi_buffers 256 16k; + fastcgi_busy_buffers_size 256k; + fastcgi_temp_file_write_size 256k; + } + + # Security headers for all responses + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Hide PHP version in headers + fastcgi_hide_header X-Powered-By; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + application/atom+xml + application/javascript + application/json + application/ld+json + application/manifest+json + application/rss+xml + application/vnd.geo+json + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml + application/xml + font/opentype + image/bmp + image/svg+xml + image/x-icon + text/cache-manifest + text/css + text/plain + text/vcard + text/vnd.rim.location.xloc + text/vtt + text/x-component + text/x-cross-domain-policy; + + # Handle .htaccess files (if using Apache-style rewrites) + location ~ /\.ht { + deny all; + } + + # Prevent access to sensitive files + location ~* \.(env|log|sql|bak|backup)$ { + deny all; + } + + # Health check endpoint for monitoring + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..37b4827 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1607 @@ +{ + "name": "media-collector", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "media-collector", + "version": "1.0.0", + "dependencies": { + "alpinejs": "^3.12.0", + "axios": "^1.4.0", + "bootstrap": "^5.3.0" + }, + "devDependencies": { + "autoprefixer": "^10.4.14", + "postcss": "^8.4.21", + "sass": "^1.93.2", + "vite": "^4.3.9" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz", + "integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.17.tgz", + "integrity": "sha512-j5zJcx6golJYTG6c05LUZ3Z8Gi+M62zRT/ycz4Xq4iCOdpcxwg7ngEYD4KA0eWZC7U17qh/Smq8bYbACJ0ipBA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", + "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f37980b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "media-collector", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "autoprefixer": "^10.4.14", + "postcss": "^8.4.21", + "sass": "^1.93.2", + "vite": "^4.3.9" + }, + "dependencies": { + "alpinejs": "^3.12.0", + "axios": "^1.4.0", + "bootstrap": "^5.3.0" + } +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..c0daa95 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,20 @@ +// Import required modules +import Alpine from 'alpinejs'; +import axios from 'axios'; + +// Initialize Alpine.js +window.Alpine = Alpine; +Alpine.start(); + +// Set up axios defaults +axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; +axios.defaults.withCredentials = true; + +// Global error handler +window.onerror = function(message, source, lineno, colno, error) { + console.error('Global error:', { message, source, lineno, colno, error }); + return false; +}; + +// Export for potential future use +export { Alpine, axios }; diff --git a/resources/scss/app.css b/resources/scss/app.css new file mode 100644 index 0000000..49bc599 --- /dev/null +++ b/resources/scss/app.css @@ -0,0 +1,2 @@ +/* Import Bootstrap CSS */ +@import '~bootstrap/dist/css/bootstrap.css'; diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig new file mode 100644 index 0000000..6650f9d --- /dev/null +++ b/resources/views/admin/index.twig @@ -0,0 +1,241 @@ +{% extends 'layouts/app.twig' %} + +{% block content %} +
+

Admin Dashboard

+

Manage your media sources and synchronization

+
+ + +
+
+

Source Management

+

Configure and sync your media sources

+
+ +
+
+ {% for source in sources %} +
+
+
+
+
{{ source.display_name }}
+ {% if source.is_active %} + Active + {% else %} + Inactive + {% endif %} +
+ +
+ +
+ + +
+ + + {% if source.last_sync_at %} +
+ Last sync: {{ source.last_sync_at|date('M j, Y H:i') }} +
+ {% else %} +
+ Never synced +
+ {% endif %} + + +
+
+
+
+
+
+ Preparing sync... +
+
+
+
+
+
+ {% endfor %} +
+
+
+ + +
+
+

Recent Sync Activity

+

Latest synchronization logs and status

+
+ +
+
+ + + + + + + + + + + + + {% for sync in recent_syncs %} + + + + + + + + + {% endfor %} + +
SourceTypeStatusProgressStartedDuration
{{ sync.source_name }}{{ sync.sync_type|title }} + {% if sync.status == 'completed' %} + Completed + {% elseif sync.status == 'failed' %} + Failed + {% elseif sync.status == 'running' %} + Running + {% else %} + {{ sync.status|title }} + {% endif %} + + {% if sync.total_items > 0 %} + {{ sync.processed_items }} / {{ sync.total_items }} + {% else %} + - + {% endif %} + + {% if sync.started_at %} + {{ sync.started_at|date('M j, H:i') }} + {% else %} + - + {% endif %} + + {% if sync.started_at and sync.completed_at %} + {% set duration = sync.completed_at|date('U') - sync.started_at|date('U') %} + {% if duration < 60 %} + {{ duration }}s + {% elseif duration < 3600 %} + {{ (duration / 60)|round }}m + {% else %} + {{ (duration / 3600)|round }}h + {% endif %} + {% else %} + - + {% endif %} +
+
+
+
+ + +{% endblock %} diff --git a/resources/views/adult/index.twig b/resources/views/adult/index.twig new file mode 100644 index 0000000..502e829 --- /dev/null +++ b/resources/views/adult/index.twig @@ -0,0 +1,307 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+

Adult Videos

+ {% if pagination.total_items > 0 %} +
+ {{ pagination.total_items }} videos + {% if search %} + matching "{{ search }}" + {% endif %} +
+ {% endif %} +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + +
+
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if movies is empty %} +
+ + + +

+ {% if search %} + No adult videos found matching "{{ search }}" + {% else %} + No adult videos found + {% endif %} +

+

+ {% if search %} + Try adjusting your search terms or browse all adult videos. + {% else %} + Adult videos will appear here after syncing with XBVR or Stash sources. + {% endif %} +

+ {% if search %} + + View all adult videos + + {% endif %} +
+ {% else %} + + {% if view_mode == 'list' %} + +
+
    + {% for movie in movies %} +
  • +
    +
    + {% 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 movies %} +
+
+ {% 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 movies %} +
+
+
+
+
+ {% 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/resources/views/adult/show.twig b/resources/views/adult/show.twig new file mode 100644 index 0000000..65dd29f --- /dev/null +++ b/resources/views/adult/show.twig @@ -0,0 +1,183 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ + + +
+
+ +
+
+
+ {% 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.watch_count > 0 %} + {{ movie.watch_count }} watch{{ movie.watch_count > 1 ? 'es' : '' }} + {% endif %} + + {% if movie.is_favorite %} + + + + + Favorite + + {% endif %} +
+
+ + + {% if movie.overview %} +
+

Overview

+

{{ movie.overview }}

+
+ {% endif %} + + +
+ + {% if movie.cast or movie.director or movie.writer %} +
+

Cast & Crew

+
+ {% if movie.director %} +
+
Director
+
{{ movie.director }}
+
+ {% endif %} + {% if movie.writer %} +
+
Writer
+
{{ movie.writer }}
+
+ {% endif %} + {% if movie.cast %} +
+
Cast
+
{{ movie.cast }}
+
+ {% endif %} +
+
+ {% endif %} + + + {% if movie.genre or metadata.studios %} +
+

Details

+
+ {% if movie.genre %} +
+
Genre
+
{{ movie.genre }}
+
+ {% endif %} + {% if metadata.studios %} +
+
Studio
+
{{ metadata.studios|join(', ') }}
+
+ {% endif %} +
+
+ {% endif %} +
+ + + {% if metadata %} +
+
+ + + + + Technical Details + +
+
{{ metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
+
+
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/resources/views/auth/login.twig b/resources/views/auth/login.twig new file mode 100644 index 0000000..c5b2f28 --- /dev/null +++ b/resources/views/auth/login.twig @@ -0,0 +1,78 @@ +{% extends 'layouts/app.twig' %} + +{% block content %} +
+
+
+

+ Sign in to Media Collector +

+

+ Access your media dashboard +

+
+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + +
+ +
+ +
+ +
+

+ Don't have an account? + + Contact your administrator + +

+
+
+
+
+{% endblock %} diff --git a/resources/views/dashboard/index.twig b/resources/views/dashboard/index.twig new file mode 100644 index 0000000..7a6efa9 --- /dev/null +++ b/resources/views/dashboard/index.twig @@ -0,0 +1,330 @@ +{% extends 'layouts/app.twig' %} + +{% block content %} +
+

Dashboard

+

Overview of your media collection

+
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + +
+ +
+
+
+
+
+ + + +
+
+
+
Total Media
+
+
{{ stats.total_media|number_format }}
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
Games
+
+
{{ stats.total_games|number_format }}
+
{{ stats.favorite_games }} favorites
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
Movies & TV
+
+
+ {{ (stats.total_movies + stats.total_tv_shows)|number_format }} +
+
{{ stats.watched_movies }} watched
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
Music
+
+
{{ stats.total_music|number_format }}
+
{{ stats.favorite_music }} favorites
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+ + + +
+
+
+
Total Playtime
+
+
+ {% if stats.total_playtime %} + {{ (stats.total_playtime / 60)|round }}h + {% else %} + 0h + {% endif %} +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
TV Episodes
+
+
{{ stats.total_episodes|number_format }}
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
Sync Status
+
+
+ {% if sync_stats.successful_syncs > 0 %} + {{ sync_stats.successful_syncs }}/{{ sync_stats.total_syncs }} Success + {% else %} + No syncs yet + {% endif %} +
+
+
+
+
+
+
+
+
+ + +
+

Recent Activity

+ + + {% if recent_games %} +
+

Recently Played Games

+
+
    + {% for game in recent_games %} +
  • +
    +
    + {% if game.image_url %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    {{ game.title }}

    +

    {{ game.source_name }}

    +
    +
    +
    + {% if game.playtime_minutes %} + {{ (game.playtime_minutes / 60)|round }}h played + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + + {% if recent_movies %} +
+

Recently Watched Movies

+
+
    + {% for movie in recent_movies %} +
  • +
    +
    + {% if movie.poster_url %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    {{ movie.title }}

    +

    {{ movie.source_name }}

    +
    +
    +
    + {% if movie.watch_count %} + Watched {{ movie.watch_count }} times + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + + {% if recent_syncs %} +
+

Recent Sync Activities

+
+
    + {% for sync in recent_syncs %} +
  • +
    +
    +
    + {% if sync.status == 'completed' %} + + + + {% elseif sync.status == 'failed' %} + + + + {% else %} + + + + {% endif %} +
    +
    +

    {{ sync.source_name }}

    +

    {{ sync.sync_type|title }} sync

    +
    +
    +
    + {{ sync.processed_items }} items • {{ sync.created_at|date('M j, Y') }} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + {% if not recent_games and not recent_movies and not recent_syncs %} +
+
+ + + +

No recent activity

+

Start adding media to see your activity here.

+
+
+ {% endif %} +
+{% endblock %} diff --git a/resources/views/games/index.twig b/resources/views/games/index.twig new file mode 100644 index 0000000..70ed769 --- /dev/null +++ b/resources/views/games/index.twig @@ -0,0 +1,282 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+

Games

+ {% if pagination.total_items > 0 %} +
+ {{ pagination.total_items }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms + {% if search %} + matching "{{ search }}" + {% endif %} +
+ {% endif %} +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + +
+
+ + {% if games is empty %} +
+ + + +

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

+

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

+ {% if search %} + + View all games + + {% endif %} +
+ {% else %} + + {% if view_mode == 'list' %} + +
+
    + {% for game in games %} +
  • +
    +
    + {% if game.image_url %} + {{ game.title }} + {% else %} +
    + + + +
    + {% endif %} +
    +

    + + {{ game.title }} + +

    +
    + {{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }} + {% if game.platforms %} + + {{ game.platforms|join(', ') }} + + {% endif %} + {{ game.total_playtime|format_duration }} played + {% if game.max_completion > 0 %} + {{ game.max_completion }}% complete + {% endif %} +
    +
    +
    + {% if game.genres %} +
    + {% for genre in game.genres|slice(0, 3) %} + + {{ genre }} + + {% endfor %} +
    + {% endif %} +
    +
  • + {% endfor %} +
+
+ + {% elseif view_mode == 'covers' %} + +
+ {% for game in games %} +
+
+ {% if game.image_url %} +
+ {{ game.title }} +
+ {% else %} +
+ + + +
+ {% endif %} +
+
+ {{ game.title }} +
+

{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}

+
+
+
+ {% endfor %} +
+ + {% else %} + +
+ {% for game in games %} +
+
+
+
+
+ {% if game.image_url %} + {{ game.title }} + {% else %} +
+ + + +
+ {% endif %} +
+
+
+ + {{ game.title }} + +
+

+ {{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }} + {% if game.platforms %} + + {{ game.platforms|join(', ') }} + + {% endif %} +

+
+
+
+
+ {{ game.total_playtime|format_duration }} played + {% if game.max_completion > 0 %} + {{ game.max_completion }}% complete + {% endif %} +
+ {% if game.genres %} +
+ {% for genre in game.genres|slice(0, 3) %} + + {{ genre }} + + {% endfor %} +
+ {% 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/resources/views/games/show.twig b/resources/views/games/show.twig new file mode 100644 index 0000000..e259c8e --- /dev/null +++ b/resources/views/games/show.twig @@ -0,0 +1,212 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+
+
+ {% if main_game.image_url %} + {{ main_game.title }} + {% else %} +
+ + + +
+ {% endif %} +
+

{{ main_game.title }}

+
+ + {{ platform_versions|length }} platform{{ platform_versions|length > 1 ? 's' : '' }} + + {% if main_game.genre %} + {{ main_game.genre }} + {% endif %} +
+
+
+ + ← Back to Games + +
+
+
+ + +
+
+ +
+ + + {% for version in platform_versions %} +
+
+
+ +
+

Game Information

+
+ {% if version.developer %} +
+
Developer
+
{{ version.developer }}
+
+ {% endif %} + {% if version.publisher %} +
+
Publisher
+
{{ version.publisher }}
+
+ {% endif %} + {% if version.release_date %} +
+
Release Date
+
{{ version.release_date|date('M j, Y') }}
+
+ {% endif %} +
+
Playtime
+
{{ version.playtime_minutes|format_duration }}
+
+ {% if version.rating %} +
+
Rating
+
{{ version.rating }}/10
+
+ {% endif %} + {% if version.completion_percentage > 0 %} +
+
Completion
+
{{ version.completion_percentage }}%
+
+ {% endif %} +
+
+ + +
+

Platform Statistics

+
+
+
Source
+
{{ version.source_name }}
+
+ {% if version.last_played_at %} +
+
Last Played
+
{{ version.last_played_at|date('M j, Y') }}
+
+ {% endif %} + {% if version.is_installed %} +
+
Status
+
+ + Installed + +
+
+ {% endif %} + {% if version.is_favorite %} +
+
Favorite
+
+ + Yes + +
+
+ {% endif %} +
+ + + {% set metadata = version.metadata|json_decode %} + {% if metadata %} +
+

Platform Details

+
+
+ {% if metadata.appid %} +
+
App ID
+
{{ metadata.appid }}
+
+ {% endif %} + {% if metadata.playtime_windows or metadata.playtime_mac or metadata.playtime_linux %} +
+
Platform Playtime
+
+ {% if metadata.playtime_windows %}Windows: {{ metadata.playtime_windows|format_duration }}{% endif %} + {% if metadata.playtime_mac %}Mac: {{ metadata.playtime_mac|format_duration }}{% endif %} + {% if metadata.playtime_linux %}Linux: {{ metadata.playtime_linux|format_duration }}{% endif %} +
+
+ {% endif %} +
+
+
+ {% endif %} +
+
+ + {% if version.description %} +
+

Description

+

{{ version.description }}

+
+ {% endif %} +
+
+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/resources/views/layouts/app.twig b/resources/views/layouts/app.twig new file mode 100644 index 0000000..59e4e30 --- /dev/null +++ b/resources/views/layouts/app.twig @@ -0,0 +1,96 @@ + + + + + + {{ title }} - Media Collector + + + {% if app_env == 'production' %} + + {% else %} + + {% endif %} + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + {% if app_env == 'production' %} + + {% else %} + + {% endif %} + + + + + diff --git a/resources/views/movies/index.twig b/resources/views/movies/index.twig new file mode 100644 index 0000000..006e984 --- /dev/null +++ b/resources/views/movies/index.twig @@ -0,0 +1,301 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+

Movies

+ {% if pagination.total_items > 0 %} +
+ {{ pagination.total_items }} movies + {% if search %} + matching "{{ search }}" + {% endif %} +
+ {% endif %} +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + +
+
+ + {% if movies is empty %} +
+ + + +

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

+

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

+ {% if search %} + + View all movies + + {% endif %} +
+ {% else %} + + {% if view_mode == 'list' %} + +
+
    + {% for movie in movies %} +
  • +
    +
    + {% 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 movies %} +
+
+ {% 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 movies %} +
+
+
+
+
+ {% 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/resources/views/movies/show.twig b/resources/views/movies/show.twig new file mode 100644 index 0000000..77987bf --- /dev/null +++ b/resources/views/movies/show.twig @@ -0,0 +1,181 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ + + +
+
+ +
+
+ {% 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.watch_count > 0 %} + + {{ movie.watch_count }} watch{{ movie.watch_count > 1 ? 'es' : '' }} + + {% endif %} + + {% if movie.is_favorite %} + + + + + Favorite + + {% endif %} +
+
+ + + {% if movie.overview %} +
+

Overview

+

{{ movie.overview }}

+
+ {% endif %} + + +
+ + {% if movie.cast or movie.director or movie.writer %} +
+

Cast & Crew

+
+ {% if movie.director %} +
+
Director
+
{{ movie.director }}
+
+ {% endif %} + {% if movie.writer %} +
+
Writer
+
{{ movie.writer }}
+
+ {% endif %} + {% if movie.cast %} +
+
Cast
+
{{ movie.cast }}
+
+ {% endif %} +
+
+ {% endif %} + + + {% if movie.genre or metadata.studios %} +
+

Details

+
+ {% if movie.genre %} +
+
Genre
+
{{ movie.genre }}
+
+ {% endif %} + {% if metadata.studios %} +
+
Studio
+
{{ metadata.studios|join(', ') }}
+
+ {% endif %} +
+
+ {% endif %} +
+ + + {% if metadata %} +
+
+ + + + + Technical Details + +
+
{{ metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
+
+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/resources/views/music/index.twig b/resources/views/music/index.twig new file mode 100644 index 0000000..38d46be --- /dev/null +++ b/resources/views/music/index.twig @@ -0,0 +1,70 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+

Music

+
+ Music collection coming soon +
+
+ +
+ +
+ + +
+ + + + +
+ +
+ + +
+ {% for mode in view_modes %} + + {% endfor %} +
+
+
+ + +
+ + + +

Music Coming Soon

+

Music collection and management features are currently in development.

+
+
+{% endblock %} diff --git a/resources/views/music/show.twig b/resources/views/music/show.twig new file mode 100644 index 0000000..a152bda --- /dev/null +++ b/resources/views/music/show.twig @@ -0,0 +1,32 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ + + + +
+ + + +

Music Details Coming Soon

+

{{ message }}

+
+
+ + + + Music ID: {{ music.id }} +
+
+
+
+{% endblock %} diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig new file mode 100644 index 0000000..a4b88b1 --- /dev/null +++ b/resources/views/search/index.twig @@ -0,0 +1,110 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+
+

Search

+ {% if search %} +

Search results for "{{ search }}"

+ {% else %} +

Search across all your media collections

+ {% endif %} +
+ + +
+
+ + +
+
+ + {% if search and results %} + + {% if results.movies %} +
+

Movies ({{ results.movies|length }})

+
+ {% for movie in results.movies %} +
+
+ {% if movie.poster_url %} +
+ {{ movie.title }} +
+ {% endif %} +
+
+ + {{ movie.title }} + +
+

{{ movie.source_name }}

+
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if results.games %} +
+

Games ({{ results.games|length }})

+
+ {% for game in results.games %} +
+
+ {% if game.image_url %} +
+ {{ game.name }} +
+ {% endif %} +
+
+ + {{ game.name }} + +
+

{{ game.source_name }}

+
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if not results.movies and not results.games %} +
+ + + +

No results found

+

Try adjusting your search terms or browse categories directly.

+
+ {% endif %} + + {% elseif search %} +
+ + + +

No results found

+

Try adjusting your search terms or browse categories directly.

+
+ {% endif %} +
+{% endblock %} diff --git a/resources/views/tvshows/index.twig b/resources/views/tvshows/index.twig new file mode 100644 index 0000000..a1239ca --- /dev/null +++ b/resources/views/tvshows/index.twig @@ -0,0 +1,70 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+

TV Shows

+
+ TV Shows collection coming soon +
+
+ +
+ +
+ + +
+ + + + +
+ +
+ + +
+ {% for mode in view_modes %} + + {% endfor %} +
+
+
+ + +
+ + + +

TV Shows Coming Soon

+

TV show collection and management features are currently in development.

+
+
+{% endblock %} diff --git a/resources/views/tvshows/show.twig b/resources/views/tvshows/show.twig new file mode 100644 index 0000000..0ba3ff4 --- /dev/null +++ b/resources/views/tvshows/show.twig @@ -0,0 +1,32 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ + + + +
+ + + +

TV Show Details Coming Soon

+

{{ message }}

+
+
+ + + + TV Show ID: {{ tvshow.id }} +
+
+
+
+{% endblock %} diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..b1eb350 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,58 @@ +get('/login', AuthController::class . ':showLogin')->setName('auth.login'); +$app->post('/login', AuthController::class . ':login')->setName('auth.login.post'); +$app->post('/logout', AuthController::class . ':logout')->setName('auth.logout'); +$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'); + + // Global Search + $group->get('/search', 'App\Controllers\SearchController:index')->setName('search.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'); + + // Movies + $mediaGroup->get('/movies', 'App\Controllers\MovieController:index')->setName('movies.index'); + $mediaGroup->get('/movies/{id:\d+}', 'App\Controllers\MovieController:show')->setName('movies.show'); + + // 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'); + + // Music + $mediaGroup->get('/music', 'App\Controllers\MusicController:index')->setName('music.index'); + $mediaGroup->get('/music/{id:\d+}', 'App\Controllers\MusicController:show')->setName('music.show'); + + // Adult Videos + $mediaGroup->get('/adult', AdultController::class . ':index')->setName('adult.index'); + $mediaGroup->get('/adult/{id:\d+}', AdultController::class . ':show')->setName('adult.show'); + }); + +})->add(AuthMiddleware::class); + +// 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); diff --git a/setup.php b/setup.php new file mode 100644 index 0000000..8781aca --- /dev/null +++ b/setup.php @@ -0,0 +1,84 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; + +// Set up database connection +try { + \App\Database\Database::setConfig($dbConfig); + $pdo = \App\Database\Database::getInstance(); + echo "Database connection established successfully.\n"; +} catch (Exception $e) { + die('Database connection failed: ' . $e->getMessage() . "\n"); +} + +// Set up Laravel facades for migrations +$capsule = \App\Database\Database::getCapsule(); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +// Run migrations +echo "Running database migrations...\n"; +try { + \App\Database\Database::migrate(); + echo "✓ Migrations completed successfully.\n"; +} catch (Exception $e) { + echo "✗ Migration failed: " . $e->getMessage() . "\n"; + exit(1); +} + +// Seed the database +echo "Seeding database with initial data...\n"; +try { + \App\Database\Database::seed(); + echo "✓ Database seeded successfully.\n"; +} catch (Exception $e) { + echo "✗ Seeding failed: " . $e->getMessage() . "\n"; + exit(1); +} + +// Create initial admin user +echo "Creating initial admin user...\n"; +try { + $adminUsername = env('ADMIN_USERNAME', 'admin'); + $adminEmail = env('ADMIN_EMAIL', 'admin@example.com'); + $adminPassword = env('ADMIN_PASSWORD', 'admin123'); + + if (empty($adminPassword)) { + echo "! Warning: ADMIN_PASSWORD not set in environment variables.\n"; + echo " Please set ADMIN_PASSWORD in your .env file or environment.\n"; + echo " You can manually create an admin user later.\n"; + } else { + $userModel = new \App\Models\User($pdo); + + // Check if admin user already exists + $existingUser = $userModel::findByUsername($pdo, $adminUsername); + if (!$existingUser) { + $userModel::createAdmin($pdo, $adminUsername, $adminEmail, $adminPassword); + echo "✓ Admin user created successfully.\n"; + echo " Username: {$adminUsername}\n"; + echo " Email: {$adminEmail}\n"; + echo " Password: {$adminPassword}\n"; + } else { + echo "✓ Admin user already exists.\n"; + } + } +} catch (Exception $e) { + echo "✗ Admin user creation failed: " . $e->getMessage() . "\n"; + exit(1); +} + +echo "\n🎉 Database setup completed successfully!\n"; +echo "You can now start your application with: php -S localhost:8000 -t public\n"; +echo "\nDefault admin credentials:\n"; +echo " Username: " . env('ADMIN_USERNAME', 'admin') . "\n"; +echo " Password: " . env('ADMIN_PASSWORD', 'admin123') . "\n"; diff --git a/setup_adult_source.php b/setup_adult_source.php new file mode 100644 index 0000000..6f844a8 --- /dev/null +++ b/setup_adult_source.php @@ -0,0 +1,154 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; + +// Set up database connection +try { + \App\Database\Database::setConfig($dbConfig); + $pdo = \App\Database\Database::getInstance(); + echo "Database connection established successfully.\n"; +} catch (Exception $e) { + die('Database connection failed: ' . $e->getMessage() . "\n"); +} + +echo "Setting up Adult Video Media Source...\n"; + +try { + + // Check if adult source already exists + $stmt = $pdo->prepare("SELECT id FROM sources WHERE name = 'adult' LIMIT 1"); + $stmt->execute(); + $existingSource = $stmt->fetch(PDO::FETCH_ASSOC); + + // Create adult_videos table if it doesn't exist + $stmt = $pdo->query("SHOW TABLES LIKE 'adult_videos'"); + if ($stmt->rowCount() == 0) { + echo "Creating adult_videos table...\n"; + + $pdo->exec(" + CREATE TABLE adult_videos ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + overview TEXT, + poster_url VARCHAR(500), + backdrop_url VARCHAR(500), + rating DECIMAL(3,1), + runtime_minutes INT, + release_date DATE, + director VARCHAR(255), + writer VARCHAR(255), + cast TEXT, + genre VARCHAR(255), + metadata JSON, + watched BOOLEAN DEFAULT FALSE, + watch_count INT DEFAULT 0, + is_favorite BOOLEAN DEFAULT FALSE, + source_id BIGINT UNSIGNED NOT NULL, + external_id VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE, + INDEX idx_source_external (source_id, external_id), + INDEX idx_title (title) + ) + "); + + echo "✓ Created adult_videos table\n"; + } else { + echo "✓ adult_videos table already exists\n"; + } + + // Check for XBVR sources and migrate their data + $stmt = $pdo->prepare("SELECT id, display_name FROM sources WHERE name = 'xbvr' AND is_active = 1"); + $stmt->execute(); + $xbvrSources = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (!empty($xbvrSources)) { + echo "\nFound XBVR sources:\n"; + foreach ($xbvrSources as $xbvrSource) { + echo "- {$xbvrSource['display_name']} (ID: {$xbvrSource['id']})\n"; + + // Count movies to migrate + $countStmt = $pdo->prepare("SELECT COUNT(*) as count FROM movies WHERE source_id = ?"); + $countStmt->execute([$xbvrSource['id']]); + $count = $countStmt->fetch(PDO::FETCH_ASSOC)['count']; + + if ($count > 0) { + echo " Migrating {$count} movies...\n"; + + // Migrate XBVR movies to adult source + $migrateStmt = $pdo->prepare(" + UPDATE movies + SET source_id = (SELECT id FROM sources WHERE name = 'adult' LIMIT 1), updated_at = NOW() + WHERE source_id = ? + "); + $migrateStmt->execute([$xbvrSource['id']]); + + echo " ✓ Migrated {$count} XBVR movies to adult source\n"; + } else { + echo " No movies to migrate\n"; + } + } + } else { + echo "\nNo active XBVR sources found\n"; + } + + // Check for Stash sources and migrate their data + $stmt = $pdo->prepare("SELECT id, display_name FROM sources WHERE name = 'stash' AND is_active = 1"); + $stmt->execute(); + $stashSources = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (!empty($stashSources)) { + echo "\nFound Stash sources:\n"; + foreach ($stashSources as $stashSource) { + echo "- {$stashSource['display_name']} (ID: {$stashSource['id']})\n"; + + // Count movies to migrate + $countStmt = $pdo->prepare("SELECT COUNT(*) as count FROM movies WHERE source_id = ?"); + $countStmt->execute([$stashSource['id']]); + $count = $countStmt->fetch(PDO::FETCH_ASSOC)['count']; + + if ($count > 0) { + echo " Migrating {$count} movies...\n"; + + // Migrate Stash movies to adult source + $migrateStmt = $pdo->prepare(" + UPDATE movies + SET source_id = (SELECT id FROM sources WHERE name = 'adult' LIMIT 1), updated_at = NOW() + WHERE source_id = ? + "); + $migrateStmt->execute([$stashSource['id']]); + + echo " ✓ Migrated {$count} Stash movies to adult source\n"; + } else { + echo " No movies to migrate\n"; + } + } + } else { + echo "\nNo active Stash sources found\n"; + } + + // Show final adult source stats + $adultSourceId = $pdo->query("SELECT id FROM sources WHERE name = 'adult' LIMIT 1")->fetch(PDO::FETCH_ASSOC)['id']; + $countStmt = $pdo->prepare("SELECT COUNT(*) as count FROM movies WHERE source_id = ?"); + $countStmt->execute([$adultSourceId]); + $adultCount = $countStmt->fetch(PDO::FETCH_ASSOC)['count']; + + echo "\n✅ Adult Video Media Source Setup Complete!\n"; + echo "Adult source now contains {$adultCount} videos\n"; + echo "\nYou can now sync adult content through the Admin Dashboard!\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} diff --git a/test_auth.php b/test_auth.php new file mode 100644 index 0000000..96b648e --- /dev/null +++ b/test_auth.php @@ -0,0 +1,70 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); +} catch (Exception $e) { + die('Database connection failed: ' . $e->getMessage()); +} + +// Start session +if (session_status() === PHP_SESSION_NONE) { + session_start(); +} + +// Test authentication service +$authService = new \App\Services\AuthService($pdo); + +echo "Testing login functionality...\n"; + +// Test 1: Check if admin user exists +$user = \App\Models\User::findByUsername($pdo, 'admin'); +if (!$user) { + echo "ERROR: Admin user not found in database!\n"; + exit(1); +} +echo "✓ Admin user exists in database\n"; + +// Test 2: Test password verification +$userModel = new \App\Models\User($pdo); +$userModel->password = $user['password']; +$passwordValid = $userModel->verifyPassword('admin123'); +if (!$passwordValid) { + echo "ERROR: Password verification failed!\n"; + exit(1); +} +echo "✓ Password verification works\n"; + +// Test 3: Test login method +$loginSuccess = $authService->login('admin', 'admin123', '127.0.0.1'); +if (!$loginSuccess) { + echo "ERROR: Login method failed!\n"; + exit(1); +} +echo "✓ Login method works\n"; + +// Test 4: Check if user is logged in +if (!$authService->isLoggedIn()) { + echo "ERROR: User is not logged in after successful login!\n"; + exit(1); +} +echo "✓ User is logged in\n"; + +// Test 5: Check if user is admin +if (!$authService->isAdmin()) { + echo "ERROR: User is not recognized as admin!\n"; + exit(1); +} +echo "✓ User is recognized as admin\n"; + +echo "\n🎉 All authentication tests passed!\n"; diff --git a/test_jellyfin.php b/test_jellyfin.php new file mode 100644 index 0000000..91baccd --- /dev/null +++ b/test_jellyfin.php @@ -0,0 +1,115 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); + echo "✅ Database connection successful\n"; +} catch (Exception $e) { + die('❌ Database connection failed: ' . $e->getMessage()); +} + +// Check if tables exist +try { + $tables = ['sources', 'movies', 'sync_logs', 'games']; + foreach ($tables as $table) { + $stmt = $pdo->query("SHOW TABLES LIKE '{$table}'"); + if ($stmt->rowCount() > 0) { + echo "✅ Table '{$table}' exists\n"; + } else { + echo "❌ Table '{$table}' does not exist\n"; + } + } +} catch (Exception $e) { + echo "❌ Error checking tables: " . $e->getMessage() . "\n"; +} + +// Test Jellyfin connectivity (if configured) +echo "\n🔍 Testing Jellyfin connectivity...\n"; +try { + $sourceModel = new \App\Models\Source($pdo); + $sources = $sourceModel->findAll(['name' => 'jellyfin']); + + if (empty($sources)) { + echo "❌ No Jellyfin source configured\n"; + exit; + } + + $jellyfinSource = $sources[0]; + echo "✅ Found Jellyfin source: {$jellyfinSource['display_name']}\n"; + + if (empty($jellyfinSource['api_key']) || empty($jellyfinSource['api_url'])) { + echo "❌ Jellyfin API key or URL not configured\n"; + exit; + } + + // Test HTTP connection to Jellyfin + $client = new GuzzleHttp\Client(['timeout' => 10]); + $url = rtrim($jellyfinSource['api_url'], '/') . '/Users'; + + echo "🔗 Testing connection to: {$url}\n"; + + $response = $client->get($url, [ + 'headers' => [ + 'X-MediaBrowser-Token' => $jellyfinSource['api_key'], + 'User-Agent' => 'MediaCollector-Test/1.0' + ] + ]); + + $httpCode = $response->getStatusCode(); + echo "✅ HTTP Response: {$httpCode}\n"; + + if ($httpCode === 200) { + $data = json_decode($response->getBody(), true); + $userCount = count($data); + echo "✅ Found {$userCount} users in Jellyfin\n"; + + if ($userCount > 0) { + $userId = $data[0]['Id']; + echo "✅ Using user ID: {$userId}\n"; + + // Test getting movies + $moviesUrl = rtrim($jellyfinSource['api_url'], '/') . "/Users/{$userId}/Items"; + echo "🔗 Testing movies endpoint: {$moviesUrl}\n"; + + $moviesResponse = $client->get($moviesUrl, [ + 'headers' => [ + 'X-MediaBrowser-Token' => $jellyfinSource['api_key'], + 'User-Agent' => 'MediaCollector-Test/1.0' + ], + 'query' => [ + 'IncludeItemTypes' => 'Movie', + 'Recursive' => 'true', + 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating' + ] + ]); + + $moviesHttpCode = $moviesResponse->getStatusCode(); + echo "✅ Movies HTTP Response: {$moviesHttpCode}\n"; + + if ($moviesHttpCode === 200) { + $moviesData = json_decode($moviesResponse->getBody(), true); + $movieCount = count($moviesData['Items'] ?? []); + echo "✅ Found {$movieCount} movies in Jellyfin library\n"; + } else { + echo "❌ Failed to fetch movies from Jellyfin\n"; + } + } + } else { + echo "❌ Jellyfin API returned HTTP {$httpCode}\n"; + } + +} catch (Exception $e) { + echo "❌ Jellyfin connectivity test failed: " . $e->getMessage() . "\n"; +} + +echo "\n✨ Test completed!\n"; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..3999c0a --- /dev/null +++ b/vite.config.js @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + root: 'public', + base: '/build/', + publicDir: 'public', + build: { + outDir: '../public/build', + emptyOutDir: true, + manifest: true, + rollupOptions: { + input: 'resources/js/app.js', + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './resources/js'), + '~': path.resolve(__dirname, './node_modules'), + }, + }, + server: { + host: '0.0.0.0', + port: 3000, + strictPort: true, + hmr: { + host: 'localhost', + }, + }, +});