Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 | ||
|
|
7b780e4fa1 | ||
|
|
444b30ef3d | ||
|
|
42b46496d1 | ||
|
|
f092da4108 | ||
|
|
8e635c1d28 | ||
|
|
3b8b7a0454 | ||
|
|
9ef1516b0b | ||
|
|
1af210058c | ||
|
|
6caad83081 | ||
|
|
520bfcfae1 | ||
|
|
2e5bc8953a | ||
|
|
f304562f01 | ||
|
|
c9690d9a1b | ||
|
|
5daface279 | ||
|
|
5687aaa5d9 | ||
|
|
ba67817ea0 |
No files matched your search
@@ -55,7 +55,7 @@ jobs:
|
|||||||
archive: fankarr-linux-x64.zip
|
archive: fankarr-linux-x64.zip
|
||||||
- target: linux-arm64
|
- target: linux-arm64
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
bun-target: bun-linux-arm64-termux
|
bun-target: bun-linux-arm64
|
||||||
binary: binaries/fankarr
|
binary: binaries/fankarr
|
||||||
archive: fankarr-linux-arm64-termux.zip
|
archive: fankarr-linux-arm64-termux.zip
|
||||||
- target: macos
|
- target: macos
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
|
||||||
|
|
||||||
|
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
|
||||||
|
|
||||||
|
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||||
|
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||||
|
|
||||||
|
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
|
||||||
|
|
||||||
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
|
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import {
|
|||||||
sanitizeClient, dispatchDownload, dispatchList
|
sanitizeClient, dispatchDownload, dispatchList
|
||||||
} 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 { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||||
|
import { systemInfo } from './system.js'
|
||||||
|
|
||||||
registerDriver(qbittorrentDriver)
|
registerDriver(qbittorrentDriver)
|
||||||
|
registerDriver(transmissionDriver)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT) || 9898
|
const PORT = Number(process.env.PORT) || 9898
|
||||||
@@ -36,6 +39,9 @@ app.post('/api/auth/setup', authSetup)
|
|||||||
app.post('/api/auth/login', authLogin)
|
app.post('/api/auth/login', authLogin)
|
||||||
app.post('/api/auth/logout', authLogout)
|
app.post('/api/auth/logout', authLogout)
|
||||||
|
|
||||||
|
// system info
|
||||||
|
app.get('/api/system/info', systemInfo)
|
||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────
|
||||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
app.get('/api/settings', requireAuth, (_req, res) => {
|
||||||
res.json(readSettings())
|
res.json(readSettings())
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
|
import type { Request, Response } from 'express'
|
||||||
|
|
||||||
|
// GET /api/system/info
|
||||||
|
export function systemInfo(_req: Request, res: Response): void {
|
||||||
|
const isTermux = fs.existsSync('/data/data/com.termux')
|
||||||
|
const platform = os.platform()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
platform,
|
||||||
|
isTermux,
|
||||||
|
defaultPath: isTermux ? '/sdcard/' : '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Transmission Driver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||||
|
import { logger } from '../logger.js'
|
||||||
|
|
||||||
|
function mapState(status: number): TorrentInfo['state'] {
|
||||||
|
// Transmission status codes :
|
||||||
|
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
|
||||||
|
// 4 = downloading, 5 = seed queued, 6 = seeding
|
||||||
|
if (status === 4 || status === 3) return 'downloading'
|
||||||
|
if (status === 6 || status === 5) return 'seeding'
|
||||||
|
if (status === 0) return 'paused'
|
||||||
|
if (status === 1 || status === 2) return 'checking'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trRequest(
|
||||||
|
config: Record<string, string | number>,
|
||||||
|
method: string,
|
||||||
|
args: Record<string, unknown> = {},
|
||||||
|
sessionId = ''
|
||||||
|
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Transmission-Session-Id': sessionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.username && config.password) {
|
||||||
|
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${config.url}/transmission/rpc`, {
|
||||||
|
method : 'POST',
|
||||||
|
headers,
|
||||||
|
body : JSON.stringify({ method, arguments: args }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transmission renvoie 409 avec le session ID à la première requête
|
||||||
|
if (res.status === 409) {
|
||||||
|
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
|
||||||
|
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
|
||||||
|
return trRequest(config, method, args, newSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
|
||||||
|
|
||||||
|
return { ...data, sessionId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const TR: TorrentClientDriver = {
|
||||||
|
definition: {
|
||||||
|
id : 'transmission',
|
||||||
|
label : 'Transmission',
|
||||||
|
fields: [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||||
|
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(config) {
|
||||||
|
try {
|
||||||
|
await trRequest(config, 'session-get')
|
||||||
|
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
|
||||||
|
return { ok: true, message: 'Connexion réussie' }
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||||
|
return { ok: false, message: msg }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async healthcheck(config) {
|
||||||
|
try {
|
||||||
|
const data = await trRequest(config, 'session-get')
|
||||||
|
const version = data.arguments?.version ?? 'inconnue'
|
||||||
|
logger.debug('transmission', `Healthcheck OK — version ${version}`)
|
||||||
|
return { online: true, version }
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||||
|
return { online: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(config, category) {
|
||||||
|
const fields = ['hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||||
|
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels']
|
||||||
|
const data = await trRequest(config, 'torrent-get', { fields })
|
||||||
|
const torrents: any[] = data.arguments?.torrents ?? []
|
||||||
|
|
||||||
|
return torrents
|
||||||
|
.filter(t => {
|
||||||
|
if (!category) return true
|
||||||
|
// Filtrer par label ou par dossier contenant la catégorie
|
||||||
|
return t.labels?.includes(category) ||
|
||||||
|
(t.downloadDir as string)?.includes(category)
|
||||||
|
})
|
||||||
|
.map(t => ({
|
||||||
|
hash : t.hashString,
|
||||||
|
name : t.name,
|
||||||
|
state : mapState(t.status),
|
||||||
|
progress : Math.round(t.percentDone * 100),
|
||||||
|
size : t.totalSize,
|
||||||
|
downloaded: t.downloadedEver,
|
||||||
|
speed : t.rateDownload,
|
||||||
|
eta : t.eta ?? -1,
|
||||||
|
save_path : t.downloadDir,
|
||||||
|
category : t.labels?.[0] ?? '',
|
||||||
|
} satisfies TorrentInfo))
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(config, url) {
|
||||||
|
const args: Record<string, unknown> = { filename: url }
|
||||||
|
|
||||||
|
if (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) {
|
||||||
|
args['labels'] = [String(config.category)]
|
||||||
|
}
|
||||||
|
|
||||||
|
await trRequest(config, 'torrent-add', args)
|
||||||
|
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TR
|
||||||
@@ -130,11 +130,13 @@ const form = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||||
open: false, field: 'completePath', currentPath: '/',
|
open: false,
|
||||||
|
field: 'completePath',
|
||||||
|
currentPath: '/',
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
||||||
@@ -143,12 +145,15 @@ 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