Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244fa4ae43 | ||
|
|
5cfb449c3c | ||
|
|
3c9b9f5344 | ||
|
|
9ebaca92f1 | ||
|
|
494266953c | ||
|
|
583097cd16 | ||
|
|
cb1d1759e3 | ||
|
|
563ffb2bd3 | ||
|
|
25ab373539 | ||
|
|
ed0acdda22 | ||
|
|
a84337c928 | ||
|
|
cface2944e | ||
|
|
1e92934f53 | ||
|
|
bf3f30356e | ||
|
|
bfabf9b793 | ||
|
|
1f50f9ae72 | ||
|
|
7da5a8d4e8 | ||
|
|
2b3fd79a92 | ||
|
|
aa8b500f57 | ||
|
|
702847a5c2 | ||
|
|
5cc1aa29fe | ||
|
|
105cc05bdf | ||
|
|
50b8b2992b |
No files matched your search
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
|
||||||
|
|
||||||
|
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
|
||||||
|
|
||||||
|
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
|
||||||
|
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
|
||||||
|
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
|
||||||
|
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
|
||||||
|
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
|
||||||
|
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
|
||||||
|
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
|
||||||
|
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
|
||||||
|
|
||||||
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
|
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from 'path'
|
||||||
|
|
||||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||||
|
|
||||||
export async function fankaiGet(endpoint: string): Promise<any> {
|
export async function fankaiGet(endpoint: string): Promise<any> {
|
||||||
@@ -67,6 +69,25 @@ export function resolveEpNaming(ep: any, hash?: string | null): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
|
||||||
|
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
|
||||||
|
*/
|
||||||
|
export function computeExpectedName(
|
||||||
|
ep : any,
|
||||||
|
orgEntry: any,
|
||||||
|
hash : string | null,
|
||||||
|
nfoSupport: boolean,
|
||||||
|
): { currentName: string; expectedName: string; needsRename: boolean } {
|
||||||
|
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
||||||
|
const srcExt = path.extname(currentName)
|
||||||
|
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
|
||||||
|
const expectedName = nfoSupport
|
||||||
|
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
|
||||||
|
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
|
||||||
|
return { currentName, expectedName, needsRename: expectedName !== currentName }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dédoublonne une liste d'épisodes par episode_number.
|
* Dédoublonne une liste d'épisodes par episode_number.
|
||||||
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
|
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
|
||||||
|
|||||||
+30
-40
@@ -7,7 +7,7 @@ import { DATA_DIR } from '../config.js'
|
|||||||
import { readSettings } from '../settings.js'
|
import { readSettings } from '../settings.js'
|
||||||
import { getGitlabTitle } from '../gitlab-map.js'
|
import { getGitlabTitle } from '../gitlab-map.js'
|
||||||
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
|
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||||
import { resolveEpNaming } from '../lib/serie-helpers.js'
|
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
|
||||||
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
||||||
import { dispatchRemove } from '../torrent-clients/index.js'
|
import { dispatchRemove } from '../torrent-clients/index.js'
|
||||||
|
|
||||||
@@ -139,19 +139,26 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const sd = await readSerieData(serieId)
|
const sd = await readSerieData(serieId)
|
||||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||||
const organized = loadOrganizedJson()
|
const organized = loadOrganizedJson()
|
||||||
|
const { nfoSupport } = readSettings()
|
||||||
const result: Record<string, any> = {}
|
const result: Record<string, any> = {}
|
||||||
for (const season of sd.seasons ?? []) {
|
for (const season of sd.seasons ?? []) {
|
||||||
for (const ep of season.episodes ?? []) {
|
for (const ep of season.episodes ?? []) {
|
||||||
|
let entry: any = null
|
||||||
|
let entryHash = 'manual'
|
||||||
for (const p of ep.paths ?? []) {
|
for (const p of ep.paths ?? []) {
|
||||||
if (typeof p !== 'object' || !p.infohash) continue
|
if (typeof p !== 'object' || !p.infohash) continue
|
||||||
const hash = p.infohash.toLowerCase()
|
const h = p.infohash.toLowerCase()
|
||||||
const entry = (organized[hash] ?? {})[String(ep.id)]
|
const e = (organized[h] ?? {})[String(ep.id)]
|
||||||
if (entry) { result[String(ep.id)] = { ...entry, hash }; break }
|
if (e) { entry = e; entryHash = h; break }
|
||||||
}
|
}
|
||||||
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
|
if (!entry && organized['manual']?.[String(ep.id)]) {
|
||||||
result[String(ep.id)] = organized['manual'][String(ep.id)]
|
entry = organized['manual'][String(ep.id)]
|
||||||
}
|
}
|
||||||
|
if (!entry) continue
|
||||||
|
|
||||||
|
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||||
|
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(result)
|
res.json(result)
|
||||||
@@ -186,16 +193,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||||
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
|
||||||
const srcExt = path.extname(currentName)
|
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
|
||||||
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, entryHash)
|
|
||||||
let newName: string
|
|
||||||
if (nfoSupport) {
|
|
||||||
newName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
|
|
||||||
} else {
|
|
||||||
newName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
|
||||||
}
|
|
||||||
if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
|
|
||||||
const oldPath = orgEntry.dest_path
|
const oldPath = orgEntry.dest_path
|
||||||
const newPath = path.join(path.dirname(oldPath), newName)
|
const newPath = path.join(path.dirname(oldPath), newName)
|
||||||
try {
|
try {
|
||||||
@@ -335,21 +334,20 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
|||||||
for (const ep of season.episodes ?? []) {
|
for (const ep of season.episodes ?? []) {
|
||||||
let orgEntry: any = null
|
let orgEntry: any = null
|
||||||
let orgHash: string | null = null
|
let orgHash: string | null = null
|
||||||
for (const [hash, eps] of Object.entries(organized)) {
|
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
|
||||||
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
|
||||||
|
for (const p of ep.paths ?? []) {
|
||||||
|
if (typeof p !== 'object' || !p.infohash) continue
|
||||||
|
const h = p.infohash.toLowerCase()
|
||||||
|
const e = (organized[h] ?? {})[String(ep.id)]
|
||||||
|
if (e) { orgEntry = e; orgHash = h; break }
|
||||||
|
}
|
||||||
|
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
|
||||||
|
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
|
||||||
}
|
}
|
||||||
if (!orgEntry) continue
|
if (!orgEntry) continue
|
||||||
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||||
const srcExt = path.extname(currentName)
|
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||||
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
|
|
||||||
let expectedName: string
|
|
||||||
if (nfoSupport) {
|
|
||||||
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
|
|
||||||
} else {
|
|
||||||
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
|
||||||
}
|
|
||||||
const needsRename = expectedName !== currentName
|
|
||||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
|
||||||
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
|
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,16 +380,8 @@ router.post('/rename-all', requireAuth, async (req, res) => {
|
|||||||
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
|
||||||
}
|
}
|
||||||
if (!orgEntry || !orgHash) continue
|
if (!orgEntry || !orgHash) continue
|
||||||
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
|
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||||
const srcExt = path.extname(currentName)
|
if (!needsRename) continue
|
||||||
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, orgHash)
|
|
||||||
let expectedName: string
|
|
||||||
if (nfoSupport) {
|
|
||||||
expectedName = rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName
|
|
||||||
} else {
|
|
||||||
expectedName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
|
|
||||||
}
|
|
||||||
if (expectedName === currentName) continue
|
|
||||||
const oldPath = orgEntry.dest_path
|
const oldPath = orgEntry.dest_path
|
||||||
const newPath = path.join(path.dirname(oldPath), expectedName)
|
const newPath = path.join(path.dirname(oldPath), expectedName)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface TorrentFileProgress {
|
|||||||
index : number
|
index : number
|
||||||
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
|
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
|
||||||
progress: number // 0–1
|
progress: number // 0–1
|
||||||
|
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TorrentInfo {
|
export interface TorrentInfo {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* qBittorrent Driver
|
* qBittorrent Driver
|
||||||
|
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||||
@@ -14,6 +15,15 @@ function mapState(state: string): TorrentInfo['state'] {
|
|||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
|
||||||
|
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
|
||||||
|
if (config.apiKey) {
|
||||||
|
return { Authorization: `Bearer ${config.apiKey}` }
|
||||||
|
}
|
||||||
|
const cookie = await qbLogin(config)
|
||||||
|
return { Cookie: cookie }
|
||||||
|
}
|
||||||
|
|
||||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||||
const form = new URLSearchParams()
|
const form = new URLSearchParams()
|
||||||
form.append('username', String(config.username ?? ''))
|
form.append('username', String(config.username ?? ''))
|
||||||
@@ -26,18 +36,17 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
|||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
|
||||||
|
|
||||||
const cookie = res.headers.get('set-cookie') ?? ''
|
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
|
||||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
if (!cookie) throw new Error('Cookie de session introuvable')
|
||||||
if (!sid) throw new Error('Cookie SID introuvable')
|
return cookie
|
||||||
return sid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function qbTorrentExists(config: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
|
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authHeaders,
|
||||||
})
|
})
|
||||||
if (!res.ok) return false
|
if (!res.ok) return false
|
||||||
const list = await res.json()
|
const list = await res.json()
|
||||||
@@ -61,9 +70,9 @@ async function qbApplyFilePriority(
|
|||||||
for (let attempt = 0; attempt < 60; attempt++) {
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||||
try {
|
try {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (!res.ok) continue
|
if (!res.ok) continue
|
||||||
const files: any[] = await res.json()
|
const files: any[] = await res.json()
|
||||||
@@ -79,7 +88,7 @@ async function qbApplyFilePriority(
|
|||||||
// on se contente d'activer la nouvelle cible sans toucher aux autres.
|
// on se contente d'activer la nouvelle cible sans toucher aux autres.
|
||||||
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
|
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
|
||||||
|
|
||||||
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
|
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
|
||||||
if (isInitialState) {
|
if (isInitialState) {
|
||||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||||
@@ -99,7 +108,7 @@ async function qbApplyFilePriority(
|
|||||||
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
||||||
|
|
||||||
// Vérification : le fichier cible est bien à priorité > 0
|
// Vérification : le fichier cible est bien à priorité > 0
|
||||||
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: { Cookie: `SID=${sid}` } })
|
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
|
||||||
if (verif.ok) {
|
if (verif.ok) {
|
||||||
const verifiedFiles: any[] = await verif.json()
|
const verifiedFiles: any[] = await verif.json()
|
||||||
const target = verifiedFiles[fileIndex]
|
const target = verifiedFiles[fileIndex]
|
||||||
@@ -129,8 +138,8 @@ async function qbApplyFilePriority(
|
|||||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
||||||
if (resume) {
|
if (resume) {
|
||||||
try {
|
try {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
|
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
const body = new URLSearchParams({ hashes: hash })
|
const body = new URLSearchParams({ hashes: hash })
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
||||||
@@ -148,8 +157,9 @@ const QB: TorrentClientDriver = {
|
|||||||
label : 'qBittorrent',
|
label : 'qBittorrent',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
|
||||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
|
||||||
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||||
@@ -159,7 +169,9 @@ const QB: TorrentClientDriver = {
|
|||||||
|
|
||||||
async test(config) {
|
async test(config) {
|
||||||
try {
|
try {
|
||||||
await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
|
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||||
return { ok: true, message: 'Connexion réussie' }
|
return { ok: true, message: 'Connexion réussie' }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -171,9 +183,9 @@ const QB: TorrentClientDriver = {
|
|||||||
|
|
||||||
async healthcheck(config) {
|
async healthcheck(config) {
|
||||||
try {
|
try {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (!res.ok) return { online: false }
|
if (!res.ok) return { online: false }
|
||||||
const version = (await res.text()).trim()
|
const version = (await res.text()).trim()
|
||||||
@@ -186,11 +198,11 @@ const QB: TorrentClientDriver = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async list(config, category) {
|
async list(config, category) {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (category) params.set('category', category)
|
if (category) params.set('category', category)
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||||
const data: any[] = await res.json()
|
const data: any[] = await res.json()
|
||||||
@@ -200,7 +212,7 @@ const QB: TorrentClientDriver = {
|
|||||||
await Promise.all(data.map(async (t: any) => {
|
await Promise.all(data.map(async (t: any) => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -227,6 +239,7 @@ const QB: TorrentClientDriver = {
|
|||||||
index : i,
|
index : i,
|
||||||
name : String(f.name ?? ''),
|
name : String(f.name ?? ''),
|
||||||
progress: f.progress ?? 0, // 0–1
|
progress: f.progress ?? 0, // 0–1
|
||||||
|
priority: f.priority ?? 1,
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
} satisfies TorrentInfo
|
} satisfies TorrentInfo
|
||||||
@@ -234,9 +247,9 @@ const QB: TorrentClientDriver = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getFiles(config, hash) {
|
async getFiles(config, hash) {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data: any[] = await res.json()
|
const data: any[] = await res.json()
|
||||||
@@ -244,11 +257,12 @@ const QB: TorrentClientDriver = {
|
|||||||
index : i,
|
index : i,
|
||||||
name : String(f.name ?? ''),
|
name : String(f.name ?? ''),
|
||||||
progress: f.progress ?? 0,
|
progress: f.progress ?? 0,
|
||||||
|
priority: f.priority ?? 1,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
async add(config, url, options?: DownloadOptions) {
|
async add(config, url, options?: DownloadOptions) {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
|
|
||||||
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
|
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
|
||||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
const hash = (options?.infohash?.toLowerCase() ?? null)
|
||||||
@@ -258,7 +272,7 @@ const QB: TorrentClientDriver = {
|
|||||||
|
|
||||||
if (options?.file_index != null) {
|
if (options?.file_index != null) {
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const exists = await qbTorrentExists(config, sid, hash)
|
const exists = await qbTorrentExists(config, authH, hash)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
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, false).catch(err =>
|
||||||
@@ -272,7 +286,7 @@ const QB: TorrentClientDriver = {
|
|||||||
let knownHashes: Set<string> = new Set()
|
let knownHashes: Set<string> = new Set()
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
try {
|
try {
|
||||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } })
|
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
|
||||||
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
|
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -302,7 +316,7 @@ const QB: TorrentClientDriver = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||||
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
|
method: 'POST', body: form, headers: authH,
|
||||||
})
|
})
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||||
@@ -320,8 +334,8 @@ const QB: TorrentClientDriver = {
|
|||||||
for (let attempt = 0; attempt < 20; attempt++) {
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
try {
|
try {
|
||||||
const s = await qbLogin(config)
|
const ah = await qbAuth(config)
|
||||||
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } })
|
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
|
||||||
if (!lr.ok) continue
|
if (!lr.ok) continue
|
||||||
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
||||||
if (!newT) continue
|
if (!newT) continue
|
||||||
@@ -344,7 +358,7 @@ const QB: TorrentClientDriver = {
|
|||||||
if (config.savePath) form.append('savepath', String(config.savePath))
|
if (config.savePath) form.append('savepath', String(config.savePath))
|
||||||
|
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||||
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
|
method: 'POST', body: form, headers: authH,
|
||||||
})
|
})
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||||
@@ -352,14 +366,14 @@ const QB: TorrentClientDriver = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async remove(config, hash, deleteFiles = false) {
|
async remove(config, hash, deleteFiles = false) {
|
||||||
const sid = await qbLogin(config)
|
const authH = await qbAuth(config)
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('hashes', hash)
|
form.append('hashes', hash)
|
||||||
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
body : form,
|
body : form,
|
||||||
headers: { Cookie: `SID=${sid}` },
|
headers: authH,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
|
||||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ const RT: TorrentClientDriver = {
|
|||||||
index : i,
|
index : i,
|
||||||
name : String(f[2] ?? ''),
|
name : String(f[2] ?? ''),
|
||||||
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
|
||||||
|
priority: 1,
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
} satisfies TorrentInfo
|
} satisfies TorrentInfo
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ function epProgress(ep: any): ActiveTorrent | null {
|
|||||||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
// 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
|
// 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.
|
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||||||
if (file != null && (sessionDl || (file.progress > 0 && file.progress < 1))) {
|
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
|
||||||
return { ...active, progress: Math.round(file.progress * 100) }
|
return { ...active, progress: Math.round(file.progress * 100) }
|
||||||
}
|
}
|
||||||
if (!sessionDl) continue
|
if (!sessionDl) continue
|
||||||
@@ -519,8 +519,7 @@ function epExpectedName(ep: any): string {
|
|||||||
function epNeedsRename(ep: any): boolean {
|
function epNeedsRename(ep: any): boolean {
|
||||||
const entry = props.organizedByEpisode[String(ep.id)]
|
const entry = props.organizedByEpisode[String(ep.id)]
|
||||||
if (!entry) return false
|
if (!entry) return false
|
||||||
const expected = epExpectedName(ep)
|
return entry.needs_rename === true
|
||||||
return !!expected && expected !== entry.dest_filename
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.13.2
|
3.14.2
|
||||||
Reference in new issue
Block a user