Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b39a5a8c4 | ||
|
|
50144c70dc | ||
|
|
482743bf3a | ||
|
|
f3a36af629 | ||
|
|
bc960ba585 | ||
|
|
e648764715 | ||
|
|
15fac6c472 | ||
|
|
61591020be | ||
|
|
f92b3c5fb9 | ||
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 |
No files matched your search
@@ -1,5 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.4.0](https://github.com/Masutayunikon/FanKarr/compare/v3.3.3...v3.4.0) (2026-04-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ajout d'un retour visuel si on a un path par defaut sur / ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||||
|
* Ajout du support pour l'édition/mise à jour des clients torrent ([dda8a03](https://github.com/Masutayunikon/FanKarr/commit/dda8a03403b46027a23b4a2812ca3797a2628673))
|
||||||
|
* Ajout du support pour l'édition/mise à jour des clients torrent ([a3e1c34](https://github.com/Masutayunikon/FanKarr/commit/a3e1c34e7547bab75c45ddd3eace0d211409ccd5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* le driver de Transmission n'est plus basé sur android ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||||
|
* les torrent inconnue en états seront mis dans la catégorie terminée ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||||
|
* typo ([18ec548](https://github.com/Masutayunikon/FanKarr/commit/18ec548d76c929afac11cd16d88e734480ee2755))
|
||||||
|
* utilisation de nfo_filename au lieu de original_filename ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
|
||||||
|
|
||||||
|
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
|
||||||
|
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
|
||||||
|
|
||||||
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
|
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -7,7 +7,7 @@ import { readSettings, writeSettings } from './settings.js'
|
|||||||
import {
|
import {
|
||||||
registerDriver, getDriver, getAvailableClients,
|
registerDriver, getDriver, getAvailableClients,
|
||||||
listClients, addClient, removeClient, getClient,
|
listClients, addClient, removeClient, getClient,
|
||||||
sanitizeClient, dispatchDownload, dispatchList
|
sanitizeClient, dispatchDownload, dispatchList, updateClient
|
||||||
} from './torrent-clients/index.js'
|
} from './torrent-clients/index.js'
|
||||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||||
import transmissionDriver from './torrent-clients/transmission.js'
|
import transmissionDriver from './torrent-clients/transmission.js'
|
||||||
@@ -71,11 +71,31 @@ app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
|||||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
app.put('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||||
|
const { name, type, config } = req.body
|
||||||
|
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||||
|
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||||
|
const updated = updateClient(String(req.params.uuid), name, type, config)
|
||||||
|
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||||
|
res.json(sanitizeClient(updated))
|
||||||
|
})
|
||||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||||
const { type, config } = req.body
|
const { type, config, uuid } = req.body
|
||||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
||||||
const driver = getDriver(type)
|
const driver = getDriver(type)
|
||||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||||
|
|
||||||
|
if (uuid) {
|
||||||
|
const oldClient = getClient(uuid)
|
||||||
|
if (oldClient) {
|
||||||
|
for (const field of driver.definition.fields) {
|
||||||
|
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||||
|
config[field.key] = oldClient.config[field.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(await driver.test(config))
|
res.json(await driver.test(config))
|
||||||
})
|
})
|
||||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
||||||
|
|||||||
+39
-23
@@ -13,7 +13,6 @@ import { DATA_DIR } from './config.js'
|
|||||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||||
|
|
||||||
// ─── Utils log ────────────────────────────────────────────────
|
// ─── Utils log ────────────────────────────────────────────────
|
||||||
// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe
|
|
||||||
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||||
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
||||||
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||||
@@ -51,29 +50,23 @@ function isOrganized(hash: string, filename: string): boolean {
|
|||||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||||
const h = hash.toLowerCase()
|
const h = hash.toLowerCase()
|
||||||
for (const sd of seriesData) {
|
for (const sd of seriesData) {
|
||||||
// Niveau série (pack intégrale)
|
|
||||||
for (const t of sd.torrents ?? []) {
|
for (const t of sd.torrents ?? []) {
|
||||||
if (t.infohash?.toLowerCase() === h)
|
if (t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: t, serieData: sd }
|
return { torrent: t, serieData: sd }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons
|
|
||||||
// Si oui, pas de _season pour que buildFileMap scanne toutes les saisons
|
|
||||||
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||||
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
)
|
)
|
||||||
if (seasonsWithHash.length === 1) {
|
if (seasonsWithHash.length === 1) {
|
||||||
// Torrent propre à une seule saison → on filtre sur cette saison
|
|
||||||
const season = seasonsWithHash[0]
|
const season = seasonsWithHash[0]
|
||||||
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||||
} else if (seasonsWithHash.length > 1) {
|
} else if (seasonsWithHash.length > 1) {
|
||||||
// Même torrent dans plusieurs saisons → pas de filtre saison
|
|
||||||
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
return { torrent: t, serieData: sd }
|
return { torrent: t, serieData: sd }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Niveau épisode
|
|
||||||
for (const season of sd.seasons ?? []) {
|
for (const season of sd.seasons ?? []) {
|
||||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||||
@@ -97,8 +90,9 @@ function buildFileMap(
|
|||||||
hash : string,
|
hash : string,
|
||||||
nfoSupport : boolean,
|
nfoSupport : boolean,
|
||||||
seasonFilter?: number,
|
seasonFilter?: number,
|
||||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
): Map<string, { season_number: number; nfo_filename: string; fullPath: string }> {
|
||||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
// FIX : type de retour utilise nfo_filename au lieu de original_filename
|
||||||
|
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
|
||||||
const h = hash.toLowerCase()
|
const h = hash.toLowerCase()
|
||||||
|
|
||||||
for (const season of serieData.seasons ?? []) {
|
for (const season of serieData.seasons ?? []) {
|
||||||
@@ -124,17 +118,21 @@ function buildFileMap(
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (!nfoSupport && !fmtName) {
|
if (!nfoSupport && !fmtName) {
|
||||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`)
|
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const destName = nfoSupport
|
// FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut
|
||||||
? (ep.original_filename ?? filename)
|
// On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv)
|
||||||
: (fmtName ?? ep.original_filename ?? filename)
|
const srcExt = path.extname(filename)
|
||||||
|
const resolvedName = nfoSupport
|
||||||
|
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||||
|
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||||
|
const destName = swapExtension(resolvedName, srcExt)
|
||||||
|
|
||||||
map.set(filename, {
|
map.set(filename, {
|
||||||
season_number : season.season_number,
|
season_number: season.season_number,
|
||||||
original_filename: destName,
|
nfo_filename : destName,
|
||||||
fullPath : matchedPath,
|
fullPath : matchedPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +239,18 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Filesystem ops ───────────────────────────────────────────
|
// ─── Filesystem ops ───────────────────────────────────────────
|
||||||
function seasonFolder(n: number): string { return `Saison ${n}` }
|
|
||||||
|
// FIX : remplace l'extension d'un nom de fichier par celle du fichier source
|
||||||
|
// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv"
|
||||||
|
function swapExtension(filename: string, ext: string): string {
|
||||||
|
const currentExt = path.extname(filename)
|
||||||
|
if (!currentExt || currentExt === ext) return filename
|
||||||
|
return filename.slice(0, -currentExt.length) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
function seasonFolder(n: number): string {
|
||||||
|
return n === 0 ? 'Specials' : `Saison ${n}`
|
||||||
|
}
|
||||||
|
|
||||||
function tryHardlink(src: string, dest: string): boolean {
|
function tryHardlink(src: string, dest: string): boolean {
|
||||||
try { fs.linkSync(src, dest); return true }
|
try { fs.linkSync(src, dest); return true }
|
||||||
@@ -294,9 +303,15 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
const fmtName = ep.formatted_name?.trim()
|
const fmtName = ep.formatted_name?.trim()
|
||||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||||
: null
|
: null
|
||||||
const destName = nfoSupport
|
|
||||||
? (ep.original_filename ?? filename)
|
// FIX : priorité à nfo_filename pour épisode unique aussi
|
||||||
: (fmtName ?? ep.original_filename ?? filename)
|
// On swap l'extension (.nfo → extension du fichier source)
|
||||||
|
const srcExt = path.extname(filename)
|
||||||
|
const resolvedName = nfoSupport
|
||||||
|
? (ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||||
|
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
|
||||||
|
const destName = swapExtension(resolvedName, srcExt)
|
||||||
|
|
||||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||||
const dest = path.join(destDir, destName)
|
const dest = path.join(destDir, destName)
|
||||||
|
|
||||||
@@ -345,7 +360,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
|
|
||||||
result.total = fileMap.size
|
result.total = fileMap.size
|
||||||
|
|
||||||
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
|
// FIX : destructure nfo_filename au lieu de original_filename
|
||||||
|
for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) {
|
||||||
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -364,7 +380,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
||||||
const dest = path.join(destDir, original_filename)
|
const dest = path.join(destDir, nfo_filename)
|
||||||
|
|
||||||
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
|
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
|
||||||
|
|
||||||
@@ -378,7 +394,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
|||||||
}
|
}
|
||||||
markOrganized(hash, filename)
|
markOrganized(hash, filename)
|
||||||
result.done++
|
result.done++
|
||||||
debug(`${original_filename} → Saison ${season_number}`)
|
debug(`${nfo_filename} → Saison ${season_number}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
error(`Échec import "${filename}" : ${msg}`)
|
error(`Échec import "${filename}" : ${msg}`)
|
||||||
|
|||||||
@@ -103,6 +103,30 @@ export function addClient(name: string, type: string, config: Record<string, str
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
|
||||||
|
const clients = loadClients()
|
||||||
|
const index = clients.findIndex(c => c.uuid === uuid)
|
||||||
|
if (index === -1) return null
|
||||||
|
|
||||||
|
const oldClient = clients[index]
|
||||||
|
const definition = getDriver(type)?.definition
|
||||||
|
|
||||||
|
// Conserver les mots de passe si "••••••••" est envoyé
|
||||||
|
if (definition) {
|
||||||
|
for (const field of definition.fields) {
|
||||||
|
if (field.type === 'password' && config[field.key] === '••••••••') {
|
||||||
|
config[field.key] = oldClient.config[field.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: SavedClient = { uuid, name, type, config }
|
||||||
|
clients[index] = client
|
||||||
|
saveClients(clients)
|
||||||
|
logger.info('torrent-clients', `Client mis à jour : "${name}" (${type})`)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
export function removeClient(uuid: string): boolean {
|
export function removeClient(uuid: string): boolean {
|
||||||
const clients = loadClients()
|
const clients = loadClients()
|
||||||
const target = clients.find(c => c.uuid === uuid)
|
const target = clients.find(c => c.uuid === uuid)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async function trRequest(
|
|||||||
sessionId = ''
|
sessionId = ''
|
||||||
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type' : 'application/json',
|
||||||
'X-Transmission-Session-Id': sessionId,
|
'X-Transmission-Session-Id': sessionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ const TR: TorrentClientDriver = {
|
|||||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
|
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
|
||||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||||
{ key: 'category', label: 'Dossier cible',type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -89,17 +89,18 @@ const TR: TorrentClientDriver = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async list(config, category) {
|
async list(config, category) {
|
||||||
const fields = ['hashString', 'name', 'status', 'percentDone', 'totalSize',
|
const fields = [
|
||||||
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels']
|
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||||
|
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels',
|
||||||
|
]
|
||||||
const data = await trRequest(config, 'torrent-get', { fields })
|
const data = await trRequest(config, 'torrent-get', { fields })
|
||||||
const torrents: any[] = data.arguments?.torrents ?? []
|
const torrents: any[] = data.arguments?.torrents ?? []
|
||||||
|
|
||||||
return torrents
|
return torrents
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!category) return true
|
if (!category) return true
|
||||||
// Filtrer par label ou par dossier contenant la catégorie
|
// Filtrer par label uniquement, comme qBittorrent filtre par catégorie
|
||||||
return t.labels?.includes(category) ||
|
return t.labels?.includes(category)
|
||||||
(t.downloadDir as string)?.includes(category)
|
|
||||||
})
|
})
|
||||||
.map(t => ({
|
.map(t => ({
|
||||||
hash : t.hashString,
|
hash : t.hashString,
|
||||||
@@ -120,9 +121,6 @@ const TR: TorrentClientDriver = {
|
|||||||
|
|
||||||
if (config.savePath) {
|
if (config.savePath) {
|
||||||
args['download-dir'] = String(config.savePath)
|
args['download-dir'] = String(config.savePath)
|
||||||
} else if (config.category) {
|
|
||||||
// Si pas de savePath explicite, ajouter la catégorie comme sous-dossier
|
|
||||||
args['download-dir'] = `/sdcard/Download/${config.category}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.category) {
|
if (config.category) {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
|
|
||||||
<!-- Chemin éditable -->
|
<!-- Chemin éditable -->
|
||||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||||
|
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
v-model="inputPath"
|
v-model="inputPath"
|
||||||
@keydown.enter="navigateTo(inputPath)"
|
@keydown.enter="navigateTo(inputPath)"
|
||||||
@@ -62,7 +68,8 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
|
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
|
||||||
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
||||||
<button @click="$emit('select', current)" class="btn-primary">
|
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
|
||||||
|
<button @click="selectCurrent()" class="btn-primary">
|
||||||
Choisir
|
Choisir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,8 +92,15 @@ const parent = ref<string | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||||
|
function selectCurrent() {
|
||||||
|
emit('select', current.value.replace(/\/+$/, '') || '/')
|
||||||
|
}
|
||||||
|
|
||||||
function joinPath(base: string, name: string): string {
|
function joinPath(base: string, name: string): string {
|
||||||
return base === '/' ? `/${name}` : `${base}/${name}`
|
if (base === '/') return `/${name}`
|
||||||
|
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||||
|
return `${cleanBase}/${name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateTo(p: string) {
|
async function navigateTo(p: string) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right shrink-0">
|
<div class="text-right shrink-0">
|
||||||
<p class="text-sm font-semibold text-primary">{{ t.progress }}%</p>
|
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
|
||||||
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,10 +168,10 @@
|
|||||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all duration-500"
|
class="h-full rounded-full transition-all duration-500"
|
||||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
|
||||||
t.state === 'error' ? 'bg-red-500' :
|
t.state === 'error' ? 'bg-red-500' :
|
||||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||||
:style="{ width: `${t.progress}%` }"
|
:style="{ width: `${clampedProgress(t)}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,9 +181,10 @@
|
|||||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||||
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
||||||
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
|
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
|
||||||
|
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
|
||||||
|
|
||||||
<!-- Erreurs fichiers -->
|
<!-- Erreurs fichiers -->
|
||||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||||
@@ -275,12 +276,13 @@ function setSort(val: typeof activeSort.value) {
|
|||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents
|
||||||
const activeTorrents = computed(() =>
|
const activeTorrents = computed(() =>
|
||||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
||||||
)
|
)
|
||||||
|
|
||||||
const doneTorrents = computed(() =>
|
const doneTorrents = computed(() =>
|
||||||
torrents.value.filter(t => t.state === 'seeding')
|
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleTorrents = computed(() => {
|
const visibleTorrents = computed(() => {
|
||||||
@@ -320,6 +322,11 @@ const stats = computed(() => [
|
|||||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100%
|
||||||
|
function clampedProgress(t: any): number {
|
||||||
|
return Math.min(100, Math.max(0, t.progress ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchTorrents() {
|
async function fetchTorrents() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||||
@@ -406,7 +413,8 @@ function stateBadge(state: string) {
|
|||||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||||
unknown : { label: 'Inconnu', class: 'border-border text-muted' },
|
// FIX : badge jaune pour signaler état dégradé sans alarmer
|
||||||
|
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||||
}
|
}
|
||||||
return map[state] ?? map.unknown
|
return map[state] ?? map.unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
||||||
|
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||||
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
|
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold text-primary">Ajouter un client</h3>
|
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
|
||||||
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ const healthStatus = ref<Record<string, boolean | null>>({})
|
|||||||
|
|
||||||
const modal = ref({
|
const modal = ref({
|
||||||
open : false,
|
open : false,
|
||||||
|
uuid : null as string | null,
|
||||||
name : '',
|
name : '',
|
||||||
type : '',
|
type : '',
|
||||||
config : {} as Record<string, string>,
|
config : {} as Record<string, string>,
|
||||||
@@ -171,7 +173,20 @@ async function deleteClient(uuid: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAddModal() {
|
function openAddModal() {
|
||||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function editClient(client: any) {
|
||||||
|
modal.value = {
|
||||||
|
open : true,
|
||||||
|
uuid : client.uuid,
|
||||||
|
name : client.name,
|
||||||
|
type : client.type,
|
||||||
|
config : JSON.parse(JSON.stringify(client.config)),
|
||||||
|
testing: false,
|
||||||
|
saving : false,
|
||||||
|
tested : true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() { modal.value.open = false }
|
function closeModal() { modal.value.open = false }
|
||||||
@@ -193,7 +208,7 @@ async function testNewClient() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/torrent-clients/test-config', {
|
const res = await fetch('/api/torrent-clients/test-config', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config }),
|
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
|
||||||
})
|
})
|
||||||
const { ok, message } = await res.json()
|
const { ok, message } = await res.json()
|
||||||
modal.value.tested = ok
|
modal.value.tested = ok
|
||||||
@@ -209,8 +224,12 @@ async function saveClient() {
|
|||||||
if (!modal.value.tested) return
|
if (!modal.value.tested) return
|
||||||
modal.value.saving = true
|
modal.value.saving = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/torrent-clients', {
|
const isEdit = !!modal.value.uuid
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
const method = isEdit ? 'PUT' : 'POST'
|
||||||
|
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name : modal.value.name || clientLabel(modal.value.type),
|
name : modal.value.name || clientLabel(modal.value.type),
|
||||||
type : modal.value.type,
|
type : modal.value.type,
|
||||||
@@ -219,10 +238,15 @@ async function saveClient() {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const client = await res.json()
|
const client = await res.json()
|
||||||
clients.value.push(client)
|
if (isEdit) {
|
||||||
|
const index = clients.value.findIndex(c => c.uuid === client.uuid)
|
||||||
|
if (index !== -1) clients.value[index] = client
|
||||||
|
} else {
|
||||||
|
clients.value.push(client)
|
||||||
|
}
|
||||||
refreshHealth(client.uuid)
|
refreshHealth(client.uuid)
|
||||||
closeModal()
|
closeModal()
|
||||||
toast('Client enregistré ✓', 'success')
|
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
|
||||||
} else {
|
} else {
|
||||||
const { error } = await res.json()
|
const { error } = await res.json()
|
||||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||||
|
|||||||
@@ -15,10 +15,13 @@
|
|||||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||||
:class="form.completePath ? 'text-primary' : 'text-muted'">
|
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
|
||||||
{{ form.completePath || '/' }}
|
{{ form.completePath || '/' }}
|
||||||
</button>
|
</button>
|
||||||
<p class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||||
|
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -27,14 +30,29 @@
|
|||||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||||
:class="form.mediaPath ? 'text-primary' : 'text-muted'">
|
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||||
{{ form.mediaPath || '/' }}
|
{{ form.mediaPath || '/' }}
|
||||||
</button>
|
</button>
|
||||||
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||||
|
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerte globale si un des deux paths est sur / -->
|
||||||
|
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||||
|
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||||
|
Configurez-les ci-dessus avant de sauvegarder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mode import -->
|
<!-- Mode import -->
|
||||||
<div class="settings-card flex flex-col gap-2">
|
<div class="settings-card flex flex-col gap-2">
|
||||||
<label class="settings-label">Mode d'import</label>
|
<label class="settings-label">Mode d'import</label>
|
||||||
@@ -109,7 +127,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import FolderPicker from '@/components/FolderPicker.vue'
|
import FolderPicker from '@/components/FolderPicker.vue'
|
||||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||||
@@ -129,16 +147,23 @@ const form = ref({
|
|||||||
autoImport : true,
|
autoImport : true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: systemInfo } = await fetch('/api/system/info')
|
|
||||||
|
|
||||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||||
open: false,
|
open: false,
|
||||||
field: 'completePath',
|
field: 'completePath',
|
||||||
currentPath: systemInfo.value?.defaultPath ?? '/',
|
currentPath: '/',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// FIX : détection path non configuré (vide ou racine /)
|
||||||
|
function isRootPath(p: string): boolean {
|
||||||
|
return !p || p === '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnconfiguredPaths = computed(() =>
|
||||||
|
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
|
||||||
|
)
|
||||||
|
|
||||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||||
picker.value = { open: true, field, currentPath: form.value[field] || '/' }
|
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPickerSelect(path: string) {
|
function onPickerSelect(path: string) {
|
||||||
@@ -147,12 +172,14 @@ function onPickerSelect(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [settingsRes, systemRes] = await Promise.all([
|
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||||
fetch('/api/settings', { credentials: 'include' }),
|
fetch('/api/settings', { credentials: 'include' }),
|
||||||
fetch('/api/system', { credentials: 'include' }),
|
fetch('/api/system', { credentials: 'include' }),
|
||||||
|
fetch('/api/system/info', { credentials: 'include' }),
|
||||||
])
|
])
|
||||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||||
|
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|||||||
Reference in new issue
Block a user