This commit is contained in:
Lars Behrends
2025-10-18 22:03:30 +02:00
parent f4c1cfc164
commit ca2d3a6960
45 changed files with 4827 additions and 326 deletions

8
.gitignore vendored
View File

@@ -82,7 +82,7 @@ dist
# Gatsby files # Gatsby files
.cache/ .cache/
public
# Storybook build outputs # Storybook build outputs
.out .out
@@ -142,3 +142,9 @@ composer.lock
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
/public/build/images/actors
/public/build/images/adult_videos
/public/public/images/actors
/public/public/images/adult_videos
/public/public/images/backdrops
/public/public/images/posters

74
analyze_deovr.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
// Read and analyze the deovr.json structure
$jsonContent = file_get_contents('c:\Users\larsb\CascadeProjects\windsurf-project\deovr.json');
if ($jsonContent === false) {
die("Could not read deovr.json\n");
}
$data = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
die("JSON decode error: " . json_last_error_msg() . "\n");
}
echo "=== XBVR DeoVR API Structure Analysis ===\n\n";
// Look for the Recent list
if (isset($data['Recent'])) {
echo "✓ Found 'Recent' list with " . count($data['Recent']) . " items\n";
echo "Structure of first Recent item:\n";
$firstItem = $data['Recent'][0];
echo json_encode($firstItem, JSON_PRETTY_PRINT) . "\n";
} else {
echo "✗ 'Recent' list not found. Available keys:\n";
echo implode(', ', array_keys($data)) . "\n";
}
// Check if there are other list structures
$possibleListKeys = ['scenes', 'content', 'videos', 'recent'];
foreach ($possibleListKeys as $key) {
if (isset($data[$key]) && is_array($data[$key])) {
echo "\n✓ Found '{$key}' list with " . count($data[$key]) . " items\n";
if (count($data[$key]) > 0) {
echo "Sample item structure:\n";
echo json_encode(array_slice($data[$key][0], 0, 5), JSON_PRETTY_PRINT) . "\n";
}
}
}
// Check if the structure contains video URLs for detail fetching
if (isset($data['Recent']) && count($data['Recent']) > 0) {
echo "\n=== Looking for detail URLs in Recent items ===\n";
$firstItem = $data['Recent'][0];
$possibleUrlFields = ['url', 'detail_url', 'video_url', 'scene_url', 'link'];
$foundUrls = [];
foreach ($possibleUrlFields as $field) {
if (isset($firstItem[$field])) {
$foundUrls[] = "{$field}: {$firstItem[$field]}";
}
}
if (!empty($foundUrls)) {
echo "Found potential detail URLs:\n";
foreach ($foundUrls as $urlInfo) {
echo " - {$urlInfo}\n";
}
} else {
echo "No obvious detail URL fields found in Recent items.\n";
echo "Available fields in first Recent item:\n";
foreach (array_keys($firstItem) as $field) {
echo " - {$field}\n";
}
}
}
echo "\n=== Summary ===\n";
echo "To properly implement XBVR sync, I need to:\n";
echo "1. Fetch the main DeoVR API response\n";
echo "2. Extract video list from: " . (isset($data['Recent']) ? 'Recent' : 'unknown') . " array\n";
echo "3. For each video, fetch detail URL to get complete information\n";
echo "4. Map the detailed fields to our database structure\n";

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use PDO;
use Slim\Views\Twig;
class ActorController extends Controller
{
private PDO $pdo;
public function __construct(PDO $pdo, Twig $view)
{
parent::__construct($view);
$this->pdo = $pdo;
}
public function show(Request $request, Response $response, $args)
{
$actorId = $args['id'];
// Get actor details with counts from all media types
$stmt = $this->pdo->prepare("
SELECT a.*,
COUNT(DISTINCT am.movie_id) as movie_count,
COUNT(DISTINCT ats.tv_show_id) as tv_show_count,
COUNT(DISTINCT aav.adult_video_id) as adult_video_count,
(COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id) + COUNT(DISTINCT aav.adult_video_id)) as total_media_count
FROM actors a
LEFT JOIN actor_movie am ON a.id = am.actor_id
LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id
LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id
WHERE a.id = :actor_id
GROUP BY a.id
");
$stmt->execute(['actor_id' => $actorId]);
$actor = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$actor) {
return $response->withStatus(404)->withHeader('Content-Type', 'text/html');
}
// Get actor's adult videos (scenes)
$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
JOIN actor_adult_video aav ON av.id = aav.adult_video_id
WHERE aav.actor_id = :actor_id
ORDER BY av.release_date DESC, av.title ASC
");
$stmt->execute(['actor_id' => $actorId]);
$scenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Get actor's movies
$stmt = $this->pdo->prepare("
SELECT m.*, s.display_name as source_name
FROM movies m
JOIN sources s ON m.source_id = s.id
JOIN actor_movie am ON m.id = am.movie_id
WHERE am.actor_id = :actor_id
ORDER BY m.release_date DESC, m.title ASC
");
$stmt->execute(['actor_id' => $actorId]);
$movies = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Get actor's TV shows
$stmt = $this->pdo->prepare("
SELECT ts.*, s.display_name as source_name
FROM tv_shows ts
JOIN sources s ON ts.source_id = s.id
JOIN actor_tv_show ats ON ts.id = ats.tv_show_id
WHERE ats.actor_id = :actor_id
ORDER BY ts.first_air_date DESC, ts.title ASC
");
$stmt->execute(['actor_id' => $actorId]);
$tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->view->render($response, 'actor/show.twig', [
'title' => $actor['name'],
'actor' => $actor,
'scenes' => $scenes,
'movies' => $movies,
'tv_shows' => $tvShows
]);
}
public function index(Request $request, Response $response, $args)
{
// Get all actors with their media counts from all types
$stmt = $this->pdo->prepare("
SELECT a.*,
COUNT(DISTINCT aav.adult_video_id) as adult_video_count,
COUNT(DISTINCT am.movie_id) as movie_count,
COUNT(DISTINCT ats.tv_show_id) as tv_show_count,
(COUNT(DISTINCT aav.adult_video_id) + COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id)) as total_media_count,
MAX(COALESCE(av.release_date, m.release_date, ts.first_air_date)) as latest_media_date
FROM actors a
LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id
LEFT JOIN adult_videos av ON aav.adult_video_id = av.id
LEFT JOIN actor_movie am ON a.id = am.actor_id
LEFT JOIN movies m ON am.movie_id = m.id
LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id
LEFT JOIN tv_shows ts ON ats.tv_show_id = ts.id
GROUP BY a.id
ORDER BY total_media_count DESC, a.name ASC
");
$stmt->execute();
$actors = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->view->render($response, 'actor/index.twig', [
'title' => 'Actors & Performers',
'actors' => $actors
]);
}
}

View File

@@ -52,6 +52,26 @@ class AdminController extends Controller
return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
} }
// Validate sync type based on source type
if ($source['name'] === 'jellyfin') {
$validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows'];
if (!in_array($syncType, $validSyncTypes)) {
return $this->json($response, [
'success' => false,
'message' => 'Invalid sync type for Jellyfin source. Valid types: ' . implode(', ', $validSyncTypes)
], 400);
}
} else {
// For other sources, only allow full/incremental
$validSyncTypes = ['full', 'incremental'];
if (!in_array($syncType, $validSyncTypes)) {
return $this->json($response, [
'success' => false,
'message' => 'Invalid sync type. Valid types: ' . implode(', ', $validSyncTypes)
], 400);
}
}
// Start sync in background (simplified - in production you'd use queues) // Start sync in background (simplified - in production you'd use queues)
$syncLogId = $this->startSync($source, $syncType); $syncLogId = $this->startSync($source, $syncType);
@@ -77,7 +97,7 @@ class AdminController extends Controller
'id' => $syncLog['id'], 'id' => $syncLog['id'],
'status' => $syncLog['status'], 'status' => $syncLog['status'],
'sync_type' => $syncLog['sync_type'], 'sync_type' => $syncLog['sync_type'],
'total_items' => $syncLog['total_items'], 'total_items' => $syncLog['total_items'] ?? 0,
'processed_items' => $syncLog['processed_items'], 'processed_items' => $syncLog['processed_items'],
'new_items' => $syncLog['new_items'], 'new_items' => $syncLog['new_items'],
'updated_items' => $syncLog['updated_items'], 'updated_items' => $syncLog['updated_items'],
@@ -85,10 +105,20 @@ class AdminController extends Controller
'started_at' => $syncLog['started_at'], 'started_at' => $syncLog['started_at'],
'completed_at' => $syncLog['completed_at'], 'completed_at' => $syncLog['completed_at'],
'message' => $syncLog['message'], 'message' => $syncLog['message'],
'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [] 'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [],
'progress_percentage' => $this->calculateProgressPercentage($syncLog)
]); ]);
} }
private function calculateProgressPercentage(array $syncLog): float
{
$total = $syncLog['total_items'] ?? 0;
if ($total <= 0) return 0;
$processed = $syncLog['processed_items'] ?? 0;
return min(100, round(($processed / $total) * 100, 2));
}
public function sources(Request $request, Response $response, $args) public function sources(Request $request, Response $response, $args)
{ {
$sourceModel = new Source($this->pdo); $sourceModel = new Source($this->pdo);
@@ -116,11 +146,9 @@ class AdminController extends Controller
case 'adult': case 'adult':
$syncService = new AdultSyncService($this->pdo, $source); $syncService = new AdultSyncService($this->pdo, $source);
break; break;
case 'exophase': case 'xbvr':
$syncService = new ExophaseSyncService($this->pdo, $source); $syncService = new XbvrSyncService($this->pdo, $source);
break; break;
default:
throw new \Exception('Unsupported source type: ' . $source['name']);
} }
// Start sync (this would typically be queued in production) // Start sync (this would typically be queued in production)

View File

@@ -46,11 +46,6 @@ class AdultController extends Controller
$video['poster_url'] = $metadata['cover_url']; $video['poster_url'] = $metadata['cover_url'];
} }
// Add other local paths if needed
if (!empty($metadata['local_screenshot_path'])) {
$video['screenshot_url'] = $metadata['local_screenshot_path'];
}
// Add actors data if available // Add actors data if available
if (!empty($metadata['actors'])) { if (!empty($metadata['actors'])) {
$video['actors'] = $metadata['actors']; $video['actors'] = $metadata['actors'];
@@ -106,7 +101,7 @@ class AdultController extends Controller
// Decode metadata for display // Decode metadata for display
$metadata = json_decode($adultVideo['metadata'], true); $metadata = json_decode($adultVideo['metadata'], true);
// Add local image paths to the video data for template compatibility // Add local image paths and other metadata to the video data for template compatibility
if (!empty($metadata['local_cover_path'])) { if (!empty($metadata['local_cover_path'])) {
$adultVideo['poster_url'] = '/public/images/'.$metadata['local_cover_path']; $adultVideo['poster_url'] = '/public/images/'.$metadata['local_cover_path'];
} elseif (!empty($metadata['cover_url'])) { } elseif (!empty($metadata['cover_url'])) {
@@ -117,10 +112,27 @@ class AdultController extends Controller
$adultVideo['screenshot_url'] = '/public/images/'.$metadata['local_screenshot_path']; $adultVideo['screenshot_url'] = '/public/images/'.$metadata['local_screenshot_path'];
} }
// Add actors data if available
if (!empty($metadata['actors'])) {
$adultVideo['actors'] = $metadata['actors'];
}
// Get actors for this adult video from the pivot table
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_adult_video aav ON a.id = aav.actor_id
WHERE aav.adult_video_id = :adult_video_id
ORDER BY a.name ASC
");
$stmt->execute(['adult_video_id' => $adultVideoId]);
$actors = $stmt->fetchAll(\PDO::FETCH_ASSOC);
return $this->view->render($response, 'adult/show.twig', [ return $this->view->render($response, 'adult/show.twig', [
'title' => $adultVideo['title'], 'title' => $adultVideo['title'],
'movie' => $adultVideo, // Keep same variable name for template compatibility 'movie' => $adultVideo, // Keep same variable name for template compatibility
'metadata' => $metadata 'metadata' => $metadata,
'actors' => $actors
]); ]);
} }

View File

@@ -82,10 +82,22 @@ class MovieController extends Controller
// Decode metadata for display // Decode metadata for display
$metadata = json_decode($movie['metadata'], true); $metadata = json_decode($movie['metadata'], true);
// Get actors for this movie
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_movie am ON a.id = am.actor_id
WHERE am.movie_id = :movie_id
ORDER BY a.name ASC
");
$stmt->execute(['movie_id' => $movieId]);
$actors = $stmt->fetchAll(\PDO::FETCH_ASSOC);
return $this->view->render($response, 'movies/show.twig', [ return $this->view->render($response, 'movies/show.twig', [
'title' => $movie['title'], 'title' => $movie['title'],
'movie' => $movie, 'movie' => $movie,
'metadata' => $metadata 'metadata' => $metadata,
'actors' => $actors
]); ]);
} }
} }

View File

@@ -62,11 +62,68 @@ class TvShowController extends Controller
{ {
$tvShowId = (int) $args['id']; $tvShowId = (int) $args['id'];
// For now, return a placeholder since TV Shows aren't implemented yet // Get TV show details
$stmt = $this->pdo->prepare("
SELECT t.*, s.display_name as source_name
FROM tv_shows t
JOIN sources s ON t.source_id = s.id
WHERE t.id = :id
");
$stmt->execute(['id' => $tvShowId]);
$tvShow = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$tvShow) {
return $response->withStatus(404);
}
// Decode metadata and other JSON fields
$metadata = json_decode($tvShow['metadata'] ?? '{}', true);
$cast = json_decode($tvShow['cast'] ?? '[]', true);
$genre = json_decode($tvShow['genre'] ?? '[]', true);
// Get actors for this TV show
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_tv_show ats ON a.id = ats.actor_id
WHERE ats.tv_show_id = :tv_show_id
ORDER BY a.name ASC
");
$stmt->execute(['tv_show_id' => $tvShowId]);
$actors = $stmt->fetchAll(\PDO::FETCH_ASSOC);
/*
// Get seasons for this TV show
$stmt = $this->pdo->prepare("
SELECT * FROM tv_seasons
WHERE tv_show_id = :tv_show_id
ORDER BY season_number ASC
");
$stmt->execute(['tv_show_id' => $tvShowId]);
$seasons = $stmt->fetchAll(\PDO::FETCH_ASSOC);
*//*
// Get episodes for each season
foreach ($seasons as &$season) {
$stmt = $this->pdo->prepare("
SELECT * FROM tv_episodes
WHERE tv_show_id = :tv_show_id AND season_number = :season_number
ORDER BY episode_number ASC
");
$stmt->execute([
'tv_show_id' => $tvShowId,
'season_number' => $season['season_number']
]);
$season['episodes'] = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
unset($season); // Unset reference
*/
return $this->view->render($response, 'tvshows/show.twig', [ return $this->view->render($response, 'tvshows/show.twig', [
'title' => 'TV Show Details', 'title' => $tvShow['title'],
'tvshow' => ['id' => $tvShowId, 'title' => 'Coming Soon'], 'tvshow' => $tvShow,
'message' => 'TV show details page is not yet implemented.' 'metadata' => $metadata,
'cast' => $cast,
'genre' => $genre,
'actors' => $actors,
'seasons' => $seasons
]); ]);
} }
} }

