first commit

This commit is contained in:
2026-01-02 15:49:01 +03:00
commit 4348f76a7c
80 changed files with 10133 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { QbitClient } from "./qbit.client"
import { QbitCapabilities } from "./qbit.types"
export const detectCapabilities = async (
client: QbitClient
): Promise<QbitCapabilities> => {
const version = await client.getVersion();
let hasPeersEndpoint = true;
let hasBanEndpoint = true;
try {
await client.getTorrentPeers("__probe__");
} catch (error) {
const status = (error as any)?.response?.status;
if (status !== 400 && status !== 404) {
hasPeersEndpoint = false;
}
}
try {
await client.banPeers(["127.0.0.1:1"]);
} catch (error) {
const status = (error as any)?.response?.status;
if (status !== 400 && status !== 404) {
hasBanEndpoint = false;
}
}
return { version, hasPeersEndpoint, hasBanEndpoint };
};

View File

@@ -0,0 +1,177 @@
import axios, { AxiosInstance } from "axios";
import { CookieJar } from "tough-cookie";
import { wrapper } from "axios-cookiejar-support";
import FormData from "form-data";
import fs from "node:fs";
import { config } from "../config"
import { logger } from "../utils/logger"
import {
QbitPeerList,
QbitTorrentInfo,
QbitTorrentProperties,
QbitTransferInfo,
} from "./qbit.types"
export class QbitClient {
private client: AxiosInstance;
private jar: CookieJar;
private loggedIn = false;
constructor() {
this.jar = new CookieJar();
this.client = wrapper(
axios.create({
baseURL: config.qbitBaseUrl,
jar: this.jar,
withCredentials: true,
})
);
}
async login(): Promise<void> {
if (!config.qbitBaseUrl) {
throw new Error("QBIT_BASE_URL missing");
}
const form = new URLSearchParams();
form.append("username", config.qbitUsername);
form.append("password", config.qbitPassword);
await this.client.post("/api/v2/auth/login", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
this.loggedIn = true;
}
private async request<T>(fn: () => Promise<T>): Promise<T> {
try {
if (!this.loggedIn) {
await this.login();
}
return await fn();
} catch (error) {
if (
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
logger.warn("qBittorrent session expired, re-login");
this.loggedIn = false;
await this.login();
return await fn();
}
throw error;
}
}
async getVersion(): Promise<string> {
const response = await this.request(() =>
this.client.get<string>("/api/v2/app/version")
);
return response.data;
}
async getTorrentsInfo(): Promise<QbitTorrentInfo[]> {
const response = await this.request(() =>
this.client.get<QbitTorrentInfo[]>("/api/v2/torrents/info", {
params: { filter: "all" },
})
);
return response.data;
}
async getTransferInfo(): Promise<QbitTransferInfo> {
const response = await this.request(() =>
this.client.get<QbitTransferInfo>("/api/v2/transfer/info")
);
return response.data;
}
async getTorrentProperties(hash: string): Promise<QbitTorrentProperties> {
const response = await this.request(() =>
this.client.get<QbitTorrentProperties>("/api/v2/torrents/properties", {
params: { hash },
})
);
return response.data;
}
async getTorrentPeers(hash: string): Promise<QbitPeerList> {
const response = await this.request(() =>
this.client.get<QbitPeerList>("/api/v2/sync/torrentPeers", {
params: { hash },
})
);
return response.data;
}
async exportTorrent(hash: string): Promise<Buffer> {
const response = await this.request(() =>
this.client.get<ArrayBuffer>("/api/v2/torrents/export", {
params: { hashes: hash },
responseType: "arraybuffer",
})
);
return Buffer.from(response.data);
}
async addTorrentByMagnet(magnet: string, options: Record<string, string> = {}) {
const form = new URLSearchParams();
form.append("urls", magnet);
Object.entries(options).forEach(([key, value]) => form.append(key, value));
await this.request(() =>
this.client.post("/api/v2/torrents/add", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
);
}
async addTorrentByFile(filePath: string, options: Record<string, string> = {}) {
const form = new FormData();
form.append("torrents", fs.createReadStream(filePath));
Object.entries(options).forEach(([key, value]) => form.append(key, value));
await this.request(() =>
this.client.post("/api/v2/torrents/add", form, {
headers: form.getHeaders(),
})
);
}
async deleteTorrent(hash: string, deleteFiles = true) {
const form = new URLSearchParams();
form.append("hashes", hash);
form.append("deleteFiles", deleteFiles ? "true" : "false");
await this.request(() =>
this.client.post("/api/v2/torrents/delete", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
);
}
async pauseTorrent(hash: string) {
const form = new URLSearchParams();
form.append("hashes", hash);
await this.request(() =>
this.client.post("/api/v2/torrents/pause", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
);
}
async resumeTorrent(hash: string) {
const form = new URLSearchParams();
form.append("hashes", hash);
await this.request(() =>
this.client.post("/api/v2/torrents/resume", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
);
}
async banPeers(peers: string[]) {
const form = new URLSearchParams();
form.append("peers", peers.join("|"));
await this.request(() =>
this.client.post("/api/v2/transfer/banPeers", form, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
);
}
}

View File

@@ -0,0 +1,22 @@
import { QbitClient } from "./qbit.client"
import { QbitCapabilities } from "./qbit.types"
let client: QbitClient | null = null;
let capabilities: QbitCapabilities | null = null;
export const setQbitClient = (instance: QbitClient) => {
client = instance;
};
export const getQbitClient = () => {
if (!client) {
throw new Error("Qbit client not initialized");
}
return client;
};
export const setQbitCapabilities = (caps: QbitCapabilities) => {
capabilities = caps;
};
export const getQbitCapabilities = () => capabilities;

View File

@@ -0,0 +1,30 @@
import { Router } from "express";
import { getQbitClient } from "./qbit.context"
const router = Router();
router.get("/torrents", async (_req, res) => {
const qbit = getQbitClient();
const torrents = await qbit.getTorrentsInfo();
res.json(torrents);
});
router.get("/transfer", async (_req, res) => {
const qbit = getQbitClient();
const transfer = await qbit.getTransferInfo();
res.json(transfer);
});
router.get("/torrent/:hash", async (req, res) => {
const qbit = getQbitClient();
const props = await qbit.getTorrentProperties(req.params.hash);
res.json(props);
});
router.delete("/torrent/:hash", async (req, res) => {
const qbit = getQbitClient();
await qbit.deleteTorrent(req.params.hash, true);
res.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,51 @@
export interface QbitTorrentInfo {
hash: string;
name: string;
size: number;
progress: number;
dlspeed: number;
state: string;
magnet_uri?: string;
completed?: number;
tags?: string;
category?: string;
tracker?: string;
seeding_time?: number;
uploaded?: number;
}
export interface QbitTransferInfo {
dl_info_speed: number;
dl_info_data: number;
up_info_speed: number;
up_info_data: number;
connection_status: string;
}
export interface QbitTorrentProperties {
save_path?: string;
completion_on?: number;
comment?: string;
total_size?: number;
piece_size?: number;
}
export interface QbitPeerList {
peers: Record<
string,
{
ip: string;
port: number;
client: string;
progress: number;
dl_speed: number;
up_speed: number;
}
>;
}
export interface QbitCapabilities {
version: string;
hasPeersEndpoint: boolean;
hasBanEndpoint: boolean;
}