first commit
This commit is contained in:
30
apps/server/src/qbit/qbit.capabilities.ts
Normal file
30
apps/server/src/qbit/qbit.capabilities.ts
Normal 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 };
|
||||
};
|
||||
177
apps/server/src/qbit/qbit.client.ts
Normal file
177
apps/server/src/qbit/qbit.client.ts
Normal 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" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
22
apps/server/src/qbit/qbit.context.ts
Normal file
22
apps/server/src/qbit/qbit.context.ts
Normal 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;
|
||||
30
apps/server/src/qbit/qbit.routes.ts
Normal file
30
apps/server/src/qbit/qbit.routes.ts
Normal 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;
|
||||
51
apps/server/src/qbit/qbit.types.ts
Normal file
51
apps/server/src/qbit/qbit.types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user