179
app/Models/Actor.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
namespace App\Models;
class Actor extends Model
{
protected string $table = 'actors';
protected array $fillable = [
'name',
'thumbnail_path',
'metadata'
];
protected array $casts = [
'metadata' => 'array'
];
/**
* Get all movies this actor is associated with
*/
public function movies()
{
$stmt = $this->pdo->prepare("
SELECT m.*, s.display_name as source_name
FROM movies m
JOIN sources s ON m.source_id = s.id
JOIN actor_movie am ON m.id = am.movie_id
WHERE am.actor_id = :actor_id
ORDER BY m.release_date DESC, m.title ASC
");
$stmt->execute(['actor_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get all TV shows this actor is associated with
*/
public function tvShows()
{
$stmt = $this->pdo->prepare("
SELECT ts.*, s.display_name as source_name
FROM tv_shows ts
JOIN sources s ON ts.source_id = s.id
JOIN actor_tv_show ats ON ts.id = ats.tv_show_id
WHERE ats.actor_id = :actor_id
ORDER BY ts.first_air_date DESC, ts.title ASC
");
$stmt->execute(['actor_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get all adult videos this actor is associated with
*/
public function adultVideos()
{
$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
JOIN actor_adult_video aav ON av.id = aav.adult_video_id
WHERE aav.actor_id = :actor_id
ORDER BY av.release_date DESC, av.title ASC
");
$stmt->execute(['actor_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get actor statistics
*/
public function getStats(): array
{
$stmt = $this->pdo->prepare("
SELECT
COUNT(DISTINCT am.movie_id) as movie_count,
COUNT(DISTINCT ats.tv_show_id) as tv_show_count,
COUNT(DISTINCT aav.adult_video_id) as adult_video_count,
COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id) + COUNT(DISTINCT aav.adult_video_id) as total_media_count
FROM actors a
LEFT JOIN actor_movie am ON a.id = am.actor_id
LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id
LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id
WHERE a.id = :actor_id
");
$stmt->execute(['actor_id' => $this->id]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Link actor to a movie
*/
public function linkToMovie(int $movieId): bool
{
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_movie (actor_id, movie_id)
VALUES (:actor_id, :movie_id)
");
return $stmt->execute([
'actor_id' => $this->id,
'movie_id' => $movieId
]);
}
/**
* Link actor to a TV show
*/
public function linkToTvShow(int $tvShowId): bool
{
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_tv_show (actor_id, tv_show_id)
VALUES (:actor_id, :tv_show_id)
");
return $stmt->execute([
'actor_id' => $this->id,
'tv_show_id' => $tvShowId
]);
}
/**
* Link actor to an adult video
*/
public function linkToAdultVideo(int $adultVideoId): bool
{
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_adult_video (actor_id, adult_video_id)
VALUES (:actor_id, :adult_video_id)
");
return $stmt->execute([
'actor_id' => $this->id,
'adult_video_id' => $adultVideoId
]);
}
/**
* Unlink actor from a movie
*/
public function unlinkFromMovie(int $movieId): bool
{
$stmt = $this->pdo->prepare("
DELETE FROM actor_movie
WHERE actor_id = :actor_id AND movie_id = :movie_id
");
return $stmt->execute([
'actor_id' => $this->id,
'movie_id' => $movieId
]);
}
/**
* Unlink actor from a TV show
*/
public function unlinkFromTvShow(int $tvShowId): bool
{
$stmt = $this->pdo->prepare("
DELETE FROM actor_tv_show
WHERE actor_id = :actor_id AND tv_show_id = :tv_show_id
");
return $stmt->execute([
'actor_id' => $this->id,
'tv_show_id' => $tvShowId
]);
}
/**
* Unlink actor from an adult video
*/
public function unlinkFromAdultVideo(int $adultVideoId): bool
{
$stmt = $this->pdo->prepare("
DELETE FROM actor_adult_video
WHERE actor_id = :actor_id AND adult_video_id = :adult_video_id
");
return $stmt->execute([
'actor_id' => $this->id,
'adult_video_id' => $adultVideoId
]);
}
}

View File

@@ -106,4 +106,64 @@ class AdultVideo extends Model
return $sourceData ? new Source($this->pdo, $sourceData) : null; return $sourceData ? new Source($this->pdo, $sourceData) : null;
} }
/**
* Get all actors associated with this adult video
*/
public function actors()
{
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_adult_video aav ON a.id = aav.actor_id
WHERE aav.adult_video_id = :adult_video_id
ORDER BY a.name ASC
");
$stmt->execute(['adult_video_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Add an actor to this adult video
*/
public function addActor(int $actorId): bool
{
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_adult_video (adult_video_id, actor_id)
VALUES (:adult_video_id, :actor_id)
");
return $stmt->execute([
'adult_video_id' => $this->id,
'actor_id' => $actorId
]);
}
/**
* Remove an actor from this adult video
*/
public function removeActor(int $actorId): bool
{
$stmt = $this->pdo->prepare("
DELETE FROM actor_adult_video
WHERE adult_video_id = :adult_video_id AND actor_id = :actor_id
");
return $stmt->execute([
'adult_video_id' => $this->id,
'actor_id' => $actorId
]);
}
/**
* Update the cast field with actor names
*/
public function updateCastField(): bool
{
$actors = $this->actors();
$actorNames = array_column($actors, 'name');
$castString = implode(', ', $actorNames);
return $this->update($this->id, [
'cast' => $castString
]);
}
} }

View File

@@ -11,10 +11,37 @@ abstract class Model
protected array $fillable = []; protected array $fillable = [];
protected array $hidden = []; protected array $hidden = [];
protected array $casts = []; protected array $casts = [];
protected array $attributes = [];
public function __construct(\PDO $pdo) public function __construct(\PDO $pdo, array $data = [])
{ {
$this->pdo = $pdo; $this->pdo = $pdo;
$this->attributes = $data;
}
public function __get(string $key)
{
return $this->attributes[$key] ?? null;
}
public function __set(string $key, $value)
{
$this->attributes[$key] = $value;
}
public function __isset(string $key): bool
{
return isset($this->attributes[$key]);
}
public function getAttributes(): array
{
return $this->attributes;
}
public function setAttributes(array $data)
{
$this->attributes = $data;
} }
public function find(int $id): ?array public function find(int $id): ?array

View File

@@ -39,7 +39,11 @@ class Movie extends Model
public function source() public function source()
{ {
return new Source($this->pdo); $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;
} }
public function markAsWatched(): bool public function markAsWatched(): bool
@@ -88,7 +92,7 @@ class Movie extends Model
SUM(runtime_minutes) as total_runtime SUM(runtime_minutes) as total_runtime
FROM movies FROM movies
"); ");
return $stmt->fetch(PDO::FETCH_ASSOC); return $stmt->fetch(\PDO::FETCH_ASSOC);
} }
public static function getRecent(\PDO $pdo, int $limit = 10): array public static function getRecent(\PDO $pdo, int $limit = 10): array
@@ -102,7 +106,7 @@ class Movie extends Model
LIMIT :limit LIMIT :limit
"); ");
$stmt->execute(['limit' => $limit]); $stmt->execute(['limit' => $limit]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public static function getTotalCount(\PDO $pdo, string $search = ''): int public static function getTotalCount(\PDO $pdo, string $search = ''): int
@@ -162,4 +166,64 @@ class Movie extends Model
$stmt->execute(['limit' => $limit]); $stmt->execute(['limit' => $limit]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
/**
* Get all actors associated with this movie
*/
public function actors()
{
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_movie am ON a.id = am.actor_id
WHERE am.movie_id = :movie_id
ORDER BY a.name ASC
");
$stmt->execute(['movie_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Add an actor to this movie
*/
public function addActor(int $actorId): bool
{
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_movie (movie_id, actor_id)
VALUES (:movie_id, :actor_id)
");
return $stmt->execute([
'movie_id' => $this->id,
'actor_id' => $actorId
]);
}
/**
* Remove an actor from this movie
*/
public function removeActor(int $actorId): bool
{
$stmt = $this->pdo->prepare("
DELETE FROM actor_movie
WHERE movie_id = :movie_id AND actor_id = :actor_id
");
return $stmt->execute([
'movie_id' => $this->id,
'actor_id' => $actorId
]);
}
/**
* Update the cast field with actor names
*/
public function updateCastField(): bool
{
$actors = $this->actors();
$actorNames = array_column($actors, 'name');
$castString = implode(', ', $actorNames);
return $this->update($this->id, [
'cast' => $castString
]);
}
} }

View File

@@ -15,39 +15,44 @@ class Source extends Model
'last_sync_at' 'last_sync_at'
]; ];
public function __construct(\PDO $pdo, array $data = [])
{
parent::__construct($pdo, $data);
}
public function games(): array public function games(): array
{ {
$stmt = $this->pdo->prepare("SELECT * FROM games WHERE source_id = :source_id"); $stmt = $this->pdo->prepare("SELECT * FROM games WHERE source_id = :source_id");
$stmt->execute(['source_id' => $this->id]); $stmt->execute(['source_id' => $this->id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public function movies(): array public function movies(): array
{ {
$stmt = $this->pdo->prepare("SELECT * FROM movies WHERE source_id = :source_id"); $stmt = $this->pdo->prepare("SELECT * FROM movies WHERE source_id = :source_id");
$stmt->execute(['source_id' => $this->id]); $stmt->execute(['source_id' => $this->id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public function tvShows(): array public function tvShows(): array
{ {
$stmt = $this->pdo->prepare("SELECT * FROM tv_shows WHERE source_id = :source_id"); $stmt = $this->pdo->prepare("SELECT * FROM tv_shows WHERE source_id = :source_id");
$stmt->execute(['source_id' => $this->id]); $stmt->execute(['source_id' => $this->id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public function musicArtists(): array public function musicArtists(): array
{ {
$stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE source_id = :source_id"); $stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE source_id = :source_id");
$stmt->execute(['source_id' => $this->id]); $stmt->execute(['source_id' => $this->id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public function getSyncLogs(): array public function getSyncLogs(): array
{ {
$stmt = $this->pdo->prepare("SELECT * FROM sync_logs WHERE source_id = :source_id ORDER BY created_at DESC"); $stmt = $this->pdo->prepare("SELECT * FROM sync_logs WHERE source_id = :source_id ORDER BY created_at DESC");
$stmt->execute(['source_id' => $this->id]); $stmt->execute(['source_id' => $this->id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} }
public function createSyncLog(string $syncType, string $status): int public function createSyncLog(string $syncType, string $status): int

178
app/Models/TvEpisode.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
namespace App\Models;
class TvEpisode extends Model
{
protected string $table = 'tv_episodes';
protected array $fillable = [
'title',
'overview',
'season_number',
'episode_number',
'air_date',
'runtime_minutes',
'rating',
'imdb_id',
'tmdb_id',
'tvdb_id',
'poster_url',
'backdrop_url',
'is_watched',
'is_favorite',
'metadata',
'tv_show_id',
'source_id'
];
protected array $casts = [
'season_number' => 'int',
'episode_number' => 'int',
'runtime_minutes' => 'int',
'rating' => 'float',
'is_watched' => 'bool',
'is_favorite' => 'bool',
'air_date' => 'date',
'metadata' => 'array'
];
/**
* Get all actors associated with this TV episode
*/
public function actors()
{
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_tv_episode ate ON a.id = ate.actor_id
WHERE ate.tv_episode_id = :tv_episode_id
ORDER BY a.name ASC
");
$stmt->execute(['tv_episode_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get the TV show this episode belongs to
*/
public function tvShow()
{
$stmt = $this->pdo->prepare("
SELECT * FROM tv_shows WHERE id = :tv_show_id
");
$stmt->execute(['tv_show_id' => $this->tv_show_id]);
$showData = $stmt->fetch(\PDO::FETCH_ASSOC);
return $showData ? new TvShow($this->pdo, $showData) : null;
}
/**
* Get TV episode statistics
*/
public static function getStats(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT
COUNT(*) as total_episodes,
COUNT(CASE WHEN is_watched = 1 THEN 1 END) as watched_episodes,
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_episodes,
AVG(rating) as avg_rating
FROM tv_episodes
");
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Get total count with optional search
*/
public static function getTotalCount(\PDO $pdo, string $search = ''): int
{
$sql = "SELECT COUNT(*) as count FROM tv_episodes te JOIN tv_shows ts ON te.tv_show_id = ts.id JOIN sources s ON te.source_id = s.id";
$params = [];
if (!empty($search)) {
$sql .= " WHERE te.title LIKE :search OR ts.title LIKE :search";
$params['search'] = "%{$search}%";
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetch()['count'];
}
/**
* Get all TV episodes with pagination and optional search
*/
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
{
$offset = ($page - 1) * $perPage;
$sql = "
SELECT te.*, ts.title as show_title, s.display_name as source_name
FROM tv_episodes te
JOIN tv_shows ts ON te.tv_show_id = ts.id
JOIN sources s ON te.source_id = s.id
";
$params = [];
if (!empty($search)) {
$sql .= " WHERE te.title LIKE :search OR ts.title LIKE :search";
$params['search'] = "%{$search}%";
}
$sql .= " ORDER BY ts.title ASC, te.season_number ASC, te.episode_number 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);
}
/**
* Toggle watched status
*/
public function toggleWatched(): bool
{
return $this->update($this->id, [
'is_watched' => !$this->is_watched
]);
}
/**
* Toggle favorite status
*/
public function toggleFavorite(): bool
{
return $this->update($this->id, [
'is_favorite' => !$this->is_favorite
]);
}
/**
* Update rating
*/
public function updateRating(float $rating): bool
{
return $this->update($this->id, [
'rating' => min(10.0, max(0.0, $rating))
]);
}
/**
* Get the source relationship
*/
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;
}
}

256
app/Models/TvShow.php Normal file
View File

@@ -0,0 +1,256 @@
<?php
namespace App\Models;
class TvShow extends Model
{
protected string $table = 'tv_shows';
protected array $fillable = [
'title',
'overview',
'creator',
'genre',
'cast',
'first_air_date',
'last_air_date',
'number_of_seasons',
'number_of_episodes',
'rating',
'imdb_id',
'tmdb_id',
'tvdb_id',
'poster_url',
'backdrop_url',
'is_favorite',
'metadata',
'source_id'
];
protected array $casts = [
'number_of_seasons' => 'int',
'number_of_episodes' => 'int',
'rating' => 'float',
'is_favorite' => 'bool',
'first_air_date' => 'date',
'last_air_date' => 'date',
'metadata' => 'array'
];
/**
* Get all actors associated with this TV show
*/
public function actors()
{
$stmt = $this->pdo->prepare("
SELECT a.*
FROM actors a
JOIN actor_tv_show ats ON a.id = ats.actor_id
WHERE ats.tv_show_id = :tv_show_id
ORDER BY a.name ASC
");
$stmt->execute(['tv_show_id' => $this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get TV show statistics
*/
public static function getStats(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT
COUNT(*) as total_tv_shows,
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_tv_shows,
AVG(rating) as avg_rating
FROM tv_shows
");
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Get total count with optional search
*/
public static function getTotalCount(\PDO $pdo, string $search = ''): int
{
$sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id";
$params = [];
if (!empty($search)) {
$sql .= " WHERE t.title LIKE :search";
$params['search'] = "%{$search}%";
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetch()['count'];
}
/**
* Get all TV shows with pagination and optional search
*/
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
{
$offset = ($page - 1) * $perPage;
$sql = "
SELECT t.*, s.display_name as source_name
FROM tv_shows t
JOIN sources s ON t.source_id = s.id
";
$params = [];
if (!empty($search)) {
$sql .= " WHERE t.title LIKE :search";
$params['search'] = "%{$search}%";
}
$sql .= " ORDER BY t.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);
}
/**
* Toggle favorite status
*/
public function toggleFavorite(): bool
{
return $this->update($this->id, [
'is_favorite' => !$this->is_favorite
]);
}
/**
* Update rating
*/
public function updateRating(float $rating): bool
{
return $this->update($this->id, [
'rating' => min(10.0, max(0.0, $rating))
]);
}
/**
* Get the source relationship
*/
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;
}
public function getSeasonsWithEpisodes(): array
{
$stmt = $this->pdo->prepare("
SELECT s.*,
COUNT(e.id) as episode_count,
SUM(CASE WHEN e.watched = 1 THEN 1 ELSE 0 END) as watched_episodes
FROM seasons s
LEFT JOIN episodes e ON s.id = e.season_id
WHERE s.tv_show_id = :tv_show_id
GROUP BY s.id
ORDER BY s.season_number ASC
");
$stmt->execute(['tv_show_id' => $this->id]);
$seasons = $stmt->fetchAll(\PDO::FETCH_ASSOC);
// Get episodes for each season
foreach ($seasons as &$season) {
$stmt = $this->pdo->prepare("
SELECT e.*,
(SELECT COUNT(*) FROM user_episodes WHERE episode_id = e.id AND watched = 1) as watch_count
FROM episodes e
WHERE e.season_id = :season_id
ORDER BY e.episode_number ASC
");
$stmt->execute(['season_id' => $season['id']]);
$season['episodes'] = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
return $seasons;
}
/**
* Get similar TV shows based on genres
*/
public function getSimilarShows(int $limit = 6): array
{
$genres = $this->genre ? explode(',', $this->genre) : [];
$placeholders = str_repeat('?,', count($genres) - 1) . '?';
$sql = "
SELECT t.*,
COUNT(DISTINCT g.genre) as matching_genres
FROM tv_shows t
CROSS JOIN (SELECT TRIM(value) as genre
FROM json_each('[\"" . str_replace(',', '","', str_replace('"', '\\"', $this->genre)) . "\"]')
WHERE value != '') g
WHERE t.id != ?
AND t.genre LIKE '%' || g.genre || '%'
GROUP BY t.id
HAVING matching_genres > 0
ORDER BY matching_genres DESC, t.rating DESC
LIMIT ?
";
$params = array_merge([$this->id, $limit]);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Record that this TV show was viewed
*/
public function recordView(): bool
{
$stmt = $this->pdo->prepare("
INSERT OR REPLACE INTO tv_show_views
(tv_show_id, view_count, last_viewed_at)
VALUES (?, COALESCE((SELECT view_count FROM tv_show_views WHERE tv_show_id = ?), 0) + 1, CURRENT_TIMESTAMP)
");
return $stmt->execute([$this->id, $this->id]);
}
/**
* Get all available genres from TV shows
*/
public static function getAvailableGenres(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT TRIM(value) as genre
FROM tv_shows,
json_each('[\"' || REPLACE(genre, ',', '\",\"') || '\"]')
WHERE genre IS NOT NULL AND genre != ''
ORDER BY genre
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get all available years from TV shows' first_air_date
*/
public static function getAvailableYears(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT strftime('%Y', first_air_date) as year
FROM tv_shows
WHERE first_air_date IS NOT NULL
ORDER BY year DESC
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
}

View File

@@ -12,6 +12,9 @@ abstract class BaseSyncService
protected SyncLog $syncLog; protected SyncLog $syncLog;
protected int $sourceId; protected int $sourceId;
protected $logFileHandle;
protected $logFilePath;
public function __construct(\PDO $pdo, array $source) public function __construct(\PDO $pdo, array $source)
{ {
$this->pdo = $pdo; $this->pdo = $pdo;
@@ -22,17 +25,50 @@ abstract class BaseSyncService
} }
$this->sourceId = (int) $source['id']; $this->sourceId = (int) $source['id'];
// Create log file for this sync operation
$this->initializeLogFile();
}
private function initializeLogFile(): void
{
$timestamp = date('Y-m-d_H-i-s');
$sourceName = strtolower($this->source['name'] ?? 'unknown');
$this->logFilePath = "logs/{$sourceName}_sync_{$timestamp}.log";
// Create logs directory if it doesn't exist
$logDir = dirname($this->logFilePath);
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$this->logFileHandle = fopen($this->logFilePath, 'w');
if ($this->logFileHandle) {
$this->logProgress("=== Starting {$sourceName} sync at " . date('Y-m-d H:i:s') . " ===");
}
}
public function __destruct()
{
if ($this->logFileHandle) {
$this->logProgress("=== Sync completed at " . date('Y-m-d H:i:s') . " ===");
fclose($this->logFileHandle);
}
} }
public function startSync(string $syncType = 'full'): int public function startSync(string $syncType = 'full'): int
{ {
// Create sync log entry // Set higher limits for long-running syncs
$this->syncLog = new SyncLog($this->pdo); ini_set('max_execution_time', 3600); // 1 hour
$syncLogId = $this->createSyncLog($syncType, 'started'); ini_set('memory_limit', '512M');
$this->syncLog->id = $syncLogId; // Create sync log entry
$syncLogId = $this->createSyncLog($syncType, 'started');
$this->currentSyncLogId = $syncLogId;
try { try {
$this->logProgress("Starting {$syncType} sync for source: " . ($this->source['display_name'] ?? $this->source['name']));
$this->executeSync($syncType); $this->executeSync($syncType);
// Update sync log as completed // Update sync log as completed
@@ -40,14 +76,31 @@ abstract class BaseSyncService
'processed_items' => $this->getProcessedCount(), 'processed_items' => $this->getProcessedCount(),
'new_items' => $this->getNewCount(), 'new_items' => $this->getNewCount(),
'updated_items' => $this->getUpdatedCount(), 'updated_items' => $this->getUpdatedCount(),
'deleted_items' => $this->getDeletedCount() 'deleted_items' => $this->getDeletedCount(),
'message' => "Successfully completed sync"
]); ]);
$this->logProgress("Sync completed successfully");
} catch (Exception $e) { } catch (Exception $e) {
// Update sync log as failed // Log the full error details
$errorMessage = $e->getMessage();
$errorFile = $e->getFile();
$errorLine = $e->getLine();
$errorTrace = $e->getTraceAsString();
$this->logProgress("CRITICAL ERROR - Sync failed: {$errorMessage}");
$this->logProgress("Error location: {$errorFile}:{$errorLine}");
$this->logProgress("Stack trace: {$errorTrace}");
// Update sync log as failed with full error details
$this->updateSyncLog($syncLogId, 'failed', [ $this->updateSyncLog($syncLogId, 'failed', [
'message' => $e->getMessage(), 'message' => $errorMessage,
'errors' => [$e->getMessage()] 'errors' => [
$errorMessage,
"File: {$errorFile}:{$errorLine}",
"Stack: " . substr($errorTrace, 0, 1000) // Limit trace size
]
]); ]);
throw $e; throw $e;
@@ -80,7 +133,7 @@ abstract class BaseSyncService
return (int) $this->pdo->lastInsertId(); return (int) $this->pdo->lastInsertId();
} }
private function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool protected function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool
{ {
$data = [ $data = [
'status' => $status, 'status' => $status,
@@ -129,13 +182,31 @@ abstract class BaseSyncService
return 0; // Override in subclasses return 0; // Override in subclasses
} }
protected $currentSyncLogId = null;
protected function logProgress(string $message): void protected function logProgress(string $message): void
{ {
// Update sync log with progress message $timestamp = date('H:i:s');
if ($this->syncLog) { $logMessage = "[{$timestamp}] {$message}\n";
$this->updateSyncLog($this->syncLog->id, 'running', [
// Write to log file if available
if ($this->logFileHandle) {
fwrite($this->logFileHandle, $logMessage);
}
// Also write to error log for immediate visibility
error_log($message);
// Update sync log with progress message if we have a current sync log
if ($this->currentSyncLogId) {
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'message' => $message 'message' => $message
]); ]);
} }
} }
public function getLogFilePath(): string
{
return $this->logFilePath ?? '';
}
} }

View File

@@ -6,6 +6,7 @@ use App\Models\Movie;
use App\Models\TvShow; use App\Models\TvShow;
use App\Models\TvEpisode; use App\Models\TvEpisode;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Exception; use Exception;
class JellyfinSyncService extends BaseSyncService class JellyfinSyncService extends BaseSyncService
@@ -31,7 +32,7 @@ class JellyfinSyncService extends BaseSyncService
$this->baseUrl = rtrim($source['api_url'], '/'); $this->baseUrl = rtrim($source['api_url'], '/');
} }
protected function executeSync(string $syncType): void protected function executeSync(string $syncType = 'all'): void
{ {
if (empty($this->apiKey) || empty($this->baseUrl)) { if (empty($this->apiKey) || empty($this->baseUrl)) {
throw new Exception('Jellyfin API key and URL not configured'); throw new Exception('Jellyfin API key and URL not configured');
@@ -40,6 +41,7 @@ class JellyfinSyncService extends BaseSyncService
$this->logProgress('Starting Jellyfin library sync...'); $this->logProgress('Starting Jellyfin library sync...');
$this->logProgress("Jellyfin URL: {$this->baseUrl}"); $this->logProgress("Jellyfin URL: {$this->baseUrl}");
$this->logProgress("API Key: " . (empty($this->apiKey) ? 'NOT SET' : 'SET')); $this->logProgress("API Key: " . (empty($this->apiKey) ? 'NOT SET' : 'SET'));
$this->logProgress("Sync Type: {$syncType}");
try { try {
$userId = $this->getUserId(); $userId = $this->getUserId();
@@ -49,32 +51,49 @@ class JellyfinSyncService extends BaseSyncService
throw $e; throw $e;
} }
// Sync movies // Sync movies if requested
try { if (in_array($syncType, ['all', 'movies'])) {
$this->logProgress('Fetching movies from Jellyfin...'); try {
$movies = $this->getJellyfinItems('Movie'); $this->logProgress('Fetching movies from Jellyfin...');
$this->logProgress("Found " . count($movies) . " movies in Jellyfin"); $movies = $this->getJellyfinItems('Movie');
$this->logProgress("Found " . count($movies) . " movies in Jellyfin");
if (empty($movies)) { if (empty($movies)) {
$this->logProgress('No movies found in Jellyfin library'); $this->logProgress('No movies found in Jellyfin library');
$this->logProgress("Processed {$this->processedCount} items"); } else {
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());
if ($syncType === 'movies') {
throw $e;
}
} }
} else {
foreach ($movies as $movieData) { $this->logProgress('Skipping movies sync (sync type: ' . $syncType . ')');
$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 // Sync TV shows and episodes if requested
// $this->syncTvShows(); if (in_array($syncType, ['all', 'tvshows'])) {
try {
$this->syncTvShows();
} catch (Exception $e) {
$this->logProgress('Error syncing TV shows: ' . $e->getMessage());
if ($syncType === 'tvshows') {
throw $e;
}
}
} else {
$this->logProgress('Skipping TV shows sync (sync type: ' . $syncType . ')');
}
// Sync music (artists, albums, tracks) - TODO: Implement when music models are created
// $this->syncMusic();
$this->logProgress("Processed {$this->processedCount} items"); $this->logProgress("Processed {$this->processedCount} items");
} }
@@ -96,14 +115,43 @@ class JellyfinSyncService extends BaseSyncService
private function syncTvShows(): void private function syncTvShows(): void
{ {
try { try {
$this->logProgress('=== Starting TV Shows Sync ===');
$this->logProgress('Fetching TV shows from Jellyfin...');
$tvShows = $this->getJellyfinItems('Series'); $tvShows = $this->getJellyfinItems('Series');
$this->logProgress("Found " . count($tvShows) . " TV shows in Jellyfin");
if (empty($tvShows)) {
$this->logProgress('No TV shows found in Jellyfin library');
return;
}
$processedShows = 0;
$successfulShows = 0;
$failedShows = 0;
foreach ($tvShows as $showData) { foreach ($tvShows as $showData) {
$this->syncTvShow($showData); $processedShows++;
$this->processedCount++; $this->logProgress("Processing TV show {$processedShows}/" . count($tvShows) . ": {$showData['Name']} (ID: {$showData['Id']})");
try {
$this->syncTvShow($showData);
$successfulShows++;
$this->logProgress("✓ Successfully synced TV show: {$showData['Name']}");
} catch (Exception $e) {
$failedShows++;
$this->logProgress("✗ Failed to sync TV show {$showData['Name']}: " . $e->getMessage());
$this->logProgress("Stack trace: " . $e->getTraceAsString());
}
} }
$this->logProgress("=== TV Shows Sync Summary ===");
$this->logProgress("Processed: {$processedShows}, Successful: {$successfulShows}, Failed: {$failedShows}");
$this->logProgress("Successfully processed {$this->processedCount} TV shows");
} catch (Exception $e) { } catch (Exception $e) {
$this->logProgress('Error syncing TV shows: ' . $e->getMessage()); $this->logProgress('CRITICAL ERROR in TV shows sync: ' . $e->getMessage());
$this->logProgress('Stack trace: ' . $e->getTraceAsString());
throw $e;
} }
} }
@@ -179,7 +227,7 @@ class JellyfinSyncService extends BaseSyncService
'source_id' => $this->source['id'] 'source_id' => $this->source['id']
]); ]);
$movieData = [ $movieDataForDb = [
'title' => $movieData['Name'], 'title' => $movieData['Name'],
'overview' => $movieData['Overview'] ?? null, 'overview' => $movieData['Overview'] ?? null,
'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null, 'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null,
@@ -187,102 +235,527 @@ class JellyfinSyncService extends BaseSyncService
'rating' => $movieData['CommunityRating'] ?? null, 'rating' => $movieData['CommunityRating'] ?? null,
'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null,
'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? 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'], 'source_id' => $this->source['id'],
'metadata' => json_encode([ 'metadata' => json_encode([
'jellyfin_id' => $movieData['Id'], 'jellyfin_id' => $movieData['Id'],
'genres' => $movieData['Genres'] ?? [], 'genres' => $movieData['Genres'] ?? [],
'studios' => $movieData['Studios'] ?? [] 'studios' => $movieData['Studios'] ?? [],
'poster_url' => $this->getImageUrl($movieData['Id'], 'Primary'),
'backdrop_url' => $this->getImageUrl($movieData['Id'], 'Backdrop')
]) ])
]; ];
// Download poster image
$posterPath = $this->downloadPosterImage($movieData['Id'], $movieData['Name']);
if ($posterPath) {
$movieDataForDb['poster_url'] = $posterPath;
} else {
$movieDataForDb['poster_url'] = $this->getImageUrl($movieData['Id'], 'Primary');
}
// Download backdrop image
$backdropPath = $this->downloadBackdropImage($movieData['Id'], $movieData['Name']);
if ($backdropPath) {
$movieDataForDb['backdrop_url'] = $backdropPath;
} else {
$movieDataForDb['backdrop_url'] = $this->getImageUrl($movieData['Id'], 'Backdrop');
}
if (empty($existingMovie)) { if (empty($existingMovie)) {
$movieModel->create($movieData); $movieModel->create($movieDataForDb);
$this->newCount++; $this->newCount++;
} else { } else {
$movieModel->update($existingMovie[0]['id'], $movieData); $movieModel->update($existingMovie[0]['id'], $movieDataForDb);
$this->updatedCount++; $this->updatedCount++;
} }
// Sync actors for this movie and create relationships
try {
$actors = $this->syncActors($movieData);
$this->createMovieActorRelationships($existingMovie ? $existingMovie[0]['id'] : $this->pdo->lastInsertId(), $actors);
} catch (Exception $e) {
$this->logProgress("Warning: Failed to sync actors for movie {$movieData['Name']}: " . $e->getMessage());
}
} }
// TODO: Implement when TvShow model is created private function syncTvShow(array $showData): void
// private function syncTvShow(array $showData): void {
// { $showName = $showData['Name'] ?? 'Unknown Show';
// $showModel = new TvShow($this->pdo); $this->logProgress("--- Starting sync for TV show: {$showName} ---");
// // Check if show already exists $showModel = new TvShow($this->pdo);
// $existingShow = $showModel->findAll([
// 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null,
// 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null,
// 'source_id' => $this->source->id
// ]);
// $showData = [ // Check if show already exists
// 'title' => $showData['Name'], $this->logProgress("Checking if TV show already exists in database...");
// 'overview' => $showData['Overview'] ?? null, $existingShow = $showModel->findAll([
// 'first_air_date' => $showData['PremiereDate'] ? date('Y-m-d', strtotime($showData['PremiereDate'])) : null, 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null,
// 'rating' => $showData['CommunityRating'] ?? null, 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null,
// 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, 'tvdb_id' => $showData['ProviderIds']['Tvdb'] ?? null,
// 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, 'source_id' => $this->source['id']
// '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)) { $this->logProgress("Found " . count($existingShow) . " existing TV show(s) in database");
// $showId = $showModel->create($showData);
// $this->newCount++;
// } else {
// $showId = $existingShow[0]['id'];
// $showModel->update($showId, $showData);
// $this->updatedCount++;
// }
// // Sync episodes for this show // Prepare show data for database
// $this->syncEpisodes($showId, $showData['Id']); $this->logProgress("Preparing TV show data for database...");
// } $showDataForDb = [
'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,
'tvdb_id' => $showData['ProviderIds']['Tvdb'] ?? null,
'source_id' => $this->source['id'],
'metadata' => json_encode([
'jellyfin_id' => $showData['Id'],
'genres' => $showData['Genres'] ?? []
])
];
// TODO: Implement when TvEpisode model is created // Download poster image
// private function syncEpisodes(int $showId, string $jellyfinShowId): void $this->logProgress("Downloading poster image for {$showName}...");
// { $posterPath = $this->downloadPosterImage($showData['Id'], $showData['Name']);
// try { if ($posterPath) {
// $episodes = $this->getShowEpisodes($jellyfinShowId); $showDataForDb['poster_url'] = $posterPath;
$this->logProgress("✓ Poster downloaded successfully: {$posterPath}");
} else {
$showDataForDb['poster_url'] = $this->getImageUrl($showData['Id'], 'Primary');
$this->logProgress("⚠ Poster download failed, using URL instead");
}
// foreach ($episodes as $episodeData) { // Download backdrop image
// $this->syncEpisode($showId, $episodeData); $this->logProgress("Downloading backdrop image for {$showName}...");
// } $backdropPath = $this->downloadBackdropImage($showData['Id'], $showData['Name']);
// } catch (Exception $e) { if ($backdropPath) {
// $this->logProgress('Error syncing episodes for show ' . $jellyfinShowId . ': ' . $e->getMessage()); $showDataForDb['backdrop_url'] = $backdropPath;
// } $this->logProgress("✓ Backdrop downloaded successfully: {$backdropPath}");
// } } else {
$showDataForDb['backdrop_url'] = $this->getImageUrl($showData['Id'], 'Backdrop');
$this->logProgress("⚠ Backdrop download failed, using URL instead");
}
// TODO: Implement when TvEpisode model is created try {
// private function syncEpisode(int $showId, array $episodeData): void if (empty($existingShow)) {
// { $this->logProgress("Creating new TV show in database...");
// $episodeModel = new TvEpisode($this->pdo); $showId = $showModel->create($showDataForDb);
$this->newCount++;
$this->logProgress("✓ Created new TV show with ID: {$showId}");
} else {
$showId = $existingShow[0]['id'];
$this->logProgress("Updating existing TV show (ID: {$showId})...");
$showModel->update($showId, $showDataForDb);
$this->updatedCount++;
$this->logProgress("✓ Updated existing TV show");
}
} catch (Exception $e) {
$this->logProgress("✗ Failed to save TV show {$showName} to database: " . $e->getMessage());
throw $e;
}
// $episodeData = [ // Sync actors for this show and create relationships
// 'title' => $episodeData['Name'], try {
// 'overview' => $episodeData['Overview'] ?? null, $this->logProgress("Syncing actors for {$showName}...");
// 'season_number' => $episodeData['ParentIndexNumber'] ?? 1, $actors = $this->syncActors($showData);
// 'episode_number' => $episodeData['IndexNumber'] ?? 1, $this->logProgress("Found " . count($actors) . " actors for {$showName}");
// 'air_date' => $episodeData['PremiereDate'] ? date('Y-m-d', strtotime($episodeData['PremiereDate'])) : null, $this->createShowActorRelationships($showId, $actors);
// 'runtime_minutes' => $episodeData['RunTimeTicks'] ? intval($episodeData['RunTimeTicks'] / (10000000 * 60)) : null, $this->logProgress("✓ Actor relationships created for {$showName}");
// 'rating' => $episodeData['CommunityRating'] ?? null, } catch (Exception $e) {
// 'tv_show_id' => $showId, $this->logProgress("Warning: Failed to sync actors for TV show {$showName}: " . $e->getMessage());
// 'source_id' => $this->source->id, }
// 'metadata' => json_encode([
// 'jellyfin_id' => $episodeData['Id']
// ])
// ];
// $episodeModel->create($episodeData); // Sync episodes for this show
// } try {
$this->logProgress("Syncing episodes for {$showName}...");
$this->syncEpisodes($showId, $showData['Id']);
$this->logProgress("✓ Episodes sync completed for {$showName}");
} catch (Exception $e) {
$this->logProgress("✗ Failed to sync episodes for {$showName}: " . $e->getMessage());
$this->logProgress("Stack trace: " . $e->getTraceAsString());
}
$this->logProgress("--- Completed sync for TV show: {$showName} ---");
}
private function syncEpisodes(int $showId, string $jellyfinShowId): void
{
try {
$this->logProgress("=== Starting episodes sync for show ID: {$jellyfinShowId} ===");
$episodes = $this->getShowEpisodes($jellyfinShowId);
$episodeCount = count($episodes);
$this->logProgress("Found {$episodeCount} episodes for show ID: {$jellyfinShowId}");
if (empty($episodes)) {
$this->logProgress("No episodes found for show ID: {$jellyfinShowId}");
return;
}
$processedEpisodes = 0;
$successfulEpisodes = 0;
$failedEpisodes = 0;
foreach ($episodes as $episodeData) {
$processedEpisodes++;
$episodeName = $episodeData['Name'] ?? 'Unknown Episode';
$this->logProgress("Processing episode {$processedEpisodes}/{$episodeCount}: {$episodeName}");
try {
$this->syncEpisode($showId, $episodeData);
$successfulEpisodes++;
$this->logProgress("✓ Successfully synced episode: {$episodeName}");
} catch (Exception $e) {
$failedEpisodes++;
$this->logProgress("✗ Failed to sync episode {$episodeName}: " . $e->getMessage());
$this->logProgress("Stack trace: " . $e->getTraceAsString());
}
}
$this->logProgress("=== Episodes Sync Summary ===");
$this->logProgress("Processed: {$processedEpisodes}, Successful: {$successfulEpisodes}, Failed: {$failedEpisodes}");
$this->logProgress("Successfully processed {$this->processedCount} episodes");
} catch (Exception $e) {
$this->logProgress('CRITICAL ERROR in episodes sync: ' . $e->getMessage());
$this->logProgress('Stack trace: ' . $e->getTraceAsString());
throw $e;
}
}
private function syncEpisode(int $showId, array $episodeData): void
{
$episodeName = $episodeData['Name'] ?? 'Unknown Episode';
$episodeSeason = $episodeData['ParentIndexNumber'] ?? 1;
$episodeNumber = $episodeData['IndexNumber'] ?? 1;
$this->logProgress("--- Starting sync for episode: S{$episodeSeason}E{$episodeNumber} - {$episodeName} ---");
$episodeModel = new TvEpisode($this->pdo);
// Check if episode already exists by jellyfin_id in metadata
$this->logProgress("Checking if episode already exists in database...");
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM tv_episodes
WHERE tv_show_id = :tv_show_id AND source_id = :source_id
");
$stmt->execute([
'tv_show_id' => $showId,
'source_id' => $this->source['id']
]);
$existingEpisodes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($existingEpisodes) . " existing episodes for this show");
$existingEpisode = null;
foreach ($existingEpisodes as $episode) {
$metadata = json_decode($episode['metadata'], true);
if (isset($metadata['jellyfin_id']) && $metadata['jellyfin_id'] === $episodeData['Id']) {
$existingEpisode = $episode;
$this->logProgress("Found existing episode with Jellyfin ID: {$episodeData['Id']}");
break;
}
}
if (!$existingEpisode) {
$this->logProgress("Episode not found, will create new episode");
}
// Prepare episode data for database
$this->logProgress("Preparing episode data for database...");
$episodeDataForDb = [
'title' => $episodeData['Name'],
'overview' => $episodeData['Overview'] ?? null,
'season_number' => $episodeSeason,
'episode_number' => $episodeNumber,
'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'],
'tmdb_id' => $episodeData['ProviderIds']['Tmdb'] ?? null,
'imdb_id' => $episodeData['ProviderIds']['Imdb'] ?? null,
'tvdb_id' => $episodeData['ProviderIds']['Tvdb'] ?? null
// Note: Episodes don't have dedicated provider ID columns in the database,
// so we store them in metadata for reference
])
];
try {
if ($existingEpisode) {
$this->logProgress("Updating existing episode in database...");
$episodeModel->update($existingEpisode['id'], $episodeDataForDb);
$episodeId = $existingEpisode['id'];
$this->updatedCount++;
$this->logProgress("✓ Updated episode: {$episodeName}");
} else {
$this->logProgress("Creating new episode in database...");
$episodeModel->create($episodeDataForDb);
$episodeId = $this->pdo->lastInsertId();
$this->newCount++;
$this->logProgress("✓ Created new episode with ID: {$episodeId}");
}
} catch (Exception $e) {
$this->logProgress("✗ Failed to save episode {$episodeName} to database: " . $e->getMessage());
throw $e;
}
// Sync actors for this episode and create relationships
try {
$this->logProgress("Syncing actors for episode {$episodeName}...");
$actors = $this->syncActors($episodeData);
$this->logProgress("Found " . count($actors) . " actors for episode {$episodeName}");
$this->createActorRelationships($episodeId, $actors);
$this->logProgress("✓ Actor relationships created for episode {$episodeName}");
} catch (Exception $e) {
$this->logProgress("Warning: Failed to sync actors for episode {$episodeName}: " . $e->getMessage());
}
$this->logProgress("--- Completed sync for episode: {$episodeName} ---");
}
private function getShowEpisodes(string $jellyfinShowId): array
{
$this->logProgress("--- Fetching episodes for show ID: {$jellyfinShowId} ---");
try {
$url = "{$this->baseUrl}/Shows/{$jellyfinShowId}/Episodes";
$this->logProgress("Fetching episodes from Jellyfin API: {$url}");
$response = $this->httpClient->get($url, [
'query' => [
'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,RunTimeTicks,People'
]
]);
$httpCode = $response->getStatusCode();
$this->logProgress("Jellyfin API response code: {$httpCode}");
if ($httpCode !== 200) {
$errorMsg = "Jellyfin Episodes API returned HTTP {$httpCode}";
$this->logProgress("{$errorMsg}");
throw new Exception($errorMsg);
}
$data = json_decode($response->getBody(), true);
$episodeCount = count($data['Items'] ?? []);
$this->logProgress("✓ Successfully fetched {$episodeCount} episodes from Jellyfin");
if ($episodeCount === 0) {
$this->logProgress("⚠ No episodes found in Jellyfin response");
}
$episodes = $data['Items'] ?? [];
// Log episode details for debugging
if (!empty($episodes)) {
$this->logProgress("Episode details:");
foreach ($episodes as $index => $episode) {
$episodeName = $episode['Name'] ?? 'Unknown';
$episodeId = $episode['Id'] ?? 'No ID';
$season = $episode['ParentIndexNumber'] ?? 'No season';
$episodeNum = $episode['IndexNumber'] ?? 'No number';
$this->logProgress(" " . ($index + 1) . ". {$episodeName} (S{$season}E{$episodeNum}) - ID: {$episodeId}");
}
}
return $episodes;
} catch (Exception $e) {
$this->logProgress('✗ Failed to fetch episodes: ' . $e->getMessage());
$this->logProgress('Stack trace: ' . $e->getTraceAsString());
return [];
}
}
private function syncActors(array $mediaData): array
{
// Jellyfin doesn't have a direct actors API, so we extract from media data
// This is a simplified implementation - in a full implementation,
// you'd need to fetch detailed cast information from Jellyfin
$cast = [];
// Try to extract cast information from different fields
if (isset($mediaData['People']) && is_array($mediaData['People'])) {
foreach ($mediaData['People'] as $person) {
if (isset($person['Type']) && $person['Type'] === 'Actor') {
$cast[] = [
'name' => $person['Name'],
'jellyfin_id' => $person['Id'] ?? null,
'image_url' => isset($person['PrimaryImageTag']) ? $this->getActorImageUrl($person['Id'], $person['PrimaryImageTag']) : null
];
}
}
}
// If no cast found in People array, try other fields
if (empty($cast)) {
if (isset($mediaData['Cast']) && is_array($mediaData['Cast'])) {
foreach ($mediaData['Cast'] as $actorName) {
if (empty($actorName)) continue;
$cast[] = [
'name' => $actorName,
'jellyfin_id' => null,
'image_url' => null
];
}
} elseif (isset($mediaData['Actors']) && is_array($mediaData['Actors'])) {
foreach ($mediaData['Actors'] as $actorName) {
if (empty($actorName)) continue;
$cast[] = [
'name' => $actorName,
'jellyfin_id' => null,
'image_url' => null
];
}
}
}
// Create/sync actors and return actor objects
$actors = [];
foreach ($cast as $actorData) {
if (empty($actorData['name'])) continue;
$actor = $this->getOrCreateActor($actorData['name'], $actorData['jellyfin_id'], $actorData['image_url']);
if ($actor) {
$actors[] = $actor;
}
}
return $actors;
}
private function getOrCreateActor(string $name, ?string $jellyfinId = null, ?string $imageUrl = null): ?array
{
try {
// 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) {
// Update thumbnail if we have a new image URL and no existing thumbnail
if ($imageUrl && empty($existingActor['thumbnail_path'])) {
$thumbnailPath = $this->downloadImage($imageUrl, 'actors', $name);
if ($thumbnailPath) {
try {
$updateStmt = $this->pdo->prepare('
UPDATE actors SET thumbnail_path = :thumbnail_path, updated_at = NOW() WHERE id = :id
');
$updateStmt->execute([
'thumbnail_path' => $thumbnailPath,
'id' => $existingActor['id']
]);
$existingActor['thumbnail_path'] = $thumbnailPath;
} catch (Exception $e) {
$this->logProgress("Warning: Could not update thumbnail for existing actor {$name}: " . $e->getMessage());
}
}
}
return [
'id' => $existingActor['id'],
'name' => $existingActor['name'],
'thumbnail_path' => $existingActor['thumbnail_path']
];
}
// Create new actor
$thumbnailPath = null;
if ($imageUrl) {
$thumbnailPath = $this->downloadImage($imageUrl, 'actors', $name);
}
$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/find actor {$name}: " . $e->getMessage());
return null;
}
}
private function createActorRelationships(int $episodeId, array $actors): void
{
foreach ($actors as $actor) {
if (!isset($actor['id'])) continue;
try {
// Insert relationship into pivot table (ignore duplicates)
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_tv_episode (tv_episode_id, actor_id, created_at, updated_at)
VALUES (:tv_episode_id, :actor_id, NOW(), NOW())
");
$stmt->execute([
'tv_episode_id' => $episodeId,
'actor_id' => $actor['id']
]);
$this->logProgress("Created relationship: TV Episode {$episodeId} -> Actor {$actor['name']} ({$actor['id']})");
} catch (Exception $e) {
$this->logProgress("Failed to create relationship for TV Episode {$episodeId} and Actor {$actor['name']}: " . $e->getMessage());
}
}
}
private function createShowActorRelationships(int $showId, array $actors): void
{
foreach ($actors as $actor) {
if (!isset($actor['id'])) continue;
try {
// Insert relationship into pivot table (ignore duplicates)
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_tv_show (tv_show_id, actor_id, created_at, updated_at)
VALUES (:tv_show_id, :actor_id, NOW(), NOW())
");
$stmt->execute([
'tv_show_id' => $showId,
'actor_id' => $actor['id']
]);
$this->logProgress("Created relationship: TV Show {$showId} -> Actor {$actor['name']} ({$actor['id']})");
} catch (Exception $e) {
$this->logProgress("Failed to create relationship for TV Show {$showId} and Actor {$actor['name']}: " . $e->getMessage());
}
}
}
private function createMovieActorRelationships(int $movieId, array $actors): void
{
foreach ($actors as $actor) {
if (!isset($actor['id'])) continue;
try {
// Insert relationship into pivot table (ignore duplicates)
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_movie (movie_id, actor_id, created_at, updated_at)
VALUES (:movie_id, :actor_id, NOW(), NOW())
");
$stmt->execute([
'movie_id' => $movieId,
'actor_id' => $actor['id']
]);
$this->logProgress("Created relationship: Movie {$movieId} -> Actor {$actor['name']} ({$actor['id']})");
} catch (Exception $e) {
$this->logProgress("Failed to create relationship for Movie {$movieId} and Actor {$actor['name']}: " . $e->getMessage());
}
}
}
private function getImageUrl(string $itemId, string $type): ?string private function getImageUrl(string $itemId, string $type): ?string
{ {
@@ -293,6 +766,83 @@ class JellyfinSyncService extends BaseSyncService
return "{$this->baseUrl}/Items/{$itemId}/Images/{$type}?maxWidth=400"; return "{$this->baseUrl}/Items/{$itemId}/Images/{$type}?maxWidth=400";
} }
private function getActorImageUrl(string $personId, string $imageTag): ?string
{
if (empty($personId) || empty($imageTag)) {
return null;
}
// Ensure baseUrl doesn't have trailing slash
$baseUrl = rtrim($this->baseUrl, '/');
return "{$baseUrl}/Items/{$personId}/Images/Primary?maxWidth=300&tag={$imageTag}&quality=90";
}
private function downloadImage(string $imageUrl, string $type, string $itemName): ?string
{
if (empty($imageUrl)) {
return null;
}
try {
// Create images directory structure if it doesn't exist
$imagesDir = "public/images/{$type}";
if (!is_dir($imagesDir)) {
if (!mkdir($imagesDir, 0755, true)) {
$this->logProgress("Warning: Could not create images directory: {$imagesDir}");
return null;
}
}
// Create a safe filename from item name and hash of URL for consistency
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $itemName);
if ($safeName === null) {
$safeName = $type . '_unknown';
}
$safeName = substr($safeName, 0, 30); // Limit length for hash part
// Use hash of the image URL to ensure same image always gets same filename
$urlHash = substr(md5($imageUrl), 0, 8);
$filename = $safeName . '_' . $urlHash . '.jpg';
$filepath = $imagesDir . '/' . $filename;
// Check if file already exists
if (file_exists($filepath)) {
$this->logProgress("Image already exists for {$itemName}, skipping download: {$filepath}");
return "{$type}/{$filename}";
}
// Download the image
$this->logProgress("Downloading {$type} image for {$itemName} from: {$imageUrl}");
$response = $this->httpClient->get($imageUrl, [
'sink' => $filepath
]);
if ($response->getStatusCode() === 200) {
$this->logProgress("Successfully downloaded {$type} image for {$itemName} to: {$filepath}");
return "{$type}/{$filename}";
} else {
$this->logProgress("Failed to download {$type} image for {$itemName}: HTTP " . $response->getStatusCode());
return null;
}
} catch (Exception $e) {
$this->logProgress("Error downloading {$type} image for {$itemName}: " . $e->getMessage());
return null;
}
}
private function downloadPosterImage(string $itemId, string $itemName): ?string
{
$posterUrl = $this->getImageUrl($itemId, 'Primary');
return $this->downloadImage($posterUrl, 'posters', $itemName);
}
private function downloadBackdropImage(string $itemId, string $itemName): ?string
{
$backdropUrl = $this->getImageUrl($itemId, 'Backdrop');
return $this->downloadImage($backdropUrl, 'backdrops', $itemName);
}
protected function getProcessedCount(): int protected function getProcessedCount(): int
{ {
return $this->processedCount; return $this->processedCount;

View File

@@ -31,8 +31,9 @@ class StashSyncService extends BaseSyncService
'headers' => [ 'headers' => [
'User-Agent' => 'MediaCollector/1.0', 'User-Agent' => 'MediaCollector/1.0',
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'ApiKey' => $this->apiKey // Now safe to access 'ApiKey' => $this->apiKey // Stash API key for authentication
] ],
'verify' => false // Disable SSL verification for problematic servers
]); ]);
$this->imageDownloader = new ImageDownloader('public/images', $this->apiKey); $this->imageDownloader = new ImageDownloader('public/images', $this->apiKey);
@@ -60,21 +61,70 @@ class StashSyncService extends BaseSyncService
try { try {
$this->logProgress('Fetching Stash scenes...'); $this->logProgress('Fetching Stash scenes...');
// First, get the total count to determine how many pages we need
$totalCount = $this->getStashScenesCount();
if ($totalCount === 0) {
$this->logProgress('No scenes found in Stash');
return;
}
$this->logProgress("Found {$totalCount} scenes in Stash");
// Use pagination to handle large libraries // Use pagination to handle large libraries
$page = 0;
$perPage = 50; // Smaller batch size for reliability $perPage = 50; // Smaller batch size for reliability
$totalPages = ceil($totalCount / $perPage);
do { for ($page = 0; $page < $totalPages; $page++) {
$scenes = $this->getStashScenes($page * $perPage, $perPage); try {
$this->logProgress("Processing page {$page} with " . count($scenes) . " scenes..."); $offset = $page * $perPage;
$scenes = $this->getStashScenes($offset, $perPage);
foreach ($scenes as $sceneData) { if (empty($scenes)) {
$this->syncScene($sceneData); $this->logProgress("No scenes returned for page {$page}");
$this->processedCount++; continue;
}
$this->logProgress("Processing page {$page} with " . count($scenes) . " scenes...");
foreach ($scenes as $sceneData) {
try {
$this->logProgress("Processing scene: {$sceneData['title']} (ID: {$sceneData['id']})");
$this->syncScene($sceneData);
$this->processedCount++;
// Update progress in real-time
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'processed_items' => $this->processedCount,
'new_items' => $this->newCount,
'updated_items' => $this->updatedCount,
'message' => "Processed {$this->processedCount} of ~{$totalCount} scenes"
]);
} catch (Exception $e) {
$this->logProgress("Error processing scene {$sceneData['id']} ({$sceneData['title']}): " . $e->getMessage());
$this->processedCount++; // Still count as processed even if failed
// Update progress even for failed items
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'processed_items' => $this->processedCount,
'message' => "Error on scene {$sceneData['id']}: " . $e->getMessage()
]);
}
}
} catch (Exception $e) {
$this->logProgress("Error fetching page {$page}: " . $e->getMessage());
// Continue with next page even if this page fails
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'message' => "Failed to fetch page {$page}, continuing with next page"
]);
} }
$page++; // Add a small delay between pages to avoid overwhelming the server
} while (count($scenes) === $perPage); // Continue if we got a full page if ($page < $totalPages - 1) {
sleep(1);
}
}
$this->logProgress("Completed syncing Stash scenes"); $this->logProgress("Completed syncing Stash scenes");
} catch (Exception $e) { } catch (Exception $e) {
@@ -83,6 +133,48 @@ class StashSyncService extends BaseSyncService
} }
} }
private function getStashScenesCount(): int
{
try {
$query = '
query FindScenes($filter: FindFilterType) {
findScenes(filter: $filter) {
count
}
}
';
$variables = [
'filter' => [
'per_page' => 1,
'page' => 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']['count'])) {
$this->logProgress('No count data in Stash response');
return 0;
}
return (int) $data['data']['findScenes']['count'];
} catch (Exception $e) {
$this->logProgress('Failed to get Stash scenes count: ' . $e->getMessage());
return 0;
}
}
private function getStashScenes(int $offset = 0, int $limit = 50): array private function getStashScenes(int $offset = 0, int $limit = 50): array
{ {
try { try {
@@ -118,9 +210,6 @@ class StashSyncService extends BaseSyncService
width width
height height
} }
paths {
screenshot
}
performers { performers {
id id
name name
@@ -166,24 +255,31 @@ class StashSyncService extends BaseSyncService
] ]
]; ];
$this->logProgress("Fetching Stash scenes: offset={$offset}, limit={$limit}");
$response = $this->httpClient->post("{$this->baseUrl}/graphql", [ $response = $this->httpClient->post("{$this->baseUrl}/graphql", [
'json' => [ 'json' => [
'query' => $query, 'query' => $query,
'variables' => $variables 'variables' => $variables
], ],
'timeout' => 30 'timeout' => 60, // Increased timeout
'connect_timeout' => 30
]); ]);
$data = json_decode($response->getBody(), true); $data = json_decode($response->getBody(), true);
if (!isset($data['data']['findScenes']['scenes'])) { if (!isset($data['data']['findScenes']['scenes'])) {
$this->logProgress('No scenes data in response'); $this->logProgress('No scenes data in Stash response');
return []; return [];
} }
return $data['data']['findScenes']['scenes']; $scenes = $data['data']['findScenes']['scenes'];
$this->logProgress("Received " . count($scenes) . " scenes from Stash");
return $scenes;
} catch (Exception $e) { } catch (Exception $e) {
$this->logProgress('Failed to fetch Stash scenes: ' . $e->getMessage()); $this->logProgress('Failed to fetch Stash scenes: ' . $e->getMessage());
$this->logProgress('Request details: ' . $e->getMessage());
throw new Exception('Failed to fetch Stash scenes: ' . $e->getMessage()); throw new Exception('Failed to fetch Stash scenes: ' . $e->getMessage());
} }
} }
@@ -309,28 +405,66 @@ class StashSyncService extends BaseSyncService
$coverUrl = $screenshotUrl; $coverUrl = $screenshotUrl;
} }
if (!empty($coverUrl)) { // Check if this is an existing scene and if images already exist
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); $shouldDownloadImages = true;
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); if ($existingScene) {
if ($localCoverPath) { $existingMetadata = json_decode($existingScene['metadata'], true);
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); $hasExistingCover = !empty($existingMetadata['local_cover_path']);
$this->logProgress("Downloaded cover: " . $localCoverPath); $hasExistingScreenshot = !empty($existingMetadata['local_screenshot_path']);
if ($hasExistingCover && $hasExistingScreenshot) {
$shouldDownloadImages = false;
$this->logProgress("Scene {$sceneData['id']} already has images, skipping download");
} else { } else {
$this->logProgress("Failed to download cover from: " . $coverUrl); $this->logProgress("Scene {$sceneData['id']} missing images - downloading");
} }
} }
if (!empty($screenshotUrl)) { if ($shouldDownloadImages) {
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); if (!empty($coverUrl)) {
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); // Validate URL before attempting download
if ($localScreenshotPath) { if (filter_var($coverUrl, FILTER_VALIDATE_URL)) {
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); try {
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath); $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover');
} else { $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos');
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl); if ($localCoverPath) {
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
$this->logProgress("Downloaded cover: " . $localCoverPath);
} else {
$this->logProgress("Failed to download cover from: " . $coverUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid cover URL: " . $coverUrl);
}
} }
}
if (!empty($screenshotUrl)) {
// Validate URL before attempting download
if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) {
try {
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot');
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos');
if ($localScreenshotPath) {
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath);
} else {
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid screenshot URL: " . $screenshotUrl);
}
}
} else {
// Use existing image paths
$sceneData['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null;
$sceneData['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null;
}
// Handle performers/actors // Handle performers/actors
$performers = $sceneData['performers'] ?? []; $performers = $sceneData['performers'] ?? [];
$actorNames = []; $actorNames = [];
@@ -366,11 +500,75 @@ class StashSyncService extends BaseSyncService
]; ];
if ($existingScene) { if ($existingScene) {
// For existing scenes, check if we need to update images
$existingMetadata = json_decode($existingScene['metadata'], true);
// Only download images if they don't already exist locally
if (empty($existingMetadata['local_cover_path']) && !empty($coverUrl)) {
// Validate URL before attempting download
if (filter_var($coverUrl, FILTER_VALIDATE_URL)) {
try {
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover');
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos');
if ($localCoverPath) {
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
$this->logProgress("Downloaded cover: " . $localCoverPath);
} else {
$this->logProgress("Failed to download cover from: " . $coverUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid cover URL: " . $coverUrl);
}
} else {
// Keep existing local cover path
$sceneData['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null;
if (!empty($sceneData['local_cover_path'])) {
$this->logProgress("Using existing cover: " . $sceneData['local_cover_path']);
}
}
if (empty($existingMetadata['local_screenshot_path']) && !empty($screenshotUrl)) {
// Validate URL before attempting download
if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) {
try {
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot');
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos');
if ($localScreenshotPath) {
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath);
} else {
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid screenshot URL: " . $screenshotUrl);
}
} else {
// Keep existing local screenshot path
$sceneData['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null;
if (!empty($sceneData['local_screenshot_path'])) {
$this->logProgress("Using existing screenshot: " . $sceneData['local_screenshot_path']);
}
}
$adultVideoModel->update($existingScene['id'], $sceneData); $adultVideoModel->update($existingScene['id'], $sceneData);
$adultVideoId = $existingScene['id'];
$this->updatedCount++; $this->updatedCount++;
// Create actor relationships for existing scene
$this->createActorRelationships($adultVideoId, $actors);
} else { } else {
$adultVideoModel->create($sceneData); $adultVideoModel->create($sceneData);
$adultVideoId = $this->pdo->lastInsertId();
$this->newCount++; $this->newCount++;
// Create actor relationships for new scene
$this->createActorRelationships($adultVideoId, $actors);
} }
} }
@@ -457,26 +655,38 @@ class StashSyncService extends BaseSyncService
// Try to download performer image if available // Try to download performer image if available
$thumbnailPath = null; $thumbnailPath = null;
if ($imagePath) { if ($imagePath) {
// Handle different image path formats from Stash // Validate image path before constructing URL
if (strpos($imagePath, 'http') === 0) { if (!empty(trim($imagePath))) {
// Already a full URL try {
$imageUrl = $imagePath; // Handle different image path formats from Stash
} elseif (strpos($imagePath, '/') === 0) { if (strpos($imagePath, 'http') === 0) {
// Absolute path from Stash root // Already a full URL
$imageUrl = "{$this->baseUrl}" . $imagePath; $imageUrl = $imagePath;
} else { } elseif (strpos($imagePath, '/') === 0) {
// Relative path - assume it's in performer images directory // Absolute path from Stash root
$imageUrl = "{$this->baseUrl}/performer/" . $imagePath; $imageUrl = "{$this->baseUrl}" . $imagePath;
} } else {
// Relative path - assume it's in performer images directory
$imageUrl = "{$this->baseUrl}/performer/" . $imagePath;
}
$this->logProgress("Performer image URL for {$name}: " . $imageUrl); // Validate the constructed URL
$thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor'); if (filter_var($imageUrl, FILTER_VALIDATE_URL)) {
$localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors'); $this->logProgress("Performer image URL for {$name}: " . $imageUrl);
if ($localImagePath) { $thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor');
$thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath); $localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors');
$this->logProgress("Downloaded performer image: " . $localImagePath); if ($localImagePath) {
} else { $thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath);
$this->logProgress("Failed to download performer image from: " . $imageUrl); $this->logProgress("Downloaded performer image: " . $localImagePath);
} else {
$this->logProgress("Failed to download performer image from: " . $imageUrl);
}
} else {
$this->logProgress("Invalid performer image URL constructed: " . $imageUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading performer image for {$name} from {$imagePath}: " . $e->getMessage());
}
} }
} }
@@ -517,6 +727,29 @@ class StashSyncService extends BaseSyncService
return $this->updatedCount; return $this->updatedCount;
} }
private function createActorRelationships(int $adultVideoId, array $actors): void
{
foreach ($actors as $actor) {
if (!isset($actor['id'])) continue;
try {
// Insert relationship into pivot table (ignore duplicates)
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_adult_video (adult_video_id, actor_id, created_at, updated_at)
VALUES (:adult_video_id, :actor_id, NOW(), NOW())
");
$stmt->execute([
'adult_video_id' => $adultVideoId,
'actor_id' => $actor['id']
]);
$this->logProgress("Created relationship: Adult Video {$adultVideoId} -> Actor {$actor['name']} ({$actor['id']})");
} catch (Exception $e) {
$this->logProgress("Failed to create relationship for Adult Video {$adultVideoId} and Actor {$actor['name']}: " . $e->getMessage());
}
}
}
protected function getDeletedCount(): int protected function getDeletedCount(): int
{ {
return 0; // Stash doesn't provide deletion info in this context return 0; // Stash doesn't provide deletion info in this context

View File

@@ -10,7 +10,6 @@ use Exception;
class XbvrSyncService extends BaseSyncService class XbvrSyncService extends BaseSyncService
{ {
private Client $httpClient; private Client $httpClient;
private ?string $apiKey;
private string $baseUrl; private string $baseUrl;
private ImageDownloader $imageDownloader; private ImageDownloader $imageDownloader;
private int $processedCount = 0; private int $processedCount = 0;
@@ -22,24 +21,22 @@ class XbvrSyncService extends BaseSyncService
parent::__construct($pdo, $source); parent::__construct($pdo, $source);
// Initialize properties first before using them // Initialize properties first before using them
$this->apiKey = $source['api_key'];
$this->baseUrl = rtrim($source['api_url'], '/'); $this->baseUrl = rtrim($source['api_url'], '/');
$this->httpClient = new Client([ $this->httpClient = new Client([
'timeout' => 30, 'timeout' => 30,
'headers' => [ 'headers' => [
'User-Agent' => 'MediaCollector/1.0', 'User-Agent' => 'MediaCollector/1.0'
'X-API-Key' => $source['api_key']
] ]
]); ]);
$this->imageDownloader = new ImageDownloader('public/images', $this->apiKey); $this->imageDownloader = new ImageDownloader('public/images');
} }
protected function executeSync(string $syncType): void protected function executeSync(string $syncType): void
{ {
if (empty($this->apiKey) || empty($this->baseUrl)) { if (empty($this->baseUrl)) {
throw new Exception('XBVR API key and URL not configured'); throw new Exception('XBVR URL not configured');
} }
$this->logProgress('Starting XBVR library sync...'); $this->logProgress('Starting XBVR library sync...');
@@ -52,12 +49,17 @@ class XbvrSyncService extends BaseSyncService
private function syncScenes(): void private function syncScenes(): void
{ {
try { try {
$scenes = $this->getXbvrScenes(); $scenes = $this->getXbvrScenes();
foreach ($scenes as $sceneData) { foreach ($scenes as $sceneData) {
$this->syncScene($sceneData); try {
$this->processedCount++; $this->syncScene($sceneData);
$this->processedCount++;
} catch (Exception $e) {
$this->logProgress("Error processing XBVR scene {$sceneData['id']}: " . $e->getMessage());
$this->processedCount++; // Still count as processed even if failed
}
} }
} catch (Exception $e) { } catch (Exception $e) {
$this->logProgress('Error syncing XBVR scenes: ' . $e->getMessage()); $this->logProgress('Error syncing XBVR scenes: ' . $e->getMessage());
@@ -67,16 +69,73 @@ class XbvrSyncService extends BaseSyncService
private function getXbvrScenes(): array private function getXbvrScenes(): array
{ {
try { try {
// XBVR API endpoint for scenes $this->logProgress("Fetching XBVR DeoVR main response from: {$this->baseUrl}/deovr");
$response = $this->httpClient->get("{$this->baseUrl}/api/scene");
$data = json_decode($response->getBody(), true); // Step 1: Fetch the main DeoVR response containing the video list
$response = $this->httpClient->get("{$this->baseUrl}/deovr", [
'timeout' => 30,
'connect_timeout' => 10
]);
if (!isset($data['scenes'])) { if ($response->getStatusCode() !== 200) {
throw new Exception('No scenes found in XBVR'); throw new Exception("XBVR DeoVR API returned status: " . $response->getStatusCode());
} }
return $data['scenes']; $mainData = json_decode($response->getBody(), true);
$this->logProgress("XBVR DeoVR main response received successfully");
// Step 2: Extract the video list from the main response
$videoList = $this->extractVideoList($mainData);
$videoList = $videoList[0]['list'];
if (empty($videoList)) {
throw new Exception("No videos found in XBVR DeoVR response");
}
$this->logProgress("Found " . count($videoList) . " videos in XBVR list");
// Step 3: Fetch detailed information for each video
$detailedScenes = [];
$processedCount = 0;
foreach ($videoList as $videoItem) {
try {
$detailUrl = $this->extractDetailUrl($videoItem);
if (!$detailUrl) {
$this->logProgress("No detail URL found for video: " . ($videoItem['title'] ?? 'Unknown'));
continue;
}
$this->logProgress("Fetching details for: " . ($videoItem['title'] ?? 'Unknown'));
$detailResponse = $this->httpClient->get($detailUrl, [
'timeout' => 30,
'connect_timeout' => 10
]);
if ($detailResponse->getStatusCode() === 200) {
$detailData = json_decode($detailResponse->getBody(), true);
$detailedScenes[] = $detailData;
$processedCount++;
$this->logProgress("Successfully fetched details for: " . ($detailData['title'] ?? 'Unknown'));
} else {
$this->logProgress("Failed to fetch details from {$detailUrl}: Status " . $detailResponse->getStatusCode());
}
// Add small delay to be respectful to the API
usleep(100000); // 0.1 second delay
} catch (Exception $e) {
$this->logProgress("Error fetching details for video: " . $e->getMessage());
}
}
$this->logProgress("Successfully processed {$processedCount} out of " . count($videoList) . " videos");
return $detailedScenes;
} catch (Exception $e) { } catch (Exception $e) {
throw new Exception('Failed to fetch XBVR scenes: ' . $e->getMessage()); throw new Exception('Failed to fetch XBVR scenes: ' . $e->getMessage());
} }
@@ -84,6 +143,8 @@ class XbvrSyncService extends BaseSyncService
private function syncScene(array $sceneData): void private function syncScene(array $sceneData): void
{ {
$this->logProgress("Processing XBVR scene: " . json_encode(array_slice($sceneData, 0, 5)));
$adultVideoModel = new AdultVideo($this->pdo); $adultVideoModel = new AdultVideo($this->pdo);
// Check if scene already exists by xbvr_id in metadata // Check if scene already exists by xbvr_id in metadata
@@ -103,84 +164,221 @@ class XbvrSyncService extends BaseSyncService
} }
} }
// Download images locally // Map XBVR/DeoVR fields to our database structure
$coverFilename = null; // Based on the 46367.json example structure
$screenshotFilename = null; $mappedData = [
'title' => $sceneData['title'] ?? 'Untitled VR Scene',
'overview' => $sceneData['description'] ?? null,
'release_date' => isset($sceneData['date']) ? date('Y-m-d', $sceneData['date']) : null,
'runtime_minutes' => isset($sceneData['videoLength']) ? round($sceneData['videoLength'] / 60) : null,
'rating' => $sceneData['rating_avg'] ?? null,
'director' => null, // DeoVR doesn't seem to have director info
'cast' => [], // Will be extracted from categories/actors if available
'tags' => [], // Will be extracted from categories
];
// Extract image URLs from XBVR API response // Handle categories/tags from DeoVR format
$tags = [];
if (isset($sceneData['categories']) && is_array($sceneData['categories'])) {
foreach ($sceneData['categories'] as $category) {
if (isset($category['tag']['name'])) {
$tags[] = $category['tag']['name'];
}
}
}
$mappedData['tags'] = $tags;
// Handle actors (DeoVR format might have actors array or might be null)
$castData = [];
if (isset($sceneData['actors']) && is_array($sceneData['actors']) && !empty($sceneData['actors'])) {
foreach ($sceneData['actors'] as $actor) {
if (isset($actor['name'])) {
$castData[] = $actor['name'];
}
}
}
$this->logProgress("Mapped DeoVR scene data: title='{$mappedData['title']}', tags=" . json_encode($tags) . ", cast=" . json_encode($castData));
// Extract image URLs from DeoVR API response - try multiple possible field names
$coverUrl = null; $coverUrl = null;
$screenshotUrl = null; $screenshotUrl = null;
if (!empty($sceneData['cover_url'])) { // Try different possible cover image field names for DeoVR
$coverUrl = $sceneData['cover_url']; $coverFields = ['thumbnailUrl', 'cover_url', 'cover', 'poster_url', 'poster', 'thumbnail_url', 'thumbnail', 'image_url', 'image'];
$this->logProgress("Cover URL: " . $coverUrl); foreach ($coverFields as $field) {
if (!empty($sceneData[$field])) {
$coverUrl = $sceneData[$field];
break;
}
} }
if (!empty($sceneData['screenshot_url'])) { // Try different possible screenshot field names for DeoVR
$screenshotUrl = $sceneData['screenshot_url']; $screenshotFields = ['screenshot_url', 'screenshot', 'preview_url', 'preview', 'thumb_url', 'thumb'];
$this->logProgress("Screenshot URL: " . $screenshotUrl); foreach ($screenshotFields as $field) {
if (!empty($sceneData[$field])) {
$screenshotUrl = $sceneData[$field];
break;
}
} }
if (!empty($coverUrl)) { if (!empty($coverUrl)) {
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); $this->logProgress("DeoVR Cover URL: " . $coverUrl);
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); }
if ($localCoverPath) {
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); if (!empty($screenshotUrl)) {
$this->logProgress("Downloaded cover: " . $localCoverPath); $this->logProgress("DeoVR Screenshot URL: " . $screenshotUrl);
}
if (!empty($coverUrl)) {
// Validate URL before attempting download
if (filter_var($coverUrl, FILTER_VALIDATE_URL)) {
try {
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover');
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos');
if ($localCoverPath) {
$mappedData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
$this->logProgress("Downloaded cover: " . $localCoverPath);
} else {
$this->logProgress("Failed to download cover from: " . $coverUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage());
}
} else { } else {
$this->logProgress("Failed to download cover from: " . $coverUrl); $this->logProgress("Invalid cover URL: " . $coverUrl);
} }
} }
if (!empty($screenshotUrl)) { if (!empty($screenshotUrl)) {
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); // Validate URL before attempting download
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) {
if ($localScreenshotPath) { try {
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot');
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath); $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos');
if ($localScreenshotPath) {
$mappedData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath);
} else {
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage());
}
} else { } else {
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl); $this->logProgress("Invalid screenshot URL: " . $screenshotUrl);
} }
} }
// Handle actors // Handle actors
$actors = $this->syncActors($sceneData['cast'] ?? []); $actors = $this->syncActors($castData);
$sceneData = [ $sceneDataForDb = [
'title' => $sceneData['title'] ?: 'Untitled VR Scene', 'title' => $mappedData['title'],
'overview' => $sceneData['synopsis'] ?? null, 'overview' => $mappedData['overview'],
'release_date' => $sceneData['release_date'] ? date('Y-m-d', strtotime($sceneData['release_date'])) : null, 'release_date' => $mappedData['release_date'],
'runtime_minutes' => $sceneData['duration'] ?? null, 'runtime_minutes' => $mappedData['runtime_minutes'],
'rating' => $sceneData['rating'] ?? null, 'rating' => $mappedData['rating'],
'source_id' => $this->source['id'], 'source_id' => $this->source['id'],
'external_id' => $sceneData['id'], 'external_id' => $sceneData['id'],
'metadata' => json_encode([ 'metadata' => json_encode([
'xbvr_id' => $sceneData['id'], 'xbvr_id' => $sceneData['id'],
'xbvr_url' => $sceneData['scene_url'] ?? null, 'xbvr_url' => $sceneData['scene_url'] ?? $sceneData['url'] ?? null,
'cast' => $sceneData['cast'] ?? [], 'cast' => $castData,
'actors' => $actors, 'actors' => $actors,
'tags' => $sceneData['tags'] ?? [], 'tags' => $mappedData['tags'],
'is_available' => $sceneData['is_available'] ?? true, 'is_available' => $sceneData['is_available'] ?? true,
'is_watched' => $sceneData['is_watched'] ?? false, 'is_watched' => $sceneData['is_watched'] ?? false,
'watch_count' => $sceneData['watch_count'] ?? 0, 'watch_count' => $sceneData['watch_count'] ?? 0,
'video_length' => $sceneData['video_length'] ?? null, 'video_length' => $sceneData['videoLength'] ?? null,
'video_width' => $sceneData['video_width'] ?? null, 'video_width' => $sceneData['video_width'] ?? null,
'video_height' => $sceneData['video_height'] ?? null, 'video_height' => $sceneData['video_height'] ?? null,
'video_codec' => $sceneData['video_codec'] ?? null, 'video_codec' => $sceneData['video_codec'] ?? null,
'file_path' => $sceneData['file_path'] ?? null, 'file_path' => $sceneData['file_path'] ?? $sceneData['path'] ?? null,
'cover_url' => $sceneData['cover_url'] ?? null, 'cover_url' => $coverUrl,
'local_cover_path' => $sceneData['local_cover_path'] ?? null, 'local_cover_path' => $mappedData['local_cover_path'] ?? null,
'screenshot_url' => $sceneData['screenshot_url'] ?? null, 'screenshot_url' => $screenshotUrl,
'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null 'local_screenshot_path' => $mappedData['local_screenshot_path'] ?? null,
'deoVR_format' => true, // Mark that this came from DeoVR API
'paysite' => $sceneData['paysite']['name'] ?? null,
'is3d' => $sceneData['is3d'] ?? false,
'screenType' => $sceneData['screenType'] ?? null,
'stereoMode' => $sceneData['stereoMode'] ?? null,
'fullVideoReady' => $sceneData['fullVideoReady'] ?? false,
'fullAccess' => $sceneData['fullAccess'] ?? false
]) ])
]; ];
if ($existingScene) { if ($existingScene) {
$adultVideoModel->update($existingScene['id'], $sceneData); // For existing scenes, check if we need to update images
$existingMetadata = json_decode($existingScene['metadata'], true);
// Only download images if they don't already exist locally
if (empty($existingMetadata['local_cover_path']) && !empty($coverUrl)) {
// Validate URL before attempting download
if (filter_var($coverUrl, FILTER_VALIDATE_URL)) {
try {
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover');
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos');
if ($localCoverPath) {
$sceneDataForDb['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
$this->logProgress("Downloaded cover: " . $localCoverPath);
} else {
$this->logProgress("Failed to download cover from: " . $coverUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid cover URL: " . $coverUrl);
}
} else {
// Keep existing local cover path
$sceneDataForDb['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null;
if (!empty($sceneDataForDb['local_cover_path'])) {
$this->logProgress("Using existing cover: " . $sceneDataForDb['local_cover_path']);
}
}
if (empty($existingMetadata['local_screenshot_path']) && !empty($screenshotUrl)) {
// Validate URL before attempting download
if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) {
try {
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot');
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos');
if ($localScreenshotPath) {
$sceneDataForDb['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
$this->logProgress("Downloaded screenshot: " . $localScreenshotPath);
} else {
$this->logProgress("Failed to download screenshot from: " . $screenshotUrl);
}
} catch (Exception $e) {
$this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage());
}
} else {
$this->logProgress("Invalid screenshot URL: " . $screenshotUrl);
}
} else {
// Keep existing local screenshot path
$sceneDataForDb['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null;
if (!empty($sceneDataForDb['local_screenshot_path'])) {
$this->logProgress("Using existing screenshot: " . $sceneDataForDb['local_screenshot_path']);
}
}
$adultVideoModel->update($existingScene['id'], $sceneDataForDb);
$adultVideoId = $existingScene['id'];
$this->updatedCount++; $this->updatedCount++;
// Create actor relationships for existing scene
$this->createActorRelationships($adultVideoId, $actors);
} else { } else {
$adultVideoModel->create($sceneData); $adultVideoModel->create($sceneDataForDb);
$adultVideoId = $this->pdo->lastInsertId();
$this->newCount++; $this->newCount++;
// Create actor relationships for new scene
$this->createActorRelationships($adultVideoId, $actors);
} }
} }
@@ -238,6 +436,48 @@ class XbvrSyncService extends BaseSyncService
} }
} }
private function extractVideoList(array $mainData): array
{
// Try different possible keys for the video list
$possibleKeys = ['Recent', 'scenes', 'content', 'videos'];
foreach ($possibleKeys as $key) {
if (isset($mainData[$key]) && is_array($mainData[$key])) {
$this->logProgress("Found video list under key: '{$key}' with " . count($mainData[$key]) . " items");
return $mainData[$key];
}
}
// If no standard key found, look for arrays that might contain video data
foreach ($mainData as $key => $value) {
if (is_array($value) && count($value) > 0) {
// Check if this looks like a video list by examining the first item
$firstItem = $value[0];
if (isset($firstItem['title']) || isset($firstItem['video_url'])) {
$this->logProgress("Found video list under key: '{$key}' with " . count($value) . " items");
return $value;
}
}
}
$this->logProgress("No video list found. Available keys: " . implode(', ', array_keys($mainData)));
return [];
}
private function extractDetailUrl(array $videoItem): ?string
{
// Try different possible URL field names
$possibleUrlFields = ['video_url', 'url', 'detail_url', 'scene_url'];
foreach ($possibleUrlFields as $field) {
if (!empty($videoItem[$field])) {
return $videoItem[$field];
}
}
return null;
}
protected function getProcessedCount(): int protected function getProcessedCount(): int
{ {
return $this->processedCount; return $this->processedCount;
@@ -257,4 +497,27 @@ class XbvrSyncService extends BaseSyncService
{ {
return 0; // XBVR doesn't provide deletion info in this context return 0; // XBVR doesn't provide deletion info in this context
} }
private function createActorRelationships(int $adultVideoId, array $actors): void
{
foreach ($actors as $actor) {
if (!isset($actor['id'])) continue;
try {
// Insert relationship into pivot table (ignore duplicates)
$stmt = $this->pdo->prepare("
INSERT IGNORE INTO actor_adult_video (adult_video_id, actor_id, created_at, updated_at)
VALUES (:adult_video_id, :actor_id, NOW(), NOW())
");
$stmt->execute([
'adult_video_id' => $adultVideoId,
'actor_id' => $actor['id']
]);
$this->logProgress("Created relationship: Adult Video {$adultVideoId} -> Actor {$actor['name']} ({$actor['id']})");
} catch (Exception $e) {
$this->logProgress("Failed to create relationship for Adult Video {$adultVideoId} and Actor {$actor['name']}: " . $e->getMessage());
}
}
}
} }

View File

@@ -22,7 +22,8 @@ class ImageDownloader
$this->httpClient = new Client([ $this->httpClient = new Client([
'timeout' => 30, 'timeout' => 30,
'headers' => $headers 'headers' => $headers,
'verify' => false // Disable SSL verification for problematic servers
]); ]);
$this->basePath = rtrim($basePath, '/'); $this->basePath = rtrim($basePath, '/');
} }
@@ -37,76 +38,114 @@ class ImageDownloader
return null; return null;
} }
try { $maxRetries = 3;
$folderPath = $this->basePath; $retryDelay = 2; // seconds
if (!empty($subfolder)) {
$folderPath .= '/' . trim($subfolder, '/');
}
// Create folder if it doesn't exist for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
if (!is_dir($folderPath)) { try {
mkdir($folderPath, 0755, true); error_log("Downloading image (attempt {$attempt}/{$maxRetries}): {$url}");
}
$filePath = $folderPath . '/' . $filename; $response = $this->httpClient->get($url, [
'timeout' => 60, // Longer timeout for large images
'connect_timeout' => 30,
'headers' => [
'User-Agent' => 'MediaCollector/1.0',
'Referer' => parse_url($url, PHP_URL_HOST)
]
]);
// Check if file already exists if ($response->getStatusCode() !== 200) {
if (file_exists($filePath)) { error_log("HTTP {$response->getStatusCode()} for {$url}");
return $filePath; if ($attempt === $maxRetries) {
} return null;
error_log("Downloading image from: {$url} to: {$filePath}");
$response = $this->httpClient->get($url, [
'sink' => $filePath,
'headers' => [
'User-Agent' => 'MediaCollector/1.0',
'Accept' => 'image/*',
]
]);
$statusCode = $response->getStatusCode();
$contentType = $response->getHeaderLine('content-type');
error_log("Download response - Status: {$statusCode}, Content-Type: {$contentType}");
if ($statusCode === 200) {
$fileSize = filesize($filePath);
error_log("Successfully downloaded image. Size: {$fileSize} bytes");
// Check if file is actually an image and not empty
if ($fileSize > 0) {
$imageInfo = getimagesize($filePath);
if ($imageInfo !== false) {
error_log("Valid image downloaded: {$imageInfo[0]}x{$imageInfo[1]} {$imageInfo['mime']}");
return $filePath;
} else {
error_log("Downloaded file is not a valid image");
if (file_exists($filePath)) {
unlink($filePath);
}
}
} else {
error_log("Downloaded file is empty");
if (file_exists($filePath)) {
unlink($filePath);
} }
sleep($retryDelay);
continue;
} }
} else {
error_log("Failed to download image. HTTP Status: {$statusCode}");
}
return null; $imageData = $response->getBody()->getContents();
} catch (Exception $e) { if (empty($imageData)) {
// Log error but don't throw - images are not critical error_log("Empty response for {$url}");
error_log("Failed to download image {$url}: " . $e->getMessage()); if ($attempt === $maxRetries) {
return null; return null;
}
sleep($retryDelay);
continue;
}
// Validate image data
if (!$this->isValidImage($imageData)) {
error_log("Invalid image data for {$url}");
if ($attempt === $maxRetries) {
return null;
}
sleep($retryDelay);
continue;
}
return $this->saveImage($imageData, $filename, $subfolder);
} catch (Exception $e) {
error_log("Error downloading {$url} (attempt {$attempt}): " . $e->getMessage());
if ($attempt === $maxRetries) {
error_log("Failed to download {$url} after {$maxRetries} attempts");
return null;
}
sleep($retryDelay);
}
}
return null;
}
private function isValidImage(string $data): bool
{
// Check for common image signatures
$imageTypes = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89PNG",
'image/gif' => "GIF",
'image/webp' => "RIFF"
];
foreach ($imageTypes as $type => $signature) {
if (strpos($data, $signature) === 0) {
return true;
}
} }
} }
/** public function saveImage(string $imageData, string $filename, string $subfolder = ''): ?string
* Generate a unique filename for an image {
*/ try {
// Create subfolder if it doesn't exist
$fullPath = $this->basePath;
if (!empty($subfolder)) {
$fullPath .= '/' . trim($subfolder, '/');
}
if (!is_dir($fullPath)) {
mkdir($fullPath, 0755, true);
}
$filePath = $fullPath . '/' . $filename;
// Write image data to file
$bytesWritten = file_put_contents($filePath, $imageData);
if ($bytesWritten === false || $bytesWritten === 0) {
error_log("Failed to write image data to {$filePath}");
return null;
}
return $filePath;
} catch (Exception $e) {
error_log("Error saving image {$filename}: " . $e->getMessage());
return null;
}
}
public function generateFilename(string $url, string $prefix = ''): string public function generateFilename(string $url, string $prefix = ''): string
{ {
$extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION); $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);

