mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
first commit
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -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
|
||||||
29
.env.example
Normal file
29
.env.example
Normal file
@@ -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
|
||||||
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
@@ -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
|
||||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -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"]
|
||||||
301
README.md
Normal file
301
README.md
Normal file
@@ -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 <your-repo-url>
|
||||||
|
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).
|
||||||
129
app/Controllers/AdminController.php
Normal file
129
app/Controllers/AdminController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Source;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use App\Services\SteamSyncService;
|
||||||
|
use App\Services\JellyfinSyncService;
|
||||||
|
use App\Services\StashSyncService;
|
||||||
|
use App\Services\XbvrSyncService;
|
||||||
|
use App\Services\AdultSyncService;
|
||||||
|
use App\Services\ExophaseSyncService;
|
||||||
|
use PDO;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class AdminController extends Controller
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Controllers/AdultController.php
Normal file
99
app/Controllers/AdultController.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\AdultVideo;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class AdultController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Controllers/AuthController.php
Normal file
76
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
private AuthService $auth;
|
||||||
|
|
||||||
|
public function __construct(AuthService $auth, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Controllers/Controller.php
Normal file
25
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
protected $view;
|
||||||
|
|
||||||
|
public function __construct(Twig $view)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
app/Controllers/DashboardController.php
Normal file
82
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\Movie;
|
||||||
|
use App\Models\TvShow;
|
||||||
|
use App\Models\MusicArtist;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Response $response, $args)
|
||||||
|
{
|
||||||
|
$pdo = $this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Controllers/GameController.php
Normal file
95
app/Controllers/GameController.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Game;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class GameController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Controllers/MovieController.php
Normal file
91
app/Controllers/MovieController.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Movie;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class MovieController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Controllers/MusicController.php
Normal file
72
app/Controllers/MusicController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class MusicController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Controllers/SearchController.php
Normal file
68
app/Controllers/SearchController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Controllers/TvShowController.php
Normal file
72
app/Controllers/TvShowController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class TvShowController extends Controller
|
||||||
|
{
|
||||||
|
private \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->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.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
app/Database/Database.php
Normal file
183
app/Database/Database.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?Capsule $capsule = null;
|
||||||
|
private static array $config = [];
|
||||||
|
|
||||||
|
public static function setConfig(array $config): void
|
||||||
|
{
|
||||||
|
self::$config = $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getInstance(): PDO
|
||||||
|
{
|
||||||
|
if (self::$capsule === null) {
|
||||||
|
self::connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$capsule->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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Middleware/AdminMiddleware.php
Normal file
33
app/Http/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
class AdminMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private AuthService $auth;
|
||||||
|
|
||||||
|
public function __construct(AuthService $auth)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Middleware/AuthMiddleware.php
Normal file
33
app/Http/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
class AuthMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
private AuthService $auth;
|
||||||
|
|
||||||
|
public function __construct(AuthService $auth)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/Models/AdultVideo.php
Normal file
109
app/Models/AdultVideo.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class AdultVideo extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'adult_videos';
|
||||||
|
protected array $fillable = [
|
||||||
|
'title',
|
||||||
|
'overview',
|
||||||
|
'poster_url',
|
||||||
|
'backdrop_url',
|
||||||
|
'rating',
|
||||||
|
'runtime_minutes',
|
||||||
|
'release_date',
|
||||||
|
'director',
|
||||||
|
'writer',
|
||||||
|
'cast',
|
||||||
|
'genre',
|
||||||
|
'metadata',
|
||||||
|
'watched',
|
||||||
|
'watch_count',
|
||||||
|
'is_favorite',
|
||||||
|
'source_id',
|
||||||
|
'external_id'
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
|
||||||
|
{
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$whereClause = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($search)) {
|
||||||
|
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)";
|
||||||
|
$params['search'] = "%{$search}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT av.*, s.display_name as source_name
|
||||||
|
FROM adult_videos av
|
||||||
|
JOIN sources s ON av.source_id = s.id
|
||||||
|
{$whereClause}
|
||||||
|
ORDER BY av.created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
298
app/Models/Game.php
Normal file
298
app/Models/Game.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class Game extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'games';
|
||||||
|
protected array $fillable = [
|
||||||
|
'title',
|
||||||
|
'game_key',
|
||||||
|
'description',
|
||||||
|
'genre',
|
||||||
|
'developer',
|
||||||
|
'publisher',
|
||||||
|
'release_date',
|
||||||
|
'platform',
|
||||||
|
'platform_game_id',
|
||||||
|
'steam_app_id',
|
||||||
|
'image_url',
|
||||||
|
'banner_url',
|
||||||
|
'rating',
|
||||||
|
'playtime_minutes',
|
||||||
|
'completion_percentage',
|
||||||
|
'is_installed',
|
||||||
|
'is_favorite',
|
||||||
|
'metadata',
|
||||||
|
'platform_achievements',
|
||||||
|
'platform_stats',
|
||||||
|
'source_id',
|
||||||
|
'last_played_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $casts = [
|
||||||
|
'rating' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Models/Model.php
Normal file
108
app/Models/Model.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
abstract class Model
|
||||||
|
{
|
||||||
|
protected \PDO $pdo;
|
||||||
|
protected string $table;
|
||||||
|
protected array $fillable = [];
|
||||||
|
protected array $hidden = [];
|
||||||
|
protected array $casts = [];
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/Models/Movie.php
Normal file
165
app/Models/Movie.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class Movie extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'movies';
|
||||||
|
protected array $fillable = [
|
||||||
|
'title',
|
||||||
|
'overview',
|
||||||
|
'director',
|
||||||
|
'writer',
|
||||||
|
'genre',
|
||||||
|
'cast',
|
||||||
|
'release_date',
|
||||||
|
'runtime_minutes',
|
||||||
|
'rating',
|
||||||
|
'imdb_id',
|
||||||
|
'tmdb_id',
|
||||||
|
'poster_url',
|
||||||
|
'backdrop_url',
|
||||||
|
'watched',
|
||||||
|
'watch_count',
|
||||||
|
'is_favorite',
|
||||||
|
'metadata',
|
||||||
|
'source_id',
|
||||||
|
'last_watched_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $casts = [
|
||||||
|
'runtime_minutes' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Models/Source.php
Normal file
103
app/Models/Source.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class Source extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'sources';
|
||||||
|
protected array $fillable = [
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'api_url',
|
||||||
|
'api_key',
|
||||||
|
'config',
|
||||||
|
'is_active',
|
||||||
|
'last_sync_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function games(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Models/SyncLog.php
Normal file
90
app/Models/SyncLog.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class SyncLog extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'sync_logs';
|
||||||
|
protected array $fillable = [
|
||||||
|
'source_id',
|
||||||
|
'sync_type',
|
||||||
|
'status',
|
||||||
|
'total_items',
|
||||||
|
'processed_items',
|
||||||
|
'new_items',
|
||||||
|
'updated_items',
|
||||||
|
'deleted_items',
|
||||||
|
'errors',
|
||||||
|
'message',
|
||||||
|
'started_at',
|
||||||
|
'completed_at'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $casts = [
|
||||||
|
'errors' => '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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Models/User.php
Normal file
92
app/Models/User.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class User extends Model
|
||||||
|
{
|
||||||
|
protected string $table = 'users';
|
||||||
|
protected array $fillable = [
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'role',
|
||||||
|
'is_active',
|
||||||
|
'last_login_at',
|
||||||
|
'login_ip'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $casts = [
|
||||||
|
'is_active' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/Services/AdultSyncService.php
Normal file
211
app/Services/AdultSyncService.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\AdultVideo;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use PDO;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class AdultSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private array $xbvrSource;
|
||||||
|
private array $stashSource;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
|
||||||
|
// Find XBVR and Stash sources
|
||||||
|
$this->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");
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Services/AuthService.php
Normal file
121
app/Services/AuthService.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class AuthService
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private ?array $user = null;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Services/BaseSyncService.php
Normal file
141
app/Services/BaseSyncService.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
abstract class BaseSyncService
|
||||||
|
{
|
||||||
|
protected \PDO $pdo;
|
||||||
|
protected array $source;
|
||||||
|
protected SyncLog $syncLog;
|
||||||
|
protected int $sourceId;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
$this->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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/Services/ExophaseSyncService.php
Normal file
174
app/Services/ExophaseSyncService.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ExophaseSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $baseUrl;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
315
app/Services/JellyfinSyncService.php
Normal file
315
app/Services/JellyfinSyncService.php
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Movie;
|
||||||
|
use App\Models\TvShow;
|
||||||
|
use App\Models\TvEpisode;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class JellyfinSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $baseUrl;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
486
app/Services/StashSyncService.php
Normal file
486
app/Services/StashSyncService.php
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Utils\ImageDownloader;
|
||||||
|
use App\Models\AdultVideo;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use PDO;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class StashSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $baseUrl;
|
||||||
|
private ImageDownloader $imageDownloader;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/Services/SteamSyncService.php
Normal file
166
app/Services/SteamSyncService.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class SteamSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $steamId;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/Services/XbvrSyncService.php
Normal file
257
app/Services/XbvrSyncService.php
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Utils\ImageDownloader;
|
||||||
|
use App\Models\AdultVideo;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class XbvrSyncService extends BaseSyncService
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private ?string $apiKey;
|
||||||
|
private string $baseUrl;
|
||||||
|
private ImageDownloader $imageDownloader;
|
||||||
|
private int $processedCount = 0;
|
||||||
|
private int $newCount = 0;
|
||||||
|
private int $updatedCount = 0;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, array $source)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $source);
|
||||||
|
$this->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
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Utils/ImageDownloader.php
Normal file
94
app/Utils/ImageDownloader.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Utils;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ImageDownloader
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
private string $basePath;
|
||||||
|
|
||||||
|
public function __construct(string $basePath = 'public/images')
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/helpers.php
Normal file
172
app/helpers.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for the application
|
||||||
|
*/
|
||||||
|
function base_url(): string
|
||||||
|
{
|
||||||
|
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
return $protocol . '://' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path for a named route
|
||||||
|
*/
|
||||||
|
function path_for(string $name, array $params = []): string
|
||||||
|
{
|
||||||
|
// This would be implemented with Slim's URL generator in a real application
|
||||||
|
$routes = [
|
||||||
|
'home' => '/',
|
||||||
|
'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);
|
||||||
|
}
|
||||||
47
check_db.php
Normal file
47
check_db.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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";
|
||||||
31
check_sources.php
Normal file
31
check_sources.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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";
|
||||||
|
}
|
||||||
39
check_user.php
Normal file
39
check_user.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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;
|
||||||
|
}
|
||||||
27
composer.json
Normal file
27
composer.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
19
config/database.php
Normal file
19
config/database.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'driver' => 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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateSourcesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Get the capsule instance from Database class
|
||||||
|
$capsule = Database::getCapsule();
|
||||||
|
|
||||||
|
$capsule->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateActorsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Get the capsule instance from Database class
|
||||||
|
Database::getCapsule()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateMediaTypesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Get the capsule instance from Database class
|
||||||
|
$capsule = Database::getCapsule();
|
||||||
|
|
||||||
|
$capsule->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
database/migrations/2023_10_15_000003_create_games_table.php
Normal file
39
database/migrations/2023_10_15_000003_create_games_table.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateGamesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateMoviesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateTvShowsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateTvEpisodesTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateMusicArtistsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateMusicAlbumsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateMusicTracksTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateSyncLogsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
database/migrations/2023_10_15_000011_create_users_table.php
Normal file
31
database/migrations/2023_10_15_000011_create_users_table.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateUsersTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class AddGameGroupingFields extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Database::getCapsule()->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']);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use App\Database\Database;
|
||||||
|
|
||||||
|
class CreateAdultVideosTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// Get the capsule instance from Database class
|
||||||
|
Database::getCapsule()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
docker-compose.override.yml
Normal file
36
docker-compose.override.yml
Normal file
@@ -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
|
||||||
13
docker-compose.prod.yml
Normal file
13
docker-compose.prod.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
restart: unless-stopped
|
||||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -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:
|
||||||
35
docker-start.sh
Normal file
35
docker-start.sh
Normal file
@@ -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 ""
|
||||||
55
docker/nginx.conf
Normal file
55
docker/nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
110
nginx.conf
Normal file
110
nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1607
package-lock.json
generated
Normal file
1607
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
resources/js/app.js
Normal file
20
resources/js/app.js
Normal file
@@ -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 };
|
||||||
2
resources/scss/app.css
Normal file
2
resources/scss/app.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* Import Bootstrap CSS */
|
||||||
|
@import '~bootstrap/dist/css/bootstrap.css';
|
||||||
241
resources/views/admin/index.twig
Normal file
241
resources/views/admin/index.twig
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
{% extends 'layouts/app.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Admin Dashboard</h1>
|
||||||
|
<p class="lead text-muted">Manage your media sources and synchronization</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Management -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Source Management</h2>
|
||||||
|
<p class="text-muted mb-0">Configure and sync your media sources</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for source in sources %}
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<div class="card border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="card-title mb-0">{{ source.display_name }}</h6>
|
||||||
|
{% if source.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<!-- Sync Buttons -->
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<button onclick="startSync({{ source.id }}, 'full')"
|
||||||
|
class="btn btn-primary btn-sm flex-fill">
|
||||||
|
Full Sync
|
||||||
|
</button>
|
||||||
|
<button onclick="startSync({{ source.id }}, 'incremental')"
|
||||||
|
class="btn btn-secondary btn-sm flex-fill">
|
||||||
|
Incremental
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Sync Status -->
|
||||||
|
{% if source.last_sync_at %}
|
||||||
|
<div class="small text-muted">
|
||||||
|
Last sync: {{ source.last_sync_at|date('M j, Y H:i') }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted">
|
||||||
|
Never synced
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Sync Progress (hidden by default) -->
|
||||||
|
<div id="sync-progress-{{ source.id }}" class="d-none mt-2">
|
||||||
|
<div class="progress">
|
||||||
|
<div id="sync-progress-bar-{{ source.id }}"
|
||||||
|
class="progress-bar bg-primary"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sync-status-{{ source.id }}" class="small text-muted mt-1">
|
||||||
|
Preparing sync...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Sync Activity -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Recent Sync Activity</h2>
|
||||||
|
<p class="text-muted mb-0">Latest synchronization logs and status</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for sync in recent_syncs %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-medium">{{ sync.source_name }}</td>
|
||||||
|
<td>{{ sync.sync_type|title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if sync.status == 'completed' %}
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
|
{% elseif sync.status == 'failed' %}
|
||||||
|
<span class="badge bg-danger">Failed</span>
|
||||||
|
{% elseif sync.status == 'running' %}
|
||||||
|
<span class="badge bg-warning text-dark">Running</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ sync.status|title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if sync.total_items > 0 %}
|
||||||
|
{{ sync.processed_items }} / {{ sync.total_items }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if sync.started_at %}
|
||||||
|
{{ sync.started_at|date('M j, H:i') }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% 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 %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let syncIntervals = {};
|
||||||
|
|
||||||
|
function startSync(sourceId, syncType) {
|
||||||
|
// Show progress indicator
|
||||||
|
const progressDiv = document.getElementById(`sync-progress-${sourceId}`);
|
||||||
|
const progressBar = document.getElementById(`sync-progress-bar-${sourceId}`);
|
||||||
|
const statusDiv = document.getElementById(`sync-status-${sourceId}`);
|
||||||
|
|
||||||
|
progressDiv.classList.remove('d-none');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
statusDiv.textContent = 'Starting sync...';
|
||||||
|
|
||||||
|
// Start sync via API
|
||||||
|
fetch(`/admin/sync/${sourceId}?type=${syncType}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Start monitoring sync status
|
||||||
|
monitorSyncStatus(data.sync_log_id, sourceId, progressBar, statusDiv);
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = 'Error: ' + (data.message || 'Unknown error');
|
||||||
|
progressDiv.classList.add('d-none');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
statusDiv.textContent = 'Error: ' + error.message;
|
||||||
|
progressDiv.classList.add('d-none');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorSyncStatus(syncLogId, sourceId, progressBar, statusDiv) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetch(`/admin/sync/status/${syncLogId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update progress
|
||||||
|
if (data.total_items > 0) {
|
||||||
|
const progress = (data.processed_items / data.total_items) * 100;
|
||||||
|
progressBar.style.width = progress + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
statusDiv.textContent = getStatusMessage(data);
|
||||||
|
|
||||||
|
// Stop monitoring if sync is complete or failed
|
||||||
|
if (['completed', 'failed'].includes(data.status)) {
|
||||||
|
clearInterval(interval);
|
||||||
|
delete syncIntervals[sourceId];
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById(`sync-progress-${sourceId}`).classList.add('d-none');
|
||||||
|
// Refresh page to show updated sync log
|
||||||
|
location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error monitoring sync:', error);
|
||||||
|
clearInterval(interval);
|
||||||
|
delete syncIntervals[sourceId];
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
syncIntervals[sourceId] = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusMessage(data) {
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
return `Completed: ${data.new_items} new, ${data.updated_items} updated`;
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
return 'Failed: ' + (data.errors.join(', ') || 'Unknown error');
|
||||||
|
} else if (data.status === 'running') {
|
||||||
|
return `Processing: ${data.processed_items}/${data.total_items} items`;
|
||||||
|
} else {
|
||||||
|
return data.message || 'Unknown status';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup intervals on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
Object.values(syncIntervals).forEach(interval => clearInterval(interval));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
307
resources/views/adult/index.twig
Normal file
307
resources/views/adult/index.twig
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Header with search and view controls -->
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Adult Videos</h1>
|
||||||
|
{% if pagination.total_items > 0 %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
{{ pagination.total_items }} videos
|
||||||
|
{% if search %}
|
||||||
|
matching "{{ search }}"
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search adult videos..."
|
||||||
|
class="form-control ps-5"
|
||||||
|
>
|
||||||
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- View mode switcher -->
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for mode in view_modes %}
|
||||||
|
<a
|
||||||
|
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}"
|
||||||
|
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
|
||||||
|
>
|
||||||
|
{% if mode == 'grid' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{% if mode == 'list' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ mode|title }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movies is empty %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">
|
||||||
|
{% if search %}
|
||||||
|
No adult videos found matching "{{ search }}"
|
||||||
|
{% else %}
|
||||||
|
No adult videos found
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
{% if search %}
|
||||||
|
<a href="{{ path_for('adult.index') }}" class="btn btn-primary mt-3">
|
||||||
|
View all adult videos
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Adult videos content based on view mode -->
|
||||||
|
{% if view_mode == 'list' %}
|
||||||
|
<!-- List view -->
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h3 class="h6 mb-1">
|
||||||
|
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ movie.source_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elseif view_mode == 'covers' %}
|
||||||
|
<!-- Cover grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
|
||||||
|
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
|
||||||
|
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate" title="{{ movie.title }}">
|
||||||
|
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Default grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
<a href="{{ path_for('adult.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if movie.source_name %}
|
||||||
|
<p class="card-text small text-muted mb-2">
|
||||||
|
{{ movie.source_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if movie.overview %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="card-text small text-muted" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
||||||
|
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted">
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.total_pages > 1 %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label for="per_page" class="form-label mb-0">Show:</label>
|
||||||
|
<select id="per_page" class="form-select form-select-sm w-auto">
|
||||||
|
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
|
||||||
|
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
|
||||||
|
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
|
||||||
|
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted small">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
|
||||||
|
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('per_page')?.addEventListener('change', function() {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('per_page', this.value);
|
||||||
|
url.searchParams.set('page', '1'); // Reset to first page
|
||||||
|
window.location = url.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
183
resources/views/adult/show.twig
Normal file
183
resources/views/adult/show.twig
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Back button -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ path_for('adult.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<svg class="me-2" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Adult Videos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row g-0">
|
||||||
|
<!-- Video poster -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="aspect-ratio: 2/3; background-color: #f8f9fa; border-radius: 0.375rem; overflow: hidden;">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-100 h-100" style="object-fit: cover;">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<svg class="text-muted" width="96" height="96" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video details -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-5 fw-bold text-dark mb-2">{{ movie.title }}</h1>
|
||||||
|
|
||||||
|
<!-- Video metadata -->
|
||||||
|
<div class="d-flex flex-wrap gap-3 small text-muted mb-3">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.release_date|date('Y') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="16" height="16" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.rating }}/10
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.source_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badges -->
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.watch_count > 0 %}
|
||||||
|
<span class="badge bg-primary">{{ movie.watch_count }} watch{{ movie.watch_count > 1 ? 'es' : '' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger d-flex align-items-center">
|
||||||
|
<svg class="me-1" width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"/>
|
||||||
|
</svg>
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
{% if movie.overview %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h5 fw-semibold text-dark mb-2">Overview</h2>
|
||||||
|
<p class="text-muted">{{ movie.overview }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Additional details -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Cast & Crew -->
|
||||||
|
{% if movie.cast or movie.director or movie.writer %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h3 class="h6 fw-semibold text-dark mb-3">Cast & Crew</h3>
|
||||||
|
<dl class="row g-2">
|
||||||
|
{% if movie.director %}
|
||||||
|
<div class="col-4">
|
||||||
|
<dt class="small text-muted">Director</dt>
|
||||||
|
<dd class="small text-dark">{{ movie.director }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.writer %}
|
||||||
|
<div class="col-4">
|
||||||
|
<dt class="small text-muted">Writer</dt>
|
||||||
|
<dd class="small text-dark">{{ movie.writer }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.cast %}
|
||||||
|
<div class="col-12">
|
||||||
|
<dt class="small text-muted">Cast</dt>
|
||||||
|
<dd class="small text-dark">{{ movie.cast }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Genres & Studios -->
|
||||||
|
{% if movie.genre or metadata.studios %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h3 class="h6 fw-semibold text-dark mb-3">Details</h3>
|
||||||
|
<dl class="row g-2">
|
||||||
|
{% if movie.genre %}
|
||||||
|
<div class="col-4">
|
||||||
|
<dt class="small text-muted">Genre</dt>
|
||||||
|
<dd class="small text-dark">{{ movie.genre }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if metadata.studios %}
|
||||||
|
<div class="col-12">
|
||||||
|
<dt class="small text-muted">Studio</dt>
|
||||||
|
<dd class="small text-dark">{{ metadata.studios|join(', ') }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata (for debugging/advanced users) -->
|
||||||
|
{% if metadata %}
|
||||||
|
<div class="mt-4 pt-4 border-top">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="cursor-pointer small fw-medium text-muted hover:text-dark d-flex align-items-center">
|
||||||
|
<svg class="me-2 group-open:rotate-90 transition-transform" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Technical Details
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 small">
|
||||||
|
<pre class="bg-light p-3 rounded"><code>{{ metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
78
resources/views/auth/login.twig
Normal file
78
resources/views/auth/login.twig
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends 'layouts/app.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Sign in to Media Collector
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Access your media dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||||
|
<div class="text-sm text-red-800">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" action="/login" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="sr-only">Username</label>
|
||||||
|
<input id="username" name="username" type="text" required
|
||||||
|
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="sr-only">Password</label>
|
||||||
|
<input id="password" name="password" type="password" required
|
||||||
|
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input id="remember-me" name="remember-me" type="checkbox"
|
||||||
|
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||||
|
<label for="remember-me" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="#" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||||
|
<svg class="h-5 w-5 text-indigo-500 group-hover:text-indigo-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="#" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||||
|
Contact your administrator
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
330
resources/views/dashboard/index.twig
Normal file
330
resources/views/dashboard/index.twig
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
{% extends 'layouts/app.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Dashboard</h1>
|
||||||
|
<p class="lead text-muted">Overview of your media collection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="row g-3 mt-4">
|
||||||
|
<!-- Total Media -->
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-primary" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Total Media</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">{{ stats.total_media|number_format }}</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Games -->
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-success" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Games</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">{{ stats.total_games|number_format }}</div>
|
||||||
|
<div class="text-muted small">{{ stats.favorite_games }} favorites</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Movies & TV -->
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-danger" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Movies & TV</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">
|
||||||
|
{{ (stats.total_movies + stats.total_tv_shows)|number_format }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">{{ stats.watched_movies }} watched</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Music -->
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-warning" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Music</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">{{ stats.total_music|number_format }}</div>
|
||||||
|
<div class="text-muted small">{{ stats.favorite_music }} favorites</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Stats -->
|
||||||
|
<div class="row g-3 mt-4">
|
||||||
|
<!-- Total Playtime -->
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-info" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Total Playtime</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">
|
||||||
|
{% if stats.total_playtime %}
|
||||||
|
{{ (stats.total_playtime / 60)|round }}h
|
||||||
|
{% else %}
|
||||||
|
0h
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Episodes -->
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-secondary" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">TV Episodes</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">{{ stats.total_episodes|number_format }}</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Status -->
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="text-muted" width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-muted small fw-medium">Sync Status</dt>
|
||||||
|
<dd>
|
||||||
|
<div class="h5 mb-0">
|
||||||
|
{% if sync_stats.successful_syncs > 0 %}
|
||||||
|
{{ sync_stats.successful_syncs }}/{{ sync_stats.total_syncs }} Success
|
||||||
|
{% else %}
|
||||||
|
No syncs yet
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<h2 class="h3 fw-medium text-dark">Recent Activity</h2>
|
||||||
|
|
||||||
|
<!-- Recent Games -->
|
||||||
|
{% if recent_games %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3 class="h5 fw-medium text-dark mb-3">Recently Played Games</h3>
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for game in recent_games %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if game.image_url %}
|
||||||
|
<img class="rounded me-3" style="width: 40px; height: 40px; object-fit: cover;" src="{{ game.image_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||||
|
<svg class="text-muted" width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 fw-medium">{{ game.title }}</p>
|
||||||
|
<p class="mb-0 text-muted small">{{ game.source_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if game.playtime_minutes %}
|
||||||
|
{{ (game.playtime_minutes / 60)|round }}h played
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Movies -->
|
||||||
|
{% if recent_movies %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3 class="h5 fw-medium text-dark mb-3">Recently Watched Movies</h3>
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for movie in recent_movies %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded me-3" style="width: 40px; height: 40px; object-fit: cover;" src="{{ movie.poster_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||||
|
<svg class="text-muted" width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 fw-medium">{{ movie.title }}</p>
|
||||||
|
<p class="mb-0 text-muted small">{{ movie.source_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if movie.watch_count %}
|
||||||
|
Watched {{ movie.watch_count }} times
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Recent Syncs -->
|
||||||
|
{% if recent_syncs %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3 class="h5 fw-medium text-dark mb-3">Recent Sync Activities</h3>
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for sync in recent_syncs %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if sync.status == 'completed' %}
|
||||||
|
<svg class="text-success" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
{% elseif sync.status == 'failed' %}
|
||||||
|
<svg class="text-danger" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="text-warning" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm2 6a2 2 0 114 0 2 2 0 01-4 0zm8 0a2 2 0 114 0 2 2 0 01-4 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<p class="mb-0 fw-medium">{{ sync.source_name }}</p>
|
||||||
|
<p class="mb-0 text-muted small">{{ sync.sync_type|title }} sync</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ sync.processed_items }} items • {{ sync.created_at|date('M j, Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not recent_games and not recent_movies and not recent_syncs %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">No recent activity</h3>
|
||||||
|
<p class="text-muted">Start adding media to see your activity here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
282
resources/views/games/index.twig
Normal file
282
resources/views/games/index.twig
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Header with search and view controls -->
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Games</h1>
|
||||||
|
{% if pagination.total_items > 0 %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
{{ pagination.total_items }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms
|
||||||
|
{% if search %}
|
||||||
|
matching "{{ search }}"
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search games..."
|
||||||
|
class="form-control ps-5"
|
||||||
|
>
|
||||||
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- View mode switcher -->
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for mode in view_modes %}
|
||||||
|
<a
|
||||||
|
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}"
|
||||||
|
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
|
||||||
|
>
|
||||||
|
{% if mode == 'grid' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{% if mode == 'list' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ mode|title }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if games is empty %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">
|
||||||
|
{% if search %}
|
||||||
|
No games found matching "{{ search }}"
|
||||||
|
{% else %}
|
||||||
|
No games found
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if search %}
|
||||||
|
Try adjusting your search terms or browse all games.
|
||||||
|
{% else %}
|
||||||
|
Start syncing your gaming libraries to see your games here.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if search %}
|
||||||
|
<a href="{{ path_for('games.index') }}" class="btn btn-primary mt-3">
|
||||||
|
View all games
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Games content based on view mode -->
|
||||||
|
{% if view_mode == 'list' %}
|
||||||
|
<!-- List view -->
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for game in games %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if game.image_url %}
|
||||||
|
<img class="rounded me-3" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h3 class="h6 mb-1">
|
||||||
|
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
|
||||||
|
{{ game.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||||
|
<span>{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</span>
|
||||||
|
{% if game.platforms %}
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
{{ game.platforms|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ game.total_playtime|format_duration }} played</span>
|
||||||
|
{% if game.max_completion > 0 %}
|
||||||
|
<span>{{ game.max_completion }}% complete</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if game.genres %}
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for genre in game.genres|slice(0, 3) %}
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elseif view_mode == 'covers' %}
|
||||||
|
<!-- Cover grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for game in games %}
|
||||||
|
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if game.image_url %}
|
||||||
|
<div class="position-relative" style="aspect-ratio: 3/4; overflow: hidden;">
|
||||||
|
<img src="{{ game.image_url }}" alt="{{ game.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 3/4; min-height: 200px;">
|
||||||
|
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate" title="{{ game.title }}">
|
||||||
|
{{ game.title }}
|
||||||
|
</h6>
|
||||||
|
<p class="card-text small text-muted">{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Default grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for game in games %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if game.image_url %}
|
||||||
|
<img class="rounded" style="width: 64px; height: 64px; object-fit: cover;" src="{{ game.image_url }}" alt="{{ game.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 64px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="text-decoration-none">
|
||||||
|
{{ game.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text small text-muted mb-2">
|
||||||
|
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
|
||||||
|
{% if game.platforms %}
|
||||||
|
<span class="badge bg-light text-dark ms-2">
|
||||||
|
{{ game.platforms|join(', ') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center small text-muted">
|
||||||
|
<span>{{ game.total_playtime|format_duration }} played</span>
|
||||||
|
{% if game.max_completion > 0 %}
|
||||||
|
<span>{{ game.max_completion }}% complete</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if game.genres %}
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-1">
|
||||||
|
{% for genre in game.genres|slice(0, 3) %}
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
{{ genre }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.total_pages > 1 %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label for="per_page" class="form-label mb-0">Show:</label>
|
||||||
|
<select id="per_page" class="form-select form-select-sm w-auto">
|
||||||
|
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
|
||||||
|
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
|
||||||
|
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
|
||||||
|
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted small">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
|
||||||
|
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('per_page')?.addEventListener('change', function() {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('per_page', this.value);
|
||||||
|
url.searchParams.set('page', '1'); // Reset to first page
|
||||||
|
window.location = url.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
212
resources/views/games/show.twig
Normal file
212
resources/views/games/show.twig
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- Game Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if main_game.image_url %}
|
||||||
|
<img class="h-16 w-16 rounded-lg object-cover mr-4" src="{{ main_game.image_url }}" alt="{{ main_game.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-16 w-16 rounded-lg bg-gray-200 flex items-center justify-center mr-4">
|
||||||
|
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-7 8h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{{ main_game.title }}</h1>
|
||||||
|
<div class="flex items-center space-x-4 mt-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ platform_versions|length }} platform{{ platform_versions|length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
{% if main_game.genre %}
|
||||||
|
<span class="text-sm text-gray-500">{{ main_game.genre }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ path_for('games.index') }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium">
|
||||||
|
← Back to Games
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Tabs -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8 px-6" aria-label="Tabs">
|
||||||
|
{% for version in platform_versions %}
|
||||||
|
<button
|
||||||
|
class="platform-tab whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm {{ loop.first ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}"
|
||||||
|
data-platform="{{ version.platform }}"
|
||||||
|
data-source="{{ version.source_id }}"
|
||||||
|
>
|
||||||
|
{{ version.platform }}
|
||||||
|
{% if version.source_name %}
|
||||||
|
<span class="ml-1 text-xs text-gray-400">({{ version.source_name }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Content -->
|
||||||
|
{% for version in platform_versions %}
|
||||||
|
<div class="platform-content {{ loop.first ? '' : 'hidden' }}" data-platform="{{ version.platform }}" data-source="{{ version.source_id }}">
|
||||||
|
<div class="px-6 py-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Game Info -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Game Information</h3>
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||||
|
{% if version.developer %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Developer</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.developer }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if version.publisher %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Publisher</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.publisher }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if version.release_date %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Release Date</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.release_date|date('M j, Y') }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Playtime</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.playtime_minutes|format_duration }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if version.rating %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Rating</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.rating }}/10</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if version.completion_percentage > 0 %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Completion</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.completion_percentage }}%</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Stats -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Platform Statistics</h3>
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Source</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.source_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if version.last_played_at %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Last Played</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ version.last_played_at|date('M j, Y') }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if version.is_installed %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if version.is_favorite %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Favorite</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
Yes
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Platform-specific metadata -->
|
||||||
|
{% set metadata = version.metadata|json_decode %}
|
||||||
|
{% if metadata %}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Platform Details</h4>
|
||||||
|
<div class="bg-gray-50 rounded-md p-3">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 text-sm">
|
||||||
|
{% if metadata.appid %}
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">App ID</dt>
|
||||||
|
<dd class="text-gray-900">{{ metadata.appid }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if metadata.playtime_windows or metadata.playtime_mac or metadata.playtime_linux %}
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Platform Playtime</dt>
|
||||||
|
<dd class="text-gray-900">
|
||||||
|
{% if metadata.playtime_windows %}<span>Windows: {{ metadata.playtime_windows|format_duration }}</span>{% endif %}
|
||||||
|
{% if metadata.playtime_mac %}<span class="ml-2">Mac: {{ metadata.playtime_mac|format_duration }}</span>{% endif %}
|
||||||
|
{% if metadata.playtime_linux %}<span class="ml-2">Linux: {{ metadata.playtime_linux|format_duration }}</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if version.description %}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Description</h3>
|
||||||
|
<p class="text-sm text-gray-600">{{ version.description }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Platform tab switching functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabs = document.querySelectorAll('.platform-tab');
|
||||||
|
const contents = document.querySelectorAll('.platform-content');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
const platform = this.dataset.platform;
|
||||||
|
const source = this.dataset.source;
|
||||||
|
|
||||||
|
// Update tab styles
|
||||||
|
tabs.forEach(t => {
|
||||||
|
t.classList.remove('border-indigo-500', 'text-indigo-600');
|
||||||
|
t.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
this.classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
this.classList.add('border-indigo-500', 'text-indigo-600');
|
||||||
|
|
||||||
|
// Update content visibility
|
||||||
|
contents.forEach(content => {
|
||||||
|
if (content.dataset.platform === platform && content.dataset.source === source) {
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
96
resources/views/layouts/app.twig
Normal file
96
resources/views/layouts/app.twig
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }} - Media Collector</title>
|
||||||
|
<!-- Bootstrap CSS CDN -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
{% if app_env == 'production' %}
|
||||||
|
<link rel="stylesheet" href="{{ base_url() }}/build/assets/app-{{ manifest['resources/js/app.js'].file|replace({'.js': '.css'}) }}">
|
||||||
|
{% else %}
|
||||||
|
<link rel="stylesheet" href="{{ base_url() }}/app.css">
|
||||||
|
{% endif %}
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ base_url() }}/favicon.svg">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-lg">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand fw-bold" href="{{ path_for('home') }}">Media Collector</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'home' %}active{% endif %}" href="{{ path_for('home') }}">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'games.index' %}active{% endif %}" href="{{ path_for('games.index') }}">Games</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'movies.index' %}active{% endif %}" href="{{ path_for('movies.index') }}">Movies</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'tvshows.index' %}active{% endif %}" href="{{ path_for('tvshows.index') }}">TV Shows</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'music.index' %}active{% endif %}" href="{{ path_for('music.index') }}">Music</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_route == 'adult.index' %}active{% endif %}" href="{{ path_for('adult.index') }}">Adult Videos</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<!-- Search Link -->
|
||||||
|
<a href="{{ path_for('search.index') }}" class="text-white text-decoration-none me-3">
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
Search
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Admin Link (only for admins) -->
|
||||||
|
{% if is_admin() %}
|
||||||
|
<a href="/admin" class="text-white text-decoration-none me-3">Admin</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-link text-white text-decoration-none dropdown-toggle" type="button" id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span>{{ current_user().username }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||||
|
<li><div class="dropdown-header">Signed in as<br><strong>{{ current_user().username }}</strong></div></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="/logout">Sign out</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
{% if app_env == 'production' %}
|
||||||
|
<script type="module" src="{{ base_url() }}/build/assets/{{ manifest['resources/js/app.js'].file }}"></script>
|
||||||
|
{% else %}
|
||||||
|
<script type="module" src="{{ base_url() }}/resources/js/app.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
301
resources/views/movies/index.twig
Normal file
301
resources/views/movies/index.twig
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Header with search and view controls -->
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Movies</h1>
|
||||||
|
{% if pagination.total_items > 0 %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
{{ pagination.total_items }} movies
|
||||||
|
{% if search %}
|
||||||
|
matching "{{ search }}"
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search movies..."
|
||||||
|
class="form-control ps-5"
|
||||||
|
>
|
||||||
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- View mode switcher -->
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for mode in view_modes %}
|
||||||
|
<a
|
||||||
|
href="?view={{ mode }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}"
|
||||||
|
class="btn btn-outline-secondary {{ view_mode == mode ? 'active' : '' }}"
|
||||||
|
>
|
||||||
|
{% if mode == 'grid' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{% if mode == 'list' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ mode|title }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if movies is empty %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">
|
||||||
|
{% if search %}
|
||||||
|
No movies found matching "{{ search }}"
|
||||||
|
{% else %}
|
||||||
|
No movies found
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if search %}
|
||||||
|
Try adjusting your search terms or browse all movies.
|
||||||
|
{% else %}
|
||||||
|
Start syncing your movie libraries to see your movies here.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if search %}
|
||||||
|
<a href="{{ path_for('movies.index') }}" class="btn btn-primary mt-3">
|
||||||
|
View all movies
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Movies content based on view mode -->
|
||||||
|
{% if view_mode == 'list' %}
|
||||||
|
<!-- List view -->
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h3 class="h6 mb-1">
|
||||||
|
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ movie.source_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elseif view_mode == 'covers' %}
|
||||||
|
<!-- Cover grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
|
||||||
|
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
|
||||||
|
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate" title="{{ movie.title }}">
|
||||||
|
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Default grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
<a href="{{ path_for('movies.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if movie.source_name %}
|
||||||
|
<p class="card-text small text-muted mb-2">
|
||||||
|
{{ movie.source_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if movie.overview %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="card-text small text-muted" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
||||||
|
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted">
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.total_pages > 1 %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label for="per_page" class="form-label mb-0">Show:</label>
|
||||||
|
<select id="per_page" class="form-select form-select-sm w-auto">
|
||||||
|
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
|
||||||
|
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
|
||||||
|
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
|
||||||
|
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted small">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
|
||||||
|
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('per_page')?.addEventListener('change', function() {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('per_page', this.value);
|
||||||
|
url.searchParams.set('page', '1'); // Reset to first page
|
||||||
|
window.location = url.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
181
resources/views/movies/show.twig
Normal file
181
resources/views/movies/show.twig
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- Back button -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="{{ path_for('movies.index') }}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Movies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<div class="md:flex">
|
||||||
|
<!-- Movie poster -->
|
||||||
|
<div class="md:w-1/3 p-6">
|
||||||
|
<div class="aspect-[2/3] bg-gray-200 rounded-lg overflow-hidden">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-full h-full object-cover">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-full h-full flex items-center justify-center bg-gray-100">
|
||||||
|
<svg class="h-24 w-24 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Movie details -->
|
||||||
|
<div class="md:w-2/3 p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ movie.title }}</h1>
|
||||||
|
|
||||||
|
<!-- Movie metadata -->
|
||||||
|
<div class="flex flex-wrap gap-4 text-sm text-gray-600 mb-4">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.release_date|date('Y') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.rating }}/10
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ movie.source_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badges -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.watch_count > 0 %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ movie.watch_count }} watch{{ movie.watch_count > 1 ? 'es' : '' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"/>
|
||||||
|
</svg>
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
{% if movie.overview %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Overview</h2>
|
||||||
|
<p class="text-gray-700 leading-relaxed">{{ movie.overview }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Additional details -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Cast & Crew -->
|
||||||
|
{% if movie.cast or movie.director or movie.writer %}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-md font-semibold text-gray-900 mb-3">Cast & Crew</h3>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
{% if movie.director %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Director</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{ movie.director }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.writer %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Writer</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{ movie.writer }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.cast %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Cast</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{ movie.cast }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Genres & Studios -->
|
||||||
|
{% if movie.genre or metadata.studios %}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-md font-semibold text-gray-900 mb-3">Details</h3>
|
||||||
|
<dl class="space-y-2">
|
||||||
|
{% if movie.genre %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Genre</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{ movie.genre }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if metadata.studios %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Studio</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{ metadata.studios|join(', ') }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata (for debugging/advanced users) -->
|
||||||
|
{% if metadata %}
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900 flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2 group-open:rotate-90 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Technical Details
|
||||||
|
</summary>
|
||||||
|
<div class="mt-4 text-sm">
|
||||||
|
<pre class="bg-gray-50 p-4 rounded-lg overflow-x-auto">{{ metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
70
resources/views/music/index.twig
Normal file
70
resources/views/music/index.twig
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Header with search and view controls -->
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Music</h1>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Music collection coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search music..."
|
||||||
|
class="form-control ps-5"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- View mode switcher -->
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for mode in view_modes %}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
disabled
|
||||||
|
title="Coming Soon"
|
||||||
|
>
|
||||||
|
{% if mode == 'grid' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{% elseif mode == 'list' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ mode|title }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coming Soon Message -->
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">Music Coming Soon</h3>
|
||||||
|
<p class="text-muted">Music collection and management features are currently in development.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
resources/views/music/show.twig
Normal file
32
resources/views/music/show.twig
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- Back button -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="{{ path_for('music.index') }}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Music
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coming Soon Message -->
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">Music Details Coming Soon</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ message }}</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||||
|
</svg>
|
||||||
|
Music ID: {{ music.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
110
resources/views/search/index.twig
Normal file
110
resources/views/search/index.twig
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Search</h1>
|
||||||
|
{% if search %}
|
||||||
|
<p class="lead text-muted">Search results for "{{ search }}"</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="lead text-muted">Search across all your media collections</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<form method="GET" class="mb-4">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search movies, games, and more..."
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg class="me-2" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if search and results %}
|
||||||
|
<!-- Search Results -->
|
||||||
|
{% if results.movies %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h5 fw-semibold text-dark mb-3">Movies ({{ results.movies|length }})</h2>
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in results.movies %}
|
||||||
|
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<div style="aspect-ratio: 2/3; background-color: #f8f9fa; border-radius: 0.375rem 0.375rem 0 0; overflow: hidden;">
|
||||||
|
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="w-100 h-100" style="object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate">
|
||||||
|
<a href="/media/movies/{{ movie.id }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
<p class="card-text small text-muted">{{ movie.source_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if results.games %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h5 fw-semibold text-dark mb-3">Games ({{ results.games|length }})</h2>
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for game in results.games %}
|
||||||
|
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if game.image_url %}
|
||||||
|
<div style="aspect-ratio: 16/9; background-color: #f8f9fa; border-radius: 0.375rem 0.375rem 0 0; overflow: hidden;">
|
||||||
|
<img src="{{ game.image_url }}" alt="{{ game.name }}" class="w-100 h-100" style="object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate">
|
||||||
|
<a href="/media/games/{{ game.game_key }}" class="text-decoration-none">
|
||||||
|
{{ game.name }}
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
<p class="card-text small text-muted">{{ game.source_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not results.movies and not results.games %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">No results found</h3>
|
||||||
|
<p class="text-muted">Try adjusting your search terms or browse categories directly.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elseif search %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">No results found</h3>
|
||||||
|
<p class="text-muted">Try adjusting your search terms or browse categories directly.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
70
resources/views/tvshows/index.twig
Normal file
70
resources/views/tvshows/index.twig
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<!-- Header with search and view controls -->
|
||||||
|
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center mb-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark">TV Shows</h1>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
TV Shows collection coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-3 w-100 w-sm-auto">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
value="{{ search }}"
|
||||||
|
placeholder="Search TV shows..."
|
||||||
|
class="form-control ps-5"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- View mode switcher -->
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for mode in view_modes %}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
disabled
|
||||||
|
title="Coming Soon"
|
||||||
|
>
|
||||||
|
{% if mode == 'grid' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{% elseif mode == 'list' %}
|
||||||
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ mode|title }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coming Soon Message -->
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="h5 fw-medium text-dark">TV Shows Coming Soon</h3>
|
||||||
|
<p class="text-muted">TV show collection and management features are currently in development.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
resources/views/tvshows/show.twig
Normal file
32
resources/views/tvshows/show.twig
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "layouts/app.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- Back button -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="{{ path_for('tvshows.index') }}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to TV Shows
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coming Soon Message -->
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">TV Show Details Coming Soon</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ message }}</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
TV Show ID: {{ tvshow.id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
58
routes/web.php
Normal file
58
routes/web.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
use App\Controllers\AuthController;
|
||||||
|
use App\Controllers\AdminController;
|
||||||
|
use App\Controllers\DashboardController;
|
||||||
|
use App\Controllers\GameController;
|
||||||
|
use App\Controllers\AdultController;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Http\Middleware\AdminMiddleware;
|
||||||
|
|
||||||
|
// Authentication routes (no middleware required)
|
||||||
|
$app->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);
|
||||||
84
setup.php
Normal file
84
setup.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load helper functions
|
||||||
|
require_once __DIR__ . '/app/helpers.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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";
|
||||||
154
setup_adult_source.php
Normal file
154
setup_adult_source.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load helper functions
|
||||||
|
require_once __DIR__ . '/app/helpers.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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);
|
||||||
|
}
|
||||||
70
test_auth.php
Normal file
70
test_auth.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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";
|
||||||
115
test_jellyfin.php
Normal file
115
test_jellyfin.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||||
|
$dotenv->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";
|
||||||
31
vite.config.js
Normal file
31
vite.config.js
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user