Merge pull request #15 from quoije/cursor/movie-player-integration-1f3a
Movie player integration
This commit was merged in pull request #15.
This commit is contained in:
+8
-1
@@ -9,7 +9,14 @@ const movieSchema = new mongoose.Schema({
|
|||||||
// Oscar pool year / edition (used to segment movies + stats)
|
// Oscar pool year / edition (used to segment movies + stats)
|
||||||
year: { type: Number, required: false, index: true },
|
year: { type: Number, required: false, index: true },
|
||||||
category: { type: String, required: true },
|
category: { type: String, required: true },
|
||||||
vod_link: { type: String, required: true },
|
// Legacy "one field" player source.
|
||||||
|
// Kept for backwards-compat. New UI prefers video_src / embed_src.
|
||||||
|
vod_link: { type: String, required: false },
|
||||||
|
|
||||||
|
// Player configuration (optional; used by player.html)
|
||||||
|
player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" },
|
||||||
|
video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8)
|
||||||
|
embed_src: { type: String, required: false }, // URL or iframe code
|
||||||
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
|
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
|
||||||
}, { timestamps: true });
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
|||||||
+51
-5
@@ -251,8 +251,31 @@
|
|||||||
<input type="text" class="form-control" id="category" placeholder="Best Picture, etc." required>
|
<input type="text" class="form-control" id="category" placeholder="Best Picture, etc." required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="vod_link" class="form-label">Lien VOD</label>
|
<label class="form-label">Lecteur (source)</label>
|
||||||
<input type="url" class="form-control" id="vod_link" placeholder="https://..." required>
|
<div class="row g-2">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<label for="player_mode" class="form-label">Mode</label>
|
||||||
|
<select id="player_mode" class="form-select">
|
||||||
|
<option value="auto" selected>Auto</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="embed">Embed</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
|
||||||
|
<input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="embed_src" class="form-label">Embed URL or iframe code</label>
|
||||||
|
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://www.youtube.com/watch?v=... OR <iframe src='...'></iframe>"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="vod_link" class="form-label">Legacy VOD link (optional)</label>
|
||||||
|
<input type="text" class="form-control" id="vod_link" placeholder="https://...">
|
||||||
|
<div class="form-text">Backward-compat. Prefer video_src / embed_src above.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Ajouter</button>
|
<button type="submit" class="btn btn-primary">Ajouter</button>
|
||||||
@@ -299,13 +322,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width: 1%;">Sel.</th>
|
<th style="width: 1%;">Sel.</th>
|
||||||
<th>Titre</th>
|
<th>Titre</th>
|
||||||
|
<th style="width: 12%;">Player</th>
|
||||||
<th style="width: 20%;">Catégorie</th>
|
<th style="width: 20%;">Catégorie</th>
|
||||||
<th style="width: 12%;">Année</th>
|
<th style="width: 12%;">Année</th>
|
||||||
<th style="width: 1%;">Actions</th>
|
<th style="width: 1%;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="admin-movies-body">
|
<tbody id="admin-movies-body">
|
||||||
<tr><td colspan="5" class="text-muted">Charge la liste…</td></tr>
|
<tr><td colspan="6" class="text-muted">Charge la liste…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,8 +366,30 @@
|
|||||||
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
|
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="edit_vod_link" class="form-label">Lien VOD</label>
|
<label class="form-label">Lecteur (source)</label>
|
||||||
<input type="url" class="form-control" id="edit_vod_link" placeholder="https://..." required>
|
<div class="row g-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_player_mode" class="form-label">Mode</label>
|
||||||
|
<select id="edit_player_mode" class="form-select">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="embed">Embed</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
|
||||||
|
<input type="text" class="form-control" id="edit_video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
|
||||||
|
<textarea class="form-control font-monospace" id="edit_embed_src" rows="3" placeholder="https://... OR <iframe ...>"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_vod_link" class="form-label">Legacy VOD link (optional)</label>
|
||||||
|
<input type="text" class="form-control" id="edit_vod_link" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ window.onload = async function () {
|
|||||||
const editYearEl = document.getElementById('edit_year');
|
const editYearEl = document.getElementById('edit_year');
|
||||||
const editCategoryEl = document.getElementById('edit_category');
|
const editCategoryEl = document.getElementById('edit_category');
|
||||||
const editVodLinkEl = document.getElementById('edit_vod_link');
|
const editVodLinkEl = document.getElementById('edit_vod_link');
|
||||||
|
const editPlayerModeEl = document.getElementById('edit_player_mode');
|
||||||
|
const editVideoSrcEl = document.getElementById('edit_video_src');
|
||||||
|
const editEmbedSrcEl = document.getElementById('edit_embed_src');
|
||||||
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
||||||
const editTitleEl = document.getElementById('edit_title');
|
const editTitleEl = document.getElementById('edit_title');
|
||||||
const editRatingEl = document.getElementById('edit_rating');
|
const editRatingEl = document.getElementById('edit_rating');
|
||||||
@@ -485,6 +488,9 @@ window.onload = async function () {
|
|||||||
const imdb_id = document.getElementById('imdb_id').value.trim();
|
const imdb_id = document.getElementById('imdb_id').value.trim();
|
||||||
const category = document.getElementById('category').value.trim();
|
const category = document.getElementById('category').value.trim();
|
||||||
const vod_link = document.getElementById('vod_link').value.trim();
|
const vod_link = document.getElementById('vod_link').value.trim();
|
||||||
|
const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim();
|
||||||
|
const video_src = document.getElementById('video_src')?.value?.trim() || '';
|
||||||
|
const embed_src = document.getElementById('embed_src')?.value?.trim() || '';
|
||||||
|
|
||||||
if (!year) {
|
if (!year) {
|
||||||
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
|
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
|
||||||
@@ -496,6 +502,11 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!vod_link && !video_src && !embed_src) {
|
||||||
|
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/movies/add', {
|
const res = await fetch('/api/movies/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -503,7 +514,7 @@ window.onload = async function () {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ year, imdb_id, category, vod_link })
|
body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, embed_src })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -596,6 +607,9 @@ window.onload = async function () {
|
|||||||
editYearEl.value = movie.year ? String(movie.year) : '';
|
editYearEl.value = movie.year ? String(movie.year) : '';
|
||||||
editCategoryEl.value = movie.category || '';
|
editCategoryEl.value = movie.category || '';
|
||||||
editVodLinkEl.value = movie.vod_link || '';
|
editVodLinkEl.value = movie.vod_link || '';
|
||||||
|
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
|
||||||
|
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
|
||||||
|
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
|
||||||
editRefreshOmdbEl.checked = false;
|
editRefreshOmdbEl.checked = false;
|
||||||
|
|
||||||
editTitleEl.value = movie.title || '';
|
editTitleEl.value = movie.title || '';
|
||||||
@@ -619,6 +633,9 @@ window.onload = async function () {
|
|||||||
const yearRaw = (editYearEl.value || '').trim();
|
const yearRaw = (editYearEl.value || '').trim();
|
||||||
const category = (editCategoryEl.value || '').trim();
|
const category = (editCategoryEl.value || '').trim();
|
||||||
const vod_link = (editVodLinkEl.value || '').trim();
|
const vod_link = (editVodLinkEl.value || '').trim();
|
||||||
|
const player_mode = (editPlayerModeEl?.value || 'auto').trim();
|
||||||
|
const video_src = (editVideoSrcEl?.value || '').trim();
|
||||||
|
const embed_src = (editEmbedSrcEl?.value || '').trim();
|
||||||
const refreshOmdb = !!editRefreshOmdbEl.checked;
|
const refreshOmdb = !!editRefreshOmdbEl.checked;
|
||||||
|
|
||||||
const title = editTitleEl.value;
|
const title = editTitleEl.value;
|
||||||
@@ -645,8 +662,8 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vod_link) {
|
if (!vod_link && !video_src && !embed_src) {
|
||||||
showResponse('warning', 'Lien VOD invalide.');
|
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,6 +672,9 @@ window.onload = async function () {
|
|||||||
year: yearRaw === '' ? null : year,
|
year: yearRaw === '' ? null : year,
|
||||||
category,
|
category,
|
||||||
vod_link,
|
vod_link,
|
||||||
|
player_mode,
|
||||||
|
video_src,
|
||||||
|
embed_src,
|
||||||
refreshOmdb
|
refreshOmdb
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -706,12 +726,12 @@ window.onload = async function () {
|
|||||||
async function loadMoviesForManagement() {
|
async function loadMoviesForManagement() {
|
||||||
const year = manageYearSelect.value;
|
const year = manageYearSelect.value;
|
||||||
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
|
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-muted">Chargement…</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Chargement…</td></tr>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'GET' });
|
const res = await fetch(url, { method: 'GET' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const movies = await res.json();
|
const movies = await res.json();
|
||||||
@@ -719,13 +739,39 @@ window.onload = async function () {
|
|||||||
moviesById = new Map(movies.map((m) => [m._id, m]));
|
moviesById = new Map(movies.map((m) => [m._id, m]));
|
||||||
|
|
||||||
if (!movies.length) {
|
if (!movies.length) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-muted">Aucun film.</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-muted">Aucun film.</td></tr>`;
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPlayerBadges(m) {
|
||||||
|
const badges = [];
|
||||||
|
const hasVideo = !!(m && m.video_src);
|
||||||
|
const hasEmbed = !!(m && m.embed_src);
|
||||||
|
const hasLegacy = !!(m && m.vod_link);
|
||||||
|
|
||||||
|
if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' });
|
||||||
|
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
|
||||||
|
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
|
||||||
|
|
||||||
|
const modeRaw = String(m?.player_mode || 'auto').toLowerCase();
|
||||||
|
const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto';
|
||||||
|
const modeText = mode === 'auto' ? 'Auto' : mode === 'video' ? 'Mode: Video' : 'Mode: Embed';
|
||||||
|
badges.push({ text: modeText, cls: 'bg-light text-dark border' });
|
||||||
|
|
||||||
|
if (!hasVideo && !hasEmbed && !hasLegacy) {
|
||||||
|
return [{ text: '—', cls: 'bg-light text-dark border' }];
|
||||||
|
}
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
adminMoviesBody.innerHTML = '';
|
adminMoviesBody.innerHTML = '';
|
||||||
movies.forEach((m) => {
|
movies.forEach((m) => {
|
||||||
|
const badges = buildPlayerBadges(m);
|
||||||
|
const badgesHtml = badges
|
||||||
|
.map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>
|
<td>
|
||||||
@@ -735,6 +781,7 @@ window.onload = async function () {
|
|||||||
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
|
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
|
||||||
<div class="text-muted small">${m.imdb_id || ''}</div>
|
<div class="text-muted small">${m.imdb_id || ''}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>${badgesHtml}</td>
|
||||||
<td>${m.category || ''}</td>
|
<td>${m.category || ''}</td>
|
||||||
<td>${m.year || ''}</td>
|
<td>${m.year || ''}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
@@ -756,7 +803,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="6" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ window.onload = async function () {
|
|||||||
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
||||||
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
||||||
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||||
|
const playerUrl = movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : (movie.vod_link || '#');
|
||||||
|
|
||||||
const movieDiv = document.createElement('div');
|
const movieDiv = document.createElement('div');
|
||||||
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card');
|
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card');
|
||||||
@@ -124,10 +125,12 @@ window.onload = async function () {
|
|||||||
movieDiv.innerHTML = `
|
movieDiv.innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''}
|
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''}
|
||||||
<a href="${movie.vod_link}"><img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;"></a>
|
<a href="${playerUrl}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;">
|
||||||
|
</a>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title d-flex justify-content-between align-items-center">
|
<h5 class="card-title d-flex justify-content-between align-items-center">
|
||||||
${movie.title}
|
<a href="${playerUrl}" target="_blank" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>
|
||||||
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span>
|
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span>
|
||||||
</h5>
|
</h5>
|
||||||
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p>
|
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p>
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
function decodeJwt(token) {
|
||||||
|
return JSON.parse(atob(token.split('.')[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryParam(name) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return url.searchParams.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(message) {
|
||||||
|
const el = document.getElementById('alert');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourceLabel(text) {
|
||||||
|
const el = document.getElementById('source-label');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || '—';
|
||||||
|
el.title = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeader(movie) {
|
||||||
|
const titleEl = document.getElementById('movie-title');
|
||||||
|
const categoryEl = document.getElementById('movie-category');
|
||||||
|
const ratingEl = document.getElementById('movie-rating');
|
||||||
|
|
||||||
|
if (titleEl) titleEl.textContent = movie?.title || 'Player';
|
||||||
|
|
||||||
|
if (categoryEl) {
|
||||||
|
const cat = movie?.category ? String(movie.category) : '';
|
||||||
|
categoryEl.textContent = cat ? `${cat} · ` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingEl) {
|
||||||
|
const r = movie?.rating ? String(movie.rating) : '';
|
||||||
|
ratingEl.textContent = r ? `⭐ ${r}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = movie?.title ? `Player - ${movie.title}` : 'Player';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVodLink(raw) {
|
||||||
|
const v = String(raw || '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
|
||||||
|
// Support pasted iframe embed code (extract src)
|
||||||
|
if (v.toLowerCase().includes('<iframe')) {
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(v, 'text/html');
|
||||||
|
const iframe = doc.querySelector('iframe');
|
||||||
|
const src = iframe?.getAttribute('src');
|
||||||
|
return src ? String(src).trim() : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHls(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
return u.pathname.toLowerCase().endsWith('.m3u8');
|
||||||
|
} catch (_) {
|
||||||
|
return String(url).toLowerCase().includes('.m3u8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeVideoFile(url) {
|
||||||
|
const s = String(url || '').toLowerCase();
|
||||||
|
// common direct file extensions
|
||||||
|
return /\.(mp4|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYoutubeEmbed(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
|
||||||
|
if (host === 'youtu.be') {
|
||||||
|
const id = u.pathname.split('/').filter(Boolean)[0];
|
||||||
|
return id ? `https://www.youtube.com/embed/${id}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.endsWith('youtube.com')) {
|
||||||
|
if (u.pathname.startsWith('/embed/')) return url;
|
||||||
|
const id = u.searchParams.get('v');
|
||||||
|
return id ? `https://www.youtube.com/embed/${id}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVimeoEmbed(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const host = u.hostname.replace(/^www\./, '');
|
||||||
|
if (host === 'player.vimeo.com' && u.pathname.startsWith('/video/')) return url;
|
||||||
|
if (host === 'vimeo.com') {
|
||||||
|
const id = u.pathname.split('/').filter(Boolean)[0];
|
||||||
|
return id ? `https://player.vimeo.com/video/${id}` : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVideoPlayer() {
|
||||||
|
const videoEl = document.getElementById('video');
|
||||||
|
const iframeEl = document.getElementById('embed');
|
||||||
|
if (iframeEl) {
|
||||||
|
iframeEl.classList.add('d-none');
|
||||||
|
iframeEl.removeAttribute('src');
|
||||||
|
}
|
||||||
|
if (videoEl) videoEl.classList.remove('d-none');
|
||||||
|
return videoEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmbedPlayer(embedUrl) {
|
||||||
|
const videoEl = document.getElementById('video');
|
||||||
|
const iframeEl = document.getElementById('embed');
|
||||||
|
|
||||||
|
if (videoEl) {
|
||||||
|
try { videoEl.pause(); } catch (_) {}
|
||||||
|
videoEl.classList.add('d-none');
|
||||||
|
// best-effort: clear any previous src
|
||||||
|
try { videoEl.removeAttribute('src'); videoEl.load(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iframeEl) {
|
||||||
|
iframeEl.classList.remove('d-none');
|
||||||
|
iframeEl.setAttribute('src', embedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playVodLink(vodLink) {
|
||||||
|
const raw = normalizeVodLink(vodLink);
|
||||||
|
if (!raw) {
|
||||||
|
showAlert('No VOD link configured for this movie.');
|
||||||
|
setSourceLabel('—');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Open original" button
|
||||||
|
const openOriginal = document.getElementById('open-original');
|
||||||
|
if (openOriginal) {
|
||||||
|
openOriginal.href = raw;
|
||||||
|
openOriginal.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer known embed providers
|
||||||
|
const yt = toYoutubeEmbed(raw);
|
||||||
|
if (yt) {
|
||||||
|
setSourceLabel('YouTube (embed)');
|
||||||
|
showEmbedPlayer(yt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vimeo = toVimeoEmbed(raw);
|
||||||
|
if (vimeo) {
|
||||||
|
setSourceLabel('Vimeo (embed)');
|
||||||
|
showEmbedPlayer(vimeo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct video file
|
||||||
|
if (looksLikeVideoFile(raw)) {
|
||||||
|
setSourceLabel('Direct video file');
|
||||||
|
const videoEl = showVideoPlayer();
|
||||||
|
if (!videoEl) return;
|
||||||
|
videoEl.src = raw;
|
||||||
|
videoEl.addEventListener('error', () => {
|
||||||
|
// If the browser can't play it (or CORS blocks), fall back to iframe.
|
||||||
|
setSourceLabel('Embed (fallback)');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
}, { once: true });
|
||||||
|
try { videoEl.load(); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS (.m3u8)
|
||||||
|
if (looksLikeHls(raw)) {
|
||||||
|
setSourceLabel('HLS stream (.m3u8)');
|
||||||
|
const videoEl = showVideoPlayer();
|
||||||
|
if (!videoEl) return;
|
||||||
|
|
||||||
|
// Native HLS (Safari) first
|
||||||
|
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
videoEl.src = raw;
|
||||||
|
try { videoEl.load(); } catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hls.js fallback (if supported)
|
||||||
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
|
const hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
});
|
||||||
|
hls.loadSource(raw);
|
||||||
|
hls.attachMedia(videoEl);
|
||||||
|
hls.on(window.Hls.Events.ERROR, function (_evt, data) {
|
||||||
|
if (data?.fatal) {
|
||||||
|
try { hls.destroy(); } catch (_) {}
|
||||||
|
setSourceLabel('Embed (fallback)');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort
|
||||||
|
setSourceLabel('Embed (fallback)');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: treat as embeddable URL
|
||||||
|
setSourceLabel('Embed');
|
||||||
|
showEmbedPlayer(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMovieSource(movie) {
|
||||||
|
const mode = String(movie?.player_mode || 'auto').toLowerCase();
|
||||||
|
const legacy = movie?.vod_link;
|
||||||
|
const video = movie?.video_src;
|
||||||
|
const embed = movie?.embed_src;
|
||||||
|
|
||||||
|
if (mode === 'video') return video || legacy || embed || null;
|
||||||
|
if (mode === 'embed') return embed || legacy || video || null;
|
||||||
|
// auto
|
||||||
|
return video || embed || legacy || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = async function () {
|
||||||
|
// Basic auth UX: require a valid local token (like the rest of the app).
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = decodeJwt(token);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
if (decoded.exp && decoded.exp < currentTime) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decoded.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nameEl = document.getElementById('player-user-name');
|
||||||
|
if (nameEl) nameEl.textContent = decoded.name || '';
|
||||||
|
} catch (_) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getQueryParam('id');
|
||||||
|
if (!id) {
|
||||||
|
showAlert('Missing movie id.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, { method: 'GET' });
|
||||||
|
const movie = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeader(movie);
|
||||||
|
const src = resolveMovieSource(movie);
|
||||||
|
await playVodLink(src);
|
||||||
|
} catch (err) {
|
||||||
|
showAlert(err.message || 'Network error.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Player</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="./css/app.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.player-wrap {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.player-surface {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.meta-muted {
|
||||||
|
color: rgba(0,0,0,0.65);
|
||||||
|
}
|
||||||
|
.badge-src {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/movies.html">Pool Oscars</a>
|
||||||
|
<div class="d-flex ms-auto align-items-center gap-2">
|
||||||
|
<span id="player-user-name" class="navbar-text text-white-50"></span>
|
||||||
|
<a id="open-original" class="btn btn-outline-light btn-sm d-none" target="_blank" rel="noopener noreferrer">Open original</a>
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="/movies.html">Back to movies</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container py-4 player-wrap">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h2 id="movie-title" class="mb-1">Loading…</h2>
|
||||||
|
<div class="meta-muted">
|
||||||
|
<span id="movie-category"></span>
|
||||||
|
<span id="movie-rating"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="player-surface">
|
||||||
|
<video id="video" class="d-none" controls playsinline></video>
|
||||||
|
<iframe
|
||||||
|
id="embed"
|
||||||
|
class="d-none"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
allowfullscreen
|
||||||
|
referrerpolicy="no-referrer-when-downgrade"
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span class="badge bg-secondary">Source</span>
|
||||||
|
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HLS support (m3u8) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
|
||||||
|
<script src="./js/app_player.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+90
-4
@@ -3,6 +3,7 @@ const Movie = require("../models/Movie");
|
|||||||
const User = require("../models/User");
|
const User = require("../models/User");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -18,6 +19,28 @@ function isValidImdbId(value) {
|
|||||||
return typeof value === "string" && /^tt\d{5,}$/.test(value.trim());
|
return typeof value === "string" && /^tt\d{5,}$/.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(v, maxLen = 2048) {
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
if (v === null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s) return "";
|
||||||
|
return s.slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlayerMode(v) {
|
||||||
|
const s = String(v || "").trim().toLowerCase();
|
||||||
|
if (!s) return "auto";
|
||||||
|
if (s === "auto" || s === "video" || s === "embed") return s;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySource({ vod_link, video_src, embed_src }) {
|
||||||
|
const a = typeof vod_link === "string" ? vod_link.trim() : "";
|
||||||
|
const b = typeof video_src === "string" ? video_src.trim() : "";
|
||||||
|
const c = typeof embed_src === "string" ? embed_src.trim() : "";
|
||||||
|
return !!(a || b || c);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||||
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
|
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
|
||||||
@@ -144,7 +167,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
|
|||||||
|
|
||||||
// Add movie
|
// Add movie
|
||||||
router.post("/add", async (req, res) => {
|
router.post("/add", async (req, res) => {
|
||||||
const { imdb_id, category, vod_link, year } = req.body;
|
const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the token from the Authorization header
|
// Get the token from the Authorization header
|
||||||
@@ -167,6 +190,17 @@ router.post("/add", async (req, res) => {
|
|||||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedVod = normalizeOptionalString(vod_link, 4096);
|
||||||
|
const normalizedVideo = normalizeOptionalString(video_src, 4096);
|
||||||
|
const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
|
||||||
|
const normalizedMode = normalizePlayerMode(player_mode);
|
||||||
|
if (normalizedMode === null) {
|
||||||
|
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
||||||
|
}
|
||||||
|
if (!hasAnySource({ vod_link: normalizedVod || "", video_src: normalizedVideo || "", embed_src: normalizedEmbed || "" })) {
|
||||||
|
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." });
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
|
const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
|
||||||
|
|
||||||
@@ -179,7 +213,10 @@ router.post("/add", async (req, res) => {
|
|||||||
poster: movieDetails.poster,
|
poster: movieDetails.poster,
|
||||||
year: parsedYear,
|
year: parsedYear,
|
||||||
category,
|
category,
|
||||||
vod_link
|
vod_link: normalizedVod || undefined,
|
||||||
|
player_mode: normalizedMode,
|
||||||
|
video_src: normalizedVideo || undefined,
|
||||||
|
embed_src: normalizedEmbed || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await movie.save();
|
await movie.save();
|
||||||
@@ -261,6 +298,9 @@ router.put("/:id", async (req, res) => {
|
|||||||
year,
|
year,
|
||||||
category,
|
category,
|
||||||
vod_link,
|
vod_link,
|
||||||
|
player_mode,
|
||||||
|
video_src,
|
||||||
|
embed_src,
|
||||||
refreshOmdb,
|
refreshOmdb,
|
||||||
} = req.body || {};
|
} = req.body || {};
|
||||||
|
|
||||||
@@ -280,7 +320,14 @@ router.put("/:id", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof vod_link === "string" && vod_link.trim() === "") {
|
if (typeof vod_link === "string" && vod_link.trim() === "") {
|
||||||
return res.status(400).json({ message: "Lien VOD invalide." });
|
// Allow clearing vod_link; enforce "at least one source" after applying changes.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player_mode !== undefined) {
|
||||||
|
const normalizedMode = normalizePlayerMode(player_mode);
|
||||||
|
if (normalizedMode === null) {
|
||||||
|
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const movie = await Movie.findById(id);
|
const movie = await Movie.findById(id);
|
||||||
@@ -288,7 +335,22 @@ router.put("/:id", async (req, res) => {
|
|||||||
|
|
||||||
if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim();
|
if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim();
|
||||||
if (category !== undefined) movie.category = String(category).trim();
|
if (category !== undefined) movie.category = String(category).trim();
|
||||||
if (vod_link !== undefined) movie.vod_link = String(vod_link).trim();
|
if (vod_link !== undefined) {
|
||||||
|
const v = normalizeOptionalString(vod_link, 4096);
|
||||||
|
movie.vod_link = v ? v : null;
|
||||||
|
}
|
||||||
|
if (player_mode !== undefined) {
|
||||||
|
const normalizedMode = normalizePlayerMode(player_mode);
|
||||||
|
movie.player_mode = normalizedMode === null ? movie.player_mode : normalizedMode;
|
||||||
|
}
|
||||||
|
if (video_src !== undefined) {
|
||||||
|
const v = normalizeOptionalString(video_src, 4096);
|
||||||
|
movie.video_src = v ? v : null;
|
||||||
|
}
|
||||||
|
if (embed_src !== undefined) {
|
||||||
|
const v = normalizeOptionalString(embed_src, 20000);
|
||||||
|
movie.embed_src = v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
if (year !== undefined && year !== null && year !== "") {
|
if (year !== undefined && year !== null && year !== "") {
|
||||||
movie.year = parseOscarYear(year);
|
movie.year = parseOscarYear(year);
|
||||||
@@ -311,6 +373,14 @@ router.put("/:id", async (req, res) => {
|
|||||||
if (poster !== undefined) movie.poster = poster;
|
if (poster !== undefined) movie.poster = poster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAnySource({
|
||||||
|
vod_link: movie.vod_link || "",
|
||||||
|
video_src: movie.video_src || "",
|
||||||
|
embed_src: movie.embed_src || "",
|
||||||
|
})) {
|
||||||
|
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." });
|
||||||
|
}
|
||||||
|
|
||||||
await movie.save();
|
await movie.save();
|
||||||
return res.status(200).json(movie);
|
return res.status(200).json(movie);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -321,4 +391,20 @@ router.put("/:id", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get one movie by Mongo _id (used by the custom player page)
|
||||||
|
router.get("/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||||
|
return res.status(400).json({ message: "Invalid movie id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const movie = await Movie.findById(id);
|
||||||
|
if (!movie) return res.status(404).json({ message: "Movie not found" });
|
||||||
|
return res.status(200).json(movie);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user