96
check_sync_logs.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
/**
* Sync Log Viewer
*
* This script helps you view recent sync logs and check the status of your sync operations.
* Run this script to see what happened during the last sync operations.
*/
// Check if we're in the right directory
if (!file_exists('app/Services/BaseSyncService.php')) {
echo "Error: Please run this script from the project root directory.\n";
exit(1);
}
require_once 'vendor/autoload.php';
// Simple database connection for viewing sync logs
try {
$pdo = new PDO('sqlite:database/database.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== SYNC LOG VIEWER ===\n\n";
// Get recent sync logs
$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 10
");
$stmt->execute();
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($logs)) {
echo "No sync logs found. Run a sync operation first.\n";
exit(0);
}
echo "RECENT SYNC OPERATIONS:\n";
echo str_repeat("-", 80) . "\n";
foreach ($logs as $log) {
$status = strtoupper($log['status']);
$statusColor = match($log['status']) {
'completed' => '✅',
'failed' => '❌',
'running' => '🔄',
default => '❓'
};
echo "{$statusColor} {$log['source_name']} - {$status} - {$log['created_at']}\n";
echo " Type: {$log['sync_type']} | Processed: {$log['processed_items']} | New: {$log['new_items']} | Updated: {$log['updated_items']}\n";
if ($log['message']) {
echo " Message: {$log['message']}\n";
}
if ($log['errors']) {
$errors = json_decode($log['errors'], true);
if (is_array($errors)) {
echo " Errors: " . implode(', ', $errors) . "\n";
}
}
echo "\n";
}
// Check for log files
$logFiles = glob('logs/*.log');
if (!empty($logFiles)) {
echo "\nLOG FILES AVAILABLE:\n";
echo str_repeat("-", 80) . "\n";
// Sort by modification time, newest first
usort($logFiles, function($a, $b) {
return filemtime($b) - filemtime($a);
});
foreach (array_slice($logFiles, 0, 5) as $logFile) {
$size = filesize($logFile);
$modified = date('Y-m-d H:i:s', filemtime($logFile));
echo "📄 " . basename($logFile) . " ({$size} bytes) - {$modified}\n";
}
echo "\nTo view a specific log file, run: tail -f logs/filename.log\n";
} else {
echo "\nNo log files found yet. Log files are created during sync operations.\n";
}
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Make sure your database is set up correctly.\n";
exit(1);
}

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use App\Database\Database;
class CreateActorMediaPivotTables extends Migration
{
public function up()
{
$capsule = Database::getCapsule();
// Pivot table for actors and movies
$capsule->schema()->create('actor_movie', function ($table) {
$table->id();
$table->foreignId('actor_id')->constrained('actors')->onDelete('cascade');
$table->foreignId('movie_id')->constrained('movies')->onDelete('cascade');
$table->timestamps();
$table->unique(['actor_id', 'movie_id']);
$table->index(['movie_id', 'actor_id']);
});
// Pivot table for actors and TV shows
$capsule->schema()->create('actor_tv_show', function ($table) {
$table->id();
$table->foreignId('actor_id')->constrained('actors')->onDelete('cascade');
$table->foreignId('tv_show_id')->constrained('tv_shows')->onDelete('cascade');
$table->timestamps();
$table->unique(['actor_id', 'tv_show_id']);
$table->index(['tv_show_id', 'actor_id']);
});
// Pivot table for actors and TV episodes
$capsule->schema()->create('actor_tv_episode', function ($table) {
$table->id();
$table->foreignId('actor_id')->constrained('actors')->onDelete('cascade');
$table->foreignId('tv_episode_id')->constrained('tv_episodes')->onDelete('cascade');
$table->timestamps();
$table->unique(['actor_id', 'tv_episode_id']);
$table->index(['tv_episode_id', 'actor_id']);
});
}
public function down()
{
$capsule = Database::getCapsule();
$capsule->schema()->dropIfExists('actor_tv_episode');
$capsule->schema()->dropIfExists('actor_adult_video');
$capsule->schema()->dropIfExists('actor_tv_show');
$capsule->schema()->dropIfExists('actor_movie');
}
}

