Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92433f33ca | ||
|
|
b8037db423 | ||
|
|
c9040ea68c | ||
|
|
8972679e25 | ||
|
|
ad829c0a99 | ||
|
|
a5e2aa0da1 | ||
|
|
086ca3053f | ||
|
|
f07b608288 | ||
|
|
42c979536b | ||
|
|
4800167979 | ||
|
|
cb28baf25d | ||
|
|
4bd15f89d6 | ||
|
|
16bca596b6 | ||
|
|
ebc902c9e7 | ||
|
|
f81969cdd0 | ||
|
|
950a2abc49 | ||
|
|
04a900e120 | ||
|
|
1c11eb1e89 | ||
|
|
4dd9ba2ff4 | ||
|
|
27a23d76dd | ||
|
|
fcb176d519 | ||
|
|
359be7fd9f | ||
|
|
ece35824d3 | ||
|
|
fe807bded4 | ||
|
|
7cdfe13151 | ||
|
|
d04ca79e00 | ||
|
|
a37b527895 | ||
|
|
1e1a04a465 | ||
|
|
c6555ae47d | ||
|
|
a76328cc16 | ||
|
|
ef527c2072 | ||
|
|
45079dce25 |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
@@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## [3.16.0](https://github.com/Masutayunikon/FanKarr/compare/v3.15.0...v3.16.0) (2026-05-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **log:** + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher) ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **rss:** ajout du rss sur les series ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
|
||||
* **UI/UX:** affichage de la serie et des episodes sur la page download du torrent ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** fix de plusieurs torrent et refonte du tableau de compatibilité ([086ca30](https://github.com/Masutayunikon/FanKarr/commit/086ca3053f5e563d8c3c2bb82379ff45d77a5499))
|
||||
* **docker:** ajout du fichier de versions pour la navbar dans docker lors du build ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **download:** le telechargement de saison ne propose plus tout les torrents des episodes de la saisons si la serie n'a que des torrent par episodes ([c9040ea](https://github.com/Masutayunikon/FanKarr/commit/c9040ea68c52e65a656824ccfd101a04a2251edd))
|
||||
* **import:** les episodes avec une association changé n'apparait plus sur la page serie ([8972679](https://github.com/Masutayunikon/FanKarr/commit/8972679e2588b6e5cd3e9fd53b0223e3d1a6e56c))
|
||||
* **import:** les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **import:** on ne peux plus importer plusieurs fichiers sur le meme episodes, celui deja importé ce retire de l'importation dans le modal ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
|
||||
* **import:** si on change un fichier son association alors il est bien retirer de l'import ([ad829c0](https://github.com/Masutayunikon/FanKarr/commit/ad829c0a99b29ade7eba4673b1218c0ab768a2d7))
|
||||
* les torrents n'utilise plus la pause ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* **rss:** le rss est maintenant sur les ajout apres l'activation de la surveillance ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
|
||||
* **UI/UX:** la bar de progression de saison a été retirer et les bars de progression s'affiche meme si le fichier est à 0% dans le torrent ([f07b608](https://github.com/Masutayunikon/FanKarr/commit/f07b608288afd4b01079367b428123b72182b714))
|
||||
* **UI/UX:** retour sur le modal d'import et refresh ([a5e2aa0](https://github.com/Masutayunikon/FanKarr/commit/a5e2aa0da1b3af445a102d1acd77a3a08017689a))
|
||||
* **UI/UX:** retour visuel lors de l'import d'une serie/episode ([cb28baf](https://github.com/Masutayunikon/FanKarr/commit/cb28baf25d7f24148ed7e13a0a4ea46f085b2ba4))
|
||||
* utilisation de URLSearchParams pour les appels autre que pour le add du fichier .torrent ([42c9795](https://github.com/Masutayunikon/FanKarr/commit/42c979536b51e8ed263b23559b7c4a2d4d2b2902))
|
||||
|
||||
## [3.15.0](https://github.com/Masutayunikon/FanKarr/compare/v3.14.6...v3.15.0) (2026-05-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ajout build docker pour arm32 ([950a2ab](https://github.com/Masutayunikon/FanKarr/commit/950a2abc49fe0b7b5bcc0f47d781a45a01838ec3))
|
||||
|
||||
## [3.14.6](https://github.com/Masutayunikon/FanKarr/compare/v3.14.5...v3.14.6) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pnpm build ([fcb176d](https://github.com/Masutayunikon/FanKarr/commit/fcb176d519b1bff49c40b79192e71b6062671998))
|
||||
|
||||
## [3.14.5](https://github.com/Masutayunikon/FanKarr/compare/v3.14.4...v3.14.5) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pnpm build ([d04ca79](https://github.com/Masutayunikon/FanKarr/commit/d04ca79e0057e3d6cf92e49822d812266f629dba))
|
||||
|
||||
## [3.14.4](https://github.com/Masutayunikon/FanKarr/compare/v3.14.3...v3.14.4) (2026-05-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* lock file for build ([ef527c2](https://github.com/Masutayunikon/FanKarr/commit/ef527c2072a93650f9159de12ae875a671fb845c))
|
||||
|
||||
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -16,6 +16,7 @@ RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
FROM node:22-slim
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -25,6 +26,7 @@ COPY --from=build /app/dist/client /app/public
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /config
|
||||
RUN echo "$BUILD_VERSION" > /app/version.txt
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -139,6 +139,13 @@ fankarr-windows-x64/
|
||||
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
wget https://github.com/Masutayunikon/FanKarr/releases/download/v3.14.6/fankarr-linux-arm64.zip
|
||||
unzip fankarr-linux-arm64.zip -d fankarr
|
||||
cd fankarr
|
||||
################
|
||||
# if the "fankarr" files in inside the binaries directory use this command
|
||||
# mv ./binaries/fankarr .
|
||||
##################
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
"start": "node dist/server/index.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild"]
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
|
||||
@@ -28,6 +28,8 @@ import importRouter from './routes/import.js'
|
||||
import systemRouter from './routes/system.js'
|
||||
import nfoUpdatesRouter from './routes/nfo-updates.js'
|
||||
import plexRouter from './routes/plex.js'
|
||||
import rssSyncRouter from './routes/rss-sync.js'
|
||||
import { runRssSync } from './lib/rss-sync.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
@@ -63,6 +65,7 @@ app.use('/api', importRouter)
|
||||
app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
app.use('/api', rssSyncRouter)
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
@@ -161,4 +164,12 @@ server.listen(PORT, async () => {
|
||||
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}, 60 * 60_000)
|
||||
|
||||
// RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
|
||||
setTimeout(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
setInterval(() => {
|
||||
runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`))
|
||||
}, 6 * 60 * 60_000)
|
||||
}, 60_000) // premier cycle 1 min après le démarrage
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance.
|
||||
*
|
||||
* Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added`
|
||||
* (date d'ajout dans la base metadata) est postérieur à la date d'activation
|
||||
* du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont
|
||||
* pas encore organisés, ils sont envoyés automatiquement au client torrent.
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SyncedSerie {
|
||||
serieId : number
|
||||
serieName: string
|
||||
addedAt : string // ISO — date d'activation de la surveillance
|
||||
}
|
||||
|
||||
type SyncedMap = Record<number, SyncedSerie>
|
||||
|
||||
// ── Persistance ───────────────────────────────────────────────
|
||||
|
||||
const SYNC_FILE = path.join(DATA_DIR, 'rss-synced.json')
|
||||
|
||||
export function loadSynced(): SyncedMap {
|
||||
try {
|
||||
if (fs.existsSync(SYNC_FILE))
|
||||
return JSON.parse(fs.readFileSync(SYNC_FILE, 'utf-8')) as SyncedMap
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveSynced(map: SyncedMap): void {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
fs.writeFileSync(SYNC_FILE, JSON.stringify(map, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export function setSync(serieId: number, serieName: string, enabled: boolean): SyncedMap {
|
||||
const map = loadSynced()
|
||||
if (enabled) {
|
||||
map[serieId] = { serieId, serieName, addedAt: new Date().toISOString() }
|
||||
} else {
|
||||
delete map[serieId]
|
||||
}
|
||||
saveSynced(map)
|
||||
return map
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function readOrganized(): Record<string, Record<string, any>> {
|
||||
try {
|
||||
const p = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch {}
|
||||
return {}
|
||||
}
|
||||
|
||||
function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string, any>>): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
|
||||
const manual = organized['manual'] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (manual[String(ep.id)]) ids.add(ep.id)
|
||||
|
||||
for (const t of sd.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of season.episodes ?? [])
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id)
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
if (pathEntry) {
|
||||
const fname = pathEntry.path?.split('/').pop()
|
||||
if (fname && orgFiles[fname] !== undefined) ids.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// ── Logique de sync ───────────────────────────────────────────
|
||||
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
|
||||
const activeHashes = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const active = await dispatchList(category ?? 'fankai', infohashMap)
|
||||
for (const t of active) if (t.hash) activeHashes.add(t.hash.toLowerCase())
|
||||
} catch (err) {
|
||||
logger.warn('rss-sync', `Impossible de lire les torrents actifs : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
|
||||
for (const serieId of ids) {
|
||||
const syncedEntry = synced[serieId]
|
||||
const sd = seriesData.find((s: any) => s.id === serieId)
|
||||
if (!sd) {
|
||||
logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`)
|
||||
continue
|
||||
}
|
||||
|
||||
const title = sd.title ?? sd.show_title ?? String(serieId)
|
||||
const activatedAt = new Date(syncedEntry.addedAt)
|
||||
const organizedIds = getOrganizedEpisodeIds(sd, organized)
|
||||
|
||||
logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`)
|
||||
|
||||
// Épisodes candidats : date_added > date d'activation ET pas encore organisés
|
||||
const candidateIds = new Set<number>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!ep.date_added) continue
|
||||
const epDate = new Date(ep.date_added)
|
||||
if (epDate <= activatedAt) continue
|
||||
if (organizedIds.has(ep.id)) continue
|
||||
// Doit avoir au moins un torrent fankai
|
||||
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
|
||||
if (!hasTorrent) continue
|
||||
candidateIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIds.size === 0) {
|
||||
logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`)
|
||||
|
||||
// Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode
|
||||
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
|
||||
const coveredIds = new Set<number>()
|
||||
|
||||
// 1. Packs saison couvrant au moins un candidat
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const coversSomeCandidate = (season.episodes ?? []).some((ep: any) =>
|
||||
candidateIds.has(ep.id) && !coveredIds.has(ep.id)
|
||||
)
|
||||
if (!coversSomeCandidate) { skipped++; continue }
|
||||
|
||||
toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` })
|
||||
for (const ep of season.episodes ?? []) coveredIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Épisodes individuels candidats non couverts par un pack
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
|
||||
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.fankai) continue
|
||||
const url = t.torrent_url ?? t.magnet
|
||||
if (!url) continue
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (hash && activeHashes.has(hash)) { skipped++; continue }
|
||||
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash)
|
||||
toDownload.push({
|
||||
url,
|
||||
infohash : hash ?? null,
|
||||
file_index: pathEntry?.file_index ?? null,
|
||||
file_path : pathEntry?.path ?? null,
|
||||
label : `S${season.season_number}E${ep.episode_number} ${title}`,
|
||||
})
|
||||
coveredIds.add(ep.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dl of toDownload) {
|
||||
try {
|
||||
const results = await dispatchDownload(dl.url, {
|
||||
file_index: dl.file_index,
|
||||
file_path : dl.file_path,
|
||||
infohash : dl.infohash,
|
||||
})
|
||||
if (results.some((r: any) => r.ok)) {
|
||||
logger.info('rss-sync', `Envoyé : ${dl.label}`)
|
||||
sent++
|
||||
} else {
|
||||
logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
|
||||
errors++
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
|
||||
return { sent, skipped, errors }
|
||||
}
|
||||
@@ -353,7 +353,7 @@ async function organizeTorrent(
|
||||
|
||||
// Vérification de complétion pour épisode unique
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
||||
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||
return { ...result, total: 1, skipped: 1 }
|
||||
}
|
||||
|
||||
@@ -371,8 +371,8 @@ async function organizeTorrent(
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`Fichier source introuvable : ${filename}`)
|
||||
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
|
||||
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||
return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
@@ -418,9 +418,9 @@ async function organizeTorrent(
|
||||
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
||||
|
||||
// Vérification de complétion : si on a les infos de progression par fichier,
|
||||
// on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée).
|
||||
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
|
||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
||||
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
@@ -436,8 +436,7 @@ async function organizeTorrent(
|
||||
|
||||
if (!src) {
|
||||
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
|
||||
// On skip silencieusement — pas une erreur.
|
||||
debug(`Fichier non disponible (skip) : ${filename}`)
|
||||
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -49,19 +49,27 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
let serieName: string | undefined
|
||||
let episodes: any[] = []
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
if (match) {
|
||||
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||
totalFiles = resolved.length || 1
|
||||
serieName = sd.title ?? sd.show_title
|
||||
episodes = resolved
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
|
||||
+20
-1
@@ -32,7 +32,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
let sd = await readSerieData(Number(serie_id))
|
||||
if (!sd) {
|
||||
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
|
||||
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
|
||||
const all = await loadEnrichedSeriesData()
|
||||
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
|
||||
}
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
@@ -66,6 +72,19 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
|
||||
// Nettoyer toute entrée organized qui référence ce même fichier physique
|
||||
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) {
|
||||
delete (organized[h] as any)[epId]
|
||||
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[h]).length === 0) delete organized[h]
|
||||
}
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (file_path !== destPath) {
|
||||
const serieRootPath = path.join(mediaPath, serieTitle)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** GET /api/rss-sync → liste des séries surveillées */
|
||||
router.get('/rss-sync', requireAuth, (_req, res) => {
|
||||
res.json(Object.values(loadSynced()))
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (avant /:id) */
|
||||
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const result = await runRssSync()
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/:id → statut sync pour une série */
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
res.json({ synced: isSynced(serieId) })
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/:id → activer la surveillance */
|
||||
router.post('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const serieName = String(req.body.serieName ?? '')
|
||||
if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return }
|
||||
const map = setSync(serieId, serieName, true)
|
||||
logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`)
|
||||
res.json({ synced: true, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
/** DELETE /api/rss-sync/:id → désactiver la surveillance */
|
||||
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
const map = setSync(serieId, '', false)
|
||||
logger.info('rss-sync', `Surveillance désactivée pour la série ${serieId}`)
|
||||
res.json({ synced: false, total: Object.keys(map).length })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -58,13 +58,12 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
|
||||
|
||||
/**
|
||||
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
||||
* applique les priorités, puis reprend le torrent.
|
||||
* puis applique les priorités (désactive tout sauf la cible).
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
resume : boolean,
|
||||
): Promise<void> {
|
||||
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
@@ -118,36 +117,13 @@ async function qbApplyFilePriority(
|
||||
}
|
||||
}
|
||||
|
||||
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
|
||||
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
|
||||
if (resume) {
|
||||
const resumeBody = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
|
||||
])
|
||||
}
|
||||
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
|
||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||
return
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
||||
if (resume) {
|
||||
try {
|
||||
const authH = await qbAuth(config)
|
||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
const body = new URLSearchParams({ hashes: hash })
|
||||
await Promise.allSettled([
|
||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
|
||||
])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||
}
|
||||
|
||||
@@ -275,7 +251,7 @@ const QB: TorrentClientDriver = {
|
||||
const exists = await qbTorrentExists(config, authH, hash)
|
||||
if (exists) {
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
return
|
||||
@@ -292,38 +268,39 @@ const QB: TorrentClientDriver = {
|
||||
}
|
||||
|
||||
// Stratégie d'ajout selon le type de lien :
|
||||
// .torrent URL → FanKarr télécharge le fichier et l'uploade directement à qBit
|
||||
// magnet link → URLSearchParams (application/x-www-form-urlencoded), pas de binaire
|
||||
// .torrent URL → FormData multipart obligatoire pour uploader le blob binaire
|
||||
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
|
||||
// magnet link → passage de l'URL brute, ajout normal sans pause
|
||||
// (les métadonnées arrivent via les pairs, la pause bloquerait tout)
|
||||
const isMagnet = url.startsWith('magnet:')
|
||||
const form = new FormData()
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
let res: Response
|
||||
|
||||
if (isMagnet) {
|
||||
form.append('urls', url)
|
||||
const params = new URLSearchParams()
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
} else {
|
||||
// FanKarr télécharge le .torrent et l'envoie directement à qBit
|
||||
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
|
||||
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
|
||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
|
||||
const torrentBytes = await torrentRes.arrayBuffer()
|
||||
const form = new FormData()
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
|
||||
form.append('paused', 'true') // qBit < 5.0
|
||||
form.append('stopped', 'true') // qBit ≥ 5.0
|
||||
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: authH,
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
|
||||
if (hash) {
|
||||
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
|
||||
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
|
||||
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
|
||||
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||
)
|
||||
} else {
|
||||
@@ -341,7 +318,7 @@ const QB: TorrentClientDriver = {
|
||||
if (!newT) continue
|
||||
const resolvedHash = String(newT.hash).toLowerCase()
|
||||
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
|
||||
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
|
||||
await qbApplyFilePriority(config, resolvedHash, fileIndex)
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
@@ -351,14 +328,15 @@ const QB: TorrentClientDriver = {
|
||||
return
|
||||
}
|
||||
|
||||
// Pas de sélection de fichier → ajout normal
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||
// Pas de sélection de fichier → ajout normal (URLSearchParams, pas de binaire)
|
||||
const params = new URLSearchParams()
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
if (config.savePath) params.set('savepath', String(config.savePath))
|
||||
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||
method: 'POST', body: form, headers: authH,
|
||||
method: 'POST', body: params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
@@ -367,13 +345,11 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const authH = await qbAuth(config)
|
||||
const form = new FormData()
|
||||
form.append('hashes', hash)
|
||||
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
||||
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||
method : 'POST',
|
||||
body : form,
|
||||
headers: authH,
|
||||
body : params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
|
||||
@@ -244,7 +244,7 @@ const RT: TorrentClientDriver = {
|
||||
await Promise.all(rows.map(async (r: any) => {
|
||||
const hash = String(r[0]).toUpperCase()
|
||||
try {
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
|
||||
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority='])
|
||||
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
|
||||
} catch {}
|
||||
}))
|
||||
@@ -279,7 +279,7 @@ const RT: TorrentClientDriver = {
|
||||
index : i,
|
||||
name : String(f[2] ?? ''),
|
||||
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
||||
priority: 1,
|
||||
priority: Number(f[3] ?? 1) === 0 ? 0 : 1,
|
||||
}))
|
||||
: undefined,
|
||||
} satisfies TorrentInfo
|
||||
|
||||
@@ -166,7 +166,7 @@ const DS: TorrentClientDriver = {
|
||||
version: '1',
|
||||
method : 'delete',
|
||||
id : found.id,
|
||||
force_complete: deleteFiles ? 'false' : 'false',
|
||||
force_complete: deleteFiles ? 'true' : 'false',
|
||||
_sid : sid,
|
||||
})
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<template v-else-if="items.length > 0">
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
|
||||
@@ -120,8 +120,10 @@
|
||||
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="importResult.errors.length === 0 ? 'bg-green-500/10' : 'bg-yellow-500/10'">
|
||||
<svg v-if="importResult.errors.length === 0" width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" stroke="#eab308" stroke-width="2" fill="none"><path d="M12 2l9.27 16H2.73L12 2z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="16.5" r=".5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Import terminé</p>
|
||||
@@ -138,7 +140,10 @@
|
||||
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="backToList" class="btn-secondary">Voir les fichiers</button>
|
||||
<button @click="$emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -180,11 +185,12 @@ const serieTitle = computed(() =>
|
||||
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
hash : string | null
|
||||
alreadyImported: boolean
|
||||
willRename : boolean
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
originalEpisodeId : number | null // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
|
||||
hash : string | null
|
||||
alreadyImported : boolean
|
||||
willRename : boolean
|
||||
}
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
@@ -261,8 +267,20 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
|
||||
items.value[i].alreadyImported = false
|
||||
const item = items.value[i]
|
||||
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
|
||||
|
||||
item.hash = getHashForEpisode(Number(newEpId))
|
||||
item.alreadyImported = false
|
||||
item.willRename = newEpId !== null
|
||||
|
||||
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
|
||||
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
|
||||
if (newEpId !== null) {
|
||||
items.value = items.value.filter(other =>
|
||||
other === item || !(other.episode_id === newEpId && other.alreadyImported)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
@@ -287,11 +305,11 @@ async function scan() {
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
}
|
||||
|
||||
const match = autoMatch(f.name)
|
||||
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
} catch {
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
@@ -322,15 +340,37 @@ async function doImport() {
|
||||
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
|
||||
if (!res.ok) {
|
||||
importError.value = data.error ?? 'Erreur inconnue'
|
||||
toast(data.error ?? 'Erreur lors de l\'import', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
|
||||
if (data.done > 0) {
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
|
||||
}
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
|
||||
}
|
||||
|
||||
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
|
||||
emit('imported')
|
||||
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
importError.value = 'Impossible de contacter le serveur'
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
step.value = 'list'
|
||||
scan() // re-scan → les fichiers importés passent en "déjà importé"
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
</script>
|
||||
@@ -4,7 +4,30 @@
|
||||
<!-- Nom + état -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p>
|
||||
<!-- Nom du torrent + tooltip épisodes -->
|
||||
<div class="relative group/eps inline-block max-w-full">
|
||||
<p class="text-sm text-primary font-medium truncate cursor-default">{{ torrent.name }}</p>
|
||||
<!-- Tooltip épisodes -->
|
||||
<div v-if="episodesWithProgress.length > 0"
|
||||
class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||
<div class="bg-card border border-border rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-muted font-medium mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<div v-for="ep in episodesWithProgress" :key="ep.episode_id"
|
||||
class="flex items-center justify-between gap-3">
|
||||
<span class="text-primary shrink-0">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
<span v-if="ep.progress !== null"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded shrink-0"
|
||||
:class="ep.progress >= 100 ? 'bg-green-500/15 text-green-500' : ep.priority === 0 ? 'bg-border/50 text-muted' : 'bg-accent/15 text-accent'">
|
||||
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress}%` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Nom de la série -->
|
||||
<p v-if="torrent.serieName" class="text-[11px] text-muted truncate mt-0.5">{{ torrent.serieName }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
||||
@@ -65,8 +88,12 @@
|
||||
: 'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
|
||||
{{ importing ? '...' : 'Importer' }}
|
||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
|
||||
class="btn-secondary text-xs py-1 flex items-center gap-1.5 min-w-[72px] justify-center">
|
||||
<svg v-if="importing" width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" fill="none" class="animate-spin shrink-0">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
{{ importing ? 'Import…' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +157,23 @@ const withFiles = ref(false)
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||
|
||||
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
|
||||
const episodesWithProgress = computed(() => {
|
||||
const eps: any[] = props.torrent.episodes ?? []
|
||||
if (!eps.length) return []
|
||||
return eps.map((ep: any) => {
|
||||
const file = (props.torrent.files ?? []).find((f: any) => {
|
||||
const basename = String(f.name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
|
||||
return basename.toLowerCase() === ep.filename?.toLowerCase()
|
||||
})
|
||||
return {
|
||||
...ep,
|
||||
progress: file != null ? Math.round((file.progress ?? 0) * 100) : null,
|
||||
priority: file?.priority ?? 1,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return ({
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
|
||||
@@ -113,14 +113,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||||
<div
|
||||
@@ -310,7 +302,7 @@
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
|
||||
|
||||
const props = defineProps<{
|
||||
season : any
|
||||
@@ -441,7 +433,7 @@ function epProgress(ep: any): ActiveTorrent | null {
|
||||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
||||
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
|
||||
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||||
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
|
||||
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
|
||||
return { ...active, progress: Math.round(file.progress * 100) }
|
||||
}
|
||||
if (!sessionDl) continue
|
||||
@@ -602,23 +594,33 @@ function groupLabels(torrents: any[]): string[] {
|
||||
}
|
||||
|
||||
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
|
||||
// Un torrent individuel par épisode (hash unique) n'est PAS un pack → ignoré ici,
|
||||
// le bouton "Saison" simple suffira pour lancer tous les épisodes.
|
||||
const uniquePackOptions = computed(() => {
|
||||
if (props.season.torrents?.length) return []
|
||||
const seen = new Set<string>()
|
||||
const raw: { infohash: string; torrent: any }[] = []
|
||||
|
||||
// Compter combien d'épisodes chaque hash couvre
|
||||
const hashCount = new Map<string, number>()
|
||||
const hashTorrent = new Map<string, any>()
|
||||
for (const ep of props.season.episodes) {
|
||||
if (!ep.available) continue
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
const hash = t.infohash ?? extractHash(t)
|
||||
if (!hash || seen.has(hash)) continue
|
||||
seen.add(hash)
|
||||
raw.push({ infohash: hash, torrent: t })
|
||||
if (!hash) continue
|
||||
hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1)
|
||||
if (!hashTorrent.has(hash)) hashTorrent.set(hash, t)
|
||||
}
|
||||
}
|
||||
if (raw.length <= 1) return []
|
||||
const labels = groupLabels(raw.map(r => r.torrent))
|
||||
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||||
|
||||
// Garder uniquement les hash qui couvrent ≥ 2 épisodes (= vrais packs / intégrales)
|
||||
const packs = [...hashCount.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
|
||||
|
||||
if (packs.length <= 1) return []
|
||||
const labels = groupLabels(packs.map(r => r.torrent))
|
||||
return packs.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
|
||||
+45
-2
@@ -50,6 +50,24 @@
|
||||
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Bouton RSS sync -->
|
||||
<button
|
||||
@click="toggleRssSync"
|
||||
:title="rssSync ? 'Surveillance activée — cliquer pour désactiver' : 'Surveiller les nouveaux épisodes'"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="rssSync
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20 hover:bg-green-500/20'
|
||||
: 'bg-shell text-muted border-border hover:text-primary hover:bg-hover'"
|
||||
>
|
||||
<!-- Icône antenne RSS -->
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
{{ rssSync ? 'Surveillé' : 'Surveiller' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
|
||||
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
|
||||
<button
|
||||
@@ -140,7 +158,7 @@
|
||||
:organized="organizedByEpisode"
|
||||
:initial-path="mediaPath"
|
||||
@close="manualImportOpen = false"
|
||||
@imported="load(); fetchOrganized(); manualImportOpen = false"
|
||||
@imported="load(); fetchOrganized()"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -173,6 +191,7 @@ const deleteSerieFiles = ref(false)
|
||||
const downloadMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
const rssSync = ref(false)
|
||||
|
||||
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
@@ -268,6 +287,30 @@ async function fetchSettings() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchRssSync() {
|
||||
try {
|
||||
const res = await fetch(`/api/rss-sync/${route.params.id}`, { credentials: 'include' })
|
||||
if (res.ok) { const d = await res.json(); rssSync.value = !!d.synced }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function toggleRssSync() {
|
||||
const serieId = Number(route.params.id)
|
||||
const serieName = data.value?.serie?.title ?? String(serieId)
|
||||
try {
|
||||
if (rssSync.value) {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { rssSync.value = false; toast('Surveillance désactivée', 'success') }
|
||||
} else {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieName }),
|
||||
})
|
||||
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
|
||||
}
|
||||
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
|
||||
}
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -514,7 +557,7 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
|
||||
@@ -48,40 +48,64 @@
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Sélection fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Dossier cible</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium">Remap. chemin</th>
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<!-- Client + version -->
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span class="text-primary font-medium">{{ row.label }}</span>
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<div>
|
||||
<p class="text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Auth -->
|
||||
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<!-- Sélection fichier -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect === '✗' ? 'text-muted' : row.fileSelect.startsWith('Async') ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ row.fileSelect }}
|
||||
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
|
||||
{{ row.fileSelect ? 'Async ✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">{{ row.categories ? '✓' : '✗' }}</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<td class="text-center px-3 py-2.5 text-green-400">✓</td>
|
||||
<!-- Progression par fichier -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.fileProgress ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Upload .torrent (FanKarr proxy) -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
|
||||
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Catégories -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.categories ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Suppression fichiers -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.deleteFiles ? '✓' : '✗' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-2">
|
||||
<span class="text-green-400">✓ Natif</span> — sélection à l'ajout, aucun fichier superflu ·
|
||||
<span class="text-green-400">Async ✓</span> — sélection dès réception des métadonnées, avant tout téléchargement ·
|
||||
<span class="text-muted">✗</span> — non supporté, torrent complet téléchargé
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
|
||||
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
|
||||
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
@@ -207,11 +231,30 @@ const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const clientMatrix = [
|
||||
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
|
||||
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
|
||||
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
|
||||
{
|
||||
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
|
||||
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
|
||||
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'utorrent', label: 'uTorrent', version: '3.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
|
||||
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14.3
|
||||
3.16.0
|
||||
Reference in new issue
Block a user