Initial commit
1424
app/static/css/admin.css
Normal file
48
app/static/css/cache-info.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/* ============================================================
|
||||
CACHE INFO - Styles communs
|
||||
============================================================ */
|
||||
|
||||
.cache-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
margin: 15px 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.cache-badge {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cache-timestamp {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: var(--bg-primary);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.cache-info {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
699
app/static/css/discover.css
Normal file
@@ -0,0 +1,699 @@
|
||||
/* ============================================================
|
||||
LYCOSTORRENT - Page Découvrir
|
||||
============================================================ */
|
||||
|
||||
/* Onglets de catégories */
|
||||
.discover-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.discover-tab {
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.discover-tab:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.discover-tab.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Grille de résultats */
|
||||
.discover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 25px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Carte de film/série */
|
||||
.discover-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.discover-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.discover-card .poster-container {
|
||||
position: relative;
|
||||
aspect-ratio: 2/3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discover-card .poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.discover-card:hover .poster {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.discover-card .poster-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.discover-card .rating-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #ffd700;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.discover-card .card-info {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.discover-card .card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.discover-card .card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.discover-card .card-year {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.discover-card .card-type {
|
||||
background: var(--accent-secondary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Badge nombre de torrents sur les cartes */
|
||||
.torrent-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #4ade80;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* Loader */
|
||||
.discover-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* État vide */
|
||||
.discover-empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.discover-empty .empty-icon {
|
||||
font-size: 4rem;
|
||||
display: block;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Modal détails */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 25px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.detail-poster {
|
||||
width: 200px;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-info h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-rating {
|
||||
color: #ffd700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-overview {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-genres {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-genres span {
|
||||
background: var(--accent-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 5px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Section torrents */
|
||||
.detail-torrents {
|
||||
padding: 25px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-torrents h3 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.torrents-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.torrents-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px 15px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.torrent-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.torrent-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.torrent-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.torrent-meta .seeds {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.torrent-meta .quality {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.torrent-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.torrent-actions button {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.torrent-actions .btn-download {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.torrent-actions .btn-download:hover {
|
||||
background: #d63850;
|
||||
}
|
||||
|
||||
.torrent-actions .btn-magnet {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.torrent-actions .btn-send {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.torrents-empty {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Footer crédit TMDb */
|
||||
.tmdb-credit {
|
||||
margin-left: 15px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tmdb-credit a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tmdb-credit a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
/* Navigation mobile */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding: 8px 15px;
|
||||
font-size: 0.85rem;
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.main-nav a.nav-logout {
|
||||
flex: 0 0 auto;
|
||||
min-width: 50px;
|
||||
max-width: 50px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.discover-tabs {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discover-tab {
|
||||
padding: 10px 15px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.discover-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.detail-poster {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.detail-info h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-overview {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-genres {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-genres span {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Bande-annonce mobile */
|
||||
.detail-trailer {
|
||||
padding: 0 15px 15px;
|
||||
}
|
||||
|
||||
.detail-trailer h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Torrents mobile */
|
||||
.detail-torrents {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.detail-torrents h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.torrents-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.torrent-info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.torrent-name {
|
||||
font-size: 0.85rem;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.torrent-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.torrent-meta span {
|
||||
background: var(--bg-secondary);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.torrent-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.torrent-actions a,
|
||||
.torrent-actions button {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Très petit écran */
|
||||
@media (max-width: 480px) {
|
||||
/* Navigation */
|
||||
.main-nav {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.main-nav a.nav-logout {
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.discover-tabs {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.discover-tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discover-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.discover-card .card-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.discover-card .card-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.discover-card .card-meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-poster {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.detail-info h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.torrent-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.torrent-actions a,
|
||||
.torrent-actions button {
|
||||
flex: 1 1 45%;
|
||||
padding: 10px 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hidden class */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Bande-annonce YouTube */
|
||||
.detail-trailer {
|
||||
padding: 0 25px 25px;
|
||||
}
|
||||
|
||||
.detail-trailer h3 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trailer-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* Ratio 16:9 */
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.trailer-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Lien vers le tracker */
|
||||
.torrent-item .torrent-link {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.torrent-item .torrent-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.torrent-actions .btn-link {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.torrent-actions .btn-link:hover {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
1678
app/static/css/latest.css
Normal file
1168
app/static/css/style.css
Normal file
168
app/static/css/themes.css
Normal file
@@ -0,0 +1,168 @@
|
||||
/* ============================================================
|
||||
LYCOSTORRENT - Système de Thèmes
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
THÈME SOMBRE (par défaut)
|
||||
============================================================ */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #1f2940;
|
||||
--text-primary: #eaeaea;
|
||||
--text-secondary: #a0a0a0;
|
||||
--accent-primary: #e94560;
|
||||
--accent-secondary: #0f3460;
|
||||
--success: #4ade80;
|
||||
--warning: #fbbf24;
|
||||
--danger: #ef4444;
|
||||
--border-color: #2d3748;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME CLAIR
|
||||
============================================================ */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #666666;
|
||||
--accent-primary: #e94560;
|
||||
--accent-secondary: #e8e8e8;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #dc2626;
|
||||
--border-color: #e0e0e0;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME BLEU OCÉAN
|
||||
============================================================ */
|
||||
[data-theme="ocean"] {
|
||||
--bg-primary: #0a192f;
|
||||
--bg-secondary: #112240;
|
||||
--bg-card: #1d3557;
|
||||
--text-primary: #e6f1ff;
|
||||
--text-secondary: #8892b0;
|
||||
--accent-primary: #64ffda;
|
||||
--accent-secondary: #233554;
|
||||
--success: #64ffda;
|
||||
--warning: #ffd700;
|
||||
--danger: #ff6b6b;
|
||||
--border-color: #233554;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME VIOLET NUIT
|
||||
============================================================ */
|
||||
[data-theme="purple"] {
|
||||
--bg-primary: #13111c;
|
||||
--bg-secondary: #1e1a2e;
|
||||
--bg-card: #2a2440;
|
||||
--text-primary: #e8e6f0;
|
||||
--text-secondary: #9d99b0;
|
||||
--accent-primary: #a855f7;
|
||||
--accent-secondary: #3b3256;
|
||||
--success: #4ade80;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f472b6;
|
||||
--border-color: #3b3256;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME VERT NATURE
|
||||
============================================================ */
|
||||
[data-theme="nature"] {
|
||||
--bg-primary: #1a2f1a;
|
||||
--bg-secondary: #0f2010;
|
||||
--bg-card: #243524;
|
||||
--text-primary: #e8f5e9;
|
||||
--text-secondary: #a5d6a7;
|
||||
--accent-primary: #4caf50;
|
||||
--accent-secondary: #2e4a2e;
|
||||
--success: #81c784;
|
||||
--warning: #ffb74d;
|
||||
--danger: #e57373;
|
||||
--border-color: #2e4a2e;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME ORANGE SUNSET
|
||||
============================================================ */
|
||||
[data-theme="sunset"] {
|
||||
--bg-primary: #1f1510;
|
||||
--bg-secondary: #2d1f15;
|
||||
--bg-card: #3d2a1a;
|
||||
--text-primary: #fff3e0;
|
||||
--text-secondary: #bcaaa4;
|
||||
--accent-primary: #ff7043;
|
||||
--accent-secondary: #4a3228;
|
||||
--success: #81c784;
|
||||
--warning: #ffb74d;
|
||||
--danger: #ef5350;
|
||||
--border-color: #4a3228;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME CYBERPUNK
|
||||
============================================================ */
|
||||
[data-theme="cyberpunk"] {
|
||||
--bg-primary: #0d0d0d;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-card: #262626;
|
||||
--text-primary: #00ff9f;
|
||||
--text-secondary: #00cc7f;
|
||||
--accent-primary: #ff00ff;
|
||||
--accent-secondary: #330033;
|
||||
--success: #00ff9f;
|
||||
--warning: #ffff00;
|
||||
--danger: #ff0066;
|
||||
--border-color: #333333;
|
||||
--shadow: 0 0 20px rgba(255, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME NORD
|
||||
============================================================ */
|
||||
[data-theme="nord"] {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-card: #434c5e;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #d8dee9;
|
||||
--accent-primary: #88c0d0;
|
||||
--accent-secondary: #4c566a;
|
||||
--success: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
--border-color: #4c566a;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TRANSITIONS FLUIDES
|
||||
============================================================ */
|
||||
body,
|
||||
.container,
|
||||
.header,
|
||||
.search-box,
|
||||
.results-table,
|
||||
.filter-bar,
|
||||
.admin-card,
|
||||
.modal-content,
|
||||
.nav-link,
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
transition: background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
BIN
app/static/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/static/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/static/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/static/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
app/static/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/static/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
app/static/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 671 B |
BIN
app/static/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 886 B |
1672
app/static/js/admin.js
Normal file
365
app/static/js/admin_latest.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Lycostorrent - Admin Latest Categories
|
||||
* Configuration des catégories par tracker pour les nouveautés
|
||||
*/
|
||||
|
||||
let allTrackers = [];
|
||||
let selectedTracker = null;
|
||||
let trackerCategories = {}; // Catégories disponibles par tracker
|
||||
let config = {}; // Configuration sauvegardée
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTrackers();
|
||||
loadConfig();
|
||||
|
||||
document.getElementById('saveConfigBtn').addEventListener('click', saveConfig);
|
||||
document.getElementById('resetConfigBtn').addEventListener('click', resetCurrentTracker);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT DES DONNÉES
|
||||
// ============================================================
|
||||
|
||||
async function loadTrackers() {
|
||||
try {
|
||||
const response = await fetch('/api/trackers');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
allTrackers = data.trackers;
|
||||
displayTrackerSelector(allTrackers);
|
||||
} else {
|
||||
showMessage('Erreur lors du chargement des trackers', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Impossible de charger les trackers', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/latest-config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
config = data.config || {};
|
||||
displayConfigSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement config:', error);
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrackerCategories(trackerId) {
|
||||
try {
|
||||
document.getElementById('availableCategories').innerHTML = '<p class="loading">Chargement des catégories...</p>';
|
||||
|
||||
const response = await fetch(`/api/admin/tracker-categories/${trackerId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
trackerCategories[trackerId] = data.categories;
|
||||
displayAvailableCategories(data.categories);
|
||||
} else {
|
||||
document.getElementById('availableCategories').innerHTML = '<p class="error">Impossible de charger les catégories</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
document.getElementById('availableCategories').innerHTML = '<p class="error">Erreur de connexion</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AFFICHAGE
|
||||
// ============================================================
|
||||
|
||||
function displayTrackerSelector(trackers) {
|
||||
const container = document.getElementById('trackerSelector');
|
||||
|
||||
if (trackers.length === 0) {
|
||||
container.innerHTML = '<p class="no-data">Aucun tracker configuré dans Jackett</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = trackers.map(tracker => `
|
||||
<button class="tracker-btn" data-tracker-id="${tracker.id}" data-tracker-name="${tracker.name}">
|
||||
${tracker.name}
|
||||
${config[tracker.id] ? '<span class="configured-badge">✓</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// Event listeners
|
||||
container.querySelectorAll('.tracker-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
selectTracker(this.dataset.trackerId, this.dataset.trackerName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectTracker(trackerId, trackerName) {
|
||||
selectedTracker = trackerId;
|
||||
|
||||
// Mettre à jour l'UI
|
||||
document.querySelectorAll('.tracker-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelector(`[data-tracker-id="${trackerId}"]`).classList.add('active');
|
||||
|
||||
document.getElementById('selectedTrackerName').textContent = trackerName;
|
||||
document.getElementById('configTrackerName').textContent = trackerName;
|
||||
|
||||
// Afficher les sections
|
||||
document.getElementById('categoriesSection').classList.remove('hidden');
|
||||
document.getElementById('configSection').classList.remove('hidden');
|
||||
|
||||
// Charger les catégories du tracker
|
||||
loadTrackerCategories(trackerId);
|
||||
|
||||
// Remplir les inputs avec la config existante
|
||||
fillConfigInputs(trackerId);
|
||||
}
|
||||
|
||||
function displayAvailableCategories(categories) {
|
||||
const container = document.getElementById('availableCategories');
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
container.innerHTML = '<p class="no-data">Aucune catégorie trouvée pour ce tracker</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouper par type (milliers)
|
||||
const grouped = {};
|
||||
categories.forEach(cat => {
|
||||
const prefix = Math.floor(parseInt(cat.id) / 1000) * 1000;
|
||||
if (!grouped[prefix]) grouped[prefix] = [];
|
||||
grouped[prefix].push(cat);
|
||||
});
|
||||
|
||||
const prefixNames = {
|
||||
1000: '🎮 Console/Jeux',
|
||||
2000: '🎥 Films',
|
||||
3000: '🎵 Audio/Musique',
|
||||
4000: '💻 PC/Logiciels',
|
||||
5000: '📺 TV/Séries',
|
||||
6000: '📦 Autre',
|
||||
7000: '📚 Livres',
|
||||
8000: '📦 Autre'
|
||||
};
|
||||
|
||||
let html = '<div class="categories-grid">';
|
||||
|
||||
for (const [prefix, cats] of Object.entries(grouped).sort((a, b) => a[0] - b[0])) {
|
||||
html += `
|
||||
<div class="category-group">
|
||||
<h4>${prefixNames[prefix] || `Catégorie ${prefix}`}</h4>
|
||||
<div class="category-list">
|
||||
${cats.map(cat => `
|
||||
<div class="category-item" data-id="${cat.id}">
|
||||
<span class="cat-id">${cat.id}</span>
|
||||
<span class="cat-name">${escapeHtml(cat.name)}</span>
|
||||
<button class="btn-add-cat" title="Ajouter">+</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Event listeners pour les boutons d'ajout
|
||||
container.querySelectorAll('.btn-add-cat').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const catId = this.parentElement.dataset.id;
|
||||
showAddCategoryModal(catId);
|
||||
});
|
||||
});
|
||||
|
||||
// Mettre à jour les quick-add buttons
|
||||
updateQuickAddButtons(categories);
|
||||
}
|
||||
|
||||
function updateQuickAddButtons(categories) {
|
||||
const targets = ['movies', 'tv', 'anime', 'music'];
|
||||
|
||||
targets.forEach(target => {
|
||||
const container = document.querySelector(`.quick-add[data-target="${target}"]`);
|
||||
if (!container) return;
|
||||
|
||||
// Filtrer les catégories pertinentes
|
||||
let relevantCats = [];
|
||||
switch (target) {
|
||||
case 'movies':
|
||||
relevantCats = categories.filter(c => c.id.startsWith('2'));
|
||||
break;
|
||||
case 'tv':
|
||||
relevantCats = categories.filter(c => c.id.startsWith('5') && !c.name.toLowerCase().includes('anime'));
|
||||
break;
|
||||
case 'anime':
|
||||
relevantCats = categories.filter(c => c.name.toLowerCase().includes('anime'));
|
||||
break;
|
||||
case 'music':
|
||||
relevantCats = categories.filter(c => c.id.startsWith('3'));
|
||||
break;
|
||||
}
|
||||
|
||||
if (relevantCats.length > 0) {
|
||||
container.innerHTML = `
|
||||
<div class="quick-add-label">Ajout rapide:</div>
|
||||
${relevantCats.slice(0, 6).map(cat => `
|
||||
<button class="quick-add-btn" data-id="${cat.id}" data-target="${target}" title="${escapeHtml(cat.name)}">
|
||||
${cat.id}
|
||||
</button>
|
||||
`).join('')}
|
||||
`;
|
||||
|
||||
container.querySelectorAll('.quick-add-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
addCategoryToInput(this.dataset.target, this.dataset.id);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addCategoryToInput(target, catId) {
|
||||
const input = document.getElementById(`config-${target}`);
|
||||
const currentValue = input.value.trim();
|
||||
const categories = currentValue ? currentValue.split(',').map(c => c.trim()) : [];
|
||||
|
||||
if (!categories.includes(catId)) {
|
||||
categories.push(catId);
|
||||
input.value = categories.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCategoryModal(catId) {
|
||||
const target = prompt(`Ajouter la catégorie ${catId} à quel type ?\n\nOptions: movies, tv, anime, music`);
|
||||
if (target && ['movies', 'tv', 'anime', 'music'].includes(target.toLowerCase())) {
|
||||
addCategoryToInput(target.toLowerCase(), catId);
|
||||
showMessage(`Catégorie ${catId} ajoutée à ${target}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function fillConfigInputs(trackerId) {
|
||||
const trackerConfig = config[trackerId] || {};
|
||||
|
||||
document.getElementById('config-movies').value = trackerConfig.movies || '';
|
||||
document.getElementById('config-tv').value = trackerConfig.tv || '';
|
||||
document.getElementById('config-anime').value = trackerConfig.anime || '';
|
||||
document.getElementById('config-music').value = trackerConfig.music || '';
|
||||
}
|
||||
|
||||
function displayConfigSummary() {
|
||||
const container = document.getElementById('configSummary');
|
||||
|
||||
if (Object.keys(config).length === 0) {
|
||||
container.innerHTML = '<p class="no-data">Aucune configuration sauvegardée. Les catégories par défaut seront utilisées.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="summary-table"><thead><tr><th>Tracker</th><th>Films</th><th>Séries</th><th>Anime</th><th>Musique</th></tr></thead><tbody>';
|
||||
|
||||
for (const [trackerId, trackerConfig] of Object.entries(config)) {
|
||||
const tracker = allTrackers.find(t => t.id === trackerId);
|
||||
const trackerName = tracker ? tracker.name : trackerId;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(trackerName)}</strong></td>
|
||||
<td>${trackerConfig.movies || '-'}</td>
|
||||
<td>${trackerConfig.tv || '-'}</td>
|
||||
<td>${trackerConfig.anime || '-'}</td>
|
||||
<td>${trackerConfig.music || '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Mettre à jour les badges "configuré"
|
||||
displayTrackerSelector(allTrackers);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAUVEGARDE
|
||||
// ============================================================
|
||||
|
||||
async function saveConfig() {
|
||||
if (!selectedTracker) {
|
||||
showMessage('Veuillez sélectionner un tracker', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const trackerConfig = {
|
||||
movies: document.getElementById('config-movies').value.trim(),
|
||||
tv: document.getElementById('config-tv').value.trim(),
|
||||
anime: document.getElementById('config-anime').value.trim(),
|
||||
music: document.getElementById('config-music').value.trim()
|
||||
};
|
||||
|
||||
// Supprimer les entrées vides
|
||||
Object.keys(trackerConfig).forEach(key => {
|
||||
if (!trackerConfig[key]) delete trackerConfig[key];
|
||||
});
|
||||
|
||||
config[selectedTracker] = trackerConfig;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/latest-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('Configuration sauvegardée !', 'success');
|
||||
displayConfigSummary();
|
||||
} else {
|
||||
showMessage(data.error || 'Erreur lors de la sauvegarde', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetCurrentTracker() {
|
||||
if (!selectedTracker) return;
|
||||
|
||||
if (confirm('Réinitialiser la configuration de ce tracker ?')) {
|
||||
document.getElementById('config-movies').value = '';
|
||||
document.getElementById('config-tv').value = '';
|
||||
document.getElementById('config-anime').value = '';
|
||||
document.getElementById('config-music').value = '';
|
||||
|
||||
delete config[selectedTracker];
|
||||
showMessage('Configuration réinitialisée (pensez à sauvegarder)', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messageBox = document.getElementById('messageBox');
|
||||
messageBox.textContent = message;
|
||||
messageBox.className = `message-box ${type}`;
|
||||
messageBox.classList.remove('hidden');
|
||||
setTimeout(() => messageBox.classList.add('hidden'), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
227
app/static/js/admin_parsing.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Lycostorrent - Admin Parsing Tags
|
||||
* Gestion des tags de coupure pour le parsing des titres
|
||||
*/
|
||||
|
||||
let currentTags = [];
|
||||
|
||||
// Présets de tags
|
||||
const PRESETS = {
|
||||
langues: ['MULTi', 'MULTI', 'VOSTFR', 'VOST', 'VFF', 'VFQ', 'VFI', 'FRENCH', 'TRUEFRENCH', 'SUBFRENCH'],
|
||||
resolutions: ['1080p', '720p', '480p', '2160p', '4K', 'UHD'],
|
||||
sources: ['WEB', 'WEBRIP', 'WEBDL', 'WEB-DL', 'HDTV', 'BLURAY', 'BDRIP', 'BRRIP', 'DVDRIP', 'HDRip', 'REMUX'],
|
||||
codecs: ['x264', 'x265', 'HEVC', 'H264', 'H265', 'AV1'],
|
||||
audio: ['HDR', 'HDR10', 'DV', 'DOLBY', 'ATMOS', 'DTS', 'AC3', 'AAC', 'FLAC', 'TrueHD']
|
||||
};
|
||||
|
||||
// Tags par défaut (copie de tmdb_api.py)
|
||||
const DEFAULT_TAGS = [
|
||||
"MULTi", "MULTI", "VOSTFR", "VOST", "VFF", "VFQ", "VFI",
|
||||
"FRENCH", "TRUEFRENCH", "SUBFRENCH",
|
||||
"1080p", "720p", "480p", "2160p", "4K", "UHD",
|
||||
"WEB", "WEBRIP", "WEBDL", "WEB-DL", "HDTV", "BLURAY", "BDRIP", "BRRIP", "DVDRIP", "HDRip", "REMUX",
|
||||
"x264", "x265", "HEVC", "H264", "H265", "AV1",
|
||||
"HDR", "HDR10", "DV", "DOLBY", "ATMOS", "DTS", "AC3", "AAC", "FLAC", "TrueHD",
|
||||
"PROPER", "REPACK"
|
||||
];
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadTags();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('addTagBtn').addEventListener('click', addNewTag);
|
||||
document.getElementById('newTagInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') addNewTag();
|
||||
});
|
||||
document.getElementById('saveTagsBtn').addEventListener('click', saveTags);
|
||||
document.getElementById('resetTagsBtn').addEventListener('click', resetToDefault);
|
||||
document.getElementById('testParsingBtn').addEventListener('click', testParsing);
|
||||
document.getElementById('testTitleInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') testParsing();
|
||||
});
|
||||
|
||||
// Présets
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
addPreset(this.dataset.preset);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT / SAUVEGARDE
|
||||
// ============================================================
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/parsing-tags');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentTags = data.tags || [];
|
||||
renderTags();
|
||||
} else {
|
||||
showMessage('Erreur lors du chargement des tags', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Impossible de charger les tags', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTags() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/parsing-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: currentTags })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`${currentTags.length} tags sauvegardés !`, 'success');
|
||||
} else {
|
||||
showMessage(data.error || 'Erreur lors de la sauvegarde', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
if (confirm('Réinitialiser tous les tags aux valeurs par défaut ?')) {
|
||||
currentTags = [...DEFAULT_TAGS];
|
||||
renderTags();
|
||||
showMessage('Tags réinitialisés (pensez à sauvegarder)', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GESTION DES TAGS
|
||||
// ============================================================
|
||||
|
||||
function renderTags() {
|
||||
const container = document.getElementById('tagsList');
|
||||
|
||||
if (currentTags.length === 0) {
|
||||
container.innerHTML = '<p class="no-data">Aucun tag configuré</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Trier alphabétiquement
|
||||
const sortedTags = [...currentTags].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
container.innerHTML = sortedTags.map(tag => `
|
||||
<span class="tag-item-editor">
|
||||
${escapeHtml(tag)}
|
||||
<button class="tag-remove" data-tag="${escapeHtml(tag)}" title="Supprimer">×</button>
|
||||
</span>
|
||||
`).join('');
|
||||
|
||||
// Event listeners pour supprimer
|
||||
container.querySelectorAll('.tag-remove').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
removeTag(this.dataset.tag);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addNewTag() {
|
||||
const input = document.getElementById('newTagInput');
|
||||
const tag = input.value.trim();
|
||||
|
||||
if (!tag) {
|
||||
showMessage('Veuillez entrer un tag', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTags.some(t => t.toLowerCase() === tag.toLowerCase())) {
|
||||
showMessage('Ce tag existe déjà', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentTags.push(tag);
|
||||
renderTags();
|
||||
input.value = '';
|
||||
showMessage(`Tag "${tag}" ajouté`, 'success');
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
currentTags = currentTags.filter(t => t !== tag);
|
||||
renderTags();
|
||||
}
|
||||
|
||||
function addPreset(presetName) {
|
||||
const presetTags = PRESETS[presetName];
|
||||
if (!presetTags) return;
|
||||
|
||||
let added = 0;
|
||||
presetTags.forEach(tag => {
|
||||
if (!currentTags.some(t => t.toLowerCase() === tag.toLowerCase())) {
|
||||
currentTags.push(tag);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
|
||||
renderTags();
|
||||
showMessage(`${added} tags ajoutés depuis le préset "${presetName}"`, 'success');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEST DE PARSING
|
||||
// ============================================================
|
||||
|
||||
async function testParsing() {
|
||||
const input = document.getElementById('testTitleInput');
|
||||
const title = input.value.trim();
|
||||
|
||||
if (!title) {
|
||||
showMessage('Veuillez entrer un titre à tester', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/test-parsing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('testOriginal').textContent = data.original;
|
||||
document.getElementById('testCleaned').textContent = data.cleaned;
|
||||
document.getElementById('testResult').classList.remove('hidden');
|
||||
} else {
|
||||
showMessage(data.error || 'Erreur de test', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messageBox = document.getElementById('messageBox');
|
||||
messageBox.textContent = message;
|
||||
messageBox.className = `message-box ${type}`;
|
||||
messageBox.classList.remove('hidden');
|
||||
setTimeout(() => messageBox.classList.add('hidden'), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
288
app/static/js/admin_rss.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Lycostorrent - Admin RSS
|
||||
* Gestion des flux RSS pour les nouveautés
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadFeeds();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// Formulaire d'ajout
|
||||
document.getElementById('add-feed-form').addEventListener('submit', addFeed);
|
||||
|
||||
// Bouton test
|
||||
document.getElementById('test-feed-btn').addEventListener('click', testFeed);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT DES FLUX
|
||||
// ============================================================
|
||||
|
||||
async function loadFeeds() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/rss');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
renderFeeds(data.feeds);
|
||||
} else {
|
||||
showError('Erreur lors du chargement des flux RSS');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showError('Impossible de charger les flux RSS');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFeeds(feeds) {
|
||||
const container = document.getElementById('feeds-list');
|
||||
|
||||
if (feeds.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>🔗 Aucun flux RSS configuré</p>
|
||||
<p>Ajoutez votre premier flux ci-dessus pour commencer</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = feeds.map(feed => `
|
||||
<div class="feed-card ${feed.enabled ? '' : 'disabled'}" data-id="${feed.id}">
|
||||
<div class="feed-header">
|
||||
<div class="feed-info">
|
||||
<h3>${escapeHtml(feed.name)}</h3>
|
||||
<span class="feed-category badge-${feed.category}">${getCategoryLabel(feed.category)}</span>
|
||||
${feed.use_flaresolverr ? '<span class="feed-badge flaresolverr">🛡️ Flaresolverr</span>' : ''}
|
||||
${feed.has_cookies ? '<span class="feed-badge cookies">🍪 Cookies</span>' : ''}
|
||||
</div>
|
||||
<div class="feed-actions">
|
||||
<button class="btn-icon" onclick="toggleFeed('${feed.id}')" title="${feed.enabled ? 'Désactiver' : 'Activer'}">
|
||||
${feed.enabled ? '✅' : '⏸️'}
|
||||
</button>
|
||||
<button class="btn-icon" onclick="testExistingFeed('${feed.id}')" title="Tester">🧪</button>
|
||||
<button class="btn-icon btn-danger" onclick="deleteFeed('${feed.id}')" title="Supprimer">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feed-url">
|
||||
<code>${maskUrl(feed.url)}</code>
|
||||
</div>
|
||||
${feed.passkey ? '<div class="feed-passkey">🔑 Passkey configuré</div>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getCategoryLabel(category) {
|
||||
const labels = {
|
||||
'movies': '🎬 Films',
|
||||
'tv': '📺 Séries',
|
||||
'anime': '🎌 Anime',
|
||||
'music': '🎵 Musique',
|
||||
'all': '📦 Toutes'
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
|
||||
function maskUrl(url) {
|
||||
// Masquer le passkey dans l'URL pour l'affichage
|
||||
return url.replace(/passkey=[^&]+/gi, 'passkey=***')
|
||||
.replace(/apikey=[^&]+/gi, 'apikey=***')
|
||||
.replace(/key=[^&]+/gi, 'key=***');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AJOUT DE FLUX
|
||||
// ============================================================
|
||||
|
||||
async function addFeed(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const feed = {
|
||||
name: document.getElementById('feed-name').value.trim(),
|
||||
url: document.getElementById('feed-url').value.trim(),
|
||||
category: document.getElementById('feed-category').value,
|
||||
passkey: document.getElementById('feed-passkey').value.trim(),
|
||||
use_flaresolverr: document.getElementById('feed-flaresolverr').checked,
|
||||
cookies: document.getElementById('feed-cookies').value.trim()
|
||||
};
|
||||
|
||||
if (!feed.name || !feed.url || !feed.category) {
|
||||
showError('Veuillez remplir tous les champs obligatoires');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/rss', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(feed)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Réinitialiser le formulaire
|
||||
document.getElementById('add-feed-form').reset();
|
||||
document.getElementById('test-result').classList.add('hidden');
|
||||
|
||||
// Recharger la liste
|
||||
loadFeeds();
|
||||
|
||||
showSuccess('Flux RSS ajouté avec succès !');
|
||||
} else {
|
||||
showError(data.error || 'Erreur lors de l\'ajout');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showError('Erreur de connexion au serveur');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEST DE FLUX
|
||||
// ============================================================
|
||||
|
||||
async function testFeed() {
|
||||
const url = document.getElementById('feed-url').value.trim();
|
||||
const passkey = document.getElementById('feed-passkey').value.trim();
|
||||
const use_flaresolverr = document.getElementById('feed-flaresolverr').checked;
|
||||
const cookies = document.getElementById('feed-cookies').value.trim();
|
||||
|
||||
if (!url) {
|
||||
showError('Veuillez entrer une URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('test-result');
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.innerHTML = '<p class="loading">🔄 Test en cours...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/rss/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, passkey, use_flaresolverr, cookies })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.count > 0) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="test-success">
|
||||
<h4>✅ Test réussi ! ${data.count} résultats trouvés</h4>
|
||||
<div class="test-samples">
|
||||
${data.sample.map(item => `
|
||||
<div class="test-item">
|
||||
<span class="test-title">${escapeHtml(item.Title)}</span>
|
||||
<span class="test-meta">${item.SizeFormatted} • ${item.Seeders} seeders</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="test-error">
|
||||
<h4>❌ Aucun résultat trouvé</h4>
|
||||
<p>Vérifiez l'URL et les cookies. Si erreur 403, activez Flaresolverr et ajoutez vos cookies.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
resultDiv.innerHTML = `
|
||||
<div class="test-error">
|
||||
<h4>❌ Erreur lors du test</h4>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function testExistingFeed(feedId) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/rss/${feedId}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.count > 0) {
|
||||
alert(`✅ Test réussi !\n${data.count} résultats trouvés\n\nExemple: ${data.sample[0]?.Title || 'N/A'}`);
|
||||
} else {
|
||||
alert('❌ Aucun résultat trouvé.\nVérifiez que le flux est toujours valide.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
alert('❌ Erreur lors du test');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GESTION DES FLUX
|
||||
// ============================================================
|
||||
|
||||
async function toggleFeed(feedId) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/rss/${feedId}/toggle`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
loadFeeds();
|
||||
} else {
|
||||
showError(data.error || 'Erreur lors de la modification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showError('Erreur de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFeed(feedId) {
|
||||
if (!confirm('Supprimer ce flux RSS ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/rss/${feedId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
loadFeeds();
|
||||
showSuccess('Flux RSS supprimé');
|
||||
} else {
|
||||
showError(data.error || 'Erreur lors de la suppression');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showError('Erreur de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
alert('❌ ' + message);
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// Simple alert pour l'instant
|
||||
console.log('✅ ' + message);
|
||||
}
|
||||
629
app/static/js/discover.js
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* Lycostorrent - Page Découvrir (Version simplifiée)
|
||||
* 2 catégories : Films récents / Séries en cours
|
||||
* Avec pré-cache des torrents
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// ÉTAT GLOBAL
|
||||
// ============================================================
|
||||
|
||||
let currentCategory = 'movies';
|
||||
let currentMedia = null;
|
||||
let torrentClientEnabled = false;
|
||||
let cachedData = {}; // Cache local des données
|
||||
|
||||
// ============================================================
|
||||
// INITIALISATION
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTabs();
|
||||
checkTorrentClient();
|
||||
loadCategory('movies');
|
||||
});
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll('.discover-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const category = tab.dataset.category;
|
||||
|
||||
// Mettre à jour l'UI
|
||||
document.querySelectorAll('.discover-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
// Charger la catégorie
|
||||
loadCategory(category);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkTorrentClient() {
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/status');
|
||||
const data = await response.json();
|
||||
torrentClientEnabled = data.success && data.enabled && data.connected;
|
||||
} catch (error) {
|
||||
torrentClientEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT DES DONNÉES
|
||||
// ============================================================
|
||||
|
||||
async function loadCategory(category) {
|
||||
currentCategory = category;
|
||||
|
||||
const grid = document.getElementById('discoverGrid');
|
||||
const loader = document.getElementById('discoverLoader');
|
||||
const empty = document.getElementById('discoverEmpty');
|
||||
|
||||
// Afficher le loader
|
||||
grid.innerHTML = '';
|
||||
loader.classList.remove('hidden');
|
||||
empty.classList.add('hidden');
|
||||
hideCacheInfo();
|
||||
|
||||
// Essayer de charger depuis le cache d'abord
|
||||
try {
|
||||
const cacheResponse = await fetch(`/api/cache/data/discover/${category}`);
|
||||
const cacheData = await cacheResponse.json();
|
||||
|
||||
if (cacheData.success && cacheData.cached && cacheData.data && cacheData.data.length > 0) {
|
||||
loader.classList.add('hidden');
|
||||
cachedData[category] = cacheData.data;
|
||||
const mediaType = category === 'movies' ? 'movie' : 'tv';
|
||||
renderGrid(cacheData.data, mediaType, true);
|
||||
showCacheInfo(cacheData.timestamp);
|
||||
console.log(`📦 Discover ${category} chargé depuis le cache: ${cacheData.data.length} résultats`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Pas de cache disponible, chargement en direct...');
|
||||
}
|
||||
|
||||
// Si pas de cache, charger en direct
|
||||
await loadCategoryLive(category);
|
||||
}
|
||||
|
||||
async function loadCategoryLive(category) {
|
||||
const grid = document.getElementById('discoverGrid');
|
||||
const loader = document.getElementById('discoverLoader');
|
||||
const empty = document.getElementById('discoverEmpty');
|
||||
|
||||
grid.innerHTML = '';
|
||||
loader.classList.remove('hidden');
|
||||
empty.classList.add('hidden');
|
||||
hideCacheInfo();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/discover/${category}`);
|
||||
const data = await response.json();
|
||||
|
||||
loader.classList.add('hidden');
|
||||
|
||||
if (data.success && data.results && data.results.length > 0) {
|
||||
cachedData[category] = data.results;
|
||||
renderGrid(data.results, data.media_type, false);
|
||||
} else {
|
||||
empty.classList.remove('hidden');
|
||||
empty.querySelector('p').textContent = data.error || 'Aucun résultat trouvé';
|
||||
}
|
||||
} catch (error) {
|
||||
loader.classList.add('hidden');
|
||||
empty.classList.remove('hidden');
|
||||
empty.querySelector('p').textContent = 'Erreur de chargement';
|
||||
console.error('Erreur:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrid(results, mediaType, fromCache) {
|
||||
const grid = document.getElementById('discoverGrid');
|
||||
|
||||
grid.innerHTML = results.map((item, index) => {
|
||||
const posterUrl = item.poster_path
|
||||
? `https://image.tmdb.org/t/p/w300${item.poster_path}`
|
||||
: null;
|
||||
|
||||
const title = item.title || item.name;
|
||||
const year = (item.release_date || item.first_air_date || '').substring(0, 4);
|
||||
const rating = item.vote_average ? item.vote_average.toFixed(1) : '--';
|
||||
const type = mediaType === 'movie' ? '🎬' : '📺';
|
||||
|
||||
// Indicateur de torrents disponibles (si depuis le cache)
|
||||
const torrentCount = item.torrent_count || 0;
|
||||
const torrentBadge = fromCache && torrentCount > 0
|
||||
? `<span class="torrent-badge">🧲 ${torrentCount}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="discover-card" onclick="openDetail(${item.id}, '${mediaType}', ${index})">
|
||||
<div class="poster-container">
|
||||
${posterUrl
|
||||
? `<img src="${posterUrl}" alt="${escapeHtml(title)}" class="poster" loading="lazy">`
|
||||
: `<div class="poster-placeholder">${type}</div>`
|
||||
}
|
||||
<span class="rating-badge">⭐ ${rating}</span>
|
||||
${torrentBadge}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-year">${year || 'N/A'}</span>
|
||||
<span class="card-type">${type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Afficher les infos du cache
|
||||
function showCacheInfo(timestamp) {
|
||||
const cacheInfo = document.getElementById('cacheInfo');
|
||||
const cacheTimestampEl = document.getElementById('cacheTimestamp');
|
||||
|
||||
if (cacheInfo && timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.floor((now - date) / 60000);
|
||||
|
||||
let timeAgo;
|
||||
if (diffMinutes < 1) {
|
||||
timeAgo = "à l'instant";
|
||||
} else if (diffMinutes < 60) {
|
||||
timeAgo = `il y a ${diffMinutes} min`;
|
||||
} else {
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
timeAgo = `il y a ${hours}h`;
|
||||
}
|
||||
|
||||
cacheTimestampEl.textContent = timeAgo;
|
||||
cacheInfo.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideCacheInfo() {
|
||||
const cacheInfo = document.getElementById('cacheInfo');
|
||||
if (cacheInfo) {
|
||||
cacheInfo.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLive() {
|
||||
loadCategoryLive(currentCategory);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MODAL DÉTAILS
|
||||
// ============================================================
|
||||
|
||||
async function openDetail(id, mediaType, index) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const listEl = document.getElementById('torrentsList');
|
||||
const loadingEl = document.getElementById('torrentsLoading');
|
||||
const emptyEl = document.getElementById('torrentsEmpty');
|
||||
|
||||
// Réinitialiser
|
||||
listEl.innerHTML = '';
|
||||
loadingEl.classList.add('hidden');
|
||||
emptyEl.classList.add('hidden');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Vérifier si on a les données en cache local (avec détails + torrents pré-chargés)
|
||||
const category = mediaType === 'movie' ? 'movies' : 'tv';
|
||||
const cachedItem = cachedData[category] ? cachedData[category][index] : null;
|
||||
|
||||
// Si les détails sont pré-cachés, on les utilise directement (INSTANTANÉ)
|
||||
if (cachedItem && cachedItem.details_cached) {
|
||||
console.log(`📦 Détails + torrents depuis le cache pour: ${cachedItem.title || cachedItem.name}`);
|
||||
|
||||
currentMedia = cachedItem;
|
||||
currentMedia.media_type = mediaType;
|
||||
|
||||
// Afficher les détails depuis le cache
|
||||
renderDetailFromCache(cachedItem, mediaType);
|
||||
|
||||
// Afficher les torrents depuis le cache
|
||||
if (cachedItem.torrents && cachedItem.torrents.length > 0) {
|
||||
renderTorrents(cachedItem.torrents);
|
||||
} else {
|
||||
emptyEl.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon, fallback : charger depuis l'API (pour les items sans cache)
|
||||
try {
|
||||
const response = await fetch(`/api/discover/detail/${mediaType}/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentMedia = data.detail;
|
||||
currentMedia.media_type = mediaType;
|
||||
renderDetail(data.detail, mediaType);
|
||||
|
||||
// Si on a des torrents pré-cachés, les afficher
|
||||
if (cachedItem && cachedItem.torrents && cachedItem.torrents.length > 0) {
|
||||
renderTorrents(cachedItem.torrents);
|
||||
} else {
|
||||
// Sinon, rechercher en direct
|
||||
searchTorrents(data.detail, mediaType);
|
||||
}
|
||||
} else {
|
||||
closeDetailModal();
|
||||
alert('Erreur lors du chargement des détails');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
closeDetailModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher les détails depuis le cache (nouvelle fonction)
|
||||
function renderDetailFromCache(item, mediaType) {
|
||||
const title = item.title || item.name;
|
||||
const year = (item.release_date || item.first_air_date || '').substring(0, 4);
|
||||
const posterUrl = item.poster_path
|
||||
? `https://image.tmdb.org/t/p/w300${item.poster_path}`
|
||||
: '/static/icons/icon-192x192.png';
|
||||
|
||||
document.getElementById('detailPoster').src = posterUrl;
|
||||
document.getElementById('detailPoster').alt = title;
|
||||
document.getElementById('detailTitle').textContent = title;
|
||||
document.getElementById('detailYear').textContent = year;
|
||||
document.getElementById('detailRating').textContent = `⭐ ${item.vote_average ? item.vote_average.toFixed(1) : '--'}`;
|
||||
document.getElementById('detailOverview').textContent = item.overview || 'Aucune description disponible.';
|
||||
|
||||
// Genres
|
||||
const genresContainer = document.getElementById('detailGenres');
|
||||
if (item.genres && item.genres.length > 0) {
|
||||
genresContainer.innerHTML = item.genres.map(g => `<span>${g.name}</span>`).join('');
|
||||
} else {
|
||||
genresContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Bande-annonce YouTube
|
||||
const trailerSection = document.getElementById('detailTrailer');
|
||||
const trailerFrame = document.getElementById('trailerFrame');
|
||||
|
||||
if (item.trailer_url) {
|
||||
trailerFrame.src = item.trailer_url;
|
||||
trailerSection.classList.remove('hidden');
|
||||
} else {
|
||||
trailerFrame.src = '';
|
||||
trailerSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(detail, mediaType) {
|
||||
const title = detail.title || detail.name;
|
||||
const year = (detail.release_date || detail.first_air_date || '').substring(0, 4);
|
||||
const posterUrl = detail.poster_path
|
||||
? `https://image.tmdb.org/t/p/w300${detail.poster_path}`
|
||||
: '/static/icons/icon-192x192.png';
|
||||
|
||||
document.getElementById('detailPoster').src = posterUrl;
|
||||
document.getElementById('detailPoster').alt = title;
|
||||
document.getElementById('detailTitle').textContent = title;
|
||||
document.getElementById('detailYear').textContent = year;
|
||||
document.getElementById('detailRating').textContent = `⭐ ${detail.vote_average ? detail.vote_average.toFixed(1) : '--'}`;
|
||||
document.getElementById('detailOverview').textContent = detail.overview || 'Aucune description disponible.';
|
||||
|
||||
// Genres
|
||||
const genresContainer = document.getElementById('detailGenres');
|
||||
if (detail.genres && detail.genres.length > 0) {
|
||||
genresContainer.innerHTML = detail.genres.map(g => `<span>${g.name}</span>`).join('');
|
||||
} else {
|
||||
genresContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Bande-annonce YouTube
|
||||
const trailerSection = document.getElementById('detailTrailer');
|
||||
const trailerFrame = document.getElementById('trailerFrame');
|
||||
|
||||
if (detail.trailer_url) {
|
||||
trailerFrame.src = detail.trailer_url;
|
||||
trailerSection.classList.remove('hidden');
|
||||
} else {
|
||||
trailerFrame.src = '';
|
||||
trailerSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
// Arrêter la vidéo YouTube
|
||||
document.getElementById('trailerFrame').src = '';
|
||||
currentMedia = null;
|
||||
}
|
||||
|
||||
// Fermer le modal en cliquant à l'extérieur
|
||||
document.getElementById('detailModal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'detailModal') {
|
||||
closeDetailModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer avec Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDetailModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RECHERCHE DE TORRENTS (fallback si pas en cache)
|
||||
// ============================================================
|
||||
|
||||
async function searchTorrents(detail, mediaType) {
|
||||
const loadingEl = document.getElementById('torrentsLoading');
|
||||
const listEl = document.getElementById('torrentsList');
|
||||
const emptyEl = document.getElementById('torrentsEmpty');
|
||||
|
||||
loadingEl.classList.remove('hidden');
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.classList.add('hidden');
|
||||
|
||||
const title = detail.title || detail.name;
|
||||
const originalTitle = detail.original_title || detail.original_name || '';
|
||||
const year = (detail.release_date || detail.first_air_date || '').substring(0, 4);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/discover/search-torrents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
original_title: originalTitle,
|
||||
year: year,
|
||||
media_type: mediaType,
|
||||
tmdb_id: detail.id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
loadingEl.classList.add('hidden');
|
||||
|
||||
if (data.success && data.results && data.results.length > 0) {
|
||||
renderTorrents(data.results);
|
||||
} else {
|
||||
emptyEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
loadingEl.classList.add('hidden');
|
||||
emptyEl.classList.remove('hidden');
|
||||
console.error('Erreur recherche torrents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTorrents(torrents) {
|
||||
const listEl = document.getElementById('torrentsList');
|
||||
|
||||
listEl.innerHTML = torrents.slice(0, 20).map((torrent, index) => {
|
||||
const size = torrent.Size ? formatSize(torrent.Size) : 'N/A';
|
||||
const seeds = torrent.Seeders || 0;
|
||||
const quality = torrent.parsed?.quality || '';
|
||||
const tracker = torrent.Tracker || torrent.TrackerName || 'Unknown';
|
||||
|
||||
const magnetUrl = torrent.MagnetUri || '';
|
||||
const downloadUrl = torrent.Link || '';
|
||||
const detailsUrl = torrent.Details || torrent.Guid || '';
|
||||
const torrentUrl = magnetUrl || downloadUrl;
|
||||
|
||||
return `
|
||||
<div class="torrent-item">
|
||||
<div class="torrent-info">
|
||||
<div class="torrent-name" title="${escapeHtml(torrent.Title)}">${escapeHtml(torrent.Title)}</div>
|
||||
<div class="torrent-meta">
|
||||
<span class="tracker">📡 ${escapeHtml(tracker)}</span>
|
||||
<span class="size">💾 ${size}</span>
|
||||
<span class="seeds">🌱 ${seeds}</span>
|
||||
${quality ? `<span class="quality">${escapeHtml(quality)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="torrent-actions">
|
||||
${detailsUrl ? `<a href="${detailsUrl}" target="_blank" class="btn-link" title="Voir sur le tracker">🔗</a>` : ''}
|
||||
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
|
||||
${downloadUrl ? `<a href="${downloadUrl}" target="_blank" class="btn-download" title="Télécharger .torrent">⬇️</a>` : ''}
|
||||
${torrentClientEnabled && torrentUrl ?
|
||||
`<button class="btn-send" id="send-btn-${index}" onclick="handleSendToClient('${escapeHtml(torrentUrl)}', 'send-btn-${index}')" title="Envoyer au client">📥</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function handleSendToClient(url, buttonId) {
|
||||
const button = document.getElementById(buttonId);
|
||||
sendToClient(url, button);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ENVOI AU CLIENT TORRENT
|
||||
// ============================================================
|
||||
|
||||
async function sendToClient(url, buttonElement) {
|
||||
if (!url) return;
|
||||
showTorrentOptionsModal(url, buttonElement);
|
||||
}
|
||||
|
||||
async function showTorrentOptionsModal(url, button) {
|
||||
let modal = document.getElementById('torrentOptionsModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'torrentOptionsModal';
|
||||
modal.className = 'torrent-options-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="torrent-options-content">
|
||||
<h3>📥 Options de téléchargement</h3>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentCategory">Catégorie</label>
|
||||
<select id="torrentCategory">
|
||||
<option value="">-- Aucune --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentSavePath">Dossier (optionnel)</label>
|
||||
<input type="text" id="torrentSavePath" placeholder="/chemin/vers/dossier">
|
||||
</div>
|
||||
<div class="torrent-option-group checkbox-group">
|
||||
<input type="checkbox" id="torrentPaused">
|
||||
<label for="torrentPaused">Démarrer en pause</label>
|
||||
</div>
|
||||
<div class="torrent-options-buttons">
|
||||
<button class="btn-cancel" onclick="closeTorrentOptionsModal()">Annuler</button>
|
||||
<button class="btn-confirm" id="confirmTorrentAdd">Envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeTorrentOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
const categorySelect = document.getElementById('torrentCategory');
|
||||
const savePathInput = document.getElementById('torrentSavePath');
|
||||
categorySelect.innerHTML = '<option value="">-- Chargement... --</option>';
|
||||
|
||||
let categoriesWithPaths = {};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/categories');
|
||||
const data = await response.json();
|
||||
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
if (data.success && data.categories) {
|
||||
data.categories.forEach(cat => {
|
||||
categorySelect.innerHTML += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
|
||||
});
|
||||
categoriesWithPaths = data.custom_categories || {};
|
||||
}
|
||||
} catch (error) {
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
}
|
||||
|
||||
categorySelect.onchange = () => {
|
||||
const selectedCat = categorySelect.value;
|
||||
if (selectedCat && categoriesWithPaths[selectedCat]) {
|
||||
savePathInput.value = categoriesWithPaths[selectedCat];
|
||||
} else {
|
||||
savePathInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
savePathInput.value = '';
|
||||
document.getElementById('torrentPaused').checked = false;
|
||||
|
||||
const confirmBtn = document.getElementById('confirmTorrentAdd');
|
||||
confirmBtn.onclick = async () => {
|
||||
const category = document.getElementById('torrentCategory').value;
|
||||
const savePath = document.getElementById('torrentSavePath').value.trim();
|
||||
const paused = document.getElementById('torrentPaused').checked;
|
||||
|
||||
closeTorrentOptionsModal();
|
||||
await doSendToTorrentClient(url, button, category, savePath, paused);
|
||||
};
|
||||
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeTorrentOptionsModal() {
|
||||
const modal = document.getElementById('torrentOptionsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async function doSendToTorrentClient(url, button, category, savePath, paused) {
|
||||
if (button) {
|
||||
button.textContent = '⏳';
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = { url: url };
|
||||
if (category) body.category = category;
|
||||
if (savePath) body.save_path = savePath;
|
||||
if (paused) body.paused = paused;
|
||||
|
||||
const response = await fetch('/api/torrent-client/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (button) {
|
||||
button.textContent = '✅';
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
showToast('Torrent envoyé !', 'success');
|
||||
} else {
|
||||
if (button) {
|
||||
button.textContent = '❌';
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
showToast(data.error || 'Erreur', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (button) {
|
||||
button.textContent = '❌';
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
showToast('Erreur de connexion', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return 'N/A';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
let toast = document.getElementById('toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'toast';
|
||||
toast.className = 'toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type}`;
|
||||
toast.classList.remove('hidden');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
901
app/static/js/latest.js
Normal file
@@ -0,0 +1,901 @@
|
||||
/**
|
||||
* Lycostorrent - Latest Releases
|
||||
* Page des nouveautés avec enrichissement TMDb/Last.fm
|
||||
*/
|
||||
|
||||
// Variables globales
|
||||
let selectedCategory = 'movies';
|
||||
let selectedTrackers = [];
|
||||
let availableTrackers = [];
|
||||
let allResults = [];
|
||||
let selectedYears = ['all']; // Par défaut: tous
|
||||
|
||||
// Images par défaut en base64 (évite les problèmes d'échappement)
|
||||
const DEFAULT_POSTER_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyODAiIGhlaWdodD0iNDIwIj48cmVjdCB3aWR0aD0iMjgwIiBoZWlnaHQ9IjQyMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjE0MCIgeT0iMjAwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNDAiPvCfjqw8L3RleHQ+PHRleHQgeD0iMTQwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCI+Tm8gSW1hZ2U8L3RleHQ+PC9zdmc+';
|
||||
const DEFAULT_COVER_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjIwMCIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNjAiPvCfjrU8L3RleHQ+PHRleHQgeD0iMjAwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCI+Tm8gQ292ZXI8L3RleHQ+PC9zdmc+';
|
||||
const DEFAULT_BACKDROP_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iOTAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzIyMiIvPjwvc3ZnPg==';
|
||||
|
||||
function getDefaultPosterUrl() {
|
||||
return DEFAULT_POSTER_B64;
|
||||
}
|
||||
|
||||
function getDefaultCoverUrl() {
|
||||
return DEFAULT_COVER_B64;
|
||||
}
|
||||
|
||||
function getDefaultBackdropUrl() {
|
||||
return DEFAULT_BACKDROP_B64;
|
||||
}
|
||||
|
||||
// Initialisation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🚀 Page latest.js chargée');
|
||||
initializeApp();
|
||||
});
|
||||
|
||||
function initializeApp() {
|
||||
// Vérifier le client torrent en premier
|
||||
checkTorrentClient();
|
||||
|
||||
loadTrackers();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('toggleTrackers').addEventListener('click', toggleTrackersPanel);
|
||||
document.getElementById('selectAllTrackers').addEventListener('click', selectAllTrackers);
|
||||
document.getElementById('deselectAllTrackers').addEventListener('click', deselectAllTrackers);
|
||||
document.getElementById('loadLatestBtn').addEventListener('click', () => loadLatestReleases(true));
|
||||
|
||||
// Bouton refresh live (dans le header des résultats)
|
||||
document.getElementById('refreshLiveBtn')?.addEventListener('click', () => loadLatestReleases(true));
|
||||
|
||||
// Pastilles d'années
|
||||
document.querySelectorAll('.year-pill').forEach(pill => {
|
||||
pill.addEventListener('click', function() {
|
||||
handleYearPillClick(this);
|
||||
});
|
||||
});
|
||||
|
||||
// Catégories
|
||||
document.querySelectorAll('.category-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
selectCategory(this.dataset.category);
|
||||
});
|
||||
});
|
||||
|
||||
// Modal
|
||||
document.querySelector('.modal-close').addEventListener('click', closeModal);
|
||||
document.getElementById('detailsModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
|
||||
// Gestion erreurs images
|
||||
document.addEventListener('error', function(e) {
|
||||
if (e.target.tagName === 'IMG') {
|
||||
const fallback = e.target.dataset.fallback;
|
||||
if (fallback === 'poster') e.target.src = getDefaultPosterUrl();
|
||||
else if (fallback === 'cover') e.target.src = getDefaultCoverUrl();
|
||||
else if (fallback === 'backdrop') e.target.src = getDefaultBackdropUrl();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Charger depuis le cache au démarrage (après chargement des trackers)
|
||||
setTimeout(() => {
|
||||
loadFromCacheOrLive();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Gestion des pastilles d'années
|
||||
function handleYearPillClick(pill) {
|
||||
const year = pill.dataset.year;
|
||||
|
||||
if (year === 'all') {
|
||||
// Clic sur "Tous" -> désactive tout le reste
|
||||
selectedYears = ['all'];
|
||||
document.querySelectorAll('.year-pill').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
} else {
|
||||
// Clic sur une année spécifique
|
||||
// Retirer "all" s'il était sélectionné
|
||||
if (selectedYears.includes('all')) {
|
||||
selectedYears = [];
|
||||
document.querySelector('.year-pill[data-year="all"]').classList.remove('active');
|
||||
}
|
||||
|
||||
// Toggle l'année cliquée
|
||||
if (selectedYears.includes(year)) {
|
||||
// Désactiver
|
||||
selectedYears = selectedYears.filter(y => y !== year);
|
||||
pill.classList.remove('active');
|
||||
|
||||
// Si plus rien de sélectionné, réactiver "Tous"
|
||||
if (selectedYears.length === 0) {
|
||||
selectedYears = ['all'];
|
||||
document.querySelector('.year-pill[data-year="all"]').classList.add('active');
|
||||
}
|
||||
} else {
|
||||
// Activer
|
||||
selectedYears.push(year);
|
||||
pill.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-filtrer les résultats
|
||||
if (allResults.length > 0) {
|
||||
displayResults(allResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Chargement des trackers (inclut les RSS pour les nouveautés)
|
||||
async function loadTrackers() {
|
||||
try {
|
||||
showLoader(true);
|
||||
const response = await fetch('/api/trackers?include_rss=true');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
availableTrackers = data.trackers;
|
||||
displayTrackers(availableTrackers);
|
||||
} else {
|
||||
showMessage('Erreur lors du chargement des trackers', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement trackers:', error);
|
||||
showMessage('Impossible de charger les trackers', 'error');
|
||||
} finally {
|
||||
showLoader(false);
|
||||
}
|
||||
}
|
||||
|
||||
function displayTrackers(trackers) {
|
||||
const trackersList = document.getElementById('trackersList');
|
||||
|
||||
if (trackers.length === 0) {
|
||||
trackersList.innerHTML = '<p class="no-trackers">Aucun tracker configuré</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Trackers sélectionnés par défaut
|
||||
const defaultTrackers = ['yggtorrent', 'sharewood-api'];
|
||||
|
||||
trackersList.innerHTML = '';
|
||||
|
||||
trackers.forEach(tracker => {
|
||||
const trackerItem = document.createElement('div');
|
||||
trackerItem.className = 'tracker-item';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = `tracker-${tracker.id}`;
|
||||
checkbox.value = tracker.id;
|
||||
checkbox.checked = defaultTrackers.includes(tracker.id.toLowerCase().replace(/\s+/g, '-'));
|
||||
checkbox.addEventListener('change', updateSelectedTrackers);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `tracker-${tracker.id}`;
|
||||
label.textContent = tracker.name;
|
||||
|
||||
// Badge de source
|
||||
let sourceBadge = '';
|
||||
if (tracker.sources && tracker.sources.length > 0) {
|
||||
if (tracker.sources.includes('rss')) {
|
||||
sourceBadge = '<span class="source-badge source-rss" title="Flux RSS">RSS</span>';
|
||||
} else if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) {
|
||||
sourceBadge = '<span class="source-badge source-both" title="Jackett + Prowlarr">J+P</span>';
|
||||
} else if (tracker.sources.includes('jackett')) {
|
||||
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
|
||||
} else if (tracker.sources.includes('prowlarr')) {
|
||||
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
|
||||
}
|
||||
} else if (tracker.source) {
|
||||
if (tracker.source === 'jackett') {
|
||||
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
|
||||
} else if (tracker.source === 'prowlarr') {
|
||||
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
|
||||
}
|
||||
}
|
||||
|
||||
trackerItem.appendChild(checkbox);
|
||||
trackerItem.appendChild(label);
|
||||
|
||||
if (sourceBadge) {
|
||||
const badgeSpan = document.createElement('span');
|
||||
badgeSpan.innerHTML = sourceBadge;
|
||||
trackerItem.appendChild(badgeSpan.firstChild);
|
||||
}
|
||||
|
||||
trackersList.appendChild(trackerItem);
|
||||
});
|
||||
|
||||
updateSelectedTrackers();
|
||||
}
|
||||
|
||||
function updateSelectedTrackers() {
|
||||
selectedTrackers = Array.from(document.querySelectorAll('#trackersList input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
function toggleTrackersPanel() {
|
||||
document.getElementById('trackersPanel').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function selectAllTrackers() {
|
||||
document.querySelectorAll('#trackersList input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||
updateSelectedTrackers();
|
||||
}
|
||||
|
||||
function deselectAllTrackers() {
|
||||
document.querySelectorAll('#trackersList input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
updateSelectedTrackers();
|
||||
}
|
||||
|
||||
function selectCategory(category) {
|
||||
selectedCategory = category;
|
||||
document.querySelectorAll('.category-btn').forEach(btn => btn.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Charger depuis le cache si disponible
|
||||
loadFromCacheOrLive();
|
||||
}
|
||||
|
||||
// Variable pour savoir si on utilise le cache
|
||||
let usingCache = false;
|
||||
|
||||
// Vérifier et charger depuis le cache au démarrage
|
||||
async function loadFromCacheOrLive() {
|
||||
try {
|
||||
// Vérifier si le cache existe pour cette catégorie
|
||||
const response = await fetch(`/api/cache/data/latest/${selectedCategory}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.cached && data.data && data.data.length > 0) {
|
||||
// Afficher les données du cache
|
||||
usingCache = true;
|
||||
allResults = data.data;
|
||||
displayResults(allResults);
|
||||
showCacheInfo(data.timestamp);
|
||||
console.log(`📦 Chargé depuis le cache: ${data.data.length} résultats`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Pas de cache disponible');
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher les infos du cache
|
||||
function showCacheInfo(timestamp) {
|
||||
const cacheInfo = document.getElementById('cacheInfo');
|
||||
const cacheTimestamp = document.getElementById('cacheTimestamp');
|
||||
|
||||
if (cacheInfo && timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.floor((now - date) / 60000);
|
||||
|
||||
let timeAgo;
|
||||
if (diffMinutes < 1) {
|
||||
timeAgo = "à l'instant";
|
||||
} else if (diffMinutes < 60) {
|
||||
timeAgo = `il y a ${diffMinutes} min`;
|
||||
} else {
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
timeAgo = `il y a ${hours}h`;
|
||||
}
|
||||
|
||||
cacheTimestamp.textContent = timeAgo;
|
||||
cacheInfo.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Masquer les infos du cache
|
||||
function hideCacheInfo() {
|
||||
const cacheInfo = document.getElementById('cacheInfo');
|
||||
if (cacheInfo) {
|
||||
cacheInfo.classList.add('hidden');
|
||||
}
|
||||
usingCache = false;
|
||||
}
|
||||
|
||||
// Chargement des dernières sorties (en direct)
|
||||
async function loadLatestReleases(forceRefresh = true) {
|
||||
if (selectedTrackers.length === 0) {
|
||||
showMessage('Veuillez sélectionner au moins un tracker', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = parseInt(document.getElementById('limitSelect').value);
|
||||
|
||||
try {
|
||||
showLoader(true);
|
||||
hideCacheInfo();
|
||||
|
||||
const response = await fetch('/api/latest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
trackers: selectedTrackers,
|
||||
category: selectedCategory,
|
||||
limit: limit
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
allResults = data.results;
|
||||
displayResults(allResults);
|
||||
|
||||
if (allResults.length > 0) {
|
||||
showMessage(`${allResults.length} nouveautés trouvées`, 'success');
|
||||
} else {
|
||||
showMessage('Aucune nouveauté trouvée', 'info');
|
||||
}
|
||||
} else {
|
||||
showMessage(data.error || 'Erreur lors de la récupération', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showMessage('Erreur lors de la récupération des nouveautés', 'error');
|
||||
} finally {
|
||||
showLoader(false);
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(results) {
|
||||
const resultsSection = document.getElementById('latestResults');
|
||||
const resultsGrid = document.getElementById('resultsGrid');
|
||||
const resultsCount = document.getElementById('resultsCount');
|
||||
const yearFiltersSection = document.getElementById('yearFilters');
|
||||
const filterCountSpan = document.getElementById('filterCount');
|
||||
|
||||
// Afficher la section des filtres
|
||||
yearFiltersSection.classList.remove('hidden');
|
||||
|
||||
// Filtrer par années sélectionnées
|
||||
let filteredResults = results;
|
||||
|
||||
if (!selectedYears.includes('all')) {
|
||||
filteredResults = results.filter(result => {
|
||||
const tmdb = result.tmdb || {};
|
||||
const year = tmdb.year ? parseInt(tmdb.year) : null;
|
||||
|
||||
// Si pas d'année TMDb, on garde le résultat (on ne peut pas filtrer)
|
||||
if (!year) return true;
|
||||
|
||||
// Vérifier si l'année correspond à une des années sélectionnées
|
||||
for (const selectedYear of selectedYears) {
|
||||
if (selectedYear === 'old') {
|
||||
// ≤2022
|
||||
if (year <= 2022) return true;
|
||||
} else {
|
||||
// Année spécifique
|
||||
if (year === parseInt(selectedYear)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour le compteur de filtre
|
||||
if (!selectedYears.includes('all')) {
|
||||
const yearsText = selectedYears.map(y => y === 'old' ? '≤2022' : y).join(', ');
|
||||
filterCountSpan.textContent = `(${filteredResults.length}/${results.length})`;
|
||||
} else {
|
||||
filterCountSpan.textContent = '';
|
||||
}
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
resultsSection.classList.remove('hidden');
|
||||
resultsCount.textContent = `0 nouveauté (${results.length} total)`;
|
||||
resultsGrid.innerHTML = '<p class="no-results">Aucun résultat pour les années sélectionnées</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsSection.classList.remove('hidden');
|
||||
|
||||
if (!selectedYears.includes('all')) {
|
||||
resultsCount.textContent = `${filteredResults.length} nouveauté${filteredResults.length > 1 ? 's' : ''} sur ${results.length}`;
|
||||
} else {
|
||||
resultsCount.textContent = `${filteredResults.length} nouveauté${filteredResults.length > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
resultsGrid.innerHTML = '';
|
||||
|
||||
filteredResults.forEach(result => {
|
||||
const card = createCard(result);
|
||||
resultsGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createCard(group) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'release-card';
|
||||
|
||||
const mainTorrent = group.torrents[0];
|
||||
const tmdb = group.tmdb || {};
|
||||
const music = group.music || {};
|
||||
const isMusic = group.is_music || false;
|
||||
const isAnime = group.is_anime || false;
|
||||
|
||||
let title = tmdb.title || music.album || mainTorrent.Title || 'Sans titre';
|
||||
let year = tmdb.year || '';
|
||||
let overview = escapeHtml(tmdb.overview || '');
|
||||
let posterUrl = sanitizeUrl(tmdb.poster_url || music.cover_url) || getDefaultPosterUrl();
|
||||
let torrentUrl = sanitizeUrl(mainTorrent.Details || mainTorrent.Guid) || '';
|
||||
let uniqueId = `result-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
let variantsCount = group.torrents.length;
|
||||
let contentType = '🎬';
|
||||
|
||||
if (isMusic && music.artist) {
|
||||
contentType = '🎵';
|
||||
title = `${music.artist} - ${music.album}`;
|
||||
overview = `
|
||||
<strong>Artiste:</strong> ${escapeHtml(music.artist)}<br>
|
||||
<strong>Album:</strong> ${escapeHtml(music.album)}<br>
|
||||
${music.tags?.length ? `<strong>Genres:</strong> ${escapeHtml(music.tags.join(', '))}` : ''}
|
||||
`;
|
||||
} else if (isAnime) {
|
||||
contentType = '🎌';
|
||||
} else if (tmdb.type === 'tv') {
|
||||
contentType = '📺';
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-poster">
|
||||
<img src="${posterUrl}" alt="${escapeHtml(title)}" class="card-image" data-fallback="poster">
|
||||
<div class="card-type">${contentType} ${isMusic ? 'Musique' : (isAnime ? 'Anime' : (tmdb.type === 'tv' ? 'Série' : 'Film'))}</div>
|
||||
${!isMusic && tmdb.vote_average ? `<div class="card-rating">⭐ ${tmdb.vote_average.toFixed(1)}</div>` : ''}
|
||||
${isMusic && music.listeners ? `<div class="card-rating">👥 ${formatNumber(music.listeners)}</div>` : ''}
|
||||
${variantsCount > 1 ? `<div class="card-variants">📦 ${variantsCount} versions</div>` : ''}
|
||||
<div class="card-seeders">🌱 ${mainTorrent.Seeders || 0}</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-title">${escapeHtml(title)}</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-year">${year}</span>
|
||||
<a href="${torrentUrl}" target="_blank" class="card-tracker-link" title="Voir sur ${escapeHtml(mainTorrent.Tracker)}">${escapeHtml(mainTorrent.Tracker)} 🔗</a>
|
||||
</div>
|
||||
<div class="card-overview">${overview}</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn-details" data-result-id="${uniqueId}">ℹ️ Détails ${variantsCount > 1 ? '(' + variantsCount + ')' : ''}</button>
|
||||
${torrentUrl ? `<a href="${torrentUrl}" target="_blank" class="btn-tracker" title="Page du torrent">🔗</a>` : ''}
|
||||
${mainTorrent.MagnetUri ? `<a href="${mainTorrent.MagnetUri}" class="btn-download-card" title="Magnet">🧲</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.dataset.resultId = uniqueId;
|
||||
card.dataset.resultData = JSON.stringify(group);
|
||||
|
||||
card.querySelector('.btn-details').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
showDetails(this.getAttribute('data-result-id'));
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function showDetails(resultId) {
|
||||
const card = document.querySelector(`[data-result-id="${resultId}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const group = JSON.parse(card.dataset.resultData);
|
||||
const isMusic = group.is_music || false;
|
||||
const isAnime = group.is_anime || false;
|
||||
|
||||
const modal = document.getElementById('detailsModal');
|
||||
const modalBody = document.getElementById('modalBody');
|
||||
|
||||
if (isMusic) {
|
||||
showMusicDetails(group, modalBody);
|
||||
} else {
|
||||
showVideoDetails(group, modalBody, isAnime);
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showMusicDetails(group, modalBody) {
|
||||
const mainTorrent = group.torrents[0];
|
||||
const music = group.music || {};
|
||||
|
||||
const coverUrl = sanitizeUrl(music.cover_url) || '';
|
||||
const artist = music.artist || mainTorrent.Title?.split(' - ')[0] || 'Artiste inconnu';
|
||||
const album = music.album || mainTorrent.Title?.split(' - ')[1] || mainTorrent.Title || 'Album inconnu';
|
||||
const listeners = formatNumber(music.listeners || 0);
|
||||
const playcount = formatNumber(music.playcount || 0);
|
||||
const tags = music.tags || [];
|
||||
const url = music.url || '';
|
||||
|
||||
// Vérifier si on a des infos Last.fm
|
||||
const hasLastFmData = music.artist && music.album;
|
||||
|
||||
// Si pas de cover, utiliser un placeholder
|
||||
const displayCover = coverUrl || 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzMzMyIvPjx0ZXh0IHg9IjIwMCIgeT0iMTkwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjNjY2IiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iNjAiPvCfjrU8L3RleHQ+PHRleHQgeD0iMjAwIiB5PSIyNDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM2NjYiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCI+Tm8gQ292ZXI8L3RleHQ+PC9zdmc+';
|
||||
|
||||
modalBody.innerHTML = `
|
||||
<div class="modal-header music-modal-header">
|
||||
<img src="${displayCover}" alt="Album art" class="modal-album-art" data-fallback="cover">
|
||||
<div class="modal-header-content music-modal-header-content">
|
||||
<h2 class="modal-title">🎵 ${escapeHtml(album)}</h2>
|
||||
<p class="modal-artist">${escapeHtml(artist)}</p>
|
||||
${hasLastFmData ? `
|
||||
<div class="modal-meta music-modal-meta">
|
||||
<span>👥 ${listeners} auditeurs</span>
|
||||
<span>▶️ ${playcount} lectures</span>
|
||||
</div>
|
||||
` : `
|
||||
<div class="modal-meta music-modal-meta">
|
||||
<span class="no-data">ℹ️ Infos Last.fm non disponibles</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body-content">
|
||||
${tags.length > 0 ? `
|
||||
<div class="modal-section">
|
||||
<h3>🏷️ Genres</h3>
|
||||
<div class="tags-cloud">
|
||||
${tags.map(tag => `<span class="tag-item">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${url ? `<p><a href="${url}" target="_blank" class="external-link">🔗 Voir sur Last.fm</a></p>` : ''}
|
||||
<div class="modal-section">
|
||||
<h3>💾 Versions disponibles (${group.torrents.length})</h3>
|
||||
<div class="torrents-list">
|
||||
${createTorrentsTable(group.torrents, true)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function showVideoDetails(group, modalBody, isAnime) {
|
||||
const mainTorrent = group.torrents[0];
|
||||
const tmdb = group.tmdb || {};
|
||||
|
||||
const backdropUrl = tmdb.backdrop_url || tmdb.poster_url || getDefaultBackdropUrl();
|
||||
const title = tmdb.title || mainTorrent.Title;
|
||||
const originalTitle = tmdb.original_title || '';
|
||||
const overview = tmdb.overview || 'Synopsis non disponible';
|
||||
const year = tmdb.year || '';
|
||||
const rating = tmdb.vote_average ? tmdb.vote_average.toFixed(1) : 'N/A';
|
||||
const trailerUrl = tmdb.trailer_url || '';
|
||||
|
||||
let youtubeId = '';
|
||||
if (trailerUrl) {
|
||||
const match = trailerUrl.match(/[?&]v=([^&]+)/);
|
||||
youtubeId = match ? match[1] : '';
|
||||
}
|
||||
|
||||
let modalType = isAnime ? '🎌 Anime' : (tmdb.type === 'tv' ? '📺 Série' : '🎬 Film');
|
||||
|
||||
modalBody.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<img src="${backdropUrl}" alt="" class="modal-backdrop" data-fallback="backdrop">
|
||||
<div class="modal-header-content">
|
||||
<h2 class="modal-title">${escapeHtml(title)}</h2>
|
||||
${originalTitle && originalTitle !== title ? `<p style="opacity: 0.8;">${escapeHtml(originalTitle)}</p>` : ''}
|
||||
<div class="modal-meta">
|
||||
<span>${year}</span>
|
||||
${tmdb.vote_average ? `<span class="modal-rating">⭐ ${rating}/10</span>` : ''}
|
||||
<span>${modalType}</span>
|
||||
<span>📦 ${group.torrents.length} version(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body-content">
|
||||
<div class="modal-section">
|
||||
<h3>📖 Synopsis</h3>
|
||||
<p class="modal-overview">${escapeHtml(overview)}</p>
|
||||
</div>
|
||||
${youtubeId ? `
|
||||
<div class="modal-section">
|
||||
<h3>🎬 Bande-annonce</h3>
|
||||
<div class="modal-trailer">
|
||||
<iframe src="https://www.youtube.com/embed/${youtubeId}" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="modal-section">
|
||||
<h3>💾 Versions disponibles (${group.torrents.length})</h3>
|
||||
<div class="torrents-list">
|
||||
${createTorrentsTable(group.torrents, false)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createTorrentsTable(torrents, isMusic) {
|
||||
// Sur mobile on utilise la même structure que discover
|
||||
// Sur desktop on garde la table pour l'alignement
|
||||
|
||||
// Version avec divs (comme discover) - fonctionne partout
|
||||
let html = `<div class="torrents-list-items">`;
|
||||
|
||||
torrents.forEach((torrent, index) => {
|
||||
const quality = extractQuality(torrent.Title);
|
||||
const language = extractLanguage(torrent.Title);
|
||||
const torrentUrl = torrent.Details || torrent.Guid || '';
|
||||
|
||||
html += `
|
||||
<div class="torrent-item ${index === 0 ? 'best-torrent' : ''}">
|
||||
<div class="torrent-info">
|
||||
<div class="torrent-name" title="${escapeHtml(torrent.Title)}">
|
||||
${torrentUrl
|
||||
? `<a href="${torrentUrl}" target="_blank" class="torrent-name-link">${escapeHtml(torrent.Title)}</a>`
|
||||
: escapeHtml(torrent.Title)
|
||||
}
|
||||
</div>
|
||||
<div class="torrent-meta">
|
||||
<span class="tracker">📡 ${escapeHtml(torrent.Tracker)}</span>
|
||||
<span class="size">💾 ${torrent.SizeFormatted || 'N/A'}</span>
|
||||
<span class="seeds">🌱 ${torrent.Seeders || 0}</span>
|
||||
${quality ? `<span class="quality">${quality}</span>` : ''}
|
||||
${language ? `<span class="language">${language}</span>` : ''}
|
||||
${index === 0 ? '<span class="best">👑 Meilleur</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="torrent-actions">
|
||||
${torrentUrl ? `<a href="${torrentUrl}" target="_blank" class="btn-link" title="Page du torrent">🔗</a>` : ''}
|
||||
${torrent.MagnetUri ? `<a href="${torrent.MagnetUri}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
|
||||
${torrent.Link ? `<a href="${torrent.Link}" target="_blank" class="btn-download" title="Télécharger">⬇️</a>` : ''}
|
||||
${torrentClientEnabled && (torrent.MagnetUri || (torrentClientSupportsTorrentFiles && torrent.Link)) ? `<button class="btn-send" title="Envoyer au client" onclick="sendToTorrentClient('${sanitizeUrl(torrent.MagnetUri || torrent.Link)}', this)">📥</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function extractQuality(title) {
|
||||
const qualities = ['2160p', '4K', '1080p', '720p', '480p'];
|
||||
for (const q of qualities) {
|
||||
if (title.toLowerCase().includes(q.toLowerCase())) return q;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractLanguage(title) {
|
||||
const languages = { 'FRENCH': 'VF', 'TRUEFRENCH': 'VFF', 'VFF': 'VFF', 'VOSTFR': 'VOSTFR', 'MULTI': 'MULTI' };
|
||||
const upper = title.toUpperCase();
|
||||
for (const [key, val] of Object.entries(languages)) {
|
||||
if (upper.includes(key)) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
if (!num) return '0';
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailsModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showLoader(show) {
|
||||
document.getElementById('loader').classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messageBox = document.getElementById('messageBox');
|
||||
messageBox.textContent = message;
|
||||
messageBox.className = `message-box ${type}`;
|
||||
messageBox.classList.remove('hidden');
|
||||
setTimeout(() => messageBox.classList.add('hidden'), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
if (!url) return '';
|
||||
|
||||
// Autoriser uniquement http, https, et magnet
|
||||
const allowedProtocols = ['http:', 'https:', 'magnet:'];
|
||||
|
||||
try {
|
||||
// Pour les URLs magnet, vérifier le préfixe
|
||||
if (url.startsWith('magnet:')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (!allowedProtocols.includes(parsed.protocol)) {
|
||||
console.warn('URL avec protocole non autorisé:', parsed.protocol);
|
||||
return '';
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
// Si ce n'est pas une URL valide, retourner vide
|
||||
console.warn('URL invalide:', url);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CLIENT TORRENT
|
||||
// ============================================================
|
||||
|
||||
let torrentClientEnabled = false;
|
||||
let torrentClientSupportsTorrentFiles = false;
|
||||
|
||||
async function checkTorrentClient() {
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/status');
|
||||
const data = await response.json();
|
||||
torrentClientEnabled = data.success && data.enabled && data.connected;
|
||||
// Par défaut true si non spécifié (qBittorrent supporte les .torrent)
|
||||
torrentClientSupportsTorrentFiles = data.supportsTorrentFiles !== false;
|
||||
console.log('🔌 Client torrent:', torrentClientEnabled ? 'connecté' : 'non connecté',
|
||||
'| Supporte .torrent:', torrentClientSupportsTorrentFiles);
|
||||
} catch (error) {
|
||||
torrentClientEnabled = false;
|
||||
torrentClientSupportsTorrentFiles = false;
|
||||
console.log('🔌 Client torrent: erreur de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToTorrentClient(url, button) {
|
||||
if (!url) {
|
||||
showMessage('Aucun lien disponible', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher le modal de sélection
|
||||
showTorrentOptionsModal(url, button);
|
||||
}
|
||||
|
||||
async function showTorrentOptionsModal(url, button) {
|
||||
// Créer le modal s'il n'existe pas
|
||||
let modal = document.getElementById('torrentOptionsModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'torrentOptionsModal';
|
||||
modal.className = 'torrent-options-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="torrent-options-content">
|
||||
<h3>📥 Options de téléchargement</h3>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentCategory">Catégorie</label>
|
||||
<select id="torrentCategory">
|
||||
<option value="">-- Aucune --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentSavePath">Dossier (optionnel)</label>
|
||||
<input type="text" id="torrentSavePath" placeholder="/chemin/vers/dossier">
|
||||
</div>
|
||||
<div class="torrent-option-group checkbox-group">
|
||||
<input type="checkbox" id="torrentPaused">
|
||||
<label for="torrentPaused">Démarrer en pause</label>
|
||||
</div>
|
||||
<div class="torrent-options-buttons">
|
||||
<button class="btn-cancel" onclick="closeTorrentOptionsModal()">Annuler</button>
|
||||
<button class="btn-confirm" id="confirmTorrentAdd">Envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Fermer en cliquant à l'extérieur
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeTorrentOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Charger les catégories
|
||||
const categorySelect = document.getElementById('torrentCategory');
|
||||
const savePathInput = document.getElementById('torrentSavePath');
|
||||
categorySelect.innerHTML = '<option value="">-- Chargement... --</option>';
|
||||
|
||||
let categoriesWithPaths = {};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/categories');
|
||||
const data = await response.json();
|
||||
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
if (data.success && data.categories) {
|
||||
data.categories.forEach(cat => {
|
||||
categorySelect.innerHTML += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
|
||||
});
|
||||
// Stocker les chemins personnalisés
|
||||
categoriesWithPaths = data.custom_categories || {};
|
||||
}
|
||||
} catch (error) {
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
}
|
||||
|
||||
// Auto-remplir le chemin quand on sélectionne une catégorie
|
||||
categorySelect.onchange = () => {
|
||||
const selectedCat = categorySelect.value;
|
||||
if (selectedCat && categoriesWithPaths[selectedCat]) {
|
||||
savePathInput.value = categoriesWithPaths[selectedCat];
|
||||
} else {
|
||||
savePathInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Reset les champs
|
||||
savePathInput.value = '';
|
||||
document.getElementById('torrentPaused').checked = false;
|
||||
|
||||
// Configurer le bouton de confirmation
|
||||
const confirmBtn = document.getElementById('confirmTorrentAdd');
|
||||
confirmBtn.onclick = async () => {
|
||||
const category = document.getElementById('torrentCategory').value;
|
||||
const savePath = document.getElementById('torrentSavePath').value.trim();
|
||||
const paused = document.getElementById('torrentPaused').checked;
|
||||
|
||||
closeTorrentOptionsModal();
|
||||
await doSendToTorrentClient(url, button, category, savePath, paused);
|
||||
};
|
||||
|
||||
// Afficher le modal
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeTorrentOptionsModal() {
|
||||
const modal = document.getElementById('torrentOptionsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async function doSendToTorrentClient(url, button, category, savePath, paused) {
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '⏳';
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const body = { url: url };
|
||||
if (category) body.category = category;
|
||||
if (savePath) body.save_path = savePath;
|
||||
if (paused) body.paused = paused;
|
||||
|
||||
const response = await fetch('/api/torrent-client/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
button.textContent = '✅';
|
||||
showMessage('Torrent envoyé !', 'success');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
button.textContent = '❌';
|
||||
showMessage(data.error || 'Erreur', 'error');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
button.textContent = '❌';
|
||||
showMessage('Erreur de connexion', 'error');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier le client torrent au chargement
|
||||
checkTorrentClient();
|
||||
57
app/static/js/nav.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Lycostorrent - Navigation dynamique
|
||||
* Génère la navigation en fonction des modules activés
|
||||
*/
|
||||
|
||||
(async function() {
|
||||
const nav = document.getElementById('mainNav');
|
||||
if (!nav) return;
|
||||
|
||||
// Déterminer la page actuelle
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/modules');
|
||||
const data = await response.json();
|
||||
|
||||
const modules = data.success ? data.modules : { search: true, latest: true, discover: false };
|
||||
|
||||
let navHTML = '';
|
||||
|
||||
// Module Recherche
|
||||
if (modules.search !== false) {
|
||||
const isActive = currentPath === '/' || currentPath === '/index' ? 'active' : '';
|
||||
navHTML += `<a href="/" class="${isActive}">🔍 Recherche</a>`;
|
||||
}
|
||||
|
||||
// Module Nouveautés
|
||||
if (modules.latest !== false) {
|
||||
const isActive = currentPath === '/latest' ? 'active' : '';
|
||||
navHTML += `<a href="/latest" class="${isActive}">🎬 Nouveautés</a>`;
|
||||
}
|
||||
|
||||
// Module Découvrir
|
||||
if (modules.discover === true) {
|
||||
const isActive = currentPath === '/discover' ? 'active' : '';
|
||||
navHTML += `<a href="/discover" class="${isActive}">🌟 Découvrir</a>`;
|
||||
}
|
||||
|
||||
// Admin toujours visible
|
||||
const isAdminActive = currentPath === '/admin' ? 'active' : '';
|
||||
navHTML += `<a href="/admin" class="${isAdminActive}">⚙️ Admin</a>`;
|
||||
|
||||
// Déconnexion
|
||||
navHTML += `<a href="/logout" class="nav-logout" title="Déconnexion">🚪</a>`;
|
||||
|
||||
nav.innerHTML = navHTML;
|
||||
|
||||
} catch (error) {
|
||||
// Fallback si erreur
|
||||
nav.innerHTML = `
|
||||
<a href="/" class="${currentPath === '/' ? 'active' : ''}">🔍 Recherche</a>
|
||||
<a href="/latest" class="${currentPath === '/latest' ? 'active' : ''}">🎬 Nouveautés</a>
|
||||
<a href="/admin" class="${currentPath === '/admin' ? 'active' : ''}">⚙️ Admin</a>
|
||||
<a href="/logout" class="nav-logout" title="Déconnexion">🚪</a>
|
||||
`;
|
||||
}
|
||||
})();
|
||||
985
app/static/js/search.js
Normal file
@@ -0,0 +1,985 @@
|
||||
/**
|
||||
* Lycostorrent - Search & Filter
|
||||
* Filtrage, tri et pagination 100% côté client
|
||||
*/
|
||||
|
||||
// État global
|
||||
let allResults = []; // Tous les résultats de la recherche
|
||||
let filteredResults = []; // Résultats après filtrage
|
||||
let activeFilters = {}; // Filtres actifs { quality: ['1080p'], language: ['FRENCH', 'MULTI'], ... }
|
||||
let availableFilters = {}; // Filtres disponibles extraits des résultats
|
||||
|
||||
// Pagination
|
||||
let currentPage = 1;
|
||||
const RESULTS_PER_PAGE = 50;
|
||||
|
||||
// Tri
|
||||
let currentSort = { field: 'Seeders', order: 'desc' };
|
||||
|
||||
// Configuration des filtres (chargée dynamiquement)
|
||||
let FILTER_CONFIG = {
|
||||
// Fallback si l'API ne répond pas
|
||||
Tracker: { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// INITIALISATION
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadFiltersConfig(); // Charger les filtres depuis l'API
|
||||
loadTrackers();
|
||||
setupEventListeners();
|
||||
|
||||
// Re-render lors du changement de taille de fenêtre
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (filteredResults.length > 0) {
|
||||
renderResults();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// Recherche au clic ou Entrée
|
||||
document.getElementById('search-btn').addEventListener('click', performSearch);
|
||||
document.getElementById('search-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') performSearch();
|
||||
});
|
||||
|
||||
// Effacer les filtres
|
||||
document.getElementById('clear-filters').addEventListener('click', clearAllFilters);
|
||||
|
||||
// Toggle panel trackers
|
||||
document.getElementById('toggleTrackers').addEventListener('click', () => {
|
||||
const panel = document.getElementById('trackersPanel');
|
||||
panel.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Tout sélectionner / désélectionner
|
||||
document.getElementById('selectAllTrackers').addEventListener('click', () => {
|
||||
document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = true);
|
||||
});
|
||||
|
||||
document.getElementById('deselectAllTrackers').addEventListener('click', () => {
|
||||
document.querySelectorAll('#trackers-list input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||
});
|
||||
|
||||
// Toggle filtres
|
||||
document.getElementById('toggle-filters')?.addEventListener('click', () => {
|
||||
const btn = document.getElementById('toggle-filters');
|
||||
const content = document.getElementById('filters-content');
|
||||
btn.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
btn.textContent = content.classList.contains('collapsed') ? '▶' : '▼';
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT DE LA CONFIG DES FILTRES
|
||||
// ============================================================
|
||||
|
||||
async function loadFiltersConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/filters');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.filters) {
|
||||
// Construire FILTER_CONFIG depuis l'API
|
||||
FILTER_CONFIG = {};
|
||||
let order = 1;
|
||||
|
||||
for (const [key, filter] of Object.entries(data.filters)) {
|
||||
FILTER_CONFIG[key] = {
|
||||
name: filter.name || key,
|
||||
icon: filter.icon || '🏷️',
|
||||
order: order++
|
||||
};
|
||||
}
|
||||
|
||||
// Toujours ajouter Tracker à la fin
|
||||
FILTER_CONFIG['Tracker'] = { name: 'Tracker', icon: '🌐', order: 999, fromRoot: true };
|
||||
|
||||
console.log('✅ Filtres chargés:', Object.keys(FILTER_CONFIG).length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement config filtres:', error);
|
||||
// Garder le fallback par défaut
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHARGEMENT DES TRACKERS
|
||||
// ============================================================
|
||||
|
||||
async function loadTrackers() {
|
||||
try {
|
||||
const response = await fetch('/api/trackers');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.trackers) {
|
||||
renderTrackers(data.trackers);
|
||||
} else {
|
||||
showError('Impossible de charger les trackers');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement trackers:', error);
|
||||
showError('Erreur de connexion au serveur');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrackers(trackers) {
|
||||
const container = document.getElementById('trackers-list');
|
||||
|
||||
if (trackers.length === 0) {
|
||||
container.innerHTML = '<p class="no-trackers">Aucun tracker configuré</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = trackers.map(tracker => {
|
||||
// Créer le badge de source
|
||||
let sourceBadge = '';
|
||||
if (tracker.sources && tracker.sources.length > 0) {
|
||||
if (tracker.sources.includes('jackett') && tracker.sources.includes('prowlarr')) {
|
||||
sourceBadge = '<span class="source-badge source-both" title="Jackett + Prowlarr">J+P</span>';
|
||||
} else if (tracker.sources.includes('jackett')) {
|
||||
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
|
||||
} else if (tracker.sources.includes('prowlarr')) {
|
||||
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
|
||||
} else if (tracker.sources.includes('rss')) {
|
||||
sourceBadge = '<span class="source-badge source-rss" title="RSS">RSS</span>';
|
||||
}
|
||||
} else {
|
||||
if (tracker.source === 'jackett') {
|
||||
sourceBadge = '<span class="source-badge source-jackett" title="Jackett">J</span>';
|
||||
} else if (tracker.source === 'prowlarr') {
|
||||
sourceBadge = '<span class="source-badge source-prowlarr" title="Prowlarr">P</span>';
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tracker-item">
|
||||
<input type="checkbox" id="tracker-${escapeHtml(tracker.id)}" value="${escapeHtml(tracker.id)}" checked>
|
||||
<label for="tracker-${escapeHtml(tracker.id)}">${escapeHtml(tracker.name)}</label>
|
||||
${sourceBadge}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getSelectedTrackers() {
|
||||
const checkboxes = document.querySelectorAll('#trackers-list input[type="checkbox"]:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RECHERCHE
|
||||
// ============================================================
|
||||
|
||||
async function performSearch() {
|
||||
const query = document.getElementById('search-input').value.trim();
|
||||
const category = document.getElementById('category-select').value;
|
||||
const trackers = getSelectedTrackers();
|
||||
|
||||
// Validation
|
||||
if (!query) {
|
||||
showError('Veuillez entrer une recherche');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackers.length === 0) {
|
||||
showError('Veuillez sélectionner au moins un tracker');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher le loading
|
||||
showLoading(true);
|
||||
|
||||
// Reset
|
||||
activeFilters = {};
|
||||
availableFilters = {};
|
||||
currentPage = 1;
|
||||
currentSort = { field: 'Seeders', order: 'desc' };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, category, trackers })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
allResults = data.results;
|
||||
filteredResults = [...allResults];
|
||||
|
||||
// Trier par seeders par défaut
|
||||
sortResults();
|
||||
|
||||
// Extraire les filtres disponibles depuis les résultats
|
||||
extractAvailableFilters();
|
||||
|
||||
// Afficher les filtres et les résultats
|
||||
renderFilters();
|
||||
renderResults();
|
||||
|
||||
// Afficher la section des filtres
|
||||
document.getElementById('filters-section').classList.remove('hidden');
|
||||
|
||||
} else {
|
||||
showError(data.error || 'Erreur lors de la recherche');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur recherche:', error);
|
||||
showError('Erreur de connexion au serveur');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TRI
|
||||
// ============================================================
|
||||
|
||||
function sortResults() {
|
||||
const { field, order } = currentSort;
|
||||
|
||||
filteredResults.sort((a, b) => {
|
||||
let valA, valB;
|
||||
|
||||
switch (field) {
|
||||
case 'Title':
|
||||
valA = (a.Title || '').toLowerCase();
|
||||
valB = (b.Title || '').toLowerCase();
|
||||
break;
|
||||
case 'Tracker':
|
||||
valA = (a.Tracker || '').toLowerCase();
|
||||
valB = (b.Tracker || '').toLowerCase();
|
||||
break;
|
||||
case 'Size':
|
||||
valA = a.Size || 0;
|
||||
valB = b.Size || 0;
|
||||
break;
|
||||
case 'Seeders':
|
||||
valA = a.Seeders || 0;
|
||||
valB = b.Seeders || 0;
|
||||
break;
|
||||
case 'PublishDate':
|
||||
valA = a.PublishDateRaw || '';
|
||||
valB = b.PublishDateRaw || '';
|
||||
break;
|
||||
default:
|
||||
valA = a[field] || 0;
|
||||
valB = b[field] || 0;
|
||||
}
|
||||
|
||||
if (valA < valB) return order === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function onSortChange(field) {
|
||||
if (currentSort.field === field) {
|
||||
// Inverser l'ordre si on clique sur la même colonne
|
||||
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.field = field;
|
||||
// Ordre par défaut selon le champ
|
||||
currentSort.order = (field === 'Title' || field === 'Tracker') ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
sortResults();
|
||||
renderResults();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXTRACTION DES FILTRES DISPONIBLES
|
||||
// ============================================================
|
||||
|
||||
function extractAvailableFilters() {
|
||||
availableFilters = {};
|
||||
|
||||
for (const torrent of allResults) {
|
||||
const parsed = torrent.parsed || {};
|
||||
|
||||
for (const [key, config] of Object.entries(FILTER_CONFIG)) {
|
||||
if (!availableFilters[key]) {
|
||||
availableFilters[key] = {};
|
||||
}
|
||||
|
||||
let values;
|
||||
if (config.fromRoot) {
|
||||
// Valeur directement sur le torrent (ex: Tracker)
|
||||
values = torrent[key] ? [torrent[key]] : [];
|
||||
} else {
|
||||
// Valeur dans parsed
|
||||
values = parsed[key] || [];
|
||||
}
|
||||
|
||||
const valueArray = Array.isArray(values) ? values : [values];
|
||||
for (const value of valueArray) {
|
||||
if (value) {
|
||||
availableFilters[key][value] = (availableFilters[key][value] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Filtres disponibles:', availableFilters);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDU DES FILTRES
|
||||
// ============================================================
|
||||
|
||||
function renderFilters() {
|
||||
const container = document.getElementById('filters-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Trier les filtres par ordre défini
|
||||
const sortedFilters = Object.keys(availableFilters)
|
||||
.filter(key => Object.keys(availableFilters[key]).length > 0)
|
||||
.sort((a, b) => (FILTER_CONFIG[a]?.order || 99) - (FILTER_CONFIG[b]?.order || 99));
|
||||
|
||||
for (const filterKey of sortedFilters) {
|
||||
const filterConfig = FILTER_CONFIG[filterKey];
|
||||
const values = availableFilters[filterKey];
|
||||
|
||||
// Trier les valeurs par nombre d'occurrences
|
||||
const sortedValues = Object.entries(values)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
const filterHTML = `
|
||||
<div class="filter-group" data-filter="${filterKey}">
|
||||
<h4>${filterConfig.icon} ${filterConfig.name}</h4>
|
||||
<div class="filter-values">
|
||||
${sortedValues.map(([value, count]) => `
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-filter="${filterKey}"
|
||||
data-value="${escapeHtml(value)}"
|
||||
${isFilterActive(filterKey, value) ? 'checked' : ''}
|
||||
>
|
||||
<span class="filter-label">${escapeHtml(value)}</span>
|
||||
<span class="filter-count">(${count})</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML += filterHTML;
|
||||
}
|
||||
|
||||
// Ajouter les event listeners sur les checkboxes
|
||||
container.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', onFilterChange);
|
||||
});
|
||||
|
||||
updateResultsCount();
|
||||
}
|
||||
|
||||
function isFilterActive(filterKey, value) {
|
||||
return activeFilters[filterKey]?.includes(value) || false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GESTION DES FILTRES
|
||||
// ============================================================
|
||||
|
||||
function onFilterChange(event) {
|
||||
const checkbox = event.target;
|
||||
const filterKey = checkbox.dataset.filter;
|
||||
const value = checkbox.dataset.value;
|
||||
|
||||
if (!activeFilters[filterKey]) {
|
||||
activeFilters[filterKey] = [];
|
||||
}
|
||||
|
||||
if (checkbox.checked) {
|
||||
if (!activeFilters[filterKey].includes(value)) {
|
||||
activeFilters[filterKey].push(value);
|
||||
}
|
||||
} else {
|
||||
activeFilters[filterKey] = activeFilters[filterKey].filter(v => v !== value);
|
||||
if (activeFilters[filterKey].length === 0) {
|
||||
delete activeFilters[filterKey];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Filtres actifs:', activeFilters);
|
||||
|
||||
// Appliquer les filtres
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
// Reset pagination
|
||||
currentPage = 1;
|
||||
|
||||
if (Object.keys(activeFilters).length === 0) {
|
||||
filteredResults = [...allResults];
|
||||
} else {
|
||||
filteredResults = allResults.filter(torrent => {
|
||||
const parsed = torrent.parsed || {};
|
||||
|
||||
for (const [filterKey, selectedValues] of Object.entries(activeFilters)) {
|
||||
if (selectedValues.length === 0) continue;
|
||||
|
||||
let torrentValues;
|
||||
if (FILTER_CONFIG[filterKey]?.fromRoot) {
|
||||
torrentValues = torrent[filterKey] ? [torrent[filterKey]] : [];
|
||||
} else {
|
||||
torrentValues = parsed[filterKey] || [];
|
||||
}
|
||||
|
||||
const torrentValuesArray = Array.isArray(torrentValues) ? torrentValues : [torrentValues];
|
||||
const hasMatch = selectedValues.some(val => torrentValuesArray.includes(val));
|
||||
|
||||
if (!hasMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Réappliquer le tri
|
||||
sortResults();
|
||||
renderResults();
|
||||
updateResultsCount();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
activeFilters = {};
|
||||
currentPage = 1;
|
||||
|
||||
document.querySelectorAll('#filters-container input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
|
||||
filteredResults = [...allResults];
|
||||
sortResults();
|
||||
renderResults();
|
||||
updateResultsCount();
|
||||
}
|
||||
|
||||
function updateResultsCount() {
|
||||
const countEl = document.getElementById('results-count');
|
||||
const total = allResults.length;
|
||||
const filtered = filteredResults.length;
|
||||
|
||||
if (total === filtered) {
|
||||
countEl.textContent = `(${total} résultats)`;
|
||||
} else {
|
||||
countEl.textContent = `(${filtered} / ${total} résultats)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PAGINATION
|
||||
// ============================================================
|
||||
|
||||
function getTotalPages() {
|
||||
return Math.ceil(filteredResults.length / RESULTS_PER_PAGE);
|
||||
}
|
||||
|
||||
function getPageResults() {
|
||||
const start = (currentPage - 1) * RESULTS_PER_PAGE;
|
||||
const end = start + RESULTS_PER_PAGE;
|
||||
return filteredResults.slice(start, end);
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
const totalPages = getTotalPages();
|
||||
if (page < 1 || page > totalPages) return;
|
||||
|
||||
currentPage = page;
|
||||
renderResults();
|
||||
|
||||
// Scroll vers le haut des résultats
|
||||
document.getElementById('results-section').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const totalPages = getTotalPages();
|
||||
if (totalPages <= 1) return '';
|
||||
|
||||
const pages = [];
|
||||
const maxVisiblePages = 7;
|
||||
|
||||
// Toujours afficher la première page
|
||||
pages.push(1);
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
for (let i = 2; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Logique pour afficher les pages autour de la page courante
|
||||
let start = Math.max(2, currentPage - 2);
|
||||
let end = Math.min(totalPages - 1, currentPage + 2);
|
||||
|
||||
if (currentPage <= 3) {
|
||||
end = 5;
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
start = totalPages - 4;
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (end < totalPages - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
const startResult = (currentPage - 1) * RESULTS_PER_PAGE + 1;
|
||||
const endResult = Math.min(currentPage * RESULTS_PER_PAGE, filteredResults.length);
|
||||
|
||||
return `
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Résultats ${startResult} - ${endResult} sur ${filteredResults.length}
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="pagination-btn" onclick="goToPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
|
||||
← Précédent
|
||||
</button>
|
||||
${pages.map(page => {
|
||||
if (page === '...') {
|
||||
return '<span class="pagination-ellipsis">...</span>';
|
||||
}
|
||||
return `<button class="pagination-btn ${page === currentPage ? 'active' : ''}" onclick="goToPage(${page})">${page}</button>`;
|
||||
}).join('')}
|
||||
<button class="pagination-btn" onclick="goToPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDU DES RÉSULTATS
|
||||
// ============================================================
|
||||
|
||||
function renderResults() {
|
||||
const container = document.getElementById('results-container');
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
if (allResults.length === 0) {
|
||||
container.innerHTML = '<p class="no-results">Aucun résultat trouvé</p>';
|
||||
} else {
|
||||
container.innerHTML = '<p class="no-results">Aucun résultat ne correspond aux filtres sélectionnés</p>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pageResults = getPageResults();
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (isMobile) {
|
||||
// Mode cards pour mobile
|
||||
container.innerHTML = `
|
||||
${renderPagination()}
|
||||
<div class="results-cards">
|
||||
${pageResults.map(torrent => renderTorrentCard(torrent)).join('')}
|
||||
</div>
|
||||
${renderPagination()}
|
||||
`;
|
||||
} else {
|
||||
// Mode table pour desktop
|
||||
container.innerHTML = `
|
||||
${renderPagination()}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name sortable" onclick="onSortChange('Title')">
|
||||
Nom ${getSortIcon('Title')}
|
||||
</th>
|
||||
<th class="col-tracker sortable" onclick="onSortChange('Tracker')">
|
||||
Tracker ${getSortIcon('Tracker')}
|
||||
</th>
|
||||
<th class="col-size sortable" onclick="onSortChange('Size')">
|
||||
Taille ${getSortIcon('Size')}
|
||||
</th>
|
||||
<th class="col-seeders sortable" onclick="onSortChange('Seeders')">
|
||||
Seeders ${getSortIcon('Seeders')}
|
||||
</th>
|
||||
<th class="col-date sortable" onclick="onSortChange('PublishDate')">
|
||||
Date ${getSortIcon('PublishDate')}
|
||||
</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${pageResults.map(torrent => renderTorrentRow(torrent)).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
${renderPagination()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTorrentCard(torrent) {
|
||||
const parsed = torrent.parsed || {};
|
||||
|
||||
const badges = [];
|
||||
if (parsed.quality?.length) {
|
||||
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
|
||||
}
|
||||
if (parsed.source?.length) {
|
||||
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
|
||||
}
|
||||
if (parsed.language?.length) {
|
||||
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
|
||||
}
|
||||
|
||||
const seedersClass = getSeedersClass(torrent.Seeders);
|
||||
|
||||
// Sanitize URLs
|
||||
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
|
||||
const downloadUrl = sanitizeUrl(torrent.Link);
|
||||
const detailsUrl = sanitizeUrl(torrent.Details);
|
||||
|
||||
return `
|
||||
<div class="result-card-mobile">
|
||||
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
|
||||
<div class="torrent-badges">${badges.join('')}</div>
|
||||
<div class="result-meta">
|
||||
<span>📁 ${escapeHtml(torrent.SizeFormatted || 'N/A')}</span>
|
||||
<span class="${seedersClass}">🌱 ${parseInt(torrent.Seeders) || 0}</span>
|
||||
<span>🏷️ ${escapeHtml(torrent.Tracker)}</span>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet-mobile" title="Magnet">🧲</a>` : ''}
|
||||
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download-mobile" title="Télécharger" target="_blank">⬇️</a>` : ''}
|
||||
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details-mobile" title="Détails" target="_blank">🔗</a>` : ''}
|
||||
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client-mobile" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getSortIcon(field) {
|
||||
if (currentSort.field !== field) {
|
||||
return '<span class="sort-icon">⇅</span>';
|
||||
}
|
||||
return currentSort.order === 'asc'
|
||||
? '<span class="sort-icon active">↑</span>'
|
||||
: '<span class="sort-icon active">↓</span>';
|
||||
}
|
||||
|
||||
function renderTorrentRow(torrent) {
|
||||
const parsed = torrent.parsed || {};
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (parsed.quality?.length) {
|
||||
badges.push(...parsed.quality.map(q => `<span class="badge badge-quality">${escapeHtml(q)}</span>`));
|
||||
}
|
||||
if (parsed.source?.length) {
|
||||
badges.push(...parsed.source.map(s => `<span class="badge badge-source">${escapeHtml(s)}</span>`));
|
||||
}
|
||||
if (parsed.video_codec?.length) {
|
||||
badges.push(...parsed.video_codec.map(c => `<span class="badge badge-codec">${escapeHtml(c)}</span>`));
|
||||
}
|
||||
if (parsed.language?.length) {
|
||||
badges.push(...parsed.language.map(l => `<span class="badge badge-language">${escapeHtml(l)}</span>`));
|
||||
}
|
||||
if (parsed.hdr?.length) {
|
||||
badges.push(...parsed.hdr.map(h => `<span class="badge badge-hdr">${escapeHtml(h)}</span>`));
|
||||
}
|
||||
|
||||
const seedersClass = getSeedersClass(torrent.Seeders);
|
||||
|
||||
// Sanitize URLs
|
||||
const magnetUrl = sanitizeUrl(torrent.MagnetUri);
|
||||
const downloadUrl = sanitizeUrl(torrent.Link);
|
||||
const detailsUrl = sanitizeUrl(torrent.Details);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="col-name">
|
||||
<div class="torrent-title">${escapeHtml(torrent.Title)}</div>
|
||||
<div class="torrent-badges">${badges.join('')}</div>
|
||||
</td>
|
||||
<td class="col-tracker">${escapeHtml(torrent.Tracker)}</td>
|
||||
<td class="col-size">${escapeHtml(torrent.SizeFormatted || 'N/A')}</td>
|
||||
<td class="col-seeders ${seedersClass}">${parseInt(torrent.Seeders) || 0}</td>
|
||||
<td class="col-date">${escapeHtml(torrent.PublishDate || 'N/A')}</td>
|
||||
<td class="col-actions">
|
||||
${magnetUrl ? `<a href="${magnetUrl}" class="btn-magnet" title="Magnet">🧲</a>` : ''}
|
||||
${downloadUrl ? `<a href="${downloadUrl}" class="btn-download" title="Télécharger" target="_blank">⬇️</a>` : ''}
|
||||
${detailsUrl ? `<a href="${detailsUrl}" class="btn-details" title="Détails" target="_blank">🔗</a>` : ''}
|
||||
${torrentClientEnabled && (magnetUrl || (torrentClientSupportsTorrentFiles && downloadUrl)) ? `<button class="btn-send-client" title="Envoyer au client" onclick="sendToTorrentClient('${magnetUrl || downloadUrl}', this)">📥</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function getSeedersClass(seeders) {
|
||||
if (!seeders || seeders === 0) return 'seeders-none';
|
||||
if (seeders < 5) return 'seeders-low';
|
||||
if (seeders < 20) return 'seeders-medium';
|
||||
return 'seeders-high';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
if (!url) return '';
|
||||
|
||||
// Autoriser uniquement http, https, et magnet
|
||||
const allowedProtocols = ['http:', 'https:', 'magnet:'];
|
||||
|
||||
try {
|
||||
// Pour les URLs magnet, vérifier le préfixe
|
||||
if (url.startsWith('magnet:')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (!allowedProtocols.includes(parsed.protocol)) {
|
||||
console.warn('URL avec protocole non autorisé:', parsed.protocol);
|
||||
return '';
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
// Si ce n'est pas une URL valide, retourner vide
|
||||
console.warn('URL invalide:', url);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (show) {
|
||||
overlay.classList.remove('hidden');
|
||||
} else {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CLIENT TORRENT
|
||||
// ============================================================
|
||||
|
||||
let torrentClientEnabled = false;
|
||||
let torrentClientSupportsTorrentFiles = false;
|
||||
|
||||
async function checkTorrentClient() {
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/status');
|
||||
const data = await response.json();
|
||||
torrentClientEnabled = data.success && data.enabled && data.connected;
|
||||
// Par défaut true si non spécifié (qBittorrent supporte les .torrent)
|
||||
torrentClientSupportsTorrentFiles = data.supportsTorrentFiles !== false;
|
||||
console.log('🔌 Client torrent:', torrentClientEnabled ? 'connecté' : 'non connecté',
|
||||
'| Supporte .torrent:', torrentClientSupportsTorrentFiles);
|
||||
} catch (error) {
|
||||
torrentClientEnabled = false;
|
||||
torrentClientSupportsTorrentFiles = false;
|
||||
console.log('🔌 Client torrent: erreur de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToTorrentClient(url, button) {
|
||||
if (!url) {
|
||||
showToast('Aucun lien disponible', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher le modal de sélection
|
||||
showTorrentOptionsModal(url, button);
|
||||
}
|
||||
|
||||
async function showTorrentOptionsModal(url, button) {
|
||||
// Créer le modal s'il n'existe pas
|
||||
let modal = document.getElementById('torrentOptionsModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'torrentOptionsModal';
|
||||
modal.className = 'torrent-options-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="torrent-options-content">
|
||||
<h3>📥 Options de téléchargement</h3>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentCategory">Catégorie</label>
|
||||
<select id="torrentCategory">
|
||||
<option value="">-- Aucune --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="torrent-option-group">
|
||||
<label for="torrentSavePath">Dossier (optionnel)</label>
|
||||
<input type="text" id="torrentSavePath" placeholder="/chemin/vers/dossier">
|
||||
</div>
|
||||
<div class="torrent-option-group checkbox-group">
|
||||
<input type="checkbox" id="torrentPaused">
|
||||
<label for="torrentPaused">Démarrer en pause</label>
|
||||
</div>
|
||||
<div class="torrent-options-buttons">
|
||||
<button class="btn-cancel" onclick="closeTorrentOptionsModal()">Annuler</button>
|
||||
<button class="btn-confirm" id="confirmTorrentAdd">Envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Fermer en cliquant à l'extérieur
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeTorrentOptionsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Charger les catégories
|
||||
const categorySelect = document.getElementById('torrentCategory');
|
||||
const savePathInput = document.getElementById('torrentSavePath');
|
||||
categorySelect.innerHTML = '<option value="">-- Chargement... --</option>';
|
||||
|
||||
let categoriesWithPaths = {};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/torrent-client/categories');
|
||||
const data = await response.json();
|
||||
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
if (data.success && data.categories) {
|
||||
data.categories.forEach(cat => {
|
||||
categorySelect.innerHTML += `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`;
|
||||
});
|
||||
// Stocker les chemins personnalisés
|
||||
categoriesWithPaths = data.custom_categories || {};
|
||||
}
|
||||
} catch (error) {
|
||||
categorySelect.innerHTML = '<option value="">-- Aucune --</option>';
|
||||
}
|
||||
|
||||
// Auto-remplir le chemin quand on sélectionne une catégorie
|
||||
categorySelect.onchange = () => {
|
||||
const selectedCat = categorySelect.value;
|
||||
if (selectedCat && categoriesWithPaths[selectedCat]) {
|
||||
savePathInput.value = categoriesWithPaths[selectedCat];
|
||||
} else {
|
||||
savePathInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Reset les champs
|
||||
savePathInput.value = '';
|
||||
document.getElementById('torrentPaused').checked = false;
|
||||
|
||||
// Configurer le bouton de confirmation
|
||||
const confirmBtn = document.getElementById('confirmTorrentAdd');
|
||||
confirmBtn.onclick = async () => {
|
||||
const category = document.getElementById('torrentCategory').value;
|
||||
const savePath = document.getElementById('torrentSavePath').value.trim();
|
||||
const paused = document.getElementById('torrentPaused').checked;
|
||||
|
||||
closeTorrentOptionsModal();
|
||||
await doSendToTorrentClient(url, button, category, savePath, paused);
|
||||
};
|
||||
|
||||
// Afficher le modal
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeTorrentOptionsModal() {
|
||||
const modal = document.getElementById('torrentOptionsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async function doSendToTorrentClient(url, button, category, savePath, paused) {
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '⏳';
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const body = { url: url };
|
||||
if (category) body.category = category;
|
||||
if (savePath) body.save_path = savePath;
|
||||
if (paused) body.paused = paused;
|
||||
|
||||
const response = await fetch('/api/torrent-client/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
button.textContent = '✅';
|
||||
showToast('Torrent envoyé !', 'success');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
button.textContent = '❌';
|
||||
showToast(data.error || 'Erreur', 'error');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
button.textContent = '❌';
|
||||
showToast('Erreur de connexion', 'error');
|
||||
setTimeout(() => {
|
||||
button.textContent = '📥';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
// Créer le toast s'il n'existe pas
|
||||
let toast = document.getElementById('toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'toast';
|
||||
toast.className = 'toast hidden';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type}`;
|
||||
|
||||
setTimeout(() => toast.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// Vérifier le client torrent au chargement
|
||||
checkTorrentClient();
|
||||
8
app/static/js/theme-loader.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lycostorrent - Chargement du thème
|
||||
* Ce script doit être chargé en premier pour éviter le flash de thème
|
||||
*/
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('lycostorrent-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
})();
|
||||
77
app/static/manifest.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "Lycostorrent",
|
||||
"short_name": "Lycostorrent",
|
||||
"description": "Recherche de torrents avec enrichissement TMDb",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f1a",
|
||||
"theme_color": "#e63946",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["entertainment", "utilities"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Recherche",
|
||||
"short_name": "Recherche",
|
||||
"description": "Rechercher des torrents",
|
||||
"url": "/",
|
||||
"icons": [{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Nouveautés",
|
||||
"short_name": "Nouveautés",
|
||||
"description": "Voir les dernières sorties",
|
||||
"url": "/latest",
|
||||
"icons": [{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
143
app/static/sw.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Lycostorrent Service Worker
|
||||
const CACHE_NAME = 'lycostorrent-v1';
|
||||
|
||||
// Assets à mettre en cache
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/latest',
|
||||
'/static/css/style.css',
|
||||
'/static/css/latest.css',
|
||||
'/static/js/search.js',
|
||||
'/static/js/latest.js',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Installation - mise en cache des assets statiques
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('🔧 Service Worker: Installation...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('📦 Service Worker: Mise en cache des assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => {
|
||||
// Activer immédiatement sans attendre
|
||||
return self.skipWaiting();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Service Worker: Erreur de cache', error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activation - nettoyage des anciens caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('✅ Service Worker: Activation');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => {
|
||||
console.log('🗑️ Service Worker: Suppression ancien cache', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// Prendre le contrôle immédiatement
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch - stratégie Network First avec fallback cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Ne pas cacher les requêtes API (toujours aller sur le réseau)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Hors ligne' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les assets statiques - Cache First
|
||||
if (url.pathname.startsWith('/static/')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
// Retourner le cache et mettre à jour en arrière-plan
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, response));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Pas en cache, récupérer du réseau
|
||||
return fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, responseClone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les pages HTML - Network First
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Mettre en cache la réponse
|
||||
if (response.ok && event.request.method === 'GET') {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, responseClone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback sur le cache
|
||||
return caches.match(event.request)
|
||||
.then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Page hors ligne par défaut
|
||||
return caches.match('/');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Gestion des messages
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||