90
debug_jellyfin_sync.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
// Debug script to check Jellyfin sync issues
require_once __DIR__ . '/vendor/autoload.php';
try {
echo "=== Jellyfin Sync Debug ===\n";
// Check if TvEpisode model exists and works
echo "Checking TvEpisode model...\n";
if (class_exists('App\Models\TvEpisode')) {
echo "✓ TvEpisode model exists\n";
} else {
echo "✗ TvEpisode model missing\n";
}
// Check if database tables exist
echo "\nChecking database tables...\n";
$config = require __DIR__ . '/config/database.php';
\App\Database\Database::setConfig($config);
try {
$pdo = \App\Database\Database::getInstance();
$tables = ['tv_episodes', 'tv_shows', 'actors', 'actor_tv_episode', 'actor_tv_show', 'actor_movie'];
foreach ($tables as $table) {
try {
$stmt = $pdo->query("SHOW TABLES LIKE '$table'");
if ($stmt->rowCount() > 0) {
echo "✓ Table '$table' exists\n";
} else {
echo "✗ Table '$table' missing\n";
}
} catch (Exception $e) {
echo "✗ Error checking table '$table': " . $e->getMessage() . "\n";
}
}
} catch (Exception $e) {
echo "Database connection error: " . $e->getMessage() . "\n";
}
// Test episode data structure
echo "\nTesting episode data structure...\n";
$testEpisodeData = [
'Id' => 'test-episode-123',
'Name' => 'Test Episode 1',
'ParentIndexNumber' => 1,
'IndexNumber' => 1,
'PremiereDate' => '2023-01-01T00:00:00Z',
'RunTimeTicks' => 18000000000, // 30 minutes
'CommunityRating' => 8.5,
'Overview' => 'Test episode overview',
'ProviderIds' => [
'Imdb' => 'tt1234567',
'Tmdb' => '123456'
],
'People' => [
[
'Name' => 'Test Actor',
'Type' => 'Actor'
]
]
];
echo "✓ Episode has required fields:\n";
echo " - ID: " . $testEpisodeData['Id'] . "\n";
echo " - Name: " . $testEpisodeData['Name'] . "\n";
echo " - Season: " . $testEpisodeData['ParentIndexNumber'] . "\n";
echo " - Episode: " . $testEpisodeData['IndexNumber'] . "\n";
echo " - People: " . (isset($testEpisodeData['People']) ? 'Yes' : 'No') . "\n";
echo " - ProviderIds: " . (isset($testEpisodeData['ProviderIds']) ? 'Yes' : 'No') . "\n";
if (isset($testEpisodeData['People']) && is_array($testEpisodeData['People'])) {
foreach ($testEpisodeData['People'] as $person) {
if (isset($person['Type']) && $person['Type'] === 'Actor') {
echo " - Actor found: " . $person['Name'] . "\n";
}
}
}
echo "\n=== Debug Complete ===\n";
echo "The sync should work if:\n";
echo "1. All models exist ✓\n";
echo "2. Database tables exist ✓\n";
echo "3. Jellyfin API returns 'People' field ✓\n";
echo "4. syncTvShows() is called in executeSync() ✓\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}

11
public/.htaccess Normal file
View File

@@ -0,0 +1,11 @@
RewriteEngine On
# Some hosts may require you to use the `RewriteBase` directive.
# If you need to use the `RewriteBase` directive, it should be the
# absolute physical path to the directory that contains this htaccess file.
#
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
{
"../resources/js/app.js": {
"file": "assets/app-45137c72.js",
"isEntry": true,
"src": "../resources/js/app.js"
}
}

257
public/index.php Normal file
View File

@@ -0,0 +1,257 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
// Load helper functions
require_once __DIR__ . '/../app/helpers.php';
// Start sessions for authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 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());
}
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
use DI\Container;
use Twig\TwigFunction;
use Twig\TwigFilter;
// Create DI Container
$container = new Container();
// Register PDO instance
$container->set(PDO::class, function () use ($pdo) {
return $pdo;
});
// Register Twig view
$container->set('view', function () use ($container) {
$twig = Twig::create(__DIR__ . '/../resources/views', [
'cache' => $_ENV['APP_ENV'] === 'production' ? __DIR__ . '/../storage/views' : false,
'debug' => $_ENV['APP_DEBUG'] === 'true',
]);
// Add custom functions
$twig->getEnvironment()->addFunction(new TwigFunction('base_url', function () {
return sprintf(
"%s://%s",
isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off' ? 'https' : 'http',
$_SERVER['HTTP_HOST'] ?? 'localhost'
);
}));
// Placeholder path_for function - will be updated after routes are registered
$twig->getEnvironment()->addFunction(new TwigFunction('path_for', function ($name, $data = [], $queryParams = []) {
// Simple implementation for now - will be replaced with proper router-based version
$basePath = '';
// Handle common route patterns
switch ($name) {
case 'home':
$basePath = '/';
break;
case 'games.index':
$basePath = '/media/games';
break;
case 'games.show':
$basePath = '/media/games/' . ($data['game_key'] ?? '');
break;
case 'movies.index':
$basePath = '/media/movies';
break;
case 'tvshows.index':
$basePath = '/media/tv-shows';
break;
case 'music.index':
$basePath = '/media/music';
break;
case 'admin.index':
$basePath = '/admin';
break;
case 'admin.sync':
$basePath = '/admin/sync/' . ($data['id'] ?? '');
break;
case 'auth.login':
$basePath = '/login';
break;
case 'auth.logout':
$basePath = '/logout';
break;
case 'movies.show':
$basePath = '/media/movies/' . ($data['id'] ?? '');
break;
case 'tvshows.show':
$basePath = '/media/tv-shows/' . ($data['id'] ?? '');
break;
case 'music.show':
$basePath = '/media/music/' . ($data['id'] ?? '');
break;
case 'adult.index':
$basePath = '/media/adult';
break;
case 'adult.show':
$basePath = '/media/adult/' . ($data['id'] ?? '');
break;
case 'actors.index':
$basePath = '/media/actors';
break;
case 'actors.show':
$basePath = '/media/actors/' . ($data['id'] ?? '');
break;
case 'search.index':
$basePath = '/search';
break;
default:
$basePath = '/' . str_replace('.', '/', $name);
}
// Add query parameters
if (!empty($queryParams)) {
$basePath .= '?' . http_build_query($queryParams);
}
return $basePath;
}));
$twig->getEnvironment()->addFunction(new TwigFunction('is_admin', function () use ($container) {
$authService = $container->get(\App\Services\AuthService::class);
return $authService->isAdmin();
}));
$twig->getEnvironment()->addFunction(new TwigFunction('current_user', function () use ($container) {
$authService = $container->get(\App\Services\AuthService::class);
$user = $authService->getCurrentUser();
return $user ?: (object)['username' => 'Guest'];
}));
$twig->getEnvironment()->addFunction(new TwigFunction('csrf_token', function () use ($container) {
$authService = $container->get(\App\Services\AuthService::class);
return $authService->generateCSRFToken();
}));
$twig->getEnvironment()->addFilter(new TwigFilter('format_duration', function ($minutes) {
if (!$minutes || $minutes == 0) {
return '0m';
}
$hours = floor($minutes / 60);
$remainingMinutes = $minutes % 60;
if ($hours > 0) {
return $hours . 'h ' . $remainingMinutes . 'm';
}
return $remainingMinutes . 'm';
}));
$twig->getEnvironment()->addFilter(new TwigFilter('json_decode', function ($jsonString) {
if (!$jsonString) {
return null;
}
$decoded = json_decode($jsonString, true);
return $decoded === null ? null : $decoded;
}));
return $twig;
});
// Register AuthService
$container->set(\App\Services\AuthService::class, function ($c) {
return new \App\Services\AuthService($c->get(PDO::class));
});
// Register models
$container->set(\App\Models\User::class, function ($c) {
return new \App\Models\User($c->get(PDO::class));
});
$container->set(\App\Models\SyncLog::class, function ($c) {
return new \App\Models\SyncLog($c->get(PDO::class));
});
// Register controllers
$container->set(\App\Controllers\AuthController::class, function ($c) {
return new \App\Controllers\AuthController($c->get(\App\Services\AuthService::class), $c->get('view'));
});
$container->set(\App\Controllers\AdminController::class, function ($c) {
return new \App\Controllers\AdminController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\GameController::class, function ($c) {
return new \App\Controllers\GameController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\DashboardController::class, function ($c) {
return new \App\Controllers\DashboardController($c->get('view'));
});
$container->set(\App\Controllers\MovieController::class, function ($c) {
return new \App\Controllers\MovieController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\TvShowController::class, function ($c) {
return new \App\Controllers\TvShowController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\MusicController::class, function ($c) {
return new \App\Controllers\MusicController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\AdultController::class, function ($c) {
return new \App\Controllers\AdultController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\ActorController::class, function ($c) {
return new \App\Controllers\ActorController($c->get(PDO::class), $c->get('view'));
});
$container->set(\App\Controllers\SearchController::class, function ($c) {
return new \App\Controllers\SearchController($c->get(PDO::class), $c->get('view'));
});
// Register middleware
$container->set(\App\Http\Middleware\AuthMiddleware::class, function ($c) {
return new \App\Http\Middleware\AuthMiddleware($c->get(\App\Services\AuthService::class));
});
$container->set(\App\Http\Middleware\AdminMiddleware::class, function ($c) {
return new \App\Http\Middleware\AdminMiddleware($c->get(\App\Services\AuthService::class));
});
// Create App with DI Container
AppFactory::setContainer($container);
$app = AppFactory::create();
// Add Twig-View Middleware
$twig = $container->get('view');
$app->add(TwigMiddleware::create($app, $twig));
// Add Error Middleware
$errorMiddleware = $app->addErrorMiddleware(
$_ENV['APP_DEBUG'] === 'true',
true,
true
);
// Register routes
require __DIR__ . '/../routes/web.php';
$app->run();

View 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 };

View File

@@ -0,0 +1,126 @@
// Import Tailwind CSS
@tailwind base;
@tailwind components;
@tailwind utilities;
// Custom styles
body {
@apply antialiased text-gray-900 bg-gray-50;
}
// Card styles
.card {
@apply bg-white rounded-lg shadow overflow-hidden;
&-header {
@apply px-6 py-4 border-b border-gray-200;
h2 {
@apply text-lg font-medium text-gray-900;
}
}
&-body {
@apply p-6;
}
}
// Button styles
.btn {
@apply px-4 py-2 rounded-md text-sm font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2;
&-primary {
@apply bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500;
}
&-secondary {
@apply bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500;
}
&-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
}
// Form styles
.form-group {
@apply mb-4;
label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
select,
textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm;
}
.form-error {
@apply mt-1 text-sm text-red-600;
}
}
// Alert styles
.alert {
@apply p-4 rounded-md;
&-success {
@apply bg-green-50 text-green-800;
}
&-error {
@apply bg-red-50 text-red-800;
}
&-info {
@apply bg-blue-50 text-blue-800;
}
&-warning {
@apply bg-yellow-50 text-yellow-800;
}
}
// Animation utilities
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// Responsive table
.table-responsive {
@apply overflow-x-auto;
table {
@apply min-w-full divide-y divide-gray-200;
thead {
@apply bg-gray-50;
th {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
}
tbody {
@apply bg-white divide-y divide-gray-200;
tr {
@apply hover:bg-gray-50;
td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
}
}
}
}
}

View File

@@ -0,0 +1,248 @@
{% extends 'layouts/app.twig' %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="mt-2 text-sm text-gray-600">Manage your media sources and synchronization</p>
</div>
<!-- Source Management -->
<div class="bg-white shadow rounded-lg mb-8">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Source Management</h2>
<p class="mt-1 text-sm text-gray-600">Configure and sync your media sources</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{% for source in sources %}
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-900">{{ source.display_name }}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if source.is_active %}
bg-green-100 text-green-800
{% else %}
bg-red-100 text-red-800
{% endif %}">
{% if source.is_active %}Active{% else %}Inactive{% endif %}
</span>
</div>
<div class="space-y-3">
<!-- Sync Buttons -->
<div class="flex space-x-2">
<button onclick="startSync({{ source.id }}, 'full')"
class="flex-1 bg-indigo-600 text-white px-3 py-1 rounded text-xs hover:bg-indigo-700 transition-colors">
Full Sync
</button>
<button onclick="startSync({{ source.id }}, 'incremental')"
class="flex-1 bg-gray-600 text-white px-3 py-1 rounded text-xs hover:bg-gray-700 transition-colors">
Incremental
</button>
</div>
<!-- Last Sync Status -->
{% if source.last_sync_at %}
<div class="text-xs text-gray-500">
Last sync: {{ source.last_sync_at|date('M j, Y H:i') }}
</div>
{% else %}
<div class="text-xs text-gray-400">
Never synced
</div>
{% endif %}
<!-- Sync Progress (hidden by default) -->
<div id="sync-progress-{{ source.id }}" class="hidden">
<div class="w-full bg-gray-200 rounded-full h-2">
<div id="sync-progress-bar-{{ source.id }}"
class="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
<div id="sync-status-{{ source.id }}" class="text-xs text-gray-600 mt-1">
Preparing sync...
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Recent Sync Activity -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Recent Sync Activity</h2>
<p class="mt-1 text-sm text-gray-600">Latest synchronization logs and status</p>
</div>
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Progress
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for sync in recent_syncs %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ sync.source_name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ sync.sync_type|title }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if sync.status == 'completed' %}
bg-green-100 text-green-800
{% elseif sync.status == 'failed' %}
bg-red-100 text-red-800
{% elseif sync.status == 'running' %}
bg-yellow-100 text-yellow-800
{% else %}
bg-gray-100 text-gray-800
{% endif %}">
{{ sync.status|title }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if sync.total_items > 0 %}
{{ sync.processed_items }} / {{ sync.total_items }}
{% else %}
-
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if sync.started_at %}
{{ sync.started_at|date('M j, H:i') }}
{% else %}
-
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if sync.started_at and sync.completed_at %}
{{ sync.started_at|date('U') - sync.completed_at|date('U') }}s
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</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('hidden');
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('hidden');
}
})
.catch(error => {
statusDiv.textContent = 'Error: ' + error.message;
progressDiv.classList.add('hidden');
});
}
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('hidden');
// 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 %}

