feat(demandes): telechargement auto lors de l'aprobation
feat(demandes): ajout des demandes par épisodes
This commit is contained in:
1 parent
f3d535d577
commit
473d97e392
5 files changed
+187
-36
No files matched your search
+28
-12
@@ -15,6 +15,7 @@ export interface Requester {
|
||||
userId : string
|
||||
username : string
|
||||
seasons : number[] // numéros de saison ; [] = toutes les saisons dispo
|
||||
episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
@@ -47,12 +48,19 @@ function writeRequests(requests: SerieRequest[]): void {
|
||||
|
||||
/** Union des saisons demandées sur tous les requesters ([] = toutes) */
|
||||
export function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0)) return []
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Union des IDs d'épisodes spécifiques sur tous les requesters */
|
||||
export function mergedEpisodes(req: SerieRequest): number[] {
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -61,10 +69,11 @@ export function mergedSeasons(req: SerieRequest): number[] {
|
||||
* - Sinon → crée une nouvelle demande
|
||||
*/
|
||||
export function upsertRequest(
|
||||
userId : string,
|
||||
serieId : number,
|
||||
userId : string,
|
||||
serieId : number,
|
||||
serieName: string,
|
||||
seasons : number[], // [] = toutes les saisons
|
||||
seasons : number[], // [] = toutes les saisons
|
||||
episodes : number[] = [], // IDs d'épisodes spécifiques
|
||||
): SerieRequest {
|
||||
const user = findById(userId)
|
||||
if (!user) throw new Error('Utilisateur introuvable')
|
||||
@@ -84,16 +93,23 @@ export function upsertRequest(
|
||||
// Mettre à jour ou ajouter ce requester dans la demande existante
|
||||
const idx = existing.requesters.findIndex(r => r.userId === userId)
|
||||
if (idx >= 0) {
|
||||
// Mettre à jour les saisons (union)
|
||||
if (seasons.length === 0 || existing.requesters[idx].seasons.length === 0) {
|
||||
existing.requesters[idx].seasons = [] // l'une ou l'autre est "toutes" → toutes
|
||||
const r = existing.requesters[idx]
|
||||
// Fusionner les saisons
|
||||
if (seasons.length === 0 && episodes.length === 0) {
|
||||
r.seasons = [] // "toutes" — efface toute granularité
|
||||
r.episodes = []
|
||||
} else {
|
||||
const merged = new Set([...existing.requesters[idx].seasons, ...seasons])
|
||||
existing.requesters[idx].seasons = [...merged].sort((a, b) => a - b)
|
||||
if (seasons.length === 0 || r.seasons.length === 0) {
|
||||
r.seasons = [] // l'une des deux est "toutes" → toutes
|
||||
} else {
|
||||
r.seasons = [...new Set([...r.seasons, ...seasons])].sort((a, b) => a - b)
|
||||
}
|
||||
// Fusionner les épisodes (union simple)
|
||||
r.episodes = [...new Set([...(r.episodes ?? []), ...episodes])].sort((a, b) => a - b)
|
||||
}
|
||||
existing.requesters[idx].requestedAt = now
|
||||
r.requestedAt = now
|
||||
} else {
|
||||
existing.requesters.push({ userId, username: user.username, seasons, requestedAt: now })
|
||||
existing.requesters.push({ userId, username: user.username, seasons, episodes, requestedAt: now })
|
||||
}
|
||||
existing.updatedAt = now
|
||||
writeRequests(requests)
|
||||
@@ -106,7 +122,7 @@ export function upsertRequest(
|
||||
id : crypto.randomUUID(),
|
||||
serieId,
|
||||
serieName,
|
||||
requesters: [{ userId, username: user.username, seasons, requestedAt: now }],
|
||||
requesters: [{ userId, username: user.username, seasons, episodes, requestedAt: now }],
|
||||
status : 'pending',
|
||||
createdAt : now,
|
||||
updatedAt : now,
|
||||
|
||||
@@ -3,8 +3,12 @@ import { requireAdmin } from '../auth.js'
|
||||
import {
|
||||
readRequests, upsertRequest, approveRequest,
|
||||
rejectRequest, completeRequest, deleteRequest,
|
||||
getRequestsForUser, getPendingCount, mergedSeasons,
|
||||
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
|
||||
type SerieRequest,
|
||||
} from '../requests.js'
|
||||
import { dispatchDownload } from '../torrent-clients/index.js'
|
||||
import { readSerieData } from '../lib/github-cache.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -26,7 +30,7 @@ router.get('/requests/pending-count', (req, res) => {
|
||||
|
||||
// POST /api/requests — créer ou mettre à jour une demande
|
||||
router.post('/requests', (req, res) => {
|
||||
const { serieId, serieName, seasons } = req.body
|
||||
const { serieId, serieName, seasons, episodes } = req.body
|
||||
if (!serieId || !serieName) {
|
||||
res.status(400).json({ error: 'serieId et serieName requis' }); return
|
||||
}
|
||||
@@ -35,7 +39,8 @@ router.post('/requests', (req, res) => {
|
||||
req.user!.id,
|
||||
Number(serieId),
|
||||
String(serieName),
|
||||
Array.isArray(seasons) ? seasons.map(Number) : [],
|
||||
Array.isArray(seasons) ? seasons.map(Number) : [],
|
||||
Array.isArray(episodes) ? episodes.map(Number) : [],
|
||||
)
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
@@ -44,14 +49,19 @@ router.post('/requests', (req, res) => {
|
||||
})
|
||||
|
||||
// PATCH /api/requests/:id — admin : approve / reject / complete
|
||||
router.patch('/requests/:id', requireAdmin, (req, res) => {
|
||||
router.patch('/requests/:id', requireAdmin, async (req, res) => {
|
||||
const { action, rejectionMessage } = req.body
|
||||
try {
|
||||
let request
|
||||
let request: SerieRequest
|
||||
const id = String(req.params.id)
|
||||
if (action === 'approve') request = approveRequest(id)
|
||||
else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
|
||||
else if (action === 'complete') request = completeRequest(id)
|
||||
if (action === 'approve') {
|
||||
request = approveRequest(id)
|
||||
// Lancer le(s) téléchargement(s) correspondant à la demande
|
||||
autoDownloadRequest(request).catch(err =>
|
||||
logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
|
||||
else if (action === 'complete') request = completeRequest(id)
|
||||
else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return }
|
||||
res.json(request)
|
||||
} catch (err) {
|
||||
@@ -59,6 +69,82 @@ router.patch('/requests/:id', requireAdmin, (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Trouve et envoie au client torrent les fichiers correspondant à la demande. */
|
||||
async function autoDownloadRequest(req: SerieRequest): Promise<void> {
|
||||
const serieData = await readSerieData(req.serieId)
|
||||
if (!serieData) {
|
||||
logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const seasons = mergedSeasons(req) // [] = toutes
|
||||
const episodes = mergedEpisodes(req) // IDs d'épisodes spécifiques
|
||||
|
||||
type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
|
||||
const toDownload: TorrentEntry[] = []
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
|
||||
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
|
||||
if (seenHashes.has(key)) return
|
||||
seenHashes.add(key)
|
||||
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
|
||||
}
|
||||
|
||||
if (episodes.length > 0) {
|
||||
// ── Épisodes ciblés ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!episodes.includes(ep.id)) continue
|
||||
if (ep.torrents?.length > 0) {
|
||||
// Torrent individuel
|
||||
for (const t of ep.torrents) addTorrent(t)
|
||||
} else if (ep.paths?.length > 0) {
|
||||
// Épisode contenu dans un pack — télécharger le fichier spécifique
|
||||
for (const p of ep.paths) {
|
||||
const pack = [...(serieData.torrents ?? []), ...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? [])]
|
||||
.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
|
||||
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (seasons.length > 0) {
|
||||
// ── Saisons ciblées ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (!seasons.includes(season.season_number)) continue
|
||||
for (const t of season.torrents ?? []) addTorrent(t)
|
||||
}
|
||||
} else {
|
||||
// ── Toutes les saisons : intégrale en priorité ────────────
|
||||
for (const t of serieData.torrents ?? []) addTorrent(t)
|
||||
// Sinon packs saison
|
||||
if (toDownload.length === 0) {
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) addTorrent(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toDownload.length === 0) {
|
||||
logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`)
|
||||
return
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
for (const t of toDownload) {
|
||||
const url = t.url ?? t.magnet
|
||||
if (!url) continue
|
||||
try {
|
||||
await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
|
||||
sent++
|
||||
} catch (err) {
|
||||
logger.warn('requests', `Auto-dl erreur torrent : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
logger.info('requests', `Auto-dl "${req.serieName}" — ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
|
||||
}
|
||||
|
||||
// DELETE /api/requests/:id — admin seulement
|
||||
router.delete('/requests/:id', requireAdmin, (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -206,12 +206,13 @@
|
||||
<button
|
||||
v-if="requestMode"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="(requestedSeasons?.includes(season.season_number))
|
||||
:class="isEpisodeRequested(ep.id)
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10 cursor-default'
|
||||
: 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'"
|
||||
@click="!requestedSeasons?.includes(season.season_number) && emit('requestEpisode', season.season_number, ep.id)"
|
||||
:title="isEpisodeRequested(ep.id) ? 'Déjà demandé' : 'Demander cet épisode'"
|
||||
@click="!isEpisodeRequested(ep.id) && emit('requestEpisode', season.season_number, ep.id)"
|
||||
>
|
||||
<component :is="requestedSeasons?.includes(season.season_number) ? checkIcon : requestIcon" />
|
||||
<component :is="isEpisodeRequested(ep.id) ? checkIcon : requestIcon" />
|
||||
</button>
|
||||
<!-- Mode téléchargement (admin) -->
|
||||
<button
|
||||
@@ -347,6 +348,7 @@ const props = defineProps<{
|
||||
nfoSupport ?: boolean
|
||||
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
|
||||
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -369,6 +371,11 @@ const isSeasonRequested = computed(() =>
|
||||
props.requestedSeasons?.includes(props.season.season_number) ?? false
|
||||
)
|
||||
|
||||
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
|
||||
function isEpisodeRequested(episodeId: number): boolean {
|
||||
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
|
||||
}
|
||||
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const plotOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = ref(false)
|
||||
|
||||
@@ -70,12 +70,14 @@
|
||||
{{ statusLabel(req.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Saisons fusionnées -->
|
||||
<!-- Saisons / épisodes fusionnés -->
|
||||
<p class="text-xs text-muted mt-1">
|
||||
<template v-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
|
||||
<template v-else>
|
||||
Saisons : {{ mergedSeasons(req).map(s => s === 0 ? 'SP' : `S${s}`).join(', ') }}
|
||||
<template v-if="mergedEpisodes(req).length > 0">
|
||||
{{ mergedEpisodes(req).length }} épisode{{ mergedEpisodes(req).length > 1 ? 's' : '' }} spécifique{{ mergedEpisodes(req).length > 1 ? 's' : '' }}
|
||||
<template v-if="mergedSeasons(req).length > 0"> · Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</template>
|
||||
<template v-else-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
|
||||
<template v-else>Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</p>
|
||||
<!-- Message de refus -->
|
||||
<p v-if="req.rejectionMessage" class="text-xs text-red-400 mt-1">
|
||||
@@ -112,7 +114,10 @@
|
||||
{{ r.username[0].toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-muted">{{ r.username }}</span>
|
||||
<span v-if="r.seasons.length > 0" class="text-muted/60">
|
||||
<span v-if="r.episodes?.length > 0" class="text-muted/60">
|
||||
({{ r.episodes.length }} ep.)
|
||||
</span>
|
||||
<span v-else-if="r.seasons.length > 0" class="text-muted/60">
|
||||
({{ r.seasons.map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }})
|
||||
</span>
|
||||
</div>
|
||||
@@ -149,7 +154,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Requester { userId: string; username: string; seasons: number[]; requestedAt: string }
|
||||
interface Requester { userId: string; username: string; seasons: number[]; episodes: number[]; requestedAt: string }
|
||||
interface SerieRequest {
|
||||
id: string; serieId: number; serieName: string
|
||||
requesters: Requester[]; status: string
|
||||
@@ -230,12 +235,18 @@ async function confirmReject() {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0)) return []
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && (r.episodes?.length ?? 0) === 0)) return []
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function mergedEpisodes(req: SerieRequest): number[] {
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[s] ?? s
|
||||
}
|
||||
|
||||
+37
-6
@@ -139,6 +139,7 @@
|
||||
:nfo-support="nfoSupport"
|
||||
:request-mode="!auth.isAdmin"
|
||||
:requested-seasons="requestedSeasonNumbers"
|
||||
:requested-episodes="requestedEpisodeIds"
|
||||
@toggle="toggleSeason"
|
||||
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
|
||||
@download-season="(s, h) => downloadSeason(s, h)"
|
||||
@@ -248,13 +249,19 @@ const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
|
||||
|
||||
const requestedSeasonNumbers = computed<number[]>(() => {
|
||||
if (!myRequest.value) return []
|
||||
// Trouver l'entrée de l'utilisateur connecté dans la demande fusionnée
|
||||
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
// [] = toutes les saisons demandées → on retourne toutes les saisons de la série
|
||||
if (r && r.seasons.length === 0) return data.value?.seasons?.map((s: any) => s.season_number) ?? []
|
||||
return r?.seasons ?? []
|
||||
})
|
||||
|
||||
const requestedEpisodeIds = computed<number[]>(() => {
|
||||
if (!myRequest.value) return []
|
||||
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
if (!r) return []
|
||||
return r.episodes ?? []
|
||||
})
|
||||
|
||||
async function fetchMyRequest() {
|
||||
try {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
@@ -282,8 +289,28 @@ function handleRequestSeason(seasonNumber: number) {
|
||||
openRequestModal(seasonNumber)
|
||||
}
|
||||
|
||||
function handleRequestEpisode(seasonNumber: number, _episodeId: number) {
|
||||
openRequestModal(seasonNumber)
|
||||
async function handleRequestEpisode(_seasonNumber: number, episodeId: number) {
|
||||
if (!data.value) return
|
||||
try {
|
||||
const res = await fetch('/api/requests', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
serieId : Number(route.params.id),
|
||||
serieName: data.value.serie.title,
|
||||
seasons : [],
|
||||
episodes : [episodeId],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
myRequest.value = await res.json()
|
||||
toast('Épisode demandé ✓', 'success')
|
||||
} else {
|
||||
const d = await res.json()
|
||||
toast(d.error ?? 'Erreur', 'error')
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
async function submitRequest() {
|
||||
@@ -701,9 +728,13 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
|
||||
if (!auth.isAdmin) fetchMyRequest()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
load()
|
||||
if (auth.isAdmin) {
|
||||
fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
} else {
|
||||
fetchMyRequest()
|
||||
}
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
|
||||
Reference in new issue
Block a user