mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
i dont know
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,3 +148,4 @@ composer.lock
|
|||||||
/public/public/images/adult_videos
|
/public/public/images/adult_videos
|
||||||
/public/public/images/backdrops
|
/public/public/images/backdrops
|
||||||
/public/public/images/posters
|
/public/public/images/posters
|
||||||
|
/storage/images
|
||||||
|
|||||||
@@ -78,6 +78,28 @@ class ActorController extends Controller
|
|||||||
$stmt->execute(['actor_id' => $actorId]);
|
$stmt->execute(['actor_id' => $actorId]);
|
||||||
$tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
foreach ($scenes as &$scene) {
|
||||||
|
if (!empty($scene['metadata'])) {
|
||||||
|
$metadata = json_decode($scene['metadata'], true);
|
||||||
|
|
||||||
|
// Use local cover path if available, otherwise fall back to original URL
|
||||||
|
if (!empty($metadata['local_cover_path'])) {
|
||||||
|
$scene['poster_url'] = $metadata['local_cover_path'];
|
||||||
|
} elseif (!empty($metadata['cover_url'])) {
|
||||||
|
$scene['poster_url'] = $metadata['cover_url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add actors data if available
|
||||||
|
if (!empty($metadata['actors'])) {
|
||||||
|
$scene['actors'] = $metadata['actors'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return $this->view->render($response, 'actor/show.twig', [
|
return $this->view->render($response, 'actor/show.twig', [
|
||||||
'title' => $actor['name'],
|
'title' => $actor['name'],
|
||||||
'actor' => $actor,
|
'actor' => $actor,
|
||||||
@@ -86,7 +108,6 @@ class ActorController extends Controller
|
|||||||
'tv_shows' => $tvShows
|
'tv_shows' => $tvShows
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Request $request, Response $response, $args)
|
public function index(Request $request, Response $response, $args)
|
||||||
{
|
{
|
||||||
// Get all actors with their media counts from all types
|
// Get all actors with their media counts from all types
|
||||||
|
|||||||
70
app/Controllers/AdminBaseController.php
Normal file
70
app/Controllers/AdminBaseController.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
|
class AdminBaseController
|
||||||
|
{
|
||||||
|
protected Twig $view;
|
||||||
|
protected \PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
$this->view = $view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a template
|
||||||
|
*/
|
||||||
|
protected function render(Response $response, string $template, array $data = []): Response
|
||||||
|
{
|
||||||
|
// Add common admin data
|
||||||
|
$data['auth'] = [
|
||||||
|
'check' => isset($_SESSION['user_id']),
|
||||||
|
'user' => [
|
||||||
|
'username' => $_SESSION['username'] ?? 'Admin',
|
||||||
|
'is_admin' => $_SESSION['is_admin'] ?? false
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add current route for active menu highlighting
|
||||||
|
$route = $this->getCurrentRoute();
|
||||||
|
if ($route) {
|
||||||
|
$data['current_route'] = $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($response, $template, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current route name
|
||||||
|
*/
|
||||||
|
protected function getCurrentRoute(): ?string
|
||||||
|
{
|
||||||
|
$route = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$basePath = '/admin/';
|
||||||
|
|
||||||
|
if (strpos($route, $basePath) === 0) {
|
||||||
|
$route = substr($route, strlen($basePath));
|
||||||
|
$parts = explode('/', $route);
|
||||||
|
return $parts[0] ?: 'index';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return JSON response
|
||||||
|
*/
|
||||||
|
protected function json(Response $response, $data, int $status = 200): Response
|
||||||
|
{
|
||||||
|
$response->getBody()->write(json_encode($data));
|
||||||
|
return $response
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withStatus($status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,13 @@ use App\Services\ExophaseSyncService;
|
|||||||
use PDO;
|
use PDO;
|
||||||
use Slim\Views\Twig;
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
class AdminController extends Controller
|
class AdminController extends AdminBaseController
|
||||||
{
|
{
|
||||||
private PDO $pdo;
|
protected PDO $pdo;
|
||||||
|
|
||||||
public function __construct(PDO $pdo, Twig $view)
|
public function __construct(PDO $pdo, Twig $view)
|
||||||
{
|
{
|
||||||
parent::__construct($view);
|
parent::__construct($pdo, $view);
|
||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class AdminController extends Controller
|
|||||||
$syncLogModel = new SyncLog($this->pdo);
|
$syncLogModel = new SyncLog($this->pdo);
|
||||||
$recentSyncs = SyncLog::getRecent($this->pdo, 10);
|
$recentSyncs = SyncLog::getRecent($this->pdo, 10);
|
||||||
|
|
||||||
return $this->view->render($response, 'admin/index.twig', [
|
return $this->render($response, 'admin/index.twig', [
|
||||||
'title' => 'Admin Dashboard',
|
'title' => 'Admin Dashboard',
|
||||||
'sources' => $sources,
|
'sources' => $sources,
|
||||||
'recent_syncs' => $recentSyncs
|
'recent_syncs' => $recentSyncs
|
||||||
@@ -119,14 +119,23 @@ class AdminController extends Controller
|
|||||||
return min(100, round(($processed / $total) * 100, 2));
|
return min(100, round(($processed / $total) * 100, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function settings(Request $request, Response $response, $args)
|
||||||
|
{
|
||||||
|
return $this->render($response, 'admin/settings.twig', [
|
||||||
|
'title' => 'Admin Settings',
|
||||||
|
'current_route' => 'settings'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function sources(Request $request, Response $response, $args)
|
public function sources(Request $request, Response $response, $args)
|
||||||
{
|
{
|
||||||
$sourceModel = new Source($this->pdo);
|
$sourceModel = new Source($this->pdo);
|
||||||
$sources = $sourceModel->findAll();
|
$sources = $sourceModel->findAll();
|
||||||
|
|
||||||
return $this->view->render($response, 'admin/sources.twig', [
|
return $this->render($response, 'admin/sources.twig', [
|
||||||
'title' => 'Source Management',
|
'title' => 'Source Management',
|
||||||
'sources' => $sources
|
'sources' => $sources,
|
||||||
|
'current_route' => 'sources'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class AdultController extends Controller
|
|||||||
|
|
||||||
// Use local cover path if available, otherwise fall back to original URL
|
// Use local cover path if available, otherwise fall back to original URL
|
||||||
if (!empty($metadata['local_cover_path'])) {
|
if (!empty($metadata['local_cover_path'])) {
|
||||||
$video['poster_url'] = '/public/images/'.$metadata['local_cover_path'];
|
$video['poster_url'] = $metadata['local_cover_path'];
|
||||||
} elseif (!empty($metadata['cover_url'])) {
|
} elseif (!empty($metadata['cover_url'])) {
|
||||||
$video['poster_url'] = $metadata['cover_url'];
|
$video['poster_url'] = $metadata['cover_url'];
|
||||||
}
|
}
|
||||||
@@ -103,13 +103,13 @@ class AdultController extends Controller
|
|||||||
|
|
||||||
// Add local image paths and other metadata to the video data for template compatibility
|
// 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'] = '/images/'.$metadata['local_cover_path'];
|
||||||
} elseif (!empty($metadata['cover_url'])) {
|
} elseif (!empty($metadata['cover_url'])) {
|
||||||
$adultVideo['poster_url'] = $metadata['cover_url'];
|
$adultVideo['poster_url'] = $metadata['cover_url'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($metadata['local_screenshot_path'])) {
|
if (!empty($metadata['local_screenshot_path'])) {
|
||||||
$adultVideo['screenshot_url'] = '/public/images/'.$metadata['local_screenshot_path'];
|
$adultVideo['screenshot_url'] = '/images/'.$metadata['local_screenshot_path'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add actors data if available
|
// Add actors data if available
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
|||||||
use App\Models\Game;
|
use App\Models\Game;
|
||||||
use App\Models\Movie;
|
use App\Models\Movie;
|
||||||
use App\Models\TvShow;
|
use App\Models\TvShow;
|
||||||
use App\Models\MusicArtist;
|
use App\Models\AdultVideo;
|
||||||
use App\Models\SyncLog;
|
use App\Models\SyncLog;
|
||||||
use Slim\Views\Twig;
|
use Slim\Views\Twig;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(Twig $view)
|
private \PDO $pdo;
|
||||||
|
public function __construct(\PDO $pdo, Twig $view)
|
||||||
{
|
{
|
||||||
parent::__construct($view);
|
parent::__construct($view);
|
||||||
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Request $request, Response $response, $args)
|
public function index(Request $request, Response $response, $args)
|
||||||
{
|
{
|
||||||
$pdo = $this->view->getEnvironment()->getGlobals()['pdo'] ?? null;
|
if (!$this->pdo) {
|
||||||
|
|
||||||
if (!$pdo) {
|
|
||||||
return $this->view->render($response, 'dashboard/index.twig', [
|
return $this->view->render($response, 'dashboard/index.twig', [
|
||||||
'title' => 'Dashboard',
|
'title' => 'Dashboard',
|
||||||
'stats' => [
|
'stats' => [
|
||||||
@@ -38,22 +38,24 @@ class DashboardController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get statistics from models
|
// Get statistics from models
|
||||||
$gameStats = Game::getStats($pdo);
|
$gameStats = Game::getStats($this->pdo);
|
||||||
$movieStats = Movie::getStats($pdo);
|
$movieStats = Movie::getStats($this->pdo);
|
||||||
$tvShowStats = TvShow::getStats($pdo);
|
$tvShowStats = TvShow::getStats($this->pdo);
|
||||||
$musicStats = MusicArtist::getStats($pdo);
|
$adultStats = AdultVideo::getStats($this->pdo);
|
||||||
$syncStats = SyncLog::getStats($pdo);
|
// $musicStats = MusicArtist::getStats($this->pdo);
|
||||||
|
//$syncStats = SyncLog::getStats($this->pdo);
|
||||||
|
|
||||||
// Get recent activity
|
// Get recent activity
|
||||||
$recentGames = Game::getRecent($pdo, 5);
|
$recentGames = Game::getRecent($this->pdo, 5);
|
||||||
$recentMovies = Movie::getRecent($pdo, 5);
|
$recentMovies = Movie::getRecent($this->pdo, 5);
|
||||||
$recentSyncs = SyncLog::getRecent($pdo, 5);
|
$recentSyncs = SyncLog::getRecent($this->pdo, 5);
|
||||||
|
|
||||||
// Calculate total media count
|
// Calculate total media count
|
||||||
$totalMedia = ($gameStats['total_games'] ?? 0) +
|
$totalMedia = ($gameStats['total_games'] ?? 0) +
|
||||||
($movieStats['total_movies'] ?? 0) +
|
($movieStats['total_movies'] ?? 0) +
|
||||||
($tvShowStats['total_shows'] ?? 0) +
|
($tvShowStats['total_shows'] ?? 0) +
|
||||||
($musicStats['total_artists'] ?? 0);
|
($musicStats['total_artists'] ?? 0)+
|
||||||
|
($adultStats['total_adult_videos'] ?? 0);
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'total_media' => $totalMedia,
|
'total_media' => $totalMedia,
|
||||||
@@ -63,11 +65,13 @@ class DashboardController extends Controller
|
|||||||
'total_episodes' => $tvShowStats['total_episodes'] ?? 0,
|
'total_episodes' => $tvShowStats['total_episodes'] ?? 0,
|
||||||
'total_music' => $musicStats['total_artists'] ?? 0,
|
'total_music' => $musicStats['total_artists'] ?? 0,
|
||||||
'total_playtime' => $gameStats['total_playtime'] ?? 0,
|
'total_playtime' => $gameStats['total_playtime'] ?? 0,
|
||||||
|
'total_adult_videos' => $adultStats['total_adult_videos'] ?? 0,
|
||||||
'watched_movies' => $movieStats['watched_movies'] ?? 0,
|
'watched_movies' => $movieStats['watched_movies'] ?? 0,
|
||||||
'favorite_games' => $gameStats['favorite_games'] ?? 0,
|
'favorite_games' => $gameStats['favorite_games'] ?? 0,
|
||||||
'favorite_movies' => $movieStats['favorite_movies'] ?? 0,
|
'favorite_movies' => $movieStats['favorite_movies'] ?? 0,
|
||||||
'favorite_shows' => $tvShowStats['favorite_shows'] ?? 0,
|
'favorite_shows' => $tvShowStats['favorite_shows'] ?? 0,
|
||||||
'favorite_music' => $musicStats['favorite_artists'] ?? 0,
|
'favorite_music' => $musicStats['favorite_artists'] ?? 0,
|
||||||
|
'favorite_adult_videos' => $adultStats['favorite_adult_videos'] ?? 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
return $this->view->render($response, 'dashboard/index.twig', [
|
return $this->view->render($response, 'dashboard/index.twig', [
|
||||||
|
|||||||
53
app/Controllers/ImageController.php
Normal file
53
app/Controllers/ImageController.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
class ImageController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Serve an image from internal storage
|
||||||
|
* @param Request $request
|
||||||
|
* @param Response $response
|
||||||
|
* @param array $args
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
public function serve(Request $request, Response $response, $args): Response
|
||||||
|
{
|
||||||
|
$imagePath = $args['path'] ?? '';
|
||||||
|
|
||||||
|
// Security: Prevent directory traversal
|
||||||
|
$imagePath = str_replace(['../', '..\\'], '', $imagePath);
|
||||||
|
|
||||||
|
$fullPath = __DIR__ . '/../../storage/images/' . $imagePath;
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!file_exists($fullPath)) {
|
||||||
|
return $response->withStatus(404, 'Image not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file extension and set appropriate content type
|
||||||
|
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||||
|
$contentTypes = [
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
];
|
||||||
|
|
||||||
|
$contentType = $contentTypes[$extension] ?? 'application/octet-stream';
|
||||||
|
|
||||||
|
// Read and serve the file
|
||||||
|
$fileContent = file_get_contents($fullPath);
|
||||||
|
|
||||||
|
$response = $response->withHeader('Content-Type', $contentType);
|
||||||
|
$response = $response->withHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
|
||||||
|
$response->getBody()->write($fileContent);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/Controllers/MediaSourceController.php
Normal file
219
app/Controllers/MediaSourceController.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Source;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
|
||||||
|
class MediaSourceController extends AdminBaseController
|
||||||
|
{
|
||||||
|
private $source;
|
||||||
|
private $syncLog;
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, \Slim\Views\Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $view);
|
||||||
|
$this->source = new Source($pdo);
|
||||||
|
$this->syncLog = new SyncLog($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all sources
|
||||||
|
public function index(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$sources = $this->source->all();
|
||||||
|
|
||||||
|
return $this->render($response, 'admin/sources.twig', [
|
||||||
|
'sources' => $sources,
|
||||||
|
'current_route' => 'sources'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show create form
|
||||||
|
public function create(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
return $this->render($response, 'admin/sources/create.twig', [
|
||||||
|
'current_route' => 'sources'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store new source
|
||||||
|
public function store(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (empty($data['name']) || empty($data['type']) || empty($data['path'])) {
|
||||||
|
$this->flash->addMessage('error', 'Name, type, and path are required');
|
||||||
|
return $response->withHeader('Location', '/admin/sources/create')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sourceData = [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'type' => $data['type'],
|
||||||
|
'path' => $data['path'],
|
||||||
|
'username' => $data['username'] ?? null,
|
||||||
|
'password' => !empty($data['password']) ? password_hash($data['password'], PASSWORD_DEFAULT) : null,
|
||||||
|
'is_active' => isset($data['is_active']) ? 1 : 0,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->source->create($sourceData);
|
||||||
|
$this->flash->addMessage('success', 'Source created successfully');
|
||||||
|
return $response->withHeader('Location', '/admin/sources')->withStatus(302);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->flash->addMessage('error', 'Error creating source: ' . $e->getMessage());
|
||||||
|
return $response->withHeader('Location', '/admin/sources/create')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show edit form
|
||||||
|
public function edit(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$id = $args['id'];
|
||||||
|
$source = $this->source->find($id);
|
||||||
|
|
||||||
|
if (!$source) {
|
||||||
|
$this->flash->addMessage('error', 'Source not found');
|
||||||
|
return $response->withHeader('Location', '/admin/sources')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render($response, 'admin/sources/edit.twig', [
|
||||||
|
'source' => $source,
|
||||||
|
'current_route' => 'sources'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update source
|
||||||
|
public function update(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$id = $args['id'];
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sourceData = [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'type' => $data['type'],
|
||||||
|
'path' => $data['path'],
|
||||||
|
'username' => $data['username'] ?? null,
|
||||||
|
'is_active' => isset($data['is_active']) ? 1 : 0,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only update password if provided
|
||||||
|
if (!empty($data['password'])) {
|
||||||
|
$sourceData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->source->update($id, $sourceData);
|
||||||
|
$this->flash->addMessage('success', 'Source updated successfully');
|
||||||
|
return $response->withHeader('Location', '/admin/sources')->withStatus(302);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->flash->addMessage('error', 'Error updating source: ' . $e->getMessage());
|
||||||
|
return $response->withHeader('Location', '/admin/sources/' . $id . '/edit')->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete source
|
||||||
|
public function destroy(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$id = $args['id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->source->delete($id);
|
||||||
|
$this->flash->addMessage('success', 'Source deleted successfully');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->flash->addMessage('error', 'Error deleting source: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->withHeader('Location', '/admin/sources')->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sync for a source
|
||||||
|
public function startSync(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$sourceId = $args['id'];
|
||||||
|
$source = $this->source->find($sourceId);
|
||||||
|
|
||||||
|
if (!$source) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Source not found'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a sync log entry
|
||||||
|
$logId = $this->syncLog->create([
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'type' => 'full',
|
||||||
|
'status' => 'pending',
|
||||||
|
'started_at' => date('Y-m-d H:i:s'),
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Start sync in background (you'll need to implement this)
|
||||||
|
$this->startBackgroundSync($sourceId, $logId);
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Sync started',
|
||||||
|
'log_id' => $logId
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error starting sync: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sync status
|
||||||
|
public function syncStatus(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$logId = $args['log_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$logId) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Log ID is required'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$log = $this->syncLog->find($logId);
|
||||||
|
|
||||||
|
if (!$log) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Sync log not found'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $log['status'],
|
||||||
|
'progress' => $log['progress'] ?? 0,
|
||||||
|
'message' => $log['message'] ?? ''
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background sync process
|
||||||
|
private function startBackgroundSync($sourceId, $logId)
|
||||||
|
{
|
||||||
|
// This is a simplified example - you'll need to implement this based on your needs
|
||||||
|
$command = sprintf(
|
||||||
|
'php %s/console.php sync:source %d --log=%d > /dev/null 2>&1 &',
|
||||||
|
dirname(__DIR__, 3),
|
||||||
|
$sourceId,
|
||||||
|
$logId
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/Controllers/SettingsController.php
Normal file
158
app/Controllers/SettingsController.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?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 SettingsController extends Controller
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo, Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($view);
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request, Response $response, $args)
|
||||||
|
{
|
||||||
|
$settings = $this->getSettings();
|
||||||
|
$sources = $this->getSources();
|
||||||
|
|
||||||
|
return $this->view->render($response, 'admin/settings.twig', [
|
||||||
|
'title' => 'Admin Settings',
|
||||||
|
'settings' => $settings,
|
||||||
|
'sources' => $sources
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request, Response $response, $args)
|
||||||
|
{
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
// Save general settings
|
||||||
|
$this->saveGeneralSettings($data);
|
||||||
|
|
||||||
|
// Save source-specific settings
|
||||||
|
if (isset($data['sources']) && is_array($data['sources'])) {
|
||||||
|
$this->saveSourceSettings($data['sources']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view->render($response, 'admin/settings.twig', [
|
||||||
|
'title' => 'Admin Settings',
|
||||||
|
'settings' => $this->getSettings(),
|
||||||
|
'sources' => $this->getSources(),
|
||||||
|
'success' => 'Settings saved successfully!'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSettings(): array
|
||||||
|
{
|
||||||
|
// Get general application settings
|
||||||
|
$settings = [];
|
||||||
|
|
||||||
|
// Get media type visibility settings
|
||||||
|
$stmt = $this->pdo->prepare("SELECT setting_key, setting_value FROM settings WHERE setting_key LIKE 'media_visibility_%'");
|
||||||
|
$stmt->execute();
|
||||||
|
$mediaVisibilitySettings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
$settings['media_visibility'] = [
|
||||||
|
'games' => $mediaVisibilitySettings['media_visibility_games'] ?? 'authenticated', // Default: authenticated users only
|
||||||
|
'movies' => $mediaVisibilitySettings['media_visibility_movies'] ?? 'authenticated',
|
||||||
|
'tvshows' => $mediaVisibilitySettings['media_visibility_tvshows'] ?? 'authenticated',
|
||||||
|
'music' => $mediaVisibilitySettings['media_visibility_music'] ?? 'authenticated',
|
||||||
|
'adult' => $mediaVisibilitySettings['media_visibility_adult'] ?? 'authenticated', // Adult content requires auth by default
|
||||||
|
'actors' => $mediaVisibilitySettings['media_visibility_actors'] ?? 'authenticated'
|
||||||
|
];
|
||||||
|
|
||||||
|
// You can extend this to include more settings like:
|
||||||
|
// - Sync intervals
|
||||||
|
// - Default sync types
|
||||||
|
// - Notification preferences
|
||||||
|
// - Theme settings
|
||||||
|
// - etc.
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSources(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare("SELECT * FROM sources ORDER BY display_name");
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveGeneralSettings(array $data): void
|
||||||
|
{
|
||||||
|
// Save general settings to a settings table or config file
|
||||||
|
// For now, we'll store them in a simple settings table
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (strpos($key, 'setting_') === 0) {
|
||||||
|
$settingKey = substr($key, 8); // Remove 'setting_' prefix
|
||||||
|
|
||||||
|
// Check if setting exists
|
||||||
|
$stmt = $this->pdo->prepare("SELECT id FROM settings WHERE setting_key = :key LIMIT 1");
|
||||||
|
$stmt->execute(['key' => $settingKey]);
|
||||||
|
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update existing setting
|
||||||
|
$stmt = $this->pdo->prepare("UPDATE settings SET setting_value = :value WHERE setting_key = :key");
|
||||||
|
$stmt->execute(['key' => $settingKey, 'value' => $value]);
|
||||||
|
} else {
|
||||||
|
// Insert new setting
|
||||||
|
$stmt = $this->pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)");
|
||||||
|
$stmt->execute(['key' => $settingKey, 'value' => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save media visibility settings
|
||||||
|
if (isset($data['media_visibility']) && is_array($data['media_visibility'])) {
|
||||||
|
foreach ($data['media_visibility'] as $mediaType => $visibility) {
|
||||||
|
$settingKey = "media_visibility_{$mediaType}";
|
||||||
|
|
||||||
|
// Check if setting exists
|
||||||
|
$stmt = $this->pdo->prepare("SELECT id FROM settings WHERE setting_key = :key LIMIT 1");
|
||||||
|
$stmt->execute(['key' => $settingKey]);
|
||||||
|
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update existing setting
|
||||||
|
$stmt = $this->pdo->prepare("UPDATE settings SET setting_value = :value WHERE setting_key = :key");
|
||||||
|
$stmt->execute(['key' => $settingKey, 'value' => $visibility]);
|
||||||
|
} else {
|
||||||
|
// Insert new setting
|
||||||
|
$stmt = $this->pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value)");
|
||||||
|
$stmt->execute(['key' => $settingKey, 'value' => $visibility]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveSourceSettings(array $sources): void
|
||||||
|
{
|
||||||
|
foreach ($sources as $sourceId => $sourceData) {
|
||||||
|
// Update source configuration
|
||||||
|
$config = isset($sourceData['config']) ? json_encode($sourceData['config']) : '{}';
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare("
|
||||||
|
UPDATE sources
|
||||||
|
SET api_url = :api_url, api_key = :api_key, config = :config, is_active = :is_active
|
||||||
|
WHERE id = :id
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $sourceId,
|
||||||
|
'api_url' => $sourceData['api_url'] ?? '',
|
||||||
|
'api_key' => $sourceData['api_key'] ?? '',
|
||||||
|
'config' => $config,
|
||||||
|
'is_active' => isset($sourceData['is_active']) ? 1 : 0
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
app/Controllers/SyncController.php
Normal file
294
app/Controllers/SyncController.php
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use App\Models\Source;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use App\Services\JellyfinSyncService;
|
||||||
|
use App\Services\LocalSyncService;
|
||||||
|
use App\Services\SambaSyncService;
|
||||||
|
use App\Services\NfsSyncService;
|
||||||
|
|
||||||
|
class SyncController extends AdminBaseController
|
||||||
|
{
|
||||||
|
private $source;
|
||||||
|
private $syncLog;
|
||||||
|
private $syncServices = [];
|
||||||
|
|
||||||
|
public function __construct(\PDO $pdo, \Slim\Views\Twig $view)
|
||||||
|
{
|
||||||
|
parent::__construct($pdo, $view);
|
||||||
|
$this->source = new Source($pdo);
|
||||||
|
$this->syncLog = new SyncLog($pdo);
|
||||||
|
|
||||||
|
// Initialize sync services
|
||||||
|
$this->syncServices = [
|
||||||
|
'jellyfin' => new JellyfinSyncService($pdo),
|
||||||
|
'local' => new LocalSyncService($pdo),
|
||||||
|
'samba' => new SambaSyncService($pdo),
|
||||||
|
'nfs' => new NfsSyncService($pdo)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sync dashboard
|
||||||
|
public function index(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
// Get recent sync logs
|
||||||
|
$recentLogs = $this->syncLog->orderBy('created_at', 'desc')->limit(10)->get();
|
||||||
|
|
||||||
|
// Get sync statistics
|
||||||
|
$stats = [
|
||||||
|
'total_syncs' => $this->syncLog->count(),
|
||||||
|
'successful_syncs' => $this->syncLog->where('status', 'completed')->count(),
|
||||||
|
'failed_syncs' => $this->syncLog->where('status', 'failed')->count(),
|
||||||
|
'pending_syncs' => $this->syncLog->where('status', 'pending')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->render($response, 'admin/sync/index.twig', [
|
||||||
|
'recent_logs' => $recentLogs,
|
||||||
|
'stats' => $stats,
|
||||||
|
'current_route' => 'sync'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new sync
|
||||||
|
public function start(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
$type = $data['type'] ?? 'full'; // full, scan, update
|
||||||
|
$sourceId = $data['source_id'] ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new sync log
|
||||||
|
$logData = [
|
||||||
|
'source_id' => $sourceId,
|
||||||
|
'sync_type' => $type,
|
||||||
|
'status' => 'pending',
|
||||||
|
'started_at' => date('Y-m-d H:i:s'),
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
$logId = $this->syncLog->create($logData);
|
||||||
|
|
||||||
|
// Start sync in background
|
||||||
|
$this->startBackgroundSync($sourceId, $logId, $type);
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Sync started',
|
||||||
|
'log_id' => $logId
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error starting sync: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sync status
|
||||||
|
public function status(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$logId = $args['log_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$logId) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Log ID is required'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$log = $this->syncLog->find($logId);
|
||||||
|
|
||||||
|
if (!$log) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Sync log not found'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get detailed status from the sync service if available
|
||||||
|
$status = [
|
||||||
|
'status' => $log['status'],
|
||||||
|
'progress' => (int)($log['progress'] ?? 0),
|
||||||
|
'message' => $log['message'] ?? '',
|
||||||
|
'started_at' => $log['started_at'],
|
||||||
|
'completed_at' => $log['completed_at'] ?? null,
|
||||||
|
'total_items' => (int)($log['total_items'] ?? 0),
|
||||||
|
'processed_items' => (int)($log['processed_items'] ?? 0),
|
||||||
|
'new_items' => (int)($log['new_items'] ?? 0),
|
||||||
|
'updated_items' => (int)($log['updated_items'] ?? 0),
|
||||||
|
'deleted_items' => (int)($log['deleted_items'] ?? 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'log' => $status
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel a running sync
|
||||||
|
public function cancel(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$logId = $args['log_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$logId) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Log ID is required'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the log status to cancelled
|
||||||
|
$this->syncLog->update($logId, [
|
||||||
|
'status' => 'cancelled',
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => 'Sync cancelled by user'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: Send a signal to the running process to cancel
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Sync cancelled'
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error cancelling sync: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear sync logs
|
||||||
|
public function clearLogs(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$type = $request->getParsedBody()['type'] ?? 'completed'; // completed, all
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($type === 'all') {
|
||||||
|
$this->syncLog->query('TRUNCATE TABLE sync_logs');
|
||||||
|
} else {
|
||||||
|
$this->syncLog->where('status', 'completed')->delete();
|
||||||
|
$this->syncLog->where('status', 'failed')->delete();
|
||||||
|
$this->syncLog->where('status', 'cancelled')->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Logs cleared successfully'
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json($response, [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Error clearing logs: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background sync process
|
||||||
|
private function startBackgroundSync($sourceId, $logId, $type = 'full')
|
||||||
|
{
|
||||||
|
// This is a simplified example - you'll need to implement this based on your needs
|
||||||
|
$command = sprintf(
|
||||||
|
'php %s/console.php sync:start --log=%d --type=%s %s > /dev/null 2>&1 &',
|
||||||
|
dirname(__DIR__, 3), // Path to your project root
|
||||||
|
$logId,
|
||||||
|
escapeshellarg($type),
|
||||||
|
$sourceId ? '--source=' . $sourceId : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
exec($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process sync (called from CLI)
|
||||||
|
public function processSync($sourceId, $logId, $type = 'full')
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$log = $this->syncLog->find($logId);
|
||||||
|
|
||||||
|
if (!$log) {
|
||||||
|
throw new \Exception('Sync log not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update log status to started
|
||||||
|
$this->syncLog->update($logId, [
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'started_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => 'Sync started'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source = null;
|
||||||
|
if ($sourceId) {
|
||||||
|
$source = $this->source->find($sourceId);
|
||||||
|
if (!$source) {
|
||||||
|
throw new \Exception('Source not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the appropriate sync service
|
||||||
|
$service = $this->getSyncService($source ? $source['type'] : 'local');
|
||||||
|
|
||||||
|
// Start sync
|
||||||
|
$result = $service->sync($source, $type, function($progress, $message) use ($logId) {
|
||||||
|
// Update progress callback
|
||||||
|
$this->updateSyncProgress($logId, $progress, $message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update log with final status
|
||||||
|
$this->syncLog->update($logId, [
|
||||||
|
'status' => $result['success'] ? 'completed' : 'failed',
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => $result['message'] ?? 'Sync completed',
|
||||||
|
'total_items' => $result['total_items'] ?? 0,
|
||||||
|
'processed_items' => $result['processed_items'] ?? 0,
|
||||||
|
'new_items' => $result['new_items'] ?? 0,
|
||||||
|
'updated_items' => $result['updated_items'] ?? 0,
|
||||||
|
'deleted_items' => $result['deleted_items'] ?? 0,
|
||||||
|
'errors' => !empty($result['errors']) ? json_encode($result['errors']) : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Update log with error
|
||||||
|
if (isset($logId) && $this->syncLog) {
|
||||||
|
$this->syncLog->update($logId, [
|
||||||
|
'status' => 'failed',
|
||||||
|
'completed_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => 'Error: ' . $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync progress
|
||||||
|
private function updateSyncProgress($logId, $progress, $message = '')
|
||||||
|
{
|
||||||
|
$this->syncLog->update($logId, [
|
||||||
|
'progress' => $progress,
|
||||||
|
'message' => $message,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the appropriate sync service
|
||||||
|
private function getSyncService($type)
|
||||||
|
{
|
||||||
|
$type = strtolower($type);
|
||||||
|
|
||||||
|
if (!isset($this->syncServices[$type])) {
|
||||||
|
throw new \Exception("Unsupported source type: $type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->syncServices[$type];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,11 @@ class TvShowController extends Controller
|
|||||||
$totalPages = ceil($totalCount / $perPage);
|
$totalPages = ceil($totalCount / $perPage);
|
||||||
$hasNextPage = $page < $totalPages;
|
$hasNextPage = $page < $totalPages;
|
||||||
$hasPrevPage = $page > 1;
|
$hasPrevPage = $page > 1;
|
||||||
|
/*
|
||||||
|
echo '<pre>';
|
||||||
|
print_r($tvshows);
|
||||||
|
die();
|
||||||
|
*/
|
||||||
return $this->view->render($response, 'tvshows/index.twig', [
|
return $this->view->render($response, 'tvshows/index.twig', [
|
||||||
'title' => 'TV Shows',
|
'title' => 'TV Shows',
|
||||||
'tvshows' => $tvshows,
|
'tvshows' => $tvshows,
|
||||||
@@ -107,4 +111,67 @@ class TvShowController extends Controller
|
|||||||
'seasons' => $seasons
|
'seasons' => $seasons
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function delete(Request $request, Response $response, $args)
|
||||||
|
{
|
||||||
|
$tvShowId = (int) $args['id'];
|
||||||
|
|
||||||
|
// Get TV show details to access metadata
|
||||||
|
$stmt = $this->pdo->prepare("
|
||||||
|
SELECT t.*
|
||||||
|
FROM tv_shows t
|
||||||
|
WHERE t.id = :id
|
||||||
|
");
|
||||||
|
$stmt->execute(['id' => $tvShowId]);
|
||||||
|
$tvShow = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$tvShow) {
|
||||||
|
return $response->withStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode metadata to find image paths
|
||||||
|
$metadata = json_decode($tvShow['metadata'], true);
|
||||||
|
|
||||||
|
// Delete associated images
|
||||||
|
$imagesDeleted = [];
|
||||||
|
if (!empty($metadata['local_poster_path'])) {
|
||||||
|
$posterPath = __DIR__ . '/../storage/images/' . $metadata['local_poster_path'];
|
||||||
|
if (file_exists($posterPath)) {
|
||||||
|
unlink($posterPath);
|
||||||
|
$imagesDeleted[] = $metadata['local_poster_path'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($metadata['local_backdrop_path'])) {
|
||||||
|
$backdropPath = __DIR__ . '/../storage/images/' . $metadata['local_backdrop_path'];
|
||||||
|
if (file_exists($backdropPath)) {
|
||||||
|
unlink($backdropPath);
|
||||||
|
$imagesDeleted[] = $metadata['local_backdrop_path'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete actor relationships
|
||||||
|
$stmt = $this->pdo->prepare("
|
||||||
|
DELETE FROM actor_tv_show
|
||||||
|
WHERE tv_show_id = :tv_show_id
|
||||||
|
");
|
||||||
|
$stmt->execute(['tv_show_id' => $tvShowId]);
|
||||||
|
|
||||||
|
// Delete the TV show record
|
||||||
|
$stmt = $this->pdo->prepare("DELETE FROM tv_shows WHERE id = :id");
|
||||||
|
$result = $stmt->execute(['id' => $tvShowId]);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
return $response->withJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'TV show deleted successfully',
|
||||||
|
'images_deleted' => $imagesDeleted
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return $response->withJson([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to delete TV show'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
app/Http/Middleware/MediaVisibilityMiddleware.php
Normal file
68
app/Http/Middleware/MediaVisibilityMiddleware.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
class MediaVisibilityMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
{
|
||||||
|
$path = $request->getUri()->getPath();
|
||||||
|
|
||||||
|
// Map routes to media types
|
||||||
|
$mediaRoutes = [
|
||||||
|
'/media/games' => 'games',
|
||||||
|
'/media/movies' => 'movies',
|
||||||
|
'/media/tv-shows' => 'tvshows',
|
||||||
|
'/media/music' => 'music',
|
||||||
|
'/media/adult' => 'adult',
|
||||||
|
'/media/actors' => 'actors'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($mediaRoutes as $route => $mediaType) {
|
||||||
|
if (strpos($path, $route) === 0) {
|
||||||
|
// Check if this media type is visible to the current user
|
||||||
|
if (!$this->isMediaTypeVisible($mediaType)) {
|
||||||
|
// Redirect to login or show 404 based on configuration
|
||||||
|
if (!is_logged_in()) {
|
||||||
|
return $handler->handle($request)->withStatus(401)->withHeader('Location', '/login');
|
||||||
|
} else {
|
||||||
|
return $handler->handle($request)->withStatus(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isMediaTypeVisible(string $mediaType): bool
|
||||||
|
{
|
||||||
|
// Get database connection
|
||||||
|
$pdo = \App\Database\Database::getInstance();
|
||||||
|
|
||||||
|
// Get media visibility setting
|
||||||
|
$stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1");
|
||||||
|
$stmt->execute(['key' => "media_visibility_{$mediaType}"]);
|
||||||
|
$visibility = $stmt->fetchColumn() ?: 'authenticated'; // Default to authenticated only
|
||||||
|
|
||||||
|
// Check user authentication status
|
||||||
|
$isLoggedIn = is_logged_in();
|
||||||
|
|
||||||
|
switch ($visibility) {
|
||||||
|
case 'public':
|
||||||
|
return true; // Visible to everyone
|
||||||
|
case 'authenticated':
|
||||||
|
return $isLoggedIn; // Visible only to authenticated users
|
||||||
|
case 'hidden':
|
||||||
|
return false; // Hidden from all users
|
||||||
|
default:
|
||||||
|
return $isLoggedIn; // Default to authenticated only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,4 +166,19 @@ class AdultVideo extends Model
|
|||||||
'cast' => $castString
|
'cast' => $castString
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TV show statistics
|
||||||
|
*/
|
||||||
|
public static function getStats(\PDO $pdo): array
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_adult_videos,
|
||||||
|
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos,
|
||||||
|
AVG(rating) as avg_rating
|
||||||
|
FROM adult_videos
|
||||||
|
");
|
||||||
|
return $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -790,7 +790,7 @@ class JellyfinSyncService extends BaseSyncService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create images directory structure if it doesn't exist
|
// Create images directory structure if it doesn't exist
|
||||||
$imagesDir = "public/images/{$type}";
|
$imagesDir = __DIR__ . "/../../storage/images/{$type}";
|
||||||
if (!is_dir($imagesDir)) {
|
if (!is_dir($imagesDir)) {
|
||||||
if (!mkdir($imagesDir, 0755, true)) {
|
if (!mkdir($imagesDir, 0755, true)) {
|
||||||
$this->logProgress("Warning: Could not create images directory: {$imagesDir}");
|
$this->logProgress("Warning: Could not create images directory: {$imagesDir}");
|
||||||
|
|||||||
313
app/Services/LocalSyncService.php
Normal file
313
app/Services/LocalSyncService.php
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\MediaItem;
|
||||||
|
use App\Models\SyncLog;
|
||||||
|
use Exception;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
use SplFileInfo;
|
||||||
|
|
||||||
|
class LocalSyncService extends BaseSyncService implements SyncServiceInterface
|
||||||
|
{
|
||||||
|
protected string $sourceType = 'local';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Supported file extensions for each media type
|
||||||
|
*/
|
||||||
|
protected array $supportedExtensions = [
|
||||||
|
'movies' => ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg'],
|
||||||
|
'tv_shows' => ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg'],
|
||||||
|
'music' => ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a'],
|
||||||
|
'games' => ['iso', 'rom', 'nsp', 'xci', 'rvz', 'ciso', 'gcm', 'wbfs'],
|
||||||
|
'books' => ['epub', 'mobi', 'pdf', 'azw', 'azw3', 'djvu'],
|
||||||
|
'pictures' => ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'raw']
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function sync($source, string $type = 'full', callable $progressCallback = null): array
|
||||||
|
{
|
||||||
|
$this->source = $source;
|
||||||
|
$this->sourceId = $source['id'] ?? null;
|
||||||
|
|
||||||
|
if (!$this->sourceId) {
|
||||||
|
throw new Exception('Source ID is required for sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->logProgress("Starting {$type} sync for local source: " . ($source['name'] ?? 'Unknown'));
|
||||||
|
|
||||||
|
$path = $source['path'] ?? null;
|
||||||
|
if (empty($path) || !is_dir($path)) {
|
||||||
|
throw new Exception("Invalid or inaccessible source path: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the media type from the source configuration
|
||||||
|
$mediaType = $this->determineMediaType($source);
|
||||||
|
|
||||||
|
// Get all files from the source directory recursively
|
||||||
|
$files = $this->scanDirectory($path, $mediaType);
|
||||||
|
$this->logProgress(sprintf('Found %d files to process', count($files)));
|
||||||
|
|
||||||
|
// Get existing media items from the database
|
||||||
|
$existingItems = $this->getExistingMediaItems($mediaType);
|
||||||
|
$this->logProgress(sprintf('Found %d existing items in database', count($existingItems)));
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'total_items' => count($files),
|
||||||
|
'processed_items' => 0,
|
||||||
|
'new_items' => 0,
|
||||||
|
'updated_items' => 0,
|
||||||
|
'deleted_items' => 0,
|
||||||
|
'errors' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// Process each file
|
||||||
|
foreach ($files as $filePath => $fileInfo) {
|
||||||
|
try {
|
||||||
|
$relativePath = $this->getRelativePath($path, $filePath);
|
||||||
|
$fileKey = $this->generateFileKey($relativePath, $fileInfo);
|
||||||
|
|
||||||
|
if (isset($existingItems[$fileKey])) {
|
||||||
|
// Update existing item if needed
|
||||||
|
$item = $existingItems[$fileKey];
|
||||||
|
$updated = $this->updateMediaItem($item, $filePath, $fileInfo, $mediaType);
|
||||||
|
|
||||||
|
if ($updated) {
|
||||||
|
$result['updated_items']++;
|
||||||
|
$this->logProgress("Updated: {$relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from existing items to track deletions
|
||||||
|
unset($existingItems[$fileKey]);
|
||||||
|
} else {
|
||||||
|
// Add new item
|
||||||
|
$this->createMediaItem($filePath, $fileInfo, $mediaType, $relativePath);
|
||||||
|
$result['new_items']++;
|
||||||
|
$this->logProgress("Added: {$relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['processed_items']++;
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
if ($progressCallback) {
|
||||||
|
$progress = (int)(($result['processed_items'] / $result['total_items']) * 100);
|
||||||
|
$progressCallback($progress, "Processing: {$relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorMsg = "Error processing {$filePath}: " . $e->getMessage();
|
||||||
|
$this->logProgress($errorMsg, 'ERROR');
|
||||||
|
$result['errors'][] = $errorMsg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deleted files
|
||||||
|
if ($type === 'full' && !empty($existingItems)) {
|
||||||
|
foreach ($existingItems as $item) {
|
||||||
|
try {
|
||||||
|
$this->deleteMediaItem($item, $mediaType);
|
||||||
|
$result['deleted_items']++;
|
||||||
|
$this->logProgress("Deleted: {$item['file_path']}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorMsg = "Error deleting {$item['file_path']}: " . $e->getMessage();
|
||||||
|
$this->logProgress($errorMsg, 'ERROR');
|
||||||
|
$result['errors'][] = $errorMsg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logProgress("Sync completed successfully");
|
||||||
|
|
||||||
|
return array_merge($result, [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Sync completed successfully',
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$errorMsg = 'Sync failed: ' . $e->getMessage();
|
||||||
|
$this->logProgress($errorMsg, 'ERROR');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $errorMsg,
|
||||||
|
'errors' => [$errorMsg]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function getSupportedTypes(): array
|
||||||
|
{
|
||||||
|
return ['local', 'file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the media type from the source configuration
|
||||||
|
*/
|
||||||
|
protected function determineMediaType(array $source): string
|
||||||
|
{
|
||||||
|
// First check if media_type is explicitly set in the source config
|
||||||
|
if (!empty($source['media_type'])) {
|
||||||
|
return strtolower($source['media_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to guess from the path or name
|
||||||
|
$path = strtolower($source['path'] ?? '');
|
||||||
|
$name = strtolower($source['name'] ?? '');
|
||||||
|
|
||||||
|
foreach (array_keys($this->supportedExtensions) as $type) {
|
||||||
|
if (strpos($path, $type) !== false || strpos($name, $type) !== false) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 'other' if we can't determine the type
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a directory recursively for media files
|
||||||
|
*/
|
||||||
|
protected function scanDirectory(string $path, string $mediaType): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
$extensions = $this->supportedExtensions[$mediaType] ?? [];
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var SplFileInfo $file */
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
$ext = strtolower($file->getExtension());
|
||||||
|
|
||||||
|
// If we have specific extensions for this media type, filter by them
|
||||||
|
if (!empty($extensions) && !in_array($ext, $extensions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[$file->getPathname()] = [
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'modified' => $file->getMTime(),
|
||||||
|
'extension' => $ext,
|
||||||
|
'path' => $file->getPathname(),
|
||||||
|
'filename' => $file->getFilename()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get existing media items from the database
|
||||||
|
*/
|
||||||
|
protected function getExistingMediaItems(string $mediaType): array
|
||||||
|
{
|
||||||
|
$mediaItem = new MediaItem($this->pdo);
|
||||||
|
$items = $mediaItem->where('source_id', $this->sourceId)
|
||||||
|
->where('media_type', $mediaType)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$result[$item['file_key']] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique key for a file
|
||||||
|
*/
|
||||||
|
protected function generateFileKey(string $relativePath, array $fileInfo): string
|
||||||
|
{
|
||||||
|
return md5($relativePath . $fileInfo['size'] . $fileInfo['modified']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the relative path from the base path
|
||||||
|
*/
|
||||||
|
protected function getRelativePath(string $basePath, string $filePath): string
|
||||||
|
{
|
||||||
|
return ltrim(str_replace('\\', '/', substr($filePath, strlen($basePath))), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new media item in the database
|
||||||
|
*/
|
||||||
|
protected function createMediaItem(string $filePath, array $fileInfo, string $mediaType, string $relativePath): void
|
||||||
|
{
|
||||||
|
$mediaItem = new MediaItem($this->pdo);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'source_id' => $this->sourceId,
|
||||||
|
'media_type' => $mediaType,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
'file_name' => $fileInfo['filename'],
|
||||||
|
'file_size' => $fileInfo['size'],
|
||||||
|
'file_modified' => date('Y-m-d H:i:s', $fileInfo['modified']),
|
||||||
|
'file_extension' => $fileInfo['extension'],
|
||||||
|
'file_key' => $this->generateFileKey($relativePath, $fileInfo),
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'original_path' => $filePath,
|
||||||
|
'imported_at' => date('Y-m-d H:i:s')
|
||||||
|
]),
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
$mediaItem->create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing media item if needed
|
||||||
|
*/
|
||||||
|
protected function updateMediaItem(array $item, string $filePath, array $fileInfo, string $mediaType): bool
|
||||||
|
{
|
||||||
|
$needsUpdate = false;
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
// Check if file has been modified
|
||||||
|
if ($item['file_size'] != $fileInfo['size'] ||
|
||||||
|
$item['file_modified'] != date('Y-m-d H:i:s', $fileInfo['modified'])) {
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'file_size' => $fileInfo['size'],
|
||||||
|
'file_modified' => date('Y-m-d H:i:s', $fileInfo['modified']),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
$needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata if needed
|
||||||
|
$metadata = json_decode($item['metadata'] ?? '{}', true);
|
||||||
|
$metadata['last_checked'] = date('Y-m-d H:i:s');
|
||||||
|
$updates['metadata'] = json_encode($metadata);
|
||||||
|
|
||||||
|
if ($needsUpdate) {
|
||||||
|
$mediaItem = new MediaItem($this->pdo);
|
||||||
|
$mediaItem->update($item['id'], $updates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a media item from the database
|
||||||
|
*/
|
||||||
|
protected function deleteMediaItem(array $item, string $mediaType): void
|
||||||
|
{
|
||||||
|
$mediaItem = new MediaItem($this->pdo);
|
||||||
|
$mediaItem->delete($item['id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ class StashSyncService extends BaseSyncService
|
|||||||
'verify' => false // Disable SSL verification for problematic servers
|
'verify' => false // Disable SSL verification for problematic servers
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->imageDownloader = new ImageDownloader('public/images', $this->apiKey);
|
$this->imageDownloader = new ImageDownloader(__DIR__ . '/../../storage/images', $this->apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function executeSync(string $syncType): void
|
protected function executeSync(string $syncType): void
|
||||||
|
|||||||
23
app/Services/SyncServiceInterface.php
Normal file
23
app/Services/SyncServiceInterface.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
interface SyncServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Synchronize media from the source
|
||||||
|
*
|
||||||
|
* @param array $source The source configuration
|
||||||
|
* @param string $type Sync type (full, scan, update)
|
||||||
|
* @param callable $progressCallback Callback for progress updates
|
||||||
|
* @return array Result of the sync operation
|
||||||
|
*/
|
||||||
|
public function sync($source, string $type = 'full', callable $progressCallback = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the supported source types
|
||||||
|
*
|
||||||
|
* @return array Array of supported source types
|
||||||
|
*/
|
||||||
|
public function getSupportedTypes(): array;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ class XbvrSyncService extends BaseSyncService
|
|||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->imageDownloader = new ImageDownloader('public/images');
|
$this->imageDownloader = new ImageDownloader(__DIR__ . '/../../storage/images');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function executeSync(string $syncType): void
|
protected function executeSync(string $syncType): void
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ImageDownloader
|
|||||||
private Client $httpClient;
|
private Client $httpClient;
|
||||||
private string $basePath;
|
private string $basePath;
|
||||||
|
|
||||||
public function __construct(string $basePath = 'public/images', ?string $apiKey = null)
|
public function __construct(string $basePath = 'storage/images', ?string $apiKey = null)
|
||||||
{
|
{
|
||||||
$headers = [
|
$headers = [
|
||||||
'User-Agent' => 'MediaCollector/1.0'
|
'User-Agent' => 'MediaCollector/1.0'
|
||||||
@@ -25,7 +25,13 @@ class ImageDownloader
|
|||||||
'headers' => $headers,
|
'headers' => $headers,
|
||||||
'verify' => false // Disable SSL verification for problematic servers
|
'verify' => false // Disable SSL verification for problematic servers
|
||||||
]);
|
]);
|
||||||
$this->basePath = rtrim($basePath, '/');
|
|
||||||
|
// Convert relative path to absolute path
|
||||||
|
if (strpos($basePath, '/') !== 0) {
|
||||||
|
$this->basePath = __DIR__ . '/../' . $basePath;
|
||||||
|
} else {
|
||||||
|
$this->basePath = $basePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +121,8 @@ class ImageDownloader
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false; // Not a valid image type
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveImage(string $imageData, string $filename, string $subfolder = ''): ?string
|
public function saveImage(string $imageData, string $filename, string $subfolder = ''): ?string
|
||||||
@@ -167,9 +175,9 @@ class ImageDownloader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the public/ prefix to get the web-accessible path
|
// Remove the absolute basePath prefix to get the relative path
|
||||||
$relativePath = str_replace($this->basePath . '/', '', $localPath);
|
$relativePath = str_replace($this->basePath . '/', '', $localPath);
|
||||||
|
|
||||||
return '/' . $relativePath;
|
return '/images/' . $relativePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,29 @@ function array_to_object(array $array): object
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert object to array recursively
|
* Check if a media type is visible to the current user
|
||||||
*/
|
*/
|
||||||
function object_to_array(object $object): array
|
function is_media_type_visible(string $mediaType): bool
|
||||||
{
|
{
|
||||||
return json_decode(json_encode($object), true);
|
// Get database connection
|
||||||
|
$pdo = \App\Database\Database::getInstance();
|
||||||
|
|
||||||
|
// Get media visibility setting
|
||||||
|
$stmt = $pdo->prepare("SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1");
|
||||||
|
$stmt->execute(['key' => "media_visibility_{$mediaType}"]);
|
||||||
|
$visibility = $stmt->fetchColumn() ?: 'authenticated'; // Default to authenticated only
|
||||||
|
|
||||||
|
// Check user authentication status
|
||||||
|
$isLoggedIn = is_logged_in();
|
||||||
|
|
||||||
|
switch ($visibility) {
|
||||||
|
case 'public':
|
||||||
|
return true; // Visible to everyone
|
||||||
|
case 'authenticated':
|
||||||
|
return $isLoggedIn; // Visible only to authenticated users
|
||||||
|
case 'hidden':
|
||||||
|
return false; // Hidden from all users
|
||||||
|
default:
|
||||||
|
return $isLoggedIn; // Default to authenticated only
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ $container->set('view', function () use ($container) {
|
|||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Add media visibility function
|
||||||
|
$twig->getEnvironment()->addFunction(new TwigFunction('is_media_type_visible', function ($mediaType) {
|
||||||
|
return is_media_type_visible($mediaType);
|
||||||
|
}));
|
||||||
|
|
||||||
// Placeholder path_for function - will be updated after routes are registered
|
// Placeholder path_for function - will be updated after routes are registered
|
||||||
$twig->getEnvironment()->addFunction(new TwigFunction('path_for', function ($name, $data = [], $queryParams = []) {
|
$twig->getEnvironment()->addFunction(new TwigFunction('path_for', function ($name, $data = [], $queryParams = []) {
|
||||||
// Simple implementation for now - will be replaced with proper router-based version
|
// Simple implementation for now - will be replaced with proper router-based version
|
||||||
@@ -84,6 +89,12 @@ $container->set('view', function () use ($container) {
|
|||||||
case 'admin.index':
|
case 'admin.index':
|
||||||
$basePath = '/admin';
|
$basePath = '/admin';
|
||||||
break;
|
break;
|
||||||
|
case 'admin.settings':
|
||||||
|
$basePath = '/admin/settings';
|
||||||
|
break;
|
||||||
|
case 'admin.sources':
|
||||||
|
$basePath = '/admin/sources';
|
||||||
|
break;
|
||||||
case 'admin.sync':
|
case 'admin.sync':
|
||||||
$basePath = '/admin/sync/' . ($data['id'] ?? '');
|
$basePath = '/admin/sync/' . ($data['id'] ?? '');
|
||||||
break;
|
break;
|
||||||
@@ -144,6 +155,9 @@ $container->set('view', function () use ($container) {
|
|||||||
$authService = $container->get(\App\Services\AuthService::class);
|
$authService = $container->get(\App\Services\AuthService::class);
|
||||||
return $authService->generateCSRFToken();
|
return $authService->generateCSRFToken();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$twig->getEnvironment()->addFilter(new TwigFilter('format_duration', function ($minutes) {
|
$twig->getEnvironment()->addFilter(new TwigFilter('format_duration', function ($minutes) {
|
||||||
if (!$minutes || $minutes == 0) {
|
if (!$minutes || $minutes == 0) {
|
||||||
return '0m';
|
return '0m';
|
||||||
@@ -199,7 +213,7 @@ $container->set(\App\Controllers\GameController::class, function ($c) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$container->set(\App\Controllers\DashboardController::class, function ($c) {
|
$container->set(\App\Controllers\DashboardController::class, function ($c) {
|
||||||
return new \App\Controllers\DashboardController($c->get('view'));
|
return new \App\Controllers\DashboardController($c->get(PDO::class), $c->get('view'));
|
||||||
});
|
});
|
||||||
|
|
||||||
$container->set(\App\Controllers\MovieController::class, function ($c) {
|
$container->set(\App\Controllers\MovieController::class, function ($c) {
|
||||||
@@ -226,14 +240,25 @@ $container->set(\App\Controllers\SearchController::class, function ($c) {
|
|||||||
return new \App\Controllers\SearchController($c->get(PDO::class), $c->get('view'));
|
return new \App\Controllers\SearchController($c->get(PDO::class), $c->get('view'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$container->set(\App\Controllers\ImageController::class, function ($c) {
|
||||||
|
return new \App\Controllers\ImageController($c->get('view'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$container->set(\App\Controllers\SettingsController::class, function ($c) {
|
||||||
|
return new \App\Controllers\SettingsController($c->get(PDO::class), $c->get('view'));
|
||||||
|
});
|
||||||
|
|
||||||
// Register middleware
|
// Register middleware
|
||||||
$container->set(\App\Http\Middleware\AuthMiddleware::class, function ($c) {
|
$container->set(\App\Http\Middleware\AuthMiddleware::class, function ($c) {
|
||||||
return new \App\Http\Middleware\AuthMiddleware($c->get(\App\Services\AuthService::class));
|
return new \App\Http\Middleware\AuthMiddleware($c->get(\App\Services\AuthService::class));
|
||||||
});
|
});
|
||||||
|
|
||||||
$container->set(\App\Http\Middleware\AdminMiddleware::class, function ($c) {
|
$container->set(\App\Controllers\MediaSourceController::class, function ($c) {
|
||||||
return new \App\Http\Middleware\AdminMiddleware($c->get(\App\Services\AuthService::class));
|
return new \App\Controllers\MediaSourceController($c->get(PDO::class), $c->get('view'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$container->set(\App\Http\Middleware\MediaVisibilityMiddleware::class, function ($c) {
|
||||||
|
return new \App\Http\Middleware\MediaVisibilityMiddleware();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create App with DI Container
|
// Create App with DI Container
|
||||||
|
|||||||
@@ -12,12 +12,12 @@
|
|||||||
{% if actors %}
|
{% if actors %}
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for actor in actors %}
|
{% for actor in actors %}
|
||||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
<div class="col-md-6 col-lg-4 col-xl-2">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<a href="{{ path_for('actors.show', {'id': actor.id}) }}" class="text-decoration-none">
|
<a href="{{ path_for('actors.show', {'id': actor.id}) }}" class="text-decoration-none">
|
||||||
{% if actor.thumbnail_path %}
|
{% 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;">
|
<img src="{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-3" style="width: 80px; height: 80px; object-fit: cover;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-3 mx-auto" style="width: 80px; height: 80px;">
|
<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">
|
<svg class="text-muted" width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
{% if actor.thumbnail_path %}
|
{% 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;">
|
<img src="{{ actor.thumbnail_path }}" alt="{{ actor.name }}" class="rounded-circle mb-3" style="width: 150px; height: 150px; object-fit: cover;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mb-3 mx-auto" style="width: 150px; height: 150px;">
|
<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">
|
<svg class="text-muted" width="75" height="75" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div style="background-color: #f8f9fa; height: 150px; overflow: hidden;">
|
<div style="background-color: #f8f9fa; height: 150px; overflow: hidden;">
|
||||||
{% if scene.poster_url %}
|
{% if scene.poster_url %}
|
||||||
<img src="/public/images/{{ scene.poster_url }}" alt="{{ scene.title }}" class="w-100 h-100" style="object-fit: cover;">
|
<img src="{{ scene.poster_url }}" alt="{{ scene.title }}" class="w-100 h-100" style="background-size: cover;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
|
<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">
|
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
{% extends 'layouts/app.twig' %}
|
{% extends 'admin/layout.twig' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4 rounded">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="navbar-nav me-auto">
|
||||||
|
<a class="nav-link active" href="{{ path_for('admin.index') }}">
|
||||||
|
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
{% if is_admin() %}
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.sources') }}">
|
||||||
|
<i class="fas fa-database me-2"></i>Sources
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.settings') }}">
|
||||||
|
<i class="fas fa-cog me-2"></i>Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h1 class="display-4 fw-bold text-dark">Admin Dashboard</h1>
|
<h1 class="display-4 fw-bold text-dark">Admin Dashboard</h1>
|
||||||
<p class="lead text-muted">Manage your media sources and synchronization</p>
|
<p class="lead text-muted">Manage your media sources and synchronization</p>
|
||||||
|
|||||||
338
resources/views/admin/layout.twig
Normal file
338
resources/views/admin/layout.twig
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Panel - MediaLib{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<!-- Custom Admin CSS -->
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 250px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--primary-color: #4e73df;
|
||||||
|
--secondary-color: #858796;
|
||||||
|
--success-color: #1cc88a;
|
||||||
|
--info-color: #36b9cc;
|
||||||
|
--warning-color: #f6c23e;
|
||||||
|
--danger-color: #e74a3b;
|
||||||
|
--light-color: #f8f9fc;
|
||||||
|
--dark-color: #5a5c69;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
#sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
height: var(--header-height);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
margin: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-heading {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: #d1d3e2;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
color: #b7b9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover i, .nav-link.active i {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
#content {
|
||||||
|
width: calc(100% - var(--sidebar-width));
|
||||||
|
min-height: 100vh;
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Navigation */
|
||||||
|
.topbar {
|
||||||
|
height: var(--header-height);
|
||||||
|
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15);
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #f8f9fc;
|
||||||
|
border-bottom: 1px solid #e3e6f0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4e73df;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2e59d9;
|
||||||
|
border-color: #2653d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
border-bottom: 2px solid #e3e6f0;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #4e73df;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#sidebar {
|
||||||
|
margin-left: -250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar.active {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content.active {
|
||||||
|
margin-left: 250px;
|
||||||
|
width: calc(100% - 250px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div id="sidebar" class="bg-dark">
|
||||||
|
<a class="sidebar-brand" href="{{ path_for('admin.index') }}">
|
||||||
|
<i class="bi bi-collection-play-fill me-2"></i>MediaLib Admin
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-divider"></div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="sidebar-heading">Core</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ current_route == 'admin.index' ? 'active' : '' }}" href="{{ path_for('admin.index') }}">
|
||||||
|
<i class="bi bi-speedometer2"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ current_route == 'admin.settings' ? 'active' : '' }}" href="{{ path_for('admin.settings') }}">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-heading mt-4">Media</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('games.index') }}" target="_blank">
|
||||||
|
<i class="bi bi-joystick"></i>
|
||||||
|
<span>Games</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('movies.index') }}" target="_blank">
|
||||||
|
<i class="bi bi-film"></i>
|
||||||
|
<span>Movies</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('tvshows.index') }}" target="_blank">
|
||||||
|
<i class="bi bi-tv"></i>
|
||||||
|
<span>TV Shows</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('music.index') }}" target="_blank">
|
||||||
|
<i class="bi bi-music-note-list"></i>
|
||||||
|
<span>Music</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-heading mt-4">System</div>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.sources') }}">
|
||||||
|
<i class="bi bi-hdd-rack"></i>
|
||||||
|
<span>Media Sources</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.sync') }}">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
<span>Sync Media</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ path_for('home') }}" target="_blank">
|
||||||
|
<i bi bi-house-door"></i>
|
||||||
|
<span>View Site</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Wrapper -->
|
||||||
|
<div id="content">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">
|
||||||
|
<!-- Sidebar Toggle (Topbar) -->
|
||||||
|
<button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
|
||||||
|
<i class="bi bi-list"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Topbar Navbar -->
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<!-- Nav Item - User Information -->
|
||||||
|
<li class="nav-item dropdown no-arrow">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<span class="me-2 d-none d-lg-inline text-gray-600 small">
|
||||||
|
{% if auth.check %}
|
||||||
|
{{ auth.user.username }}
|
||||||
|
{% else %}
|
||||||
|
Guest
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<i class="bi bi-person-circle" style="font-size: 1.5rem;"></i>
|
||||||
|
</a>
|
||||||
|
<!-- Dropdown - User Information -->
|
||||||
|
<div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
|
||||||
|
<a class="dropdown-item" href="{{ path_for('home') }}">
|
||||||
|
<i class="bi bi-house-door me-2 text-gray-400"></i>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a class="dropdown-item" href="{{ path_for('auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right me-2 text-gray-400"></i>
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<!-- End of Topbar -->
|
||||||
|
|
||||||
|
<!-- Begin Page Content -->
|
||||||
|
<div class="container-fluid">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<!-- /.container-fluid -->
|
||||||
|
</div>
|
||||||
|
<!-- End of Content Wrapper -->
|
||||||
|
|
||||||
|
<!-- Bootstrap core JavaScript -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom scripts -->
|
||||||
|
<script>
|
||||||
|
// Toggle the side navigation
|
||||||
|
document.getElementById('sidebarToggleTop').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.body.classList.toggle('sidebar-toggled');
|
||||||
|
document.getElementById('sidebar').classList.toggle('toggled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close any open menu accordions when window is resized below 768px
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
document.body.classList.add('sidebar-toggled');
|
||||||
|
document.getElementById('sidebar').classList.add('toggled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
368
resources/views/admin/settings.twig
Normal file
368
resources/views/admin/settings.twig
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
{% extends 'admin/layout.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4 rounded">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="navbar-nav me-auto">
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.index') }}">
|
||||||
|
<i class="fas fa-tachometer-alt me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
{% if is_admin() %}
|
||||||
|
<a class="nav-link" href="{{ path_for('admin.sources') }}">
|
||||||
|
<i class="fas fa-database me-2"></i>Sources
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="nav-link active" href="{{ path_for('admin.settings') }}">
|
||||||
|
<i class="fas fa-cog me-2"></i>Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="display-4 fw-bold text-dark">Admin Settings</h1>
|
||||||
|
<p class="lead text-muted">Configure your media sources and application settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
{{ success }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- General Settings -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">General Settings</h2>
|
||||||
|
<p class="text-muted mb-0">Configure general application settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="setting_sync_interval" class="form-label">Default Sync Interval (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="setting_sync_interval" name="setting_sync_interval"
|
||||||
|
value="{{ settings.sync_interval ?? 60 }}" min="5" max="1440">
|
||||||
|
<div class="form-text">How often to check for new content (5-1440 minutes)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="setting_max_sync_items" class="form-label">Max Items per Sync</label>
|
||||||
|
<input type="number" class="form-control" id="setting_max_sync_items" name="setting_max_sync_items"
|
||||||
|
value="{{ settings.max_sync_items ?? 1000 }}" min="100" max="10000">
|
||||||
|
<div class="form-text">Maximum number of items to process in a single sync operation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="setting_enable_notifications"
|
||||||
|
name="setting_enable_notifications" {{ settings.enable_notifications ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting_enable_notifications">
|
||||||
|
Enable sync notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Send notifications when sync operations complete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="setting_auto_cleanup"
|
||||||
|
name="setting_auto_cleanup" {{ settings.auto_cleanup ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting_auto_cleanup">
|
||||||
|
Auto cleanup old logs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Automatically remove sync logs older than 30 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">Save General Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Settings -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Source Configuration</h2>
|
||||||
|
<p class="text-muted mb-0">Configure your media sources and their sync settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% for source in sources %}
|
||||||
|
<div class="border rounded p-3 mb-4">
|
||||||
|
<h4 class="mb-3">{{ source.display_name }}</h4>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_api_url" class="form-label">API URL</label>
|
||||||
|
<input type="url" class="form-control" id="sources_{{ source.id }}_api_url"
|
||||||
|
name="sources[{{ source.id }}][api_url]" value="{{ source.api_url }}">
|
||||||
|
<div class="form-text">Base URL for the {{ source.name }} API</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_api_key" class="form-label">API Key</label>
|
||||||
|
<input type="password" class="form-control" id="sources_{{ source.id }}_api_key"
|
||||||
|
name="sources[{{ source.id }}][api_key]" value="{{ source.api_key }}">
|
||||||
|
<div class="form-text">API key for authenticating with {{ source.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source-specific configuration -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="sources_{{ source.id }}_config_username"
|
||||||
|
name="sources[{{ source.id }}][config][username]"
|
||||||
|
value="{{ source.config.username ?? '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_sync_type" class="form-label">Default Sync Type</label>
|
||||||
|
<select class="form-select" id="sources_{{ source.id }}_config_sync_type"
|
||||||
|
name="sources[{{ source.id }}][config][sync_type]">
|
||||||
|
<option value="full" {{ (source.config.sync_type ?? 'full') == 'full' ? 'selected' : '' }}>Full Sync</option>
|
||||||
|
<option value="incremental" {{ (source.config.sync_type ?? 'full') == 'incremental' ? 'selected' : '' }}>Incremental Sync</option>
|
||||||
|
{% if source.name == 'jellyfin' %}
|
||||||
|
<option value="all" {{ (source.config.sync_type ?? 'full') == 'all' ? 'selected' : '' }}>All Content</option>
|
||||||
|
<option value="movies" {{ (source.config.sync_type ?? 'full') == 'movies' ? 'selected' : '' }}>Movies Only</option>
|
||||||
|
<option value="tvshows" {{ (source.config.sync_type ?? 'full') == 'tvshows' ? 'selected' : '' }}>TV Shows Only</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source-specific settings based on type -->
|
||||||
|
{% if source.name == 'jellyfin' %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_libraries" class="form-label">Library IDs</label>
|
||||||
|
<input type="text" class="form-control" id="sources_{{ source.id }}_config_libraries"
|
||||||
|
name="sources[{{ source.id }}][config][libraries]"
|
||||||
|
value="{{ source.config.libraries ?? '' }}"
|
||||||
|
placeholder="e.g. 12345678-1234-1234-1234-123456789012">
|
||||||
|
<div class="form-text">Comma-separated list of Jellyfin library IDs to sync</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_user_id" class="form-label">User ID</label>
|
||||||
|
<input type="text" class="form-control" id="sources_{{ source.id }}_config_user_id"
|
||||||
|
name="sources[{{ source.id }}][config][user_id]"
|
||||||
|
value="{{ source.config.user_id ?? '' }}"
|
||||||
|
placeholder="e.g. 12345678-1234-1234-1234-123456789012">
|
||||||
|
<div class="form-text">Jellyfin user ID for accessing content</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if source.name == 'xbvr' %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_quality_filter" class="form-label">Quality Filter</label>
|
||||||
|
<select class="form-select" id="sources_{{ source.id }}_config_quality_filter"
|
||||||
|
name="sources[{{ source.id }}][config][quality_filter]">
|
||||||
|
<option value="">All Qualities</option>
|
||||||
|
<option value="4k" {{ (source.config.quality_filter ?? '') == '4k' ? 'selected' : '' }}>4K Only</option>
|
||||||
|
<option value="1080p" {{ (source.config.quality_filter ?? '') == '1080p' ? 'selected' : '' }}>1080p Only</option>
|
||||||
|
<option value="720p" {{ (source.config.quality_filter ?? '') == '720p' ? 'selected' : '' }}>720p Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_rating_filter" class="form-label">Minimum Rating</label>
|
||||||
|
<select class="form-select" id="sources_{{ source.id }}_config_rating_filter"
|
||||||
|
name="sources[{{ source.id }}][config][rating_filter]">
|
||||||
|
<option value="">No Filter</option>
|
||||||
|
<option value="3" {{ (source.config.rating_filter ?? '') == '3' ? 'selected' : '' }}>3+ Stars</option>
|
||||||
|
<option value="4" {{ (source.config.rating_filter ?? '') == '4' ? 'selected' : '' }}>4+ Stars</option>
|
||||||
|
<option value="5" {{ (source.config.rating_filter ?? '') == '5' ? 'selected' : '' }}>5 Stars Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if source.name == 'stash' %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_include_tags" class="form-label">Include Tags</label>
|
||||||
|
<input type="text" class="form-control" id="sources_{{ source.id }}_config_include_tags"
|
||||||
|
name="sources[{{ source.id }}][config][include_tags]"
|
||||||
|
value="{{ source.config.include_tags ?? '' }}"
|
||||||
|
placeholder="e.g. VR,4K,Favorite">
|
||||||
|
<div class="form-text">Comma-separated list of tags to include (empty for all)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="sources_{{ source.id }}_config_exclude_tags" class="form-label">Exclude Tags</label>
|
||||||
|
<input type="text" class="form-control" id="sources_{{ source.id }}_config_exclude_tags"
|
||||||
|
name="sources[{{ source.id }}][config][exclude_tags]"
|
||||||
|
value="{{ source.config.exclude_tags ?? '' }}"
|
||||||
|
placeholder="e.g. Low Quality,Test">
|
||||||
|
<div class="form-text">Comma-separated list of tags to exclude</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="sources_{{ source.id }}_is_active"
|
||||||
|
name="sources[{{ source.id }}][is_active]" {{ source.is_active ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="sources_{{ source.id }}_is_active">
|
||||||
|
Enable this source for syncing
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Source Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Type Visibility Settings -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Media Type Visibility</h2>
|
||||||
|
<p class="text-muted mb-0">Control which media types are visible to non-authenticated users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_games" class="form-label">Games</label>
|
||||||
|
<select class="form-select" id="media_visibility_games" name="media_visibility[games]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.games ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.games ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.games ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_movies" class="form-label">Movies</label>
|
||||||
|
<select class="form-select" id="media_visibility_movies" name="media_visibility[movies]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.movies ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.movies ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.movies ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_tvshows" class="form-label">TV Shows</label>
|
||||||
|
<select class="form-select" id="media_visibility_tvshows" name="media_visibility[tvshows]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.tvshows ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.tvshows ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.tvshows ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_music" class="form-label">Music</label>
|
||||||
|
<select class="form-select" id="media_visibility_music" name="media_visibility[music]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.music ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.music ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.music ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_adult" class="form-label">Adult Videos</label>
|
||||||
|
<select class="form-select" id="media_visibility_adult" name="media_visibility[adult]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.adult ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.adult ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.adult ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Adult content should typically require authentication</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="media_visibility_actors" class="form-label">Actors/Performers</label>
|
||||||
|
<select class="form-select" id="media_visibility_actors" name="media_visibility[actors]">
|
||||||
|
<option value="public" {{ (settings.media_visibility.actors ?? 'authenticated') == 'public' ? 'selected' : '' }}>Visible to everyone</option>
|
||||||
|
<option value="authenticated" {{ (settings.media_visibility.actors ?? 'authenticated') == 'authenticated' ? 'selected' : '' }}>Authenticated users only</option>
|
||||||
|
<option value="hidden" {{ (settings.media_visibility.actors ?? 'authenticated') == 'hidden' ? 'selected' : '' }}>Hidden from all users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Media Visibility Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Settings -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Security Settings</h2>
|
||||||
|
<p class="text-muted mb-0">Configure security and access settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="setting_session_timeout" class="form-label">Session Timeout (minutes)</label>
|
||||||
|
<input type="number" class="form-control" id="setting_session_timeout" name="setting_session_timeout"
|
||||||
|
value="{{ settings.session_timeout ?? 30 }}" min="5" max="480">
|
||||||
|
<div class="form-text">User sessions will expire after this many minutes of inactivity</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="setting_max_login_attempts" class="form-label">Max Login Attempts</label>
|
||||||
|
<input type="number" class="form-control" id="setting_max_login_attempts" name="setting_max_login_attempts"
|
||||||
|
value="{{ settings.max_login_attempts ?? 5 }}" min="3" max="10">
|
||||||
|
<div class="form-text">Number of failed login attempts before account lockout</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="setting_require_https"
|
||||||
|
name="setting_require_https" {{ settings.require_https ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting_require_https">
|
||||||
|
Require HTTPS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Force all connections to use HTTPS</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="setting_enable_audit_log"
|
||||||
|
name="setting_enable_audit_log" {{ settings.enable_audit_log ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="setting_enable_audit_log">
|
||||||
|
Enable audit logging
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Log all admin actions for security auditing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Security Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
205
resources/views/admin/sources.twig
Normal file
205
resources/views/admin/sources.twig
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
{% extends 'admin/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Media Sources - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 mb-0">Media Sources</h1>
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSourceModal">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>Add Source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sources is not empty %}
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Path/URL</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for source in sources %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ source.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ source.type == 'jellyfin' ? 'info' : 'success' }}">
|
||||||
|
{{ source.type|upper }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ source.path }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ source.is_active ? 'success' : 'secondary' }}">
|
||||||
|
{{ source.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ source.last_sync ? source.last_sync|date('Y-m-d H:i:s') : 'Never' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="Edit"
|
||||||
|
onclick="editSource({{ source|json_encode|e('js') }});">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<a href="{{ path_for('admin.sync', {'id': source.id}) }}"
|
||||||
|
class="btn btn-outline-success"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="Sync Now">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-danger"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="Delete"
|
||||||
|
onclick="confirmDelete({{ source.id }}, '{{ source.name }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-hdd-rack display-1 text-muted mb-4"></i>
|
||||||
|
<h3>No Media Sources Found</h3>
|
||||||
|
<p class="text-muted">Add your first media source to get started.</p>
|
||||||
|
<button type="button" class="btn btn-primary mt-3" data-bs-toggle="modal" data-bs-target="#addSourceModal">
|
||||||
|
<i class="bi bi-plus-circle me-2"></i>Add Media Source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Add/Edit Source Modal -->
|
||||||
|
<div class="modal fade" id="sourceModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="sourceForm" method="post">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalTitle">Add Media Source</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="id" id="sourceId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="type" class="form-label">Type</label>
|
||||||
|
<select class="form-select" id="type" name="type" required>
|
||||||
|
<option value="jellyfin">Jellyfin</option>
|
||||||
|
<option value="local">Local Filesystem</option>
|
||||||
|
<option value="samba">Samba Share</option>
|
||||||
|
<option value="nfs">NFS Share</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="path" class="form-label">Path/URL</label>
|
||||||
|
<input type="text" class="form-control" id="path" name="path" required>
|
||||||
|
<div class="form-text">
|
||||||
|
For Jellyfin: http(s)://server:port<br>
|
||||||
|
For local: /path/to/media<br>
|
||||||
|
For network shares: //server/share or nfs://server/path
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username (if required)</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password (if required)</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password">
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" checked>
|
||||||
|
<label class="form-check-label" for="isActive">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirm Deletion</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete <strong id="sourceName"></strong>?</p>
|
||||||
|
<p class="text-danger">This action cannot be undone and will remove all associated media data.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form id="deleteForm" method="post" action="">
|
||||||
|
<button type="submit" class="btn btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Initialize tooltips
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle add source button
|
||||||
|
document.getElementById('addSourceBtn').addEventListener('click', function() {
|
||||||
|
document.getElementById('modalTitle').textContent = 'Add Media Source';
|
||||||
|
document.getElementById('sourceForm').reset();
|
||||||
|
document.getElementById('sourceId').value = '';
|
||||||
|
document.getElementById('sourceForm').action = '{{ path_for("admin.sources.store") }}';
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('sourceModal'));
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle edit source
|
||||||
|
function editSource(source) {
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit Media Source';
|
||||||
|
document.getElementById('sourceId').value = source.id;
|
||||||
|
document.getElementById('name').value = source.name;
|
||||||
|
document.getElementById('type').value = source.type;
|
||||||
|
document.getElementById('path').value = source.path;
|
||||||
|
document.getElementById('username').value = source.username || '';
|
||||||
|
document.getElementById('isActive').checked = source.is_active;
|
||||||
|
document.getElementById('sourceForm').action = '{{ path_for("admin.sources.update", {id: 0}) }}'.replace('/0', '/' + source.id);
|
||||||
|
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('sourceModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete confirmation
|
||||||
|
function confirmDelete(id, name) {
|
||||||
|
document.getElementById('sourceName').textContent = name;
|
||||||
|
document.getElementById('deleteForm').action = '{{ path_for("admin.sources.destroy", {id: 0}) }}'.replace('/0', '/' + id);
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
453
resources/views/admin/sync.twig
Normal file
453
resources/views/admin/sync.twig
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
{% extends 'admin/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Sync Media - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 mb-0">Sync Media</h1>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-primary" id="runFullSync">
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i>Run Full Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Sync Status</h6>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="syncOptions" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-gear me-1"></i>Options
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="syncOptions">
|
||||||
|
<li><a class="dropdown-item" href="#" id="clearCompleted">Clear Completed</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="clearAll">Clear All</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" id="refreshLogs">Refresh</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="syncLogs" class="log-container">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-arrow-repeat display-4"></i>
|
||||||
|
<p class="mt-2">No sync activity yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Sync Statistics</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div class="position-relative d-inline-block">
|
||||||
|
<div id="syncProgress" class="progress-circle" data-value="0">
|
||||||
|
<span class="progress-left">
|
||||||
|
<span class="progress-bar"></span>
|
||||||
|
</span>
|
||||||
|
<span class="progress-right">
|
||||||
|
<span class="progress-bar"></span>
|
||||||
|
</span>
|
||||||
|
<div class="progress-value w-100 h-100 rounded-circle d-flex align-items-center justify-content-center">
|
||||||
|
<div class="h2 font-weight-bold">0<small>%</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-3" id="syncStatus">Idle</h5>
|
||||||
|
<p class="text-muted mb-0">Last sync: <span id="lastSyncTime">Never</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span>Movies</span>
|
||||||
|
<span id="movieCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 5px;">
|
||||||
|
<div id="movieProgress" class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span>TV Shows</span>
|
||||||
|
<span id="tvShowCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 5px;">
|
||||||
|
<div id="tvShowProgress" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span>Music</span>
|
||||||
|
<span id="musicCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 5px;">
|
||||||
|
<div id="musicProgress" class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Quick Actions</h6>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<button class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" id="scanLibraries">
|
||||||
|
<span><i class="bi bi-search me-2"></i> Scan Libraries</span>
|
||||||
|
<span class="badge bg-primary rounded-pill" id="pendingScans">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="list-group-item list-group-item-action">
|
||||||
|
<i class="bi bi-arrow-clockwise me-2"></i> Update Metadata
|
||||||
|
</button>
|
||||||
|
<button class="list-group-item list-group-item-action">
|
||||||
|
<i class="bi bi-images me-2"></i> Refresh Images
|
||||||
|
</button>
|
||||||
|
<button class="list-group-item list-group-item-action text-danger" id="cancelSync">
|
||||||
|
<i class="bi bi-x-circle me-2"></i> Cancel Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.log-container {
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 3px 0;
|
||||||
|
border-bottom: 1px solid #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: #6c757d;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info {
|
||||||
|
color: #4e73df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-success {
|
||||||
|
color: #1cc88a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-warning {
|
||||||
|
color: #f6c23e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error {
|
||||||
|
color: #e74a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Circular progress */
|
||||||
|
.progress-circle {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-circle .progress-left,
|
||||||
|
.progress-circle .progress-right {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border: 10px solid #f8f9fc;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-circle .progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-width: 10px;
|
||||||
|
border-style: solid;
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-circle .progress-left .progress-bar {
|
||||||
|
left: 100%;
|
||||||
|
border-top-right-radius: 80px;
|
||||||
|
border-bottom-right-radius: 80px;
|
||||||
|
border-left: 0;
|
||||||
|
transform-origin: center left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-circle .progress-right .progress-bar {
|
||||||
|
left: -100%;
|
||||||
|
border-top-left-radius: 80px;
|
||||||
|
border-bottom-left-radius: 80px;
|
||||||
|
border-right: 0;
|
||||||
|
transform-origin: center right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-circle .progress-value {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0.15rem 1rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Initialize tooltips
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle run full sync
|
||||||
|
$('#runFullSync').click(function() {
|
||||||
|
if (confirm('Are you sure you want to run a full sync? This may take a while.')) {
|
||||||
|
startSync('full');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle scan libraries
|
||||||
|
$('#scanLibraries').click(function() {
|
||||||
|
startSync('scan');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cancel sync
|
||||||
|
$('#cancelSync').click(function() {
|
||||||
|
if (confirm('Are you sure you want to cancel the current sync?')) {
|
||||||
|
cancelSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle clear logs
|
||||||
|
$('#clearCompleted').click(function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearLogs('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#clearAll').click(function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearLogs('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle refresh logs
|
||||||
|
$('#refreshLogs').click(function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
loadSyncStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to start sync
|
||||||
|
function startSync(type) {
|
||||||
|
// Show loading state
|
||||||
|
$('#runFullSync').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Syncing...');
|
||||||
|
|
||||||
|
// Make AJAX request to start sync
|
||||||
|
$.post('{{ path_for("admin.sync.start") }}', { type: type }, function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
addLog('Sync started successfully', 'success');
|
||||||
|
// Start polling for updates
|
||||||
|
pollSyncStatus();
|
||||||
|
} else {
|
||||||
|
addLog('Error starting sync: ' + (response.message || 'Unknown error'), 'error');
|
||||||
|
$('#runFullSync').prop('disabled', false).html('<i class="bi bi-arrow-repeat me-2"></i>Run Full Sync');
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
addLog('Failed to start sync. Please try again.', 'error');
|
||||||
|
$('#runFullSync').prop('disabled', false).html('<i class="bi bi-arrow-repeat me-2"></i>Run Full Sync');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to cancel sync
|
||||||
|
function cancelSync() {
|
||||||
|
$.post('{{ path_for("admin.sync.cancel") }}', function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
addLog('Sync cancelled', 'warning');
|
||||||
|
} else {
|
||||||
|
addLog('Error cancelling sync: ' + (response.message || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
addLog('Failed to cancel sync. Please try again.', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to clear logs
|
||||||
|
function clearLogs(type) {
|
||||||
|
// Make AJAX request to clear logs
|
||||||
|
$.post('{{ path_for("admin.sync.clear-logs") }}', { type: type }, function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
if (type === 'all') {
|
||||||
|
$('#syncLogs').html('<div class="text-center text-muted py-4"><i class="bi bi-arrow-repeat display-4"></i><p class="mt-2">No sync activity yet</p></div>');
|
||||||
|
} else {
|
||||||
|
$('.log-entry.completed').remove();
|
||||||
|
if ($('.log-entry').length === 0) {
|
||||||
|
$('#syncLogs').html('<div class="text-center text-muted py-4"><i class="bi bi-arrow-repeat display-4"></i><p class="mt-2">No sync activity yet</p></div>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to add log entry
|
||||||
|
function addLog(message, type = 'info') {
|
||||||
|
let logContainer = $('#syncLogs');
|
||||||
|
const now = new Date();
|
||||||
|
const timeString = now.toLocaleTimeString();
|
||||||
|
|
||||||
|
// Remove empty state if present
|
||||||
|
if (logContainer.find('.text-center').length) {
|
||||||
|
logContainer.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create log entry
|
||||||
|
const logEntry = $(`
|
||||||
|
<div class="log-entry">
|
||||||
|
<span class="log-time">[${timeString}]</span>
|
||||||
|
<span class="log-${type}">${message}</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add to container and scroll to bottom
|
||||||
|
logContainer.append(logEntry);
|
||||||
|
logContainer.scrollTop(logContainer[0].scrollHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update progress
|
||||||
|
function updateProgress(progress) {
|
||||||
|
// Update circular progress
|
||||||
|
const progressValue = progress.percent || 0;
|
||||||
|
const $progressCircle = $('.progress-circle');
|
||||||
|
|
||||||
|
// Animate progress circle
|
||||||
|
if (progressValue > 0) {
|
||||||
|
const $leftCircle = $progressCircle.find('.progress-left .progress-bar');
|
||||||
|
const $rightCircle = $progressCircle.find('.progress-right .progress-bar');
|
||||||
|
|
||||||
|
if (progressValue <= 50) {
|
||||||
|
$rightCircle.css('transform', `rotate(${progressValue * 3.6}deg)`);
|
||||||
|
$leftCircle.css('transform', 'rotate(0deg)');
|
||||||
|
} else {
|
||||||
|
$rightCircle.css('transform', 'rotate(180deg)');
|
||||||
|
$leftCircle.css('transform', `rotate(${(progressValue - 50) * 3.6}deg)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressCircle.find('.progress-value div').text(`${Math.round(progressValue)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status text
|
||||||
|
if (progress.status) {
|
||||||
|
$('#syncStatus').text(progress.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync time
|
||||||
|
if (progress.lastSync) {
|
||||||
|
$('#lastSyncTime').text(progress.lastSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update media type counts and progress
|
||||||
|
if (progress.movies) {
|
||||||
|
$('#movieCount').text(progress.movies.processed + ' / ' + progress.movies.total);
|
||||||
|
const moviePercent = progress.movies.total > 0 ? Math.round((progress.movies.processed / progress.movies.total) * 100) : 0;
|
||||||
|
$('#movieProgress').css('width', moviePercent + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.tvShows) {
|
||||||
|
$('#tvShowCount').text(progress.tvShows.processed + ' / ' + progress.tvShows.total);
|
||||||
|
const tvShowPercent = progress.tvShows.total > 0 ? Math.round((progress.tvShows.processed / progress.tvShows.total) * 100) : 0;
|
||||||
|
$('#tvShowProgress').css('width', tvShowPercent + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.music) {
|
||||||
|
$('#musicCount').text(progress.music.processed + ' / ' + progress.music.total);
|
||||||
|
const musicPercent = progress.music.total > 0 ? Math.round((progress.music.processed / progress.music.total) * 100) : 0;
|
||||||
|
$('#musicProgress').css('width', musicPercent + '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync button state
|
||||||
|
if (progress.isRunning) {
|
||||||
|
$('#runFullSync').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Syncing...');
|
||||||
|
$('#cancelSync').prop('disabled', false);
|
||||||
|
} else {
|
||||||
|
$('#runFullSync').prop('disabled', false).html('<i class="bi bi-arrow-repeat me-2"></i>Run Full Sync');
|
||||||
|
$('#cancelSync').prop('disabled', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to poll for sync status
|
||||||
|
function pollSyncStatus() {
|
||||||
|
loadSyncStatus();
|
||||||
|
|
||||||
|
// Only continue polling if sync is in progress
|
||||||
|
if ($('#syncStatus').text() === 'In Progress') {
|
||||||
|
setTimeout(pollSyncStatus, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to load sync status
|
||||||
|
function loadSyncStatus() {
|
||||||
|
$.get('{{ path_for("admin.sync.status") }}', function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
updateProgress(response.progress);
|
||||||
|
|
||||||
|
// Update logs if available
|
||||||
|
if (response.logs && response.logs.length > 0) {
|
||||||
|
const $logContainer = $('#syncLogs');
|
||||||
|
$logContainer.empty();
|
||||||
|
|
||||||
|
response.logs.forEach(log => {
|
||||||
|
const logEntry = $(`
|
||||||
|
<div class="log-entry ${log.status === 'completed' ? 'completed' : ''}">
|
||||||
|
<span class="log-time">[${log.time}]</span>
|
||||||
|
<span class="log-${log.type}">${log.message}</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
$logContainer.append(logEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
$logContainer.scrollTop($logContainer[0].scrollHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadSyncStatus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
<!-- Default grid view -->
|
<!-- Default grid view -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for movie in movies %}
|
{% for movie in movies %}
|
||||||
<div class="col-12 col-md-6 col-lg-4">
|
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
|||||||
@@ -42,18 +42,27 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if current_route == 'home' %}active{% endif %}" href="{{ path_for('home') }}">Dashboard</a>
|
<a class="nav-link {% if current_route == 'home' %}active{% endif %}" href="{{ path_for('home') }}">Dashboard</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if is_media_type_visible('games') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if current_route == 'games.index' %}active{% endif %}" href="{{ path_for('games.index') }}">Games</a>
|
<a class="nav-link {% if current_route == 'games.index' %}active{% endif %}" href="{{ path_for('games.index') }}">Games</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_media_type_visible('movies') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if current_route == 'movies.index' %}active{% endif %}" href="{{ path_for('movies.index') }}">Movies</a>
|
<a class="nav-link {% if current_route == 'movies.index' %}active{% endif %}" href="{{ path_for('movies.index') }}">Movies</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_media_type_visible('tvshows') %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if current_route == 'tvshows.index' %}active{% endif %}" href="{{ path_for('tvshows.index') }}">TV Shows</a>
|
<a class="nav-link {% if current_route == 'tvshows.index' %}active{% endif %}" href="{{ path_for('tvshows.index') }}">TV Shows</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_media_type_visible('music') %}
|
||||||
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_media_type_visible('adult') %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<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">
|
<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
|
Adult Videos
|
||||||
@@ -63,6 +72,7 @@
|
|||||||
<li><a class="dropdown-item {% if current_route == 'actors.index' %}active{% endif %}" href="{{ path_for('actors.index') }}">Performers</a></li>
|
<li><a class="dropdown-item {% if current_route == 'actors.index' %}active{% endif %}" href="{{ path_for('actors.index') }}">Performers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
{% if movie.poster_url %}
|
{% if movie.poster_url %}
|
||||||
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
{% if movie.poster_url %}
|
{% if movie.poster_url %}
|
||||||
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
|
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
|
||||||
<img src="{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
|
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
|
||||||
@@ -180,13 +180,13 @@
|
|||||||
<!-- Default grid view -->
|
<!-- Default grid view -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for movie in movies %}
|
{% for movie in movies %}
|
||||||
<div class="col-12 col-md-6 col-lg-4">
|
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
{% if movie.poster_url %}
|
{% if movie.poster_url %}
|
||||||
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="{{ movie.poster_url }}" alt="{{ movie.title }}">
|
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="display-4 fw-bold text-dark">TV Shows</h1>
|
<h1 class="display-4 fw-bold text-dark">TV Shows</h1>
|
||||||
<div class="text-muted small mt-1">
|
<div class="text-muted small mt-1">
|
||||||
TV Shows collection coming soon
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,13 +22,13 @@
|
|||||||
value="{{ search }}"
|
value="{{ search }}"
|
||||||
placeholder="Search TV shows..."
|
placeholder="Search TV shows..."
|
||||||
class="form-control ps-5"
|
class="form-control ps-5"
|
||||||
disabled
|
|
||||||
>
|
>
|
||||||
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="position-absolute top-50 start-0 translate-middle-y ms-3 text-muted" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" disabled>
|
<button type="submit" class="btn btn-primary">
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -39,8 +38,6 @@
|
|||||||
{% for mode in view_modes %}
|
{% for mode in view_modes %}
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
disabled
|
|
||||||
title="Coming Soon"
|
|
||||||
>
|
>
|
||||||
{% if mode == 'grid' %}
|
{% if mode == 'grid' %}
|
||||||
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="me-1" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -58,13 +55,232 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Coming Soon Message -->
|
|
||||||
|
{% if tvshows is empty %}
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mx-auto text-muted mb-3" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="h5 fw-medium text-dark">TV Shows Coming Soon</h3>
|
<h3 class="h5 fw-medium text-dark">
|
||||||
<p class="text-muted">TV show collection and management features are currently in development.</p>
|
{% if search %}
|
||||||
|
No TV shows found matching "{{ search }}"
|
||||||
|
{% else %}
|
||||||
|
No TV shows found
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if search %}
|
||||||
|
Try adjusting your search terms or browse all TV shows.
|
||||||
|
{% else %}
|
||||||
|
Start syncing your TV show libraries to see your TV shows here.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if search %}
|
||||||
|
<a href="{{ path_for('tvshows.index') }}" class="btn btn-primary mt-3">
|
||||||
|
View all TV shows
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Movies content based on view mode -->
|
||||||
|
{% if view_mode == 'list' %}
|
||||||
|
<!-- List view -->
|
||||||
|
<div class="card">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for movie in tvshows %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded me-3" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h3 class="h6 mb-1">
|
||||||
|
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ movie.source_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elseif view_mode == 'covers' %}
|
||||||
|
<!-- Cover grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in tvshows %}
|
||||||
|
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<div class="position-relative" style="aspect-ratio: 2/3; overflow: hidden;">
|
||||||
|
<img src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}" class="card-img-top" style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center justify-content-center bg-light" style="aspect-ratio: 2/3; min-height: 200px;">
|
||||||
|
<svg class="text-muted" width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-truncate" title="{{ movie.title }}">
|
||||||
|
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<p class="card-text small text-muted">{{ movie.release_date|date('Y') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Default grid view -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for movie in tvshows %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3 col-xl-2">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if movie.poster_url %}
|
||||||
|
<img class="rounded" style="width: 64px; height: 96px; object-fit: cover;" src="/images/{{ movie.poster_url }}" alt="{{ movie.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 64px; height: 96px;">
|
||||||
|
<svg class="text-muted" width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h4a1 1 0 010 2h-1v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6H3a1 1 0 010-2h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-3 flex-grow-1">
|
||||||
|
<h5 class="card-title mb-1">
|
||||||
|
<a href="{{ path_for('tvshows.show', {'id': movie.id}) }}" class="text-decoration-none">
|
||||||
|
{{ movie.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2 small text-muted mb-2">
|
||||||
|
{% if movie.release_date %}
|
||||||
|
<span>{{ movie.release_date|date('Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.rating %}
|
||||||
|
<span>⭐ {{ movie.rating }}/10</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if movie.source_name %}
|
||||||
|
<p class="card-text small text-muted mb-2">
|
||||||
|
{{ movie.source_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if movie.overview %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="card-text small text-muted" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
||||||
|
{{ movie.overview|slice(0, 150) }}{% if movie.overview|length > 150 %}...{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3 d-flex justify-content-between align-items-center small text-muted">
|
||||||
|
{% if movie.runtime_minutes %}
|
||||||
|
<span>{{ (movie.runtime_minutes / 60)|round(1) }}h {{ movie.runtime_minutes % 60 }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
{% if movie.watched %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
Watched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if movie.is_favorite %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
Favorite
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.total_pages > 1 %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mt-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label for="per_page" class="form-label mb-0">Show:</label>
|
||||||
|
<select id="per_page" class="form-select form-select-sm w-auto">
|
||||||
|
<option value="12" {{ pagination.per_page == 12 ? 'selected' : '' }}>12</option>
|
||||||
|
<option value="24" {{ pagination.per_page == 24 ? 'selected' : '' }}>24</option>
|
||||||
|
<option value="48" {{ pagination.per_page == 48 ? 'selected' : '' }}>48</option>
|
||||||
|
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted small">per page</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a href="?page={{ pagination.prev_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% for page_num in range(max(1, pagination.current_page - 2), min(pagination.total_pages, pagination.current_page + 2)) %}
|
||||||
|
<a href="?page={{ page_num }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-sm {{ page_num == pagination.current_page ? 'btn-primary' : 'btn-outline-secondary' }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="?page={{ pagination.next_page }}{% if search %}&search={{ search }}{% endif %}{% if pagination.per_page != 24 %}&per_page={{ pagination.per_page }}{% endif %}&view={{ view_mode }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,7 +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;
|
use App\Controllers\ImageController;
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -20,45 +20,78 @@ $app->get('/logout', AuthController::class . ':logout')->setName('auth.logout');
|
|||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
$app->group('', function (RouteCollectorProxy $group) {
|
$app->group('', function (RouteCollectorProxy $group) {
|
||||||
$group->get('/', DashboardController::class . ':index')->setName('home');
|
// Image serving (no auth required for public images)
|
||||||
|
$group->get('/images/{path:.+}', 'App\Controllers\ImageController:serve')->setName('images.serve');
|
||||||
|
|
||||||
// Global Search
|
// Global Search
|
||||||
$group->get('/search', 'App\Controllers\SearchController:index')->setName('search.index');
|
$group->get('/search', 'App\Controllers\SearchController:index')->setName('search.index');
|
||||||
|
$group->get('/', 'App\Controllers\DashboardController:index')->setName('dashboard.index');
|
||||||
|
|
||||||
// Media Routes
|
// Media Routes
|
||||||
$group->group('/media', function (RouteCollectorProxy $mediaGroup) {
|
$group->group('/media', function (RouteCollectorProxy $mediaGroup) {
|
||||||
// Games
|
// Games
|
||||||
$mediaGroup->get('/games', GameController::class . ':index')->setName('games.index');
|
$mediaGroup->get('/games', GameController::class . ':index')->setName('games.index');
|
||||||
$mediaGroup->get('/games/{game_key}', GameController::class . ':show')->setName('games.show');
|
$mediaGroup->get('/games/{game_key}', GameController::class . ':show')->setName('games.show');
|
||||||
|
$mediaGroup->delete('/games/{game_key}', GameController::class . ':delete')->setName('games.delete');
|
||||||
|
|
||||||
// Movies
|
// Movies
|
||||||
$mediaGroup->get('/movies', 'App\Controllers\MovieController:index')->setName('movies.index');
|
$mediaGroup->get('/movies', 'App\Controllers\MovieController:index')->setName('movies.index');
|
||||||
$mediaGroup->get('/movies/{id:\d+}', 'App\Controllers\MovieController:show')->setName('movies.show');
|
$mediaGroup->get('/movies/{id:\d+}', 'App\Controllers\MovieController:show')->setName('movies.show');
|
||||||
|
$mediaGroup->delete('/movies/{id:\d+}', 'App\Controllers\MovieController:delete')->setName('movies.delete');
|
||||||
|
|
||||||
// TV Shows
|
// TV Shows
|
||||||
$mediaGroup->get('/tv-shows', 'App\Controllers\TvShowController:index')->setName('tvshows.index');
|
$mediaGroup->get('/tv-shows', 'App\Controllers\TvShowController:index')->setName('tvshows.index');
|
||||||
$mediaGroup->get('/tv-shows/{id:\d+}', 'App\Controllers\TvShowController:show')->setName('tvshows.show');
|
$mediaGroup->get('/tv-shows/{id:\d+}', 'App\Controllers\TvShowController:show')->setName('tvshows.show');
|
||||||
|
$mediaGroup->delete('/tv-shows/{id:\d+}', 'App\Controllers\TvShowController:delete')->setName('tvshows.delete');
|
||||||
|
|
||||||
// Music
|
// Music
|
||||||
$mediaGroup->get('/music', 'App\Controllers\MusicController:index')->setName('music.index');
|
$mediaGroup->get('/music', 'App\Controllers\MusicController:index')->setName('music.index');
|
||||||
$mediaGroup->get('/music/{id:\d+}', 'App\Controllers\MusicController:show')->setName('music.show');
|
$mediaGroup->get('/music/{id:\d+}', 'App\Controllers\MusicController:show')->setName('music.show');
|
||||||
|
$mediaGroup->delete('/music/{id:\d+}', 'App\Controllers\MusicController:delete')->setName('music.delete');
|
||||||
|
|
||||||
// Adult Videos
|
// 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');
|
||||||
|
$mediaGroup->delete('/adult/{id:\d+}', AdultController::class . ':delete')->setName('adult.delete');
|
||||||
|
|
||||||
// Adult Performers (Actors)
|
// Adult Performers (Actors)
|
||||||
$mediaGroup->get('/actors', ActorController::class . ':index')->setName('actors.index');
|
$mediaGroup->get('/actors', 'App\Controllers\ActorController:index')->setName('actors.index');
|
||||||
$mediaGroup->get('/actors/{id:\d+}', ActorController::class . ':show')->setName('actors.show');
|
$mediaGroup->get('/actors/{id:\d+}', 'App\Controllers\ActorController:show')->setName('actors.show');
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
})->add(AuthMiddleware::class);
|
})->add(AuthMiddleware::class)->add('App\Http\Middleware\MediaVisibilityMiddleware');
|
||||||
|
|
||||||
// Admin routes (require authentication + admin role)
|
// Admin routes (require admin role)
|
||||||
$app->group('/admin', function (RouteCollectorProxy $group) {
|
$app->group('/admin', function (RouteCollectorProxy $adminGroup) {
|
||||||
$group->get('', AdminController::class . ':index')->setName('admin.index');
|
// Dashboard
|
||||||
$group->post('/sync/{id:\d+}', AdminController::class . ':syncSource')->setName('admin.sync');
|
$adminGroup->get('', AdminController::class . ':index')->setName('admin.index');
|
||||||
$group->get('/sync/status/{id:\d+}', AdminController::class . ':syncStatus')->setName('admin.sync.status');
|
$adminGroup->get('/settings', AdminController::class . ':settings')->setName('admin.settings');
|
||||||
$group->get('/sources', AdminController::class . ':sources')->setName('admin.sources');
|
|
||||||
|
|
||||||
|
// Media Sources
|
||||||
|
$adminGroup->group('/sources', function (RouteCollectorProxy $sourcesGroup) {
|
||||||
|
$sourcesGroup->get('', 'App\Controllers\MediaSourceController:index')->setName('admin.sources.index');
|
||||||
|
$sourcesGroup->get('/create', 'App\Controllers\MediaSourceController:create')->setName('admin.sources.create');
|
||||||
|
$sourcesGroup->post('', 'App\Controllers\MediaSourceController:store')->setName('admin.sources.store');
|
||||||
|
$sourcesGroup->get('/{id:\d+}/edit', 'App\Controllers\MediaSourceController:edit')->setName('admin.sources.edit');
|
||||||
|
$sourcesGroup->post('/{id:\d+}', 'App\Controllers\MediaSourceController:update')->setName('admin.sources.update');
|
||||||
|
$sourcesGroup->delete('/{id:\d+}', 'App\Controllers\MediaSourceController:destroy')->setName('admin.sources.destroy');
|
||||||
|
|
||||||
|
// Source sync operations
|
||||||
|
$sourcesGroup->post('/{id:\d+}/sync', 'App\Controllers\MediaSourceController:startSync')->setName('admin.sources.sync');
|
||||||
|
$sourcesGroup->get('/sync/status/{log_id}', 'App\Controllers\MediaSourceController:syncStatus')->setName('admin.sources.sync.status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync Management
|
||||||
|
$adminGroup->group('/sync', function (RouteCollectorProxy $syncGroup) {
|
||||||
|
$syncGroup->get('', 'App\Controllers\SyncController:index')->setName('admin.sync.index');
|
||||||
|
$syncGroup->post('', 'App\Controllers\SyncController:start')->setName('admin.sync.start');
|
||||||
|
$syncGroup->post('/{id:\d+}', AdminController::class . ':syncSource')->setName('admin.sync');
|
||||||
|
$syncGroup->get('/status/{id:\d+}', AdminController::class . ':syncStatus')->setName('admin.sync.status');
|
||||||
|
$syncGroup->get('/status/{log_id}', 'App\Controllers\SyncController:status')->setName('admin.sync.status');
|
||||||
|
$syncGroup->post('/{log_id}/cancel', 'App\Controllers\SyncController:cancel')->setName('admin.sync.cancel');
|
||||||
|
$syncGroup->post('/clear-logs', 'App\Controllers\SyncController:clearLogs')->setName('admin.sync.clearLogs');
|
||||||
|
|
||||||
|
});
|
||||||
})->add(AdminMiddleware::class);
|
})->add(AdminMiddleware::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user