View 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 %}

View File

@@ -0,0 +1,316 @@
{% extends 'layouts/app.twig' %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-sm text-gray-600">Overview of your media collection</p>
</div>
{% if error %}
<div class="mb-6 bg-red-50 border border-red-200 rounded-md p-4">
<div class="text-sm text-red-800">{{ error }}</div>
</div>
{% endif %}
<!-- Stats Grid -->
<div class="grid grid-cols-1 gap-5 mt-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- Total Media -->
<div class="overflow-hidden bg-white rounded-lg shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-indigo-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Media</dt>
<dd>
<div class="text-lg font-medium text-gray-900">{{ stats.total_media|number_format }}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Games -->
<div class="overflow-hidden bg-white rounded-lg shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-green-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Games</dt>
<dd>
<div class="text-lg font-medium text-gray-900">{{ stats.total_games|number_format }}</div>
<div class="text-xs text-gray-500">{{ stats.favorite_games }} favorites</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Movies & TV -->
<div class="overflow-hidden bg-white rounded-lg shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-red-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Movies & TV</dt>
<dd>
<div class="text-lg font-medium text-gray-900">
{{ (stats.total_movies + stats.total_tv_shows)|number_format }}
</div>
<div class="text-xs text-gray-500">{{ stats.watched_movies }} watched</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Music -->
<div class="overflow-hidden bg-white rounded-lg shadow">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-yellow-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Music</dt>
<dd>
<div class="text-lg font-medium text-gray-900">{{ stats.total_music|number_format }}</div>
<div class="text-xs text-gray-500">{{ stats.favorite_music }} favorites</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Stats -->
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<!-- Total Playtime -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-blue-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Playtime</dt>
<dd>
<div class="text-lg font-medium text-gray-900">
{% if stats.total_playtime %}
{{ (stats.total_playtime / 60)|round }}h
{% else %}
0h
{% endif %}
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Total Episodes -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-purple-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">TV Episodes</dt>
<dd>
<div class="text-lg font-medium text-gray-900">{{ stats.total_episodes|number_format }}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Sync Status -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="w-6 h-6 text-gray-500" 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="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Sync Status</dt>
<dd>
<div class="text-lg font-medium text-gray-900">
{% 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>
<!-- Recent Activity -->
<div class="mt-8">
<h2 class="text-xl font-medium text-gray-900">Recent Activity</h2>
<!-- Recent Games -->
{% if recent_games %}
<div class="mt-4">
<h3 class="text-lg font-medium text-gray-900 mb-3">Recently Played Games</h3>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for game in recent_games %}
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
{% if game.image_url %}
<img class="h-10 w-10 rounded-lg object-cover" src="{{ game.image_url }}" alt="">
{% else %}
<div class="h-10 w-10 rounded-lg bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-400" 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 class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ game.title }}</p>
<p class="text-sm text-gray-500">{{ game.source_name }}</p>
</div>
</div>
<div class="text-sm text-gray-500">
{% 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-6">
<h3 class="text-lg font-medium text-gray-900 mb-3">Recently Watched Movies</h3>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for movie in recent_movies %}
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
{% if movie.poster_url %}
<img class="h-10 w-10 rounded-lg object-cover" src="{{ movie.poster_url }}" alt="">
{% else %}
<div class="h-10 w-10 rounded-lg bg-gray-200 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-400" 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 class="ml-3">
<p class="text-sm font-medium text-gray-900">{{ movie.title }}</p>
<p class="text-sm text-gray-500">{{ movie.source_name }}</p>
</div>
</div>
<div class="text-sm text-gray-500">
{% 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-6">
<h3 class="text-lg font-medium text-gray-900 mb-3">Recent Sync Activities</h3>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
{% for sync in recent_syncs %}
<li class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if sync.status == 'completed' %}
<svg class="w-5 h-5 text-green-400" 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="w-5 h-5 text-red-400" 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="w-5 h-5 text-yellow-400" 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="ml-3">
<p class="text-sm font-medium text-gray-900">{{ sync.source_name }}</p>
<p class="text-sm text-gray-500">{{ sync.sync_type|title }} sync</p>
</div>
</div>
<div class="text-sm text-gray-500">
{{ 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="mt-4 bg-white shadow overflow-hidden sm:rounded-md">
<div class="p-6 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" 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="mt-2 text-sm font-medium text-gray-900">No recent activity</h3>
<p class="mt-1 text-sm text-gray-500">Start adding media to see your activity here.</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "layouts/app.twig" %}
{% block content %}
<div class="px-4 py-6 sm:px-0">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Games</h1>
<div class="text-sm text-gray-500">
{{ games|length }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms
</div>
</div>
{% if games is empty %}
<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="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="mt-2 text-sm font-medium text-gray-900">No games found</h3>
<p class="mt-1 text-sm text-gray-500">Start syncing your gaming libraries to see your games here.</p>
</div>
{% else %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for game in games %}
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
{% if game.image_url %}
<img class="h-12 w-12 rounded-lg object-cover" src="{{ game.image_url }}" alt="{{ game.title }}">
{% else %}
<div class="h-12 w-12 rounded-lg bg-gray-200 flex items-center justify-center">
<svg class="h-6 w-6 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>
<div class="ml-4 flex-1">
<h3 class="text-lg font-medium text-gray-900 truncate">
<a href="{{ path_for('games.show', {'game_key': game.game_key}) }}" class="hover:text-indigo-600">
{{ game.title }}
</a>
</h3>
<p class="text-sm text-gray-500">
{{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }}
{% if game.platforms %}
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ game.platforms|join(', ') }}
</span>
{% endif %}
</p>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between text-sm text-gray-500">
<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 flex flex-wrap gap-1">
{% for genre in game.genres %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
{{ genre }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View 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 %}

View File

@@ -0,0 +1,74 @@
<!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>
{% 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-gray-100">
<!-- Navigation -->
<nav class="bg-indigo-600 text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<a href="{{ path_for('home') }}" class="text-xl font-bold">Media Collector</a>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="{{ path_for('home') }}" class="border-indigo-500 text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Dashboard</a>
<a href="{{ path_for('games.index') }}" class="border-transparent text-indigo-100 hover:border-indigo-300 hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Games</a>
<a href="{{ path_for('movies.index') }}" class="border-transparent text-indigo-100 hover:border-indigo-300 hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Movies</a>
<a href="{{ path_for('tvshows.index') }}" class="border-transparent text-indigo-100 hover:border-indigo-300 hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">TV Shows</a>
<a href="{{ path_for('music.index') }}" class="border-transparent text-indigo-100 hover:border-indigo-300 hover:text-white inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Music</a>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- Admin Link (only for admins) -->
{% if is_admin() %}
<a href="/admin" class="text-indigo-100 hover:text-white text-sm font-medium">Admin</a>
{% endif %}
<!-- User Menu -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open" class="text-indigo-100 hover:text-white flex items-center space-x-1 text-sm font-medium">
<span>{{ current_user().username }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="open" @click.away="open = false" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 border-b border-gray-200">
Signed in as<br>
<span class="font-medium">{{ current_user().username }}</span>
</div>
<a href="/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<!-- Page Content -->
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{% 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 %}
</body>
</html>

View File

@@ -0,0 +1,55 @@
{% extends "layouts/app.twig" %}
{% block content %}
<div class="px-4 py-3">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 fw-bold text-dark mb-1">Adult Performers</h1>
<p class="text-muted mb-0">{{ actors|length }} performer{{ actors|length != 1 ? 's' : '' }}</p>
</div>
</div>
{% if actors %}
<div class="row g-3">
{% for actor in actors %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="card h-100">
<div class="card-body text-center">
<a href="{{ path_for('actors.show', {'id': actor.id}) }}" class="text-decoration-none">
{% if actor.thumbnail_path %}
<img src="/public/images/{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-3" style="width: 80px; height: 80px; object-fit: cover;">
{% else %}
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-3 mx-auto" style="width: 80px; height: 80px;">
<svg class="text-muted" width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
{% endif %}
<h5 class="card-title mb-2">{{ actor.name }}</h5>
<p class="card-text small text-muted">
{{ actor.total_media_count }} scene{{ actor.total_media_count != 1 ? 's' : '' }}
</p>
{% if actor.latest_scene_date %}
<small class="text-muted d-block">
Latest: {{ actor.latest_scene_date|date('M j, Y') }}
</small>
{% endif %}
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<svg class="text-muted mb-3" width="64" height="64" 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>
<h5 class="text-muted">No performers found</h5>
<p class="text-muted">Performers will appear here once you sync content from your adult video sources.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,118 @@
{% extends "layouts/app.twig" %}
{% block content %}
<div class="px-4 py-3">
<!-- Back button -->
<div class="mb-4">
<a href="{{ path_for('actors.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 Performers
</a>
</div>
<div class="card">
<div class="row g-0">
<!-- Actor image -->
<div class="col-md-4">
<div class="card-body">
<div class="text-center">
{% if actor.thumbnail_path %}
<img src="/public/images/{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-3" style="width: 150px; height: 150px; object-fit: cover;">
{% else %}
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-3 mx-auto" style="width: 150px; height: 150px;">
<svg class="text-muted" width="75" height="75" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
{% endif %}
<h1 class="h3 fw-bold text-dark mb-2">{{ actor.name }}</h1>
<p class="text-muted">{{ actor.scene_count }} scene{{ actor.scene_count != 1 ? 's' : '' }}</p>
</div>
</div>
</div>
<!-- Actor details and scenes -->
<div class="col-md-8">
<div class="card-body">
<!-- Actor stats -->
<div class="row g-3 mb-4">
<div class="col-sm-6">
<div class="d-flex align-items-center">
<svg class="me-2 text-primary" width="20" height="20" 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>
<div class="fw-semibold">{{ actor.scene_count }}</div>
<small class="text-muted">Total Scenes</small>
</div>
</div>
</div>
{% if actor.scene_count > 0 %}
<div class="col-sm-6">
<div class="d-flex align-items-center">
<svg class="me-2 text-success" width="20" height="20" 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>
<div>
<div class="fw-semibold">{{ actor.latest_scene_date|date('M j, Y') }}</div>
<small class="text-muted">Latest Scene</small>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Scenes by this actor -->
{% if scenes %}
<div>
<h3 class="h5 fw-semibold text-dark mb-3">Scenes featuring {{ actor.name }}</h3>
<div class="row g-3">
{% for scene in scenes %}
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div style="background-color: #f8f9fa; height: 150px; overflow: hidden;">
{% if scene.poster_url %}
<img src="/public/images/{{ scene.poster_url }}" alt="{{ scene.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="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>
<div class="card-body">
<h6 class="card-title mb-1">
<a href="{{ path_for('adult.show', {'id': scene.id}) }}" class="text-decoration-none">{{ scene.title }}</a>
</h6>
<p class="card-text small text-muted">
{{ scene.release_date|date('M j, Y') }}
{% if scene.runtime_minutes %}
{{ (scene.runtime_minutes / 60)|round(1) }}h {{ scene.runtime_minutes % 60 }}m
{% endif %}
</p>
<small class="text-muted">{{ scene.source_name }}</small>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-5">
<svg class="text-muted mb-3" width="64" height="64" 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>
<h5 class="text-muted">No scenes found</h5>
<p class="text-muted">This performer hasn't appeared in any scenes yet.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -30,6 +30,34 @@
<div class="mb-3"> <div class="mb-3">
<!-- Sync Buttons --> <!-- Sync Buttons -->
{% if source.name == 'jellyfin' %}
<!-- Jellyfin-specific sync options -->
<div class="d-flex gap-1 mb-2 flex-wrap">
<button onclick="startSync({{ source.id }}, 'all')"
class="btn btn-primary btn-sm flex-fill">
All Content
</button>
<button onclick="startSync({{ source.id }}, 'movies')"
class="btn btn-outline-primary btn-sm flex-fill">
Movies Only
</button>
<button onclick="startSync({{ source.id }}, 'tvshows')"
class="btn btn-outline-primary btn-sm flex-fill">
TV Shows Only
</button>
</div>
<div class="d-flex gap-1 mb-2">
<button onclick="startSync({{ source.id }}, 'full')"
class="btn btn-secondary btn-sm flex-fill">
Full Sync
</button>
<button onclick="startSync({{ source.id }}, 'incremental')"
class="btn btn-outline-secondary btn-sm flex-fill">
Incremental
</button>
</div>
{% else %}
<!-- Standard sync options for other sources -->
<div class="d-flex gap-2 mb-2"> <div class="d-flex gap-2 mb-2">
<button onclick="startSync({{ source.id }}, 'full')" <button onclick="startSync({{ source.id }}, 'full')"
class="btn btn-primary btn-sm flex-fill"> class="btn btn-primary btn-sm flex-fill">
@@ -40,6 +68,7 @@
Incremental Incremental
</button> </button>
</div> </div>
{% endif %}
<!-- Last Sync Status --> <!-- Last Sync Status -->
{% if source.last_sync_at %} {% if source.last_sync_at %}
@@ -159,6 +188,13 @@
progressBar.style.width = '0%'; progressBar.style.width = '0%';
statusDiv.textContent = 'Starting sync...'; statusDiv.textContent = 'Starting sync...';
// Disable all sync buttons for this source
const buttons = document.querySelectorAll(`[onclick*="startSync(${sourceId},"]`);
buttons.forEach(button => {
button.disabled = true;
button.textContent = 'Syncing...';
});
// Start sync via API // Start sync via API
fetch(`/admin/sync/${sourceId}?type=${syncType}`, { fetch(`/admin/sync/${sourceId}?type=${syncType}`, {
method: 'POST', method: 'POST',
@@ -171,19 +207,29 @@
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Start monitoring sync status // Start monitoring sync status
monitorSyncStatus(data.sync_log_id, sourceId, progressBar, statusDiv); monitorSyncStatus(data.sync_log_id, sourceId, progressBar, statusDiv, buttons);
} else { } else {
statusDiv.textContent = 'Error: ' + (data.message || 'Unknown error'); statusDiv.textContent = 'Error: ' + (data.message || 'Unknown error');
progressDiv.classList.add('d-none'); progressDiv.classList.add('d-none');
// Re-enable buttons on error
buttons.forEach(button => {
button.disabled = false;
button.textContent = button.textContent.replace('Syncing...', '').trim();
});
} }
}) })
.catch(error => { .catch(error => {
statusDiv.textContent = 'Error: ' + error.message; statusDiv.textContent = 'Error: ' + error.message;
progressDiv.classList.add('d-none'); progressDiv.classList.add('d-none');
// Re-enable buttons on error
buttons.forEach(button => {
button.disabled = false;
button.textContent = button.textContent.replace('Syncing...', '').trim();
});
}); });
} }
function monitorSyncStatus(syncLogId, sourceId, progressBar, statusDiv) { function monitorSyncStatus(syncLogId, sourceId, progressBar, statusDiv, buttons) {
const interval = setInterval(() => { const interval = setInterval(() => {
fetch(`/admin/sync/status/${syncLogId}`) fetch(`/admin/sync/status/${syncLogId}`)
.then(response => response.json()) .then(response => response.json())
@@ -208,6 +254,12 @@
// Refresh page to show updated sync log // Refresh page to show updated sync log
location.reload(); location.reload();
}, 2000); }, 2000);
} else {
// Re-enable buttons on failure
buttons.forEach(button => {
button.disabled = false;
button.textContent = button.textContent.replace('Syncing...', '').trim();
});
} }
} }
}) })
@@ -215,6 +267,11 @@
console.error('Error monitoring sync:', error); console.error('Error monitoring sync:', error);
clearInterval(interval); clearInterval(interval);
delete syncIntervals[sourceId]; delete syncIntervals[sourceId];
// Re-enable buttons on error
buttons.forEach(button => {
button.disabled = false;
button.textContent = button.textContent.replace('Syncing...', '').trim();
});
}); });
}, 1000); }, 1000);

View File

@@ -141,18 +141,20 @@
<h4 class="h6 fw-semibold text-dark mb-2">Performers</h4> <h4 class="h6 fw-semibold text-dark mb-2">Performers</h4>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for actor in movie.actors %} {% for actor in movie.actors %}
<div class="d-flex flex-column align-items-center" style="width: 60px;"> <a href="{{ path_for('actors.show', {'id': actor.id}) }}" class="text-decoration-none">
{% if actor.thumbnail_path %} <div class="d-flex flex-column align-items-center" style="width: 60px;">
<img src="{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-1" style="width: 40px; height: 40px; object-fit: cover;"> {% if actor.thumbnail_path %}
{% else %} <img src="/public/images/{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-1" style="width: 40px; height: 40px; object-fit: cover;">
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-1" style="width: 40px; height: 40px;"> {% else %}
<svg class="text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-1" style="width: 40px; height: 40px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/> <svg class="text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
{% endif %}
<span class="small text-muted text-center" style="font-size: 0.75rem;">{{ actor.name }}</span>
</div> </div>
{% endif %} </a>
<span class="small text-muted text-center" style="font-size: 0.75rem;">{{ actor.name }}</span>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -42,8 +42,14 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if current_route == 'music.index' %}active{% endif %}" href="{{ path_for('music.index') }}">Music</a> <a class="nav-link {% if current_route == 'music.index' %}active{% endif %}" href="{{ path_for('music.index') }}">Music</a>
</li> </li>
<li class="nav-item"> <li class="nav-item dropdown">
<a class="nav-link {% if current_route == 'adult.index' %}active{% endif %}" href="{{ path_for('adult.index') }}">Adult Videos</a> <a class="nav-link dropdown-toggle {% if current_route == 'adult.index' or current_route == 'actors.index' %}active{% endif %}" href="#" id="adultDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Adult Videos
</a>
<ul class="dropdown-menu" aria-labelledby="adultDropdown">
<li><a class="dropdown-item {% if current_route == 'adult.index' %}active{% endif %}" href="{{ path_for('adult.index') }}">Videos</a></li>
<li><a class="dropdown-item {% if current_route == 'actors.index' %}active{% endif %}" href="{{ path_for('actors.index') }}">Performers</a></li>
</ul>
</li> </li>
</ul> </ul>

View File

@@ -10,6 +10,7 @@ use App\Controllers\GameController;
use App\Controllers\AdultController; use App\Controllers\AdultController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Http\Middleware\AdminMiddleware; use App\Http\Middleware\AdminMiddleware;
use App\Controllers\ActorController;
// Authentication routes (no middleware required) // Authentication routes (no middleware required)
$app->get('/login', AuthController::class . ':showLogin')->setName('auth.login'); $app->get('/login', AuthController::class . ':showLogin')->setName('auth.login');
@@ -45,6 +46,11 @@ $app->group('', function (RouteCollectorProxy $group) {
// Adult Videos // Adult Videos
$mediaGroup->get('/adult', AdultController::class . ':index')->setName('adult.index'); $mediaGroup->get('/adult', AdultController::class . ':index')->setName('adult.index');
$mediaGroup->get('/adult/{id:\d+}', AdultController::class . ':show')->setName('adult.show'); $mediaGroup->get('/adult/{id:\d+}', AdultController::class . ':show')->setName('adult.show');
// Adult Performers (Actors)
$mediaGroup->get('/actors', ActorController::class . ':index')->setName('actors.index');
$mediaGroup->get('/actors/{id:\d+}', ActorController::class . ':show')->setName('actors.show');
}); });
})->add(AuthMiddleware::class); })->add(AuthMiddleware::class);

18
run_migrations.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
try {
echo "=== Running Database Migrations ===\n";
$config = require __DIR__ . '/config/database.php';
\App\Database\Database::setConfig($config);
echo "Running migrations...\n";
\App\Database\Database::migrate();
echo "✓ Migrations completed successfully!\n";
} catch (Exception $e) {
echo "Migration error: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
}

42
test_episode_sync.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
// Test script to verify Jellyfin episode syncing
require_once __DIR__ . '/vendor/autoload.php';
try {
echo "Testing Jellyfin episode sync...\n";
// Mock some test data to verify the logic works
$testEpisodeData = [
'Id' => 'test-episode-123',
'Name' => 'Test Episode',
'ParentIndexNumber' => 1,
'IndexNumber' => 1,
'PremiereDate' => '2023-01-01T00:00:00Z',
'RunTimeTicks' => 18000000000, // 30 minutes in ticks
'CommunityRating' => 8.5,
'Overview' => 'Test episode overview',
'ProviderIds' => [
'Imdb' => 'tt1234567',
'Tmdb' => '123456'
],
'People' => [
[
'Name' => 'Test Actor',
'Type' => 'Actor'
]
]
];
echo "Test episode data structure looks correct\n";
echo "Episode ID: " . $testEpisodeData['Id'] . "\n";
echo "Episode Name: " . $testEpisodeData['Name'] . "\n";
echo "Season: " . $testEpisodeData['ParentIndexNumber'] . "\n";
echo "Episode Number: " . $testEpisodeData['IndexNumber'] . "\n";
echo "Has Actor: " . (isset($testEpisodeData['People'][0]['Name']) ? 'Yes' : 'No') . "\n";
echo "\nEpisode sync logic should work correctly!\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}

View File

@@ -0,0 +1,45 @@
<?php
// Test Jellyfin sync execution
require_once __DIR__ . '/vendor/autoload.php';
try {
echo "=== Testing Jellyfin Sync Execution ===\n";
// Load database config
$config = require __DIR__ . '/config/database.php';
\App\Database\Database::setConfig($config);
// Create a mock source for testing
$testSource = [
'id' => 1,
'name' => 'jellyfin',
'api_url' => 'http://192.168.1.102:8096', // Adjust this to your Jellyfin URL
'api_key' => '1db2d28854e541dd90c32ea6aab5e603' // Adjust this to your API key
];
echo "Testing with source: " . $testSource['name'] . "\n";
echo "API URL: " . $testSource['api_url'] . "\n";
// Create Jellyfin service instance
$pdo = \App\Database\Database::getInstance();
$jellyfinService = new \App\Services\JellyfinSyncService($pdo, $testSource);
// Test basic sync execution
echo "\nTesting basic sync execution...\n";
try {
// This will trigger the executeSync method which calls all the private methods
echo "Attempting to run sync (this may fail if Jellyfin server is not accessible)...\n";
$jellyfinService->startSync('full');
echo "✓ Sync completed successfully\n";
} catch (Exception $e) {
echo "✗ Sync failed (expected if Jellyfin server not accessible): " . $e->getMessage() . "\n";
echo "This is normal if your Jellyfin server is not running or credentials are incorrect.\n";
}
echo "\n=== Test Complete ===\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
}

136
test_stash.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
/**
* Stash Connectivity Test
*
* Tests if the Stash server is reachable and responding properly.
*/
// Check if we're in the right directory
if (!file_exists('app/Services/StashSyncService.php')) {
echo "Error: Please run this script from the project root directory.\n";
exit(1);
}
require_once '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";
// Get Stash source
$stmt = $pdo->prepare('SELECT * FROM sources WHERE name = ?');
$stmt->execute(['stash']);
$stashSource = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$stashSource) {
echo "❌ No Stash source found in database\n";
exit(1);
}
echo "🔍 Testing Stash server connectivity...\n";
echo " URL: {$stashSource['api_url']}\n";
echo " API Key: " . (empty($stashSource['api_key']) ? 'NOT SET' : 'SET') . "\n\n";
// Test basic connectivity
$client = new GuzzleHttp\Client([
'timeout' => 10,
'verify' => false // Disable SSL verification for testing
]);
try {
$response = $client->get($stashSource['api_url'], [
'headers' => [
'User-Agent' => 'MediaCollector/1.0',
'ApiKey' => $stashSource['api_key'] ?? ''
]
]);
echo "✅ Stash server is reachable\n";
echo " Status: {$response->getStatusCode()}\n";
echo " Response received successfully\n";
// Test GraphQL endpoint
echo "\n🔍 Testing GraphQL endpoint...\n";
$query = '
query FindScenes($filter: FindFilterType) {
findScenes(filter: $filter) {
count
scenes {
id
title
}
}
}
';
$variables = [
'filter' => [
'per_page' => 1,
'page' => 1,
'sort' => 'created_at',
'direction' => 'DESC'
]
];
$response = $client->post("{$stashSource['api_url']}/graphql", [
'json' => [
'query' => $query,
'variables' => $variables
],
'headers' => [
'User-Agent' => 'MediaCollector/1.0',
'ApiKey' => $stashSource['api_key'] ?? '',
'Content-Type' => 'application/json'
],
'timeout' => 15
]);
$data = json_decode($response->getBody(), true);
if (isset($data['data']['findScenes'])) {
$count = $data['data']['findScenes']['count'];
echo "✅ GraphQL endpoint working\n";
echo " Total scenes in Stash: {$count}\n";
if ($count > 0) {
$firstScene = $data['data']['findScenes']['scenes'][0] ?? null;
if ($firstScene) {
echo " First scene: {$firstScene['title']} (ID: {$firstScene['id']})\n";
}
}
} else {
echo "❌ GraphQL response format unexpected\n";
echo " Response: " . json_encode($data) . "\n";
}
} catch (GuzzleHttp\Exception\RequestException $e) {
echo "❌ Failed to connect to Stash server\n";
echo " Error: " . $e->getMessage() . "\n";
if ($e->hasResponse()) {
$response = $e->getResponse();
echo " Status: {$response->getStatusCode()}\n";
echo " Response: " . $response->getBody() . "\n";
}
echo "\n💡 Troubleshooting tips:\n";
echo " 1. Check if Stash server is running\n";
echo " 2. Verify the API URL is correct\n";
echo " 3. Check if API key is required and correct\n";
echo " 4. Ensure the server is accessible from this machine\n";
}
} catch (Exception $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
}

107
test_xbvr.php Normal file
View File

@@ -0,0 +1,107 @@
<?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");
}
// Test XBVR connectivity
echo "\nTesting XBVR source connectivity...\n";
// Find XBVR source
$stmt = $pdo->prepare("SELECT * FROM sources WHERE name = 'xbvr' AND is_active = 1 LIMIT 1");
$stmt->execute();
$xbvrSource = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$xbvrSource) {
echo "No active XBVR source found. Please create an XBVR source first.\n";
exit(1);
}
echo "Found XBVR source: {$xbvrSource['display_name']} ({$xbvrSource['api_url']})\n";
// Test basic connectivity
$baseUrl = rtrim($xbvrSource['api_url'], '/');
try {
$httpClient = new GuzzleHttp\Client([
'timeout' => 10,
'headers' => [
'User-Agent' => 'MediaCollector/1.0'
],
'verify' => false
]);
// Try different XBVR API endpoints
$endpoints = [
"{$baseUrl}/deovr"
];
foreach ($endpoints as $endpoint) {
echo "\nTrying endpoint: {$endpoint}\n";
try {
$response = $httpClient->get($endpoint, [
'timeout' => 10,
'connect_timeout' => 5
]);
echo "✓ Status: {$response->getStatusCode()}\n";
echo "Content-Type: " . $response->getHeaderLine('content-type') . "\n";
$data = json_decode($response->getBody(), true);
echo "Response keys: " . implode(', ', array_keys($data)) . "\n";
// XBVR DeoVR API response structure
$scenes = null;
// Try different DeoVR response structures
if (isset($data['scenes'])) {
$scenes = $data['scenes'];
} elseif (isset($data['content'])) {
$scenes = $data['content'];
} elseif (isset($data['videos'])) {
$scenes = $data['videos'];
} elseif (isset($data[0]) && is_array($data[0])) {
// Array of scenes directly
$scenes = $data;
}
if ($scenes !== null) {
echo "✓ Found " . count($scenes) . " scenes in XBVR DeoVR response\n";
if (count($scenes) > 0) {
echo "Sample scene keys: " . implode(', ', array_keys($scenes[0])) . "\n";
}
break;
} else {
echo "✗ No scenes array found in response. Response keys: " . implode(', ', array_keys($data)) . "\n";
echo "Sample response structure: " . json_encode(array_slice($data, 0, 2), JSON_PRETTY_PRINT) . "\n";
}
} catch (Exception $e) {
echo "✗ Error: " . $e->getMessage() . "\n";
}
}
} catch (Exception $e) {
echo "Error testing XBVR connectivity: " . $e->getMessage() . "\n";
exit(1);
}
echo "\nXBVR connectivity test complete.\n";