├── .gitattributes ├── src ├── .gitignore ├── public │ ├── logo.png │ ├── Roboto.ttf │ └── favicon.ico ├── amule │ ├── amule.types.tsx │ ├── amule.conf │ ├── api.php │ ├── amule.tsx │ └── native-amule.ts.o ├── postcss.config.mjs ├── app │ ├── utils │ │ ├── key.ts │ │ ├── generators.ts │ │ ├── types.ts │ │ ├── mediadb.ts │ │ ├── useRevalidate.ts │ │ ├── naming.ts │ │ ├── math.ts │ │ ├── async.ts │ │ ├── time.ts │ │ ├── state.ts │ │ ├── array.ts │ │ ├── logger.ts │ │ ├── indexers.ts │ │ ├── memoize.ts │ │ └── jsonDb.ts │ ├── routes │ │ ├── api.v2.sync.maindata.tsx │ │ ├── revalidate.tsx │ │ ├── health.tsx │ │ ├── api.v2.app.webapiVersion.tsx │ │ ├── api.v2.torrents.categories.tsx │ │ ├── api.v2.app.preferences.tsx │ │ ├── api.v2.torrents.createCategory.tsx │ │ ├── api.v2.torrents.delete.tsx │ │ ├── api.v2.torrents.add.tsx │ │ ├── api.v2.torrents.setCategory.tsx │ │ ├── api.v2.auth.login.tsx │ │ ├── api.v2.ed2k.add.tsx │ │ ├── _shell._index.tsx │ │ ├── api.v2.torrents.info.tsx │ │ ├── api.tsx │ │ ├── _shell.search.tsx │ │ ├── _shell.download-client.tsx │ │ └── _shell.tsx │ ├── icons │ │ ├── deleteIcon.tsx │ │ ├── libraryIcon.tsx │ │ ├── addIcon.tsx │ │ ├── userIcon.tsx │ │ ├── searchIcon.tsx │ │ ├── upIcon.tsx │ │ ├── downIcon.tsx │ │ ├── uploadIcon.tsx │ │ ├── downloadIcon.tsx │ │ ├── bufferIcon.tsx │ │ ├── discoverIcon.tsx │ │ └── ratioIcon.tsx │ ├── entry.client.tsx │ ├── data │ │ ├── categories.ts │ │ ├── known.ts │ │ ├── downloadClient.ts │ │ └── search.ts │ ├── root.tsx │ ├── links.tsx │ ├── components │ │ └── categoryPicker.tsx │ ├── global.css │ └── entry.server.tsx ├── healthcheck.sh ├── index.d.ts ├── vite.config.ts ├── cron │ ├── detectBrokenAmule.ts │ ├── resumePausedDownloads.ts │ └── cron.ts ├── README.md ├── tsconfig.json ├── tailwind.config.ts ├── server.js ├── entrypoint.sh └── package.json ├── .prettierrc.json ├── .vscode └── settings.json ├── launchSettings.json ├── .dockerignore ├── LICENSE ├── .github └── workflows │ └── docker-image.yml ├── Dockerfile ├── .eslintrc.cjs ├── docker-compose.yml ├── README.md └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /src/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isc30/eMulerr/HEAD/src/public/logo.png -------------------------------------------------------------------------------- /src/public/Roboto.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isc30/eMulerr/HEAD/src/public/Roboto.ttf -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isc30/eMulerr/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /src/amule/amule.types.tsx: -------------------------------------------------------------------------------- 1 | export enum AmuleCategory { 2 | all = 0, 3 | downloads = 1, 4 | } 5 | -------------------------------------------------------------------------------- /src/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/utils/key.ts: -------------------------------------------------------------------------------- 1 | 2 | export function mediaFileKey(path: string, size: number) { 3 | return path + size 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "plugins": ["prettier-plugin-tailwindcss"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } -------------------------------------------------------------------------------- /src/app/routes/api.v2.sync.maindata.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | 3 | export const action = (() => 4 | json({ rid: 0, full_update: false })) satisfies ActionFunction 5 | -------------------------------------------------------------------------------- /src/app/routes/revalidate.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "@remix-run/node" 2 | 3 | export const loader = (async ({ request }) => { 4 | return null 5 | }) satisfies LoaderFunction 6 | 7 | export const action = loader 8 | -------------------------------------------------------------------------------- /src/healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | emulerrOk=$(curl -LI http://localhost:$PORT/health -o /dev/null -w '%{http_code}' -s) 4 | 5 | if [ ${emulerrOk} -eq 200 ]; then 6 | exit 0 7 | else 8 | exit 1 9 | fi 10 | -------------------------------------------------------------------------------- /src/app/routes/health.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, json } from "@remix-run/node" 2 | 3 | export const loader = (async ({ request }) => { 4 | return json({ ok: 1 }) 5 | }) satisfies LoaderFunction 6 | 7 | export const action = loader 8 | -------------------------------------------------------------------------------- /launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Docker Compose": { 4 | "commandName": "DockerCompose", 5 | "commandVersion": "1.0", 6 | "serviceActions": { 7 | "poc": "StartDebugging" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset" 2 | 3 | declare global { 4 | namespace NodeJS { 5 | interface ProcessEnv { 6 | PUID: string; 7 | PGID: string; 8 | PORT: string; 9 | ED2K_PORT: string; 10 | PASSWORD: string; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.app.webapiVersion.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "@remix-run/node" 2 | 3 | export const loader = (() => 4 | new Response(`2.8.19`, { 5 | status: 200, 6 | headers: { 7 | "Content-Type": "text", 8 | "X-Content-Type-Options": "nosniff", 9 | "Cache-Control": "public, max-age=0", 10 | }, 11 | })) satisfies LoaderFunction 12 | -------------------------------------------------------------------------------- /src/app/utils/generators.ts: -------------------------------------------------------------------------------- 1 | export type AsyncGeneratorReturnType any> = ReturnType extends AsyncGenerator ? R : never 2 | 3 | export async function yieldAll(it: AsyncGenerator, callback?: (yielded: Y) => void) { 4 | let r 5 | while (!(r = await it.next()).done) { 6 | callback?.(r.value) 7 | } 8 | 9 | return r.value 10 | } -------------------------------------------------------------------------------- /src/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { installGlobals } from "@remix-run/node"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [remix(), tsconfigPaths()], 10 | server: { 11 | watch: { 12 | usePolling: true 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.categories.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, json } from "@remix-run/node" 2 | import { getCategories } from "~/data/categories" 3 | 4 | export const loader = (async () => { 5 | const categories = await getCategories() 6 | 7 | return json( 8 | Object.fromEntries(categories.map((c) => [c, { name: c, savePath: "" }])) 9 | ) 10 | }) satisfies LoaderFunction 11 | 12 | export const action = loader 13 | -------------------------------------------------------------------------------- /src/app/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunctionArgs, LoaderFunctionArgs, TypedDeferredData } from "@remix-run/node"; 2 | 3 | export type ActionOrLoaderReturnType Promise<{ json(): Promise }>> = Awaited>['json']>> 4 | 5 | export type DeferredActionOrLoaderReturnType Promise>> = Awaited>['data'] 6 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.app.preferences.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, json } from "@remix-run/node" 2 | 3 | export const loader = (() => 4 | json({ 5 | save_path: "/downloads/complete", 6 | temp_path_enabled: true, 7 | temp_path: "/downloads/incomplete", 8 | create_subfolder_enabled: false, 9 | max_ratio_enabled: true, 10 | max_ratio: 0, 11 | max_seeding_time_enabled: true, 12 | max_seeding_time: 0, 13 | })) satisfies LoaderFunction 14 | 15 | export const action = loader 16 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.createCategory.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | import { createCategory } from "~/data/categories" 3 | import { logger } from "~/utils/logger" 4 | 5 | export const action = (async ({ request }) => { 6 | logger.debug("URL", request.url) 7 | const formData = await request.formData() 8 | const category = formData.get("category")?.toString() 9 | 10 | if (category) { 11 | createCategory(category) 12 | } 13 | return json({}) 14 | }) satisfies ActionFunction 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | **/tmp 25 | LICENSE 26 | README.md 27 | !**/.gitignore 28 | !.git/HEAD 29 | !.git/config 30 | !.git/packed-refs 31 | !.git/refs/heads/** -------------------------------------------------------------------------------- /src/app/icons/deleteIcon.tsx: -------------------------------------------------------------------------------- 1 | export function DeleteIcon() { 2 | return ( 3 | 9 | 15 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/utils/mediadb.ts: -------------------------------------------------------------------------------- 1 | export function toImdbId(imdbid: string): string 2 | export function toImdbId(imdbid: string | undefined): string | undefined 3 | export function toImdbId(imdbid: string | undefined) { 4 | if (!imdbid) return undefined 5 | return imdbid.startsWith("tt") ? imdbid : `tt${imdbid}` 6 | } 7 | 8 | export function toTvdbId(tvdbId: number | string): string 9 | export function toTvdbId(tvdbId: number | string | undefined): string | undefined 10 | export function toTvdbId(tvdbId: number | string | undefined) { 11 | if (!tvdbId) return undefined 12 | return `tv${tvdbId}` 13 | } 14 | -------------------------------------------------------------------------------- /src/app/icons/libraryIcon.tsx: -------------------------------------------------------------------------------- 1 | export function LibraryIcon() { 2 | return ( 3 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/icons/addIcon.tsx: -------------------------------------------------------------------------------- 1 | export function AddIcon() { 2 | return ( 3 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.delete.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | import { remove } from "~/data/downloadClient" 3 | import { skipFalsy } from "~/utils/array" 4 | import { logger } from "~/utils/logger" 5 | 6 | export const action = (async ({ request }) => { 7 | logger.debug("URL", request.url) 8 | const formData = await request.formData() 9 | const hashes = formData 10 | .get("hashes") 11 | ?.toString() 12 | ?.toUpperCase() 13 | ?.split("|") 14 | .filter(skipFalsy) 15 | 16 | if (hashes?.length) { 17 | await remove(hashes) 18 | } 19 | 20 | return json({}) 21 | }) satisfies ActionFunction 22 | -------------------------------------------------------------------------------- /src/cron/detectBrokenAmule.ts: -------------------------------------------------------------------------------- 1 | import { amuleGetCategories, restartAmule } from "amule/amule" 2 | import { Mutex } from "async-mutex" 3 | 4 | declare global { 5 | var detectBrokenAmuleMutex: Mutex 6 | } 7 | 8 | globalThis.detectBrokenAmuleMutex = new Mutex() 9 | 10 | export async function detectBrokenAmule() { 11 | if (globalThis.detectBrokenAmuleMutex.isLocked()) { 12 | return 13 | } 14 | 15 | await globalThis.detectBrokenAmuleMutex.runExclusive(async () => { 16 | const categories = await amuleGetCategories() 17 | if (Object.keys(categories).length === 0) { 18 | await restartAmule() 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/icons/userIcon.tsx: -------------------------------------------------------------------------------- 1 | export function UserIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/icons/searchIcon.tsx: -------------------------------------------------------------------------------- 1 | export function SearchIcon() { 2 | return ( 3 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/icons/upIcon.tsx: -------------------------------------------------------------------------------- 1 | export function UpIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/utils/useRevalidate.ts: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react" 2 | import { useCallback, useEffect } from "react" 3 | 4 | export function useRevalidate(enabled: boolean, time: number) { 5 | const fetcher = useFetcher() 6 | 7 | const revalidate = useCallback(function revalidate() { 8 | if (fetcher.state !== 'idle') return 9 | const form = window.document.createElement("form") 10 | form.action = "/revalidate" 11 | form.method = "POST" 12 | fetcher.submit(form) 13 | }, [fetcher]) 14 | 15 | useEffect(() => { 16 | if (enabled) { 17 | const i = setInterval(revalidate, time) 18 | return () => clearInterval(i) 19 | } 20 | }, [revalidate, enabled]) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/icons/downIcon.tsx: -------------------------------------------------------------------------------- 1 | export function DownIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/data/categories.ts: -------------------------------------------------------------------------------- 1 | import { getDownloadClientFiles } from "./downloadClient" 2 | import { skipFalsy } from "~/utils/array" 3 | import { createJsonDb } from "~/utils/jsonDb" 4 | 5 | export const categoriesDb = createJsonDb( 6 | "/config/categories.json", 7 | [] 8 | ) 9 | 10 | export async function getCategories() { 11 | const downloads = await getDownloadClientFiles() 12 | return [ 13 | ...new Set( 14 | [...categoriesDb.data, ...downloads.map((d) => d.meta?.category)].filter( 15 | skipFalsy 16 | ) 17 | ), 18 | ] 19 | } 20 | 21 | export function createCategory(category: string) { 22 | if (!categoriesDb.data.includes(category)) { 23 | categoriesDb.data.push(category) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix + Vite! 2 | 3 | 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/future/vite) for details on supported features. 4 | 5 | ## Development 6 | 7 | Run the Vite dev server: 8 | 9 | ```shellscript 10 | npm run dev 11 | ``` 12 | 13 | ## Deployment 14 | 15 | First, build your app for production: 16 | 17 | ```sh 18 | npm run build 19 | ``` 20 | 21 | Then run the app in production mode: 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | Now you'll need to pick a host to deploy it to. 28 | 29 | ### DIY 30 | 31 | If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. 32 | 33 | Make sure to deploy the output of `npm run build` 34 | 35 | - `build/server` 36 | - `build/client` 37 | -------------------------------------------------------------------------------- /src/app/utils/naming.ts: -------------------------------------------------------------------------------- 1 | export function setReleaseGroup(name: string) { 2 | if (/--eMulerr\.\w+$/i.test(name)) { 3 | return name 4 | } 5 | 6 | name = name.replace(/(.*)(\.\w+)$/, `$1--eMulerr$2`) 7 | return name 8 | } 9 | 10 | export function sanitizeFilename(str: string) { 11 | // remove illegal characters 12 | str = str.replace(/[/\\?%*:|"<>]/g, "_") 13 | 14 | // replace unicode chars with their ascii equivalent 15 | str = str.normalize('NFKD').replace(/[\u0100-\uFFFF]/g, '') 16 | 17 | // fix utf8 decoding artifacts 18 | while (true) { 19 | try { 20 | const nstr = decodeURIComponent(escape(str)); 21 | if (nstr === str) { 22 | break 23 | } 24 | str = nstr 25 | } catch (e) { 26 | break; 27 | } 28 | } 29 | 30 | return str 31 | } 32 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.add.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | import { download } from "~/data/downloadClient" 3 | import { fromMagnetLink } from "~/links" 4 | import { logger } from "~/utils/logger" 5 | 6 | export const action = (async ({ request }) => { 7 | logger.debug("URL", request.url) 8 | const formData = await request.formData() 9 | const urls = formData.get("urls")?.toString() 10 | const category = formData.get("category")?.toString() 11 | 12 | if (!urls) { 13 | throw new Error("No URL to download") 14 | } 15 | 16 | if (!category) { 17 | throw new Error("No download category") 18 | } 19 | 20 | const { hash, name, size } = fromMagnetLink(urls) 21 | await download(hash, name, size, category) 22 | 23 | return json({}) 24 | }) satisfies ActionFunction 25 | -------------------------------------------------------------------------------- /src/app/utils/math.ts: -------------------------------------------------------------------------------- 1 | export function roundToDecimals(value: number, decimals: number) { 2 | if (isNaN(value)) { 3 | value = 0 4 | } 5 | 6 | return Math.ceil(value * Math.pow(10, decimals)) / Math.pow(10, decimals) 7 | } 8 | 9 | export function readableSize(bytes: number, decimals = 1) { 10 | if (isNaN(bytes)) { 11 | bytes = 0 12 | } 13 | 14 | if (bytes / 1024 < 1) return `${roundToDecimals(bytes, decimals)}B` 15 | bytes /= 1024 16 | if (bytes / 1024 < 1) return `${roundToDecimals(bytes, decimals)}KB` 17 | bytes /= 1024 18 | if (bytes / 1024 < 1) return `${roundToDecimals(bytes, decimals)}MB` 19 | bytes /= 1024 20 | if (bytes / 1024 < 1) return `${roundToDecimals(bytes, decimals)}GB` 21 | bytes /= 1024 22 | return `${roundToDecimals(bytes, decimals)}TB` 23 | } 24 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.setCategory.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | import { setCategory } from "~/data/downloadClient" 3 | import { skipFalsy } from "~/utils/array" 4 | import { logger } from "~/utils/logger" 5 | 6 | export const action = (async ({ request }) => { 7 | logger.debug("URL", request.url) 8 | const formData = await request.formData() 9 | const hashes = formData 10 | .get("hashes") 11 | ?.toString() 12 | ?.toUpperCase() 13 | ?.split("|") 14 | .filter(skipFalsy) 15 | const category = formData.get("category")?.toString() 16 | 17 | if (category && hashes?.length) { 18 | await Promise.all( 19 | hashes.map(async (hash) => { 20 | setCategory(hash, category) 21 | }) 22 | ) 23 | } 24 | 25 | return json({}) 26 | }) satisfies ActionFunction 27 | -------------------------------------------------------------------------------- /src/app/utils/async.ts: -------------------------------------------------------------------------------- 1 | export async function concurrentForEach( 2 | concurrency: number, 3 | items: T[], 4 | fn: (t: T) => Promise 5 | ) { 6 | const missingItems = [...items] 7 | const tasks = [] 8 | 9 | while (missingItems.length > 0 || tasks.length > 0) { 10 | while (tasks.length < concurrency && missingItems.length > 0) { 11 | const i = missingItems.shift() 12 | if (i) tasks.push(fn(i)) 13 | } 14 | 15 | const p = await Promise.race( 16 | tasks.map((p, index) => 17 | p.then( 18 | (v) => ({ index, v }), 19 | (err) => ({ index, err }), 20 | ) 21 | ) 22 | ) 23 | tasks.splice(p.index, 1) 24 | 25 | if ('err' in p) { 26 | throw p.err 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/icons/uploadIcon.tsx: -------------------------------------------------------------------------------- 1 | export function UploadIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.auth.login.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction } from "@remix-run/node" 2 | 3 | export const action = (async ({ request }) => { 4 | const formData = await request.formData() 5 | const password = formData.get("password") 6 | 7 | if (process.env.PASSWORD !== "" && process.env.PASSWORD !== password) { 8 | return new Response(``, { 9 | status: 401, 10 | headers: { 11 | "Content-Type": "text/plain", 12 | "X-Content-Type-Options": "nosniff", 13 | "Cache-Control": "public, max-age=0", 14 | }, 15 | }) 16 | } 17 | 18 | return new Response(`Ok.`, { 19 | status: 200, 20 | headers: { 21 | "Content-Type": "text/plain", 22 | "X-Content-Type-Options": "nosniff", 23 | "Cache-Control": "public, max-age=0", 24 | "Set-Cookie": `SID=${password}; path=/`, 25 | }, 26 | }) 27 | }) satisfies ActionFunction 28 | -------------------------------------------------------------------------------- /src/app/icons/downloadIcon.tsx: -------------------------------------------------------------------------------- 1 | export function DownloadIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/icons/bufferIcon.tsx: -------------------------------------------------------------------------------- 1 | export function BufferIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/cron/resumePausedDownloads.ts: -------------------------------------------------------------------------------- 1 | import { amuleDoResume, amuleGetDownloads } from "amule/amule" 2 | import { Mutex } from "async-mutex" 3 | import { logger } from "~/utils/logger" 4 | 5 | declare global { 6 | var resumePausedDownloadsMutex: Mutex 7 | } 8 | 9 | globalThis.resumePausedDownloadsMutex = new Mutex() 10 | 11 | export async function resumePausedDownloads() { 12 | if (globalThis.resumePausedDownloadsMutex.isLocked()) { 13 | return 14 | } 15 | 16 | await globalThis.resumePausedDownloadsMutex.runExclusive(async () => { 17 | const downloads = await amuleGetDownloads() 18 | const stoppedDownloads = downloads.filter(d => d.status_str === 'stopped') 19 | if (!stoppedDownloads.length) { 20 | return 21 | } 22 | 23 | logger.info('[resumePausedDownloads] Resuming', stoppedDownloads.length, 'downloads') 24 | await Promise.all(stoppedDownloads.map(d => amuleDoResume(d.hash))) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": false, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "noUncheckedIndexedAccess": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "~/*": ["./app/*"] 28 | }, 29 | "plugins": [{ "name": "typescript-plugin-css-modules" }], 30 | // Vite takes care of building everything, not tsc. 31 | "noEmit": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.ed2k.add.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json } from "@remix-run/node" 2 | import { download } from "~/data/downloadClient" 3 | import { fromEd2kLink } from "~/links" 4 | import { logger } from "~/utils/logger" 5 | import { sanitizeFilename } from "~/utils/naming" 6 | 7 | export const action = (async ({ request }) => { 8 | logger.debug("URL", request.url) 9 | const formData = await request.formData() 10 | const category = formData.get("category")?.toString() 11 | if (!category) { 12 | throw new Error("No download category") 13 | } 14 | 15 | const urls = formData.getAll("urls").map(String) 16 | if (!urls.length) { 17 | throw new Error("No URL to download") 18 | } 19 | 20 | const tasks = urls.map(async (url) => { 21 | const { hash, name, size } = fromEd2kLink(url) 22 | const sanitizedName = sanitizeFilename(name) 23 | console.log(sanitizedName) 24 | await download(hash, sanitizedName, size, category) 25 | }) 26 | 27 | await Promise.all(tasks) 28 | return json({}) 29 | }) satisfies ActionFunction 30 | -------------------------------------------------------------------------------- /src/app/icons/discoverIcon.tsx: -------------------------------------------------------------------------------- 1 | export function DiscoverIcon() { 2 | return ( 3 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function readableEta(eta: number) { 2 | const date = new Date(0); 3 | date.setSeconds(eta); 4 | return date.toISOString().slice(11, 19); 5 | } 6 | 7 | export function buildRFC822Date(date: Date) { 8 | const dayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 9 | const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 10 | 11 | const day = dayStrings[date.getDay()]; 12 | const dayNumber = date.getDate().toString().padStart(2, "0"); 13 | const month = monthStrings[date.getMonth()]; 14 | const year = date.getFullYear(); 15 | const time = `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:00`; 16 | const timezone = date.getTimezoneOffset() === 0 ? "GMT" : "BST"; 17 | 18 | //Wed, 02 Oct 2002 13:00:00 GMT 19 | return `${day}, ${dayNumber} ${month} ${year} ${time} ${timezone}`; 20 | } 21 | 22 | export async function wait(ms: number) { 23 | await new Promise((r,) => setTimeout(r, ms)) 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ivan Sanz Carasa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/icons/ratioIcon.tsx: -------------------------------------------------------------------------------- 1 | export function RatioIcon() { 2 | return ( 3 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/utils/state.ts: -------------------------------------------------------------------------------- 1 | export type DeepReadonly = { 2 | readonly [K in keyof T]: DeepReadonly; 3 | } 4 | 5 | export function deepFreeze(obj: T): T { 6 | if (obj == null) { 7 | return obj 8 | } 9 | 10 | if (obj instanceof Map && !Object.isFrozen(obj)) { 11 | obj.clear = 12 | obj.delete = 13 | obj.set = 14 | function () { 15 | throw new Error('map is read-only'); 16 | }; 17 | } 18 | 19 | if (obj instanceof Set && !Object.isFrozen(obj)) { 20 | obj.add = 21 | obj.clear = 22 | obj.delete = 23 | function () { 24 | throw new Error('set is read-only'); 25 | }; 26 | } 27 | 28 | try { 29 | // Freeze self 30 | Object.freeze(obj); 31 | } catch { } 32 | 33 | Object.getOwnPropertyNames(obj).forEach((name) => { 34 | const prop = (obj as any)[name]; 35 | const type = typeof prop; 36 | 37 | // Freeze prop if it is an object or function and also not already frozen 38 | if ((type === 'object' || type === 'function') && !Object.isFrozen(prop)) { 39 | deepFreeze(prop); 40 | } 41 | }); 42 | 43 | return obj; 44 | } -------------------------------------------------------------------------------- /src/app/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const groupBy = ( 2 | array: T[], 3 | predicate: (value: T, index: number, array: T[]) => K 4 | ) => 5 | array.reduce( 6 | (acc, value, index, array) => { 7 | ; (acc[predicate(value, index, array)] ||= []).push(value) 8 | return acc 9 | }, 10 | {} as Record 11 | ) 12 | 13 | export const countBy = ( 14 | array: T[], 15 | predicate: (value: T, index: number, array: T[]) => K 16 | ) => 17 | array.reduce( 18 | (acc, value, index, array) => { 19 | acc[predicate(value, index, array)] ||= 0 20 | ++acc[predicate(value, index, array)]! 21 | return acc 22 | }, 23 | {} as Record 24 | ) 25 | 26 | export function toEntries(e: Record) { 27 | return Object.entries(e) as [K, V][] 28 | } 29 | 30 | export function fromEntries(e: [K, V][]) { 31 | return Object.fromEntries(e) as Record 32 | } 33 | 34 | export function skipFalsy(v: T): v is NonNullable { 35 | return !!v 36 | } 37 | 38 | export function splitIntoChunks(array: T[], chunkSize: number) { 39 | return array.flatMap((_, i) => 40 | i % chunkSize === 0 ? [array.slice(i, i + chunkSize)] : [] 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/data/known.ts: -------------------------------------------------------------------------------- 1 | import { createJsonDb } from "~/utils/jsonDb" 2 | import { logger } from "~/utils/logger" 3 | import { testQuery } from "./search" 4 | import { staleWhileRevalidate } from "~/utils/memoize" 5 | 6 | const knownDb = createJsonDb<{ hash: string, size: number, name: string }[]>( 7 | "/config/cache/known.json", 8 | []) 9 | 10 | export function trackKnown(known: { hash: string, size: number, name: string }[]) { 11 | let tracked = 0 12 | known.forEach(({ hash, size, name }) => { 13 | const entry = knownDb.data.find(i => i.hash === hash && i.size === size && i.name === name) 14 | if (!entry) { 15 | tracked++ 16 | knownDb.data.push({ hash, size, name }) 17 | } 18 | }) 19 | 20 | if (tracked) { 21 | logger.info("Tracked", tracked, "new files") 22 | } 23 | } 24 | 25 | export const searchKnown = staleWhileRevalidate(async function (q: string) { 26 | logger.info(`[local] Searching: ${q}`) 27 | const known = knownDb.data 28 | const matches = known.filter(k => testQuery(q, k.name)) 29 | logger.info(`[local] Search finished with ${matches.length} items`) 30 | return matches.map(m => ({ 31 | name: m.name, 32 | hash: m.hash, 33 | size: m.size, 34 | sources: 0, 35 | present: false 36 | })) 37 | }, { 38 | stalled: 1000 * 60, 39 | expired: 1000 * 60, 40 | shouldCache: r => r.length > 0 41 | }) 42 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | on: 3 | push: 4 | branches: ["main"] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: Login to Docker Hub 12 | uses: docker/login-action@v3 13 | with: 14 | username: ${{ secrets.DOCKERHUB_USERNAME }} 15 | password: ${{ secrets.DOCKERHUB_TOKEN }} 16 | 17 | - uses: benjlevesque/short-sha@v3.0 18 | 19 | - name: Set imgver tag 20 | id: imgver 21 | run: echo "IMG_VER=$(date +'%Y-%m-%d')_${{ env.SHA }}" >> $GITHUB_ENV 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Docker meta 30 | id: meta 31 | uses: docker/metadata-action@v4 32 | with: 33 | images: isc30/emulerr 34 | tags: | 35 | type=raw,value=latest 36 | type=raw,value=${{ env.IMG_VER }} 37 | 38 | - name: Build and push 39 | uses: docker/build-push-action@v6 40 | with: 41 | context: . 42 | push: true 43 | platforms: linux/amd64,linux/arm64 44 | tags: ${{ steps.meta.outputs.tags }} 45 | build-args: | 46 | IMG_VER=${{ env.IMG_VER }} 47 | -------------------------------------------------------------------------------- /src/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | Meta, 4 | Outlet, 5 | Scripts, 6 | ScrollRestoration, 7 | useNavigate, 8 | useRouteError, 9 | } from "@remix-run/react" 10 | 11 | import stylesheet from "~/global.css?url" 12 | import { LinksFunction, MetaFunction } from "@remix-run/node" 13 | import { action } from "./routes/_shell" 14 | import { useEffect } from "react" 15 | 16 | export const links: LinksFunction = () => [ 17 | { rel: "stylesheet", href: stylesheet }, 18 | ] 19 | 20 | export const meta: MetaFunction = () => [{ title: "eMulerr" }] 21 | 22 | export { action } 23 | 24 | export function Layout({ children }: { children: React.ReactNode }) { 25 | return ( 26 | 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default function App() { 46 | return 47 | } 48 | 49 | export function ErrorBoundary() { 50 | const navigate = useNavigate() 51 | 52 | useEffect(() => { 53 | navigate(".", { replace: true }) 54 | }, []) 55 | } 56 | -------------------------------------------------------------------------------- /src/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import * as colors from "tailwindcss/colors"; 3 | import * as defaultTheme from "tailwindcss/defaultTheme"; 4 | import { tailwindcssPaletteGenerator as palette } from '@bobthered/tailwindcss-palette-generator' 5 | import plugin from "tailwindcss/plugin" 6 | 7 | export default { 8 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 9 | theme: { 10 | extend: { 11 | colors: { 12 | radarr: palette('#ffc230').primary!, 13 | sonarr: palette('#35c5f4').primary!, 14 | primary: palette('#5c6ac4').primary!, 15 | upload: colors.blue[400], 16 | download: colors.green[400], 17 | buffer: colors.purple[300], 18 | transfer: colors.teal[400], 19 | ratio: colors.amber[400], 20 | error: colors.red[500], 21 | status: { 22 | downloading: colors.lime[800], 23 | stalled: '#3f3714', 24 | completing: colors.purple[900], 25 | downloaded: colors.sky[950] 26 | } 27 | }, 28 | fontFamily: { 29 | title: ['Impact', ...defaultTheme.fontFamily.sans], 30 | body: ['Roboto', ...defaultTheme.fontFamily.sans] 31 | } 32 | }, 33 | }, 34 | plugins: [ 35 | plugin(function ({ addVariant }) { 36 | addVariant("hover", [ 37 | "@media (hover: hover) { &:hover }", 38 | "@media (hover: none) { &:active }", 39 | ]) 40 | }), 41 | ], 42 | } satisfies Config; 43 | -------------------------------------------------------------------------------- /src/cron/cron.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "~/utils/logger" 2 | import { detectBrokenAmule } from "./detectBrokenAmule" 3 | import { resumePausedDownloads } from "./resumePausedDownloads" 4 | 5 | const jobs = [ 6 | { 7 | id: 1, 8 | everySeconds: 60, // 1 minute 9 | launchAtStart: false, 10 | launch: detectBrokenAmule, 11 | }, 12 | { 13 | id: 2, 14 | everySeconds: 60, 15 | launchAtStart: true, 16 | launch: resumePausedDownloads, 17 | } 18 | ] 19 | 20 | // Using global as live reload will re-run the file, and variables get lost 21 | declare global { 22 | var cronJobIntervalIds: NodeJS.Timeout[] | undefined 23 | } 24 | 25 | export function initializeJobs() { 26 | if (globalThis.cronJobIntervalIds) { 27 | logger.info("[initializeJobs] Clearing old jobs...") 28 | for (const interval of globalThis.cronJobIntervalIds) { 29 | clearInterval(interval) 30 | } 31 | } 32 | 33 | logger.info("[initializeJobs] Initializing jobs...") 34 | globalThis.cronJobIntervalIds = jobs.map((job) => { 35 | const interval = setInterval(() => triggerJob(job), job.everySeconds * 1000) 36 | 37 | if (job.launchAtStart) { 38 | void triggerJob(job).catch(() => { }) 39 | } 40 | 41 | return interval 42 | }) 43 | } 44 | 45 | async function triggerJob(job: typeof jobs[0]) { 46 | try { 47 | await job.launch() 48 | } 49 | catch { 50 | logger.error(`[initializeJobs] job ${job.id} (${job.launch.name}) failed. Retrying...`) 51 | setTimeout(() => void triggerJob(job).catch(() => { }), 10000) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ngosang/amule:2.3.3-19 AS amule 2 | ENV PUID=1000 3 | ENV PGID=1000 4 | ENV GUI_PWD=secret 5 | ENV WEBUI_PWD=secret 6 | ENV MOD_AUTO_RESTART_ENABLED=true 7 | ENV MOD_AUTO_RESTART_CRON="0 6 * * *" 8 | ENV MOD_AUTO_SHARE_ENABLED=true 9 | ENV MOD_AUTO_SHARE_DIRECTORIES=/shared;/downloads/complete 10 | ENV MOD_FIX_KAD_GRAPH_ENABLED=true 11 | ENV MOD_FIX_KAD_BOOTSTRAP_ENABLED=true 12 | RUN mkdir -p /shared 13 | RUN mkdir -p /downloads/complete 14 | COPY ./src/amule/api.php /usr/share/amule/webserver/AmuleWebUI-Reloaded/api.php 15 | COPY ./src/amule/amule.conf /config-base/amule/amule.conf 16 | RUN mkdir -p /config/amule 17 | RUN ln -s /config/amule /home/amule/.aMule 18 | 19 | FROM node:23-bookworm AS build 20 | RUN mkdir /app 21 | WORKDIR /app 22 | ADD ./src/package.json ./src/package-lock.json ./ 23 | RUN npm ci --production=false 24 | ADD ./src ./ 25 | RUN npm run build 26 | 27 | FROM amule 28 | ARG IMG_VER 29 | ENV IMG_VER=${IMG_VER} 30 | USER root 31 | RUN apk update 32 | RUN apk upgrade 33 | RUN apk add --update nodejs npm 34 | RUN apk add --no-cache bash 35 | RUN apk add --no-cache python3 36 | ENV NODE_ENV=production 37 | ENV PORT=3000 38 | ENV ED2K_PORT=4662 39 | ENV LOG_LEVEL=info 40 | ENV PASSWORD= 41 | RUN mkdir -p /emulerr 42 | WORKDIR /emulerr 43 | ADD ./src/package.json ./src/package-lock.json ./ 44 | RUN npm ci --production=true 45 | COPY --from=build /app/build ./build 46 | COPY --from=build /app/server.js ./server.js 47 | COPY ./src/entrypoint.sh /home/entrypoint.sh 48 | ENTRYPOINT ["/bin/bash", "/home/entrypoint.sh"] 49 | COPY ./src/healthcheck.sh /home/healthcheck.sh 50 | HEALTHCHECK --interval=20s CMD bash "/home/healthcheck.sh" 51 | -------------------------------------------------------------------------------- /src/app/links.tsx: -------------------------------------------------------------------------------- 1 | import base32 from "hi-base32" 2 | 3 | export function toMagnetLink(hash: string, name: string, size: number) { 4 | const hashBuffer = Buffer.from(hash, "hex") 5 | const base32Buffer = Buffer.alloc(20, "\0") 6 | hashBuffer.copy(base32Buffer) 7 | const base32Hash = base32.encode(base32Buffer).toUpperCase() 8 | 9 | return `magnet:?xt=urn:btih:${base32Hash}&dn=${encodeURIComponent(name)}&xl=${size}&tr=http://emulerr` 10 | } 11 | 12 | export function fromMagnetLink(magnetLink: string) { 13 | const extractMagnetLinkInfo = 14 | /magnet:\?xt=urn:btih:(?.*)&dn=(?.*)&xl=(?[^&]+)&tr=http:\/\/emulerr/ 15 | const { 16 | hash: base32Hash, 17 | name, 18 | size, 19 | } = extractMagnetLinkInfo.exec(magnetLink)?.groups ?? {} 20 | 21 | if (!base32Hash || !name || !size) { 22 | throw new Error("Invalid magnet link") 23 | } 24 | 25 | const hash = Buffer.from(base32.decode.asBytes(base32Hash)) 26 | .toString("hex") 27 | .substring(0, 32) 28 | .toUpperCase() 29 | return { hash, name: decodeURIComponent(name), size: parseInt(size) } 30 | } 31 | 32 | export function toEd2kLink(hash: string, name: string, size: number) { 33 | return `ed2k://|file|${name}|${size}|${hash}|/` 34 | } 35 | 36 | export function fromEd2kLink(ed2kLink: string) { 37 | const extractEd2kLinkInfo = 38 | /ed2k:\/\/\|file\|(?[^\|]+)\|(?[^\|]+)\|(?[^\|]+)\|/ 39 | 40 | const { hash, name, size } = extractEd2kLinkInfo.exec(ed2kLink)?.groups ?? {} 41 | 42 | if (!hash || !name || !size) { 43 | throw new Error("Invalid ed2k link") 44 | } 45 | 46 | return { hash, name: decodeURIComponent(name), size: parseInt(size) } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/components/categoryPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react" 2 | 3 | export function CategoryPicker({ 4 | hash, 5 | currentCategory, 6 | allCategories, 7 | }: { 8 | hash: string 9 | currentCategory?: string 10 | allCategories: string[] 11 | }) { 12 | const fetcher = useFetcher() 13 | 14 | return ( 15 | 16 | 17 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/app/routes/_shell._index.tsx: -------------------------------------------------------------------------------- 1 | import { json, LoaderFunction } from "@remix-run/node" 2 | import { useLoaderData } from "@remix-run/react" 3 | 4 | export const loader = (async () => { 5 | return json({ port: process.env.PORT, password: process.env.PASSWORD, ed2kPort: process.env.ED2K_PORT }) 6 | }) satisfies LoaderFunction 7 | 8 | export default function Index() { 9 | const data = useLoaderData() 10 | 11 | return ( 12 |
13 |

Welcome to eMulerr!

14 |

15 | In order to get started, configure the Download Client in *RR: 16 |

17 |
    18 |
  • Type: qBittorrent
  • 19 |
  • Name: emulerr
  • 20 |
  • Host: THIS_CONTAINER_NAME
  • 21 |
  • Port: {data.port}
  • 22 | {data.password !== "" &&
  • Username: emulerr
  • } 23 | {data.password !== "" &&
  • Password: {data.password}
  • } 24 |
  • Priority: 50
  • 25 |
  • Remove completed: Yes
  • 26 |
27 |

Then, configure the indexer in *RR:

28 |
    29 |
  • Type: Torznab
  • 30 |
  • Name: emulerr
  • 31 |
  • RSS: No
  • 32 |
  • Automatic Search: Up to you, maybe it downloads porn
  • 33 |
  • Interactive Search: Yes
  • 34 |
  • URL: http://THIS_CONTAINER_NAME:{data.port}/
  • 35 | {data.password !== "" &&
  • API Key: {data.password}
  • } 36 |
  • Download Client: emulerr
  • 37 |
38 |

In your router, open the following ports:

39 |
    40 |
  • TCP/{data.ed2kPort}
  • 41 |
  • UDP/{data.ed2kPort}
  • 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/app/routes/api.v2.torrents.info.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, json } from "@remix-run/node" 2 | import { amuleGetDownloads } from "amule/amule" 3 | import { existsSync } from "fs" 4 | import { getDownloadClientFiles } from "~/data/downloadClient" 5 | import { logger } from "~/utils/logger" 6 | 7 | export const loader = (async ({ request }) => { 8 | logger.debug("URL", request.url) 9 | const url = new URL(request.url) 10 | const category = url.searchParams.get("category") 11 | const files = await getDownloadClientFiles() 12 | 13 | return json([ 14 | ...files 15 | .filter((d) => { 16 | return !category || d.meta?.category === category 17 | }) 18 | .map((f) => ({ 19 | // qBittorrent structure 20 | hash: f.hash, 21 | name: f.name, 22 | size: f.size, 23 | size_done: f.size_done, 24 | progress: 25 | f.progress === 1 ? 1 : Math.min(0.999, Math.max(f.progress, 0.001)), 26 | dlspeed: f.speed, 27 | eta: f.eta, 28 | state: statusToQbittorrentState(f.status_str), 29 | content_path: contentPath(f.name), 30 | category: f.meta?.category, 31 | })), 32 | ]) 33 | }) satisfies LoaderFunction 34 | 35 | export const action = loader 36 | 37 | function contentPath(name: string) { 38 | if (existsSync(`/downloads/complete/${name}`)) { 39 | return `/downloads/complete/${name}` 40 | } 41 | 42 | if (existsSync(`/tmp/shared/${name}`)) { 43 | return `/tmp/shared/${name}` 44 | } 45 | 46 | return undefined 47 | } 48 | 49 | function statusToQbittorrentState( 50 | status: Awaited>[0]["status_str"] 51 | ) { 52 | switch (status) { 53 | case "downloading": 54 | return "downloading" 55 | case "downloaded": 56 | return "pausedUP" 57 | case "stalled": 58 | return "stalledDL" 59 | case "error": 60 | return "error" 61 | case "completing": 62 | return "moving" 63 | case "stopped": 64 | return "pausedDL" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | *, 7 | *::before, 8 | *::after { 9 | touch-action: manipulation; /* disable double tap zoom */ 10 | -webkit-tap-highlight-color: transparent; /* no black overlay on tap target */ 11 | overflow-wrap: break-word; /* avoid overflows caused by long words */ 12 | word-break: break-word; /* avoid overflows caused by long words */ 13 | user-select: none; /* avoid text being selectable */ 14 | -webkit-user-select: none; /* avoid text being selectable */ 15 | min-width: 0; /* some input elements have min-width by browser defaults */ 16 | } 17 | 18 | @font-face { 19 | font-family: "Roboto"; 20 | font-display: fallback; 21 | src: local("Roboto"), url("/Roboto.ttf"); 22 | } 23 | 24 | /* disabled default state */ 25 | *:disabled, 26 | [data-disabled="true"] { 27 | @apply pointer-events-none opacity-50; 28 | } 29 | 30 | /* hover default state */ 31 | select:not(:disabled):hover, 32 | select:not([aria-disabled="true"]):hover { 33 | cursor: pointer; 34 | } 35 | button:not(:disabled):hover, 36 | button:not([aria-disabled="true"]):hover, 37 | a:not(:disabled):hover, 38 | a:not([aria-disabled="true"]):hover, 39 | input:not(:disabled):hover, 40 | input:not([aria-disabled="true"]):hover, 41 | select:not(:disabled):hover, 42 | select:not([aria-disabled="true"]):hover, 43 | textarea:not(:disabled):hover, 44 | textarea:not([aria-disabled="true"]):hover { 45 | background-image: linear-gradient(rgb(127 127 127 / 20%) 0 0); 46 | } 47 | } 48 | 49 | @layer components { 50 | html, 51 | body { 52 | @apply font-body bg-neutral-900 text-neutral-50; 53 | } 54 | 55 | input, 56 | select { 57 | @apply rounded-sm border border-neutral-600 bg-neutral-700 px-2 py-1 text-base text-white; 58 | } 59 | 60 | h1, 61 | .h1 { 62 | @apply text-3xl font-medium; 63 | } 64 | 65 | h2, 66 | .h2 { 67 | @apply text-xl font-medium underline underline-offset-8; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | 23 | // Base config 24 | extends: ["eslint:recommended"], 25 | 26 | overrides: [ 27 | // React 28 | { 29 | files: ["**/*.{js,jsx,ts,tsx}"], 30 | plugins: ["react", "jsx-a11y"], 31 | extends: [ 32 | "plugin:react/recommended", 33 | "plugin:react/jsx-runtime", 34 | "plugin:react-hooks/recommended", 35 | "plugin:jsx-a11y/recommended", 36 | ], 37 | settings: { 38 | react: { 39 | version: "detect", 40 | }, 41 | formComponents: ["Form"], 42 | linkComponents: [ 43 | { name: "Link", linkAttribute: "to" }, 44 | { name: "NavLink", linkAttribute: "to" }, 45 | ], 46 | "import/resolver": { 47 | typescript: {}, 48 | }, 49 | }, 50 | }, 51 | 52 | // Typescript 53 | { 54 | files: ["**/*.{ts,tsx}"], 55 | plugins: ["@typescript-eslint", "import"], 56 | parser: "@typescript-eslint/parser", 57 | settings: { 58 | "import/internal-regex": "^~/", 59 | "import/resolver": { 60 | node: { 61 | extensions: [".ts", ".tsx"], 62 | }, 63 | typescript: { 64 | alwaysTryTypes: true, 65 | }, 66 | }, 67 | }, 68 | extends: [ 69 | "plugin:@typescript-eslint/recommended", 70 | "plugin:import/recommended", 71 | "plugin:import/typescript", 72 | ], 73 | }, 74 | 75 | // Node 76 | { 77 | files: [".eslintrc.cjs"], 78 | env: { 79 | node: true, 80 | }, 81 | }, 82 | ], 83 | }; 84 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "@remix-run/express" 2 | import { installGlobals } from "@remix-run/node" 3 | import compression from "compression" 4 | import express from "express" 5 | import cookieParser from "cookie-parser" 6 | import basicAuth from "express-basic-auth" 7 | 8 | installGlobals() 9 | 10 | const viteDevServer = 11 | process.env.NODE_ENV === "production" 12 | ? undefined 13 | : await import("vite").then((vite) => 14 | vite.createServer({ 15 | server: { middlewareMode: true }, 16 | }) 17 | ) 18 | 19 | const remixHandler = createRequestHandler({ 20 | build: viteDevServer 21 | ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build") 22 | : await import("./build/server/index.js"), 23 | }) 24 | 25 | const app = express() 26 | 27 | app.use(cookieParser()) 28 | app.use(compression()) 29 | 30 | // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header 31 | app.disable("x-powered-by") 32 | 33 | // handle asset requests 34 | if (viteDevServer) { 35 | app.use(viteDevServer.middlewares) 36 | } else { 37 | // Vite fingerprints its assets so we can cache forever. 38 | app.use( 39 | "/assets", 40 | express.static("build/client/assets", { immutable: true, maxAge: "1y" }) 41 | ) 42 | } 43 | 44 | // Everything else (like favicon.ico) is cached for an hour. You may want to be 45 | // more aggressive with this caching. 46 | app.use(express.static("build/client", { maxAge: "1h" })) 47 | 48 | // password 49 | if (process.env.PASSWORD !== "") { 50 | const authMiddleware = basicAuth({ 51 | users: { emulerr: process.env.PASSWORD }, 52 | challenge: true, 53 | realm: "emulerr", 54 | }) 55 | 56 | app.use((req, res, next) => { 57 | if (req.path === "/health" || req.path === "/api/v2/auth/login") { 58 | return next() 59 | } 60 | 61 | if ( 62 | req.query.apikey === process.env.PASSWORD || 63 | req.cookies.SID === process.env.PASSWORD 64 | ) { 65 | return next() 66 | } 67 | 68 | return authMiddleware(req, res, next) 69 | }) 70 | } 71 | 72 | // handle SSR requests 73 | app.all("*", remixHandler) 74 | 75 | const port = process.env.PORT || 3000 76 | app.listen(port, () => 77 | console.log(`Express server listening at http://localhost:${port}`) 78 | ) 79 | -------------------------------------------------------------------------------- /src/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function run_amule() { 4 | while true; do 5 | (set_amule_options) 6 | bash /home/amule/entrypoint.sh 7 | done 8 | } 9 | 10 | function run_emulerr() { 11 | # wait until amule user is ready 12 | while ! id amule &>/dev/null; do 13 | sleep 0.5 14 | done 15 | 16 | if [ -d /emulerr-dev ]; then 17 | cd /emulerr-dev 18 | npm ci --production=false 19 | env NODE_ENV=development npm run dev 20 | else 21 | chown -R "amule:amule" /emulerr 22 | cd /emulerr 23 | while true; do 24 | su amule -s /bin/sh <<'EOF' 25 | env NODE_ENV=production npm run start 26 | EOF 27 | done 28 | fi 29 | } 30 | 31 | function set_amule_options() { 32 | mkdir -p /config/amule 33 | cp /config-base/amule/amule.conf /config/amule/amule.conf 34 | touch /config/amule/amule.overrides.conf 35 | python3 - <<'EOF' 36 | import configparser 37 | 38 | config_path = "/config/amule/amule.conf" 39 | overrides_path = "/config/amule/amule.overrides.conf" 40 | 41 | class NoSpaceConfigParser(configparser.ConfigParser): 42 | def write(self, fp, space_around_delimiters=False): 43 | super().write(fp, space_around_delimiters=space_around_delimiters) 44 | 45 | config = NoSpaceConfigParser(interpolation=None) 46 | config.optionxform = str 47 | config.read(config_path) 48 | 49 | override = NoSpaceConfigParser(interpolation=None) 50 | override.optionxform = str 51 | override.read(overrides_path) 52 | 53 | for section in override.sections(): 54 | if not config.has_section(section): 55 | config.add_section(section) 56 | for key, value in override.items(section): 57 | config.set(section, key, value) 58 | 59 | with open(config_path, "w") as f: 60 | config.write(f, space_around_delimiters=False) 61 | EOF 62 | sed -i "s/Port=4662/Port=$ED2K_PORT/g" /config/amule/amule.conf 63 | sed -i "s/UDPPort=4672/UDPPort=$ED2K_PORT/g" /config/amule/amule.conf 64 | echo $'/tmp/shared\n/downloads/complete'| cat>| /config/amule/shareddir.dat 65 | rm -f /config/amule/muleLock 66 | rm -f /config/amule/ipfilter* # remove when bug is fixed 67 | chown -R "${PUID}:${PGID}" /home/amule/.aMule 68 | chown -R "${PUID}:${PGID}" /config 69 | chown -R "${PUID}:${PGID}" /downloads 70 | mkdir -p /downloads/complete 71 | } 72 | 73 | (run_amule) & 74 | (run_emulerr) & 75 | 76 | wait 77 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # poc: 3 | # image: ${DOCKER_REGISTRY-}poc 4 | # build: 5 | # context: POC 6 | # dockerfile: Dockerfile 7 | # volumes: 8 | # - ./volume/data:/data 9 | # environment: 10 | # - RADARR_URL=http://radarr:7878 11 | # - RADARR_API_KEY=304cc33ba5024182ab5a2f408f843962 12 | 13 | sonarr: # tv 14 | container_name: sonarr 15 | image: lscr.io/linuxserver/sonarr:latest 16 | ports: 17 | - 8989:8989 18 | environment: 19 | - PUID=0 20 | - PGID=0 21 | - TZ=UTC 22 | volumes: 23 | - ./volume/sonarr-config:/config 24 | - ./volume/data:/data 25 | restart: unless-stopped 26 | depends_on: 27 | emulerr: 28 | condition: service_healthy 29 | 30 | radarr: # download movies 31 | container_name: radarr 32 | image: lscr.io/linuxserver/radarr:latest 33 | ports: 34 | - 7878:7878 35 | environment: 36 | - PUID=0 37 | - PGID=0 38 | - TZ=UTC 39 | volumes: 40 | - ./volume/radarr-config:/config 41 | - ./volume/data:/data 42 | restart: unless-stopped 43 | depends_on: 44 | emulerr: 45 | condition: service_healthy 46 | 47 | emulerr: 48 | build: 49 | context: . 50 | dockerfile: Dockerfile 51 | args: 52 | - IMG_VER=2025-01-01_fafafaf 53 | image: isc30/emulerr:latest 54 | container_name: emulerr 55 | restart: unless-stopped 56 | tty: true 57 | environment: 58 | - PUID=1000 59 | - PGID=1000 60 | - PORT=3000 # optional, default=3000 61 | - ED2K_PORT=4762 # optional, default=4662 62 | - LOG_LEVEL=info # optional, default=info 63 | - PASSWORD=1234 # optional, user=emulerr 64 | ports: 65 | - "3000:3000" # web ui 66 | - "4662:4662" # ed2k tcp 67 | - "4665:4665/udp" # ed2k global search udp (tcp port +3) 68 | - "4672:4672/udp" # ed2k udp 69 | - "4711:4711" # DEV, amule webui 70 | volumes: 71 | - ./volume/config:/config 72 | - ./volume/data/usenet:/downloads 73 | 74 | # DEV MOUNTS, ignore 75 | - ./src:/emulerr-dev # DEV mount 76 | # - ./tmp:/emulerr-dev/node_modules # DEV mount 77 | - type: bind # DEV mount 78 | source: ./src/amule/api.php 79 | target: /usr/share/amule/webserver/AmuleWebUI-Reloaded/api.php 80 | - type: bind # DEV mount 81 | source: ./src/entrypoint.sh 82 | target: /home/entrypoint.sh 83 | -------------------------------------------------------------------------------- /src/app/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | 3 | const LOG_LEVEL_VALUES = { 4 | trace: 0, 5 | debug: 1, 6 | info: 2, 7 | warn: 3, 8 | error: 4, 9 | } as const 10 | 11 | type LogLevel = keyof typeof LOG_LEVEL_VALUES 12 | type LogLevelValue = (typeof LOG_LEVEL_VALUES)[LogLevel] 13 | 14 | const LOG_LEVEL_COLORS = { 15 | trace: "gray", 16 | debug: "cyan", 17 | info: "white", 18 | warn: "yellow", 19 | error: "red", 20 | } as const 21 | 22 | declare global { 23 | /** 24 | * Default log level is "debug" 25 | */ 26 | var logLevel: LogLevelValue | undefined 27 | var originalConsole: Console 28 | } 29 | 30 | export function setLogLevel(level: string | undefined) { 31 | const normalizedLevel = level?.toLowerCase() 32 | 33 | globalThis.originalConsole.log( 34 | colorize("warn", `[setLogLevel] Setting log level to "${normalizedLevel}"`) 35 | ) 36 | 37 | if (!normalizedLevel || !(normalizedLevel in LOG_LEVEL_VALUES)) { 38 | // Use default 39 | globalThis.logLevel = undefined 40 | return 41 | } 42 | 43 | globalThis.logLevel = LOG_LEVEL_VALUES[normalizedLevel as LogLevel] 44 | } 45 | 46 | function colorize(level: LogLevel, ...messages: unknown[]) { 47 | const color = LOG_LEVEL_COLORS[level] 48 | 49 | return chalk[color](...messages) 50 | } 51 | 52 | export const memoryLogs: string[] = [] 53 | 54 | function log(level: LogLevel, ...messages: unknown[]) { 55 | const currentLogLevel = globalThis.logLevel ?? LOG_LEVEL_VALUES.debug 56 | const date = new Date().toLocaleTimeString(undefined, { hour12: false }) 57 | 58 | if (LOG_LEVEL_VALUES[level] >= currentLogLevel) { 59 | memoryLogs.push(`[${date}][${level}] ${messages.join(' ')}`) 60 | globalThis.originalConsole[level](colorize(level, `[${date}][${level}]`, ...messages)) 61 | } 62 | } 63 | 64 | if (typeof globalThis.originalConsole === "undefined") { 65 | globalThis.originalConsole = console 66 | } 67 | 68 | export const logger = { 69 | log(...messages: unknown[]) { 70 | log("info", ...messages) 71 | }, 72 | trace(...messages: unknown[]) { 73 | log("trace", ...messages) 74 | }, 75 | debug(...messages: unknown[]) { 76 | log("debug", ...messages) 77 | }, 78 | info(...messages: unknown[]) { 79 | log("info", ...messages) 80 | }, 81 | warn(...messages: unknown[]) { 82 | log("warn", ...messages) 83 | }, 84 | error(...messages: unknown[]) { 85 | log("error", ...messages) 86 | }, 87 | } 88 | 89 | // Override console 90 | globalThis.console = { 91 | ...console, 92 | ...logger, 93 | } 94 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "npm run typecheck && remix vite:build", 8 | "dev": "node ./server.js", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "cross-env NODE_ENV=production node ./server.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@bobthered/tailwindcss-palette-generator": "^3.2.3", 15 | "@jcoreio/async-throttle": "^1.6.0", 16 | "@remix-run/express": "^2.9.1", 17 | "@remix-run/node": "^2.9.1", 18 | "@remix-run/react": "^2.9.1", 19 | "@remix-validated-form/with-zod": "^2.0.7", 20 | "async-mutex": "^0.5.0", 21 | "chalk": "^5.3.0", 22 | "compression": "^1.7.4", 23 | "cookie-parser": "^1.4.6", 24 | "cross-env": "^7.0.3", 25 | "deep-copy-ts": "^0.5.4", 26 | "expiry-map": "^2.0.0", 27 | "express": "^4.18.2", 28 | "express-basic-auth": "^1.2.1", 29 | "framer-motion": "^11.0.20", 30 | "hi-base32": "^0.5.1", 31 | "html-entities": "^2.5.2", 32 | "immer": "^10.1.1", 33 | "isbot": "^4.1.0", 34 | "jotai": "^2.7.1", 35 | "json-stable-stringify": "^1.1.1", 36 | "memoize": "^10.0.0", 37 | "p-memoize": "^7.1.1", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-virtualized": "^9.22.5", 41 | "react-window": "^1.8.10", 42 | "remix-validated-form": "^5.1.5", 43 | "tailwind-merge": "^2.2.2", 44 | "tiny-invariant": "^1.3.3", 45 | "zod": "^3.22.4" 46 | }, 47 | "devDependencies": { 48 | "@remix-run/dev": "^2.9.1", 49 | "@total-typescript/ts-reset": "^0.5.1", 50 | "@types/compression": "^1.7.5", 51 | "@types/express": "^4.17.20", 52 | "@types/json-stable-stringify": "^1.0.36", 53 | "@types/react": "^18.2.20", 54 | "@types/react-dom": "^18.2.7", 55 | "@types/react-virtualized": "^9.21.30", 56 | "@types/react-window": "^1.8.8", 57 | "@typescript-eslint/eslint-plugin": "^6.7.4", 58 | "@typescript-eslint/parser": "^6.7.4", 59 | "autoprefixer": "^10.4.18", 60 | "eslint": "^8.38.0", 61 | "eslint-import-resolver-typescript": "^3.6.1", 62 | "eslint-plugin-import": "^2.28.1", 63 | "eslint-plugin-jsx-a11y": "^6.7.1", 64 | "eslint-plugin-react": "^7.33.2", 65 | "eslint-plugin-react-hooks": "^4.6.0", 66 | "postcss": "^8.4.37", 67 | "prettier": "^3.2.5", 68 | "prettier-plugin-tailwindcss": "^0.5.12", 69 | "tailwindcss": "^3.4.1", 70 | "typescript": "^5.4.5", 71 | "typescript-plugin-css-modules": "^5.1.0", 72 | "vite": "^5.1.0", 73 | "vite-tsconfig-paths": "^4.2.1" 74 | }, 75 | "engines": { 76 | "node": ">=18.0.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eMulerr 2 | 3 | Seamless integration for eD2k/KAD (eMule) networks and Radarr/Sonarr, enjoy. 4 | 5 | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/isc30/emulerr) 6 | 7 | ## Running the container 8 | 9 | Add the following service to your docker-compose: 10 | 11 | ```yml 12 | services: 13 | emulerr: 14 | image: isc30/emulerr:latest 15 | container_name: emulerr 16 | restart: unless-stopped 17 | tty: true 18 | environment: 19 | # - PUID=1000 # optional 20 | # - PGID=1000 # optional 21 | # - PORT=3000 # optional, web-ui port 22 | # - ED2K_PORT=4662 # optional, only required when exposing a non-standard port 23 | # - LOG_LEVEL=info # optional 24 | # - PASSWORD=1234 # optional, user=emulerr 25 | ports: 26 | - "3000:3000" # web ui 27 | - "4662:4662" # ed2k tcp 28 | - "4662:4662/udp" # ed2k udp 29 | # - "4665:4665/udp" # optional, ed2k global search udp (tcp port +3) 30 | volumes: 31 | - ./config:/config # required 32 | - ./downloads:/downloads # required 33 | # - ./shared:/shared:ro # optional, extra files to be shared via ed2k/kad 34 | ``` 35 | 36 | (Optional) Add eMulerr as a dependency for Radarr, Sonarr, etc: 37 | 38 | ```diff 39 | radarr: 40 | image: lscr.io/linuxserver/radarr:latest 41 | + depends_on: 42 | + emulerr: 43 | + condition: service_healthy 44 | ``` 45 | 46 | ## Configuring *rr 47 | 48 | In order to get started, configure the Download Client in *RR: 49 | 50 | - Type: `qBittorrent` 51 | - Name: `emulerr` 52 | - Host: `emulerr` 53 | - Port: `3000` 54 | - Username (if using PASSWORD): `emulerr` 55 | - Password (if using PASSWORD): `PASSWORD` (from environment variable) 56 | - Priority: `50` 57 | 58 | Also set the Download Client's `Remote Path Mappings`: 59 | 60 | - Host: `emulerr` 61 | - Remote Path: `/downloads` 62 | - Local Path: `{The /downloads folder inside MOUNTED PATH FOR RADARR}` 63 | 64 | Then, add a new Indexer in *RR: 65 | 66 | - Type: `Torznab` 67 | - Name: `emulerr` 68 | - RSS: `No` 69 | - Automatic Search: `No` 70 | - Interactive Search: `Yes` 71 | - URL: `http://emulerr:3000/` 72 | - API Key (if using PASSWORD): `PASSWORD` (from environment variable) 73 | - Download Client: `emulerr` 74 | 75 | ## aMule configuration overrides 76 | 77 | You can override (or add) any setting from the base `amule.conf` without editing the original file. 78 | At container startup an override file is merged on top of the base configuration. 79 | 80 | Location inside the container: 81 | ``` 82 | /config/amule/amule.overrides.conf 83 | ``` 84 | 85 | Minimal example matching the shipped default with a changed nick: 86 | ```ini 87 | [eMule] 88 | Nick=emulerr_test_override 89 | ``` 90 | 91 | ## Removing stale downloads 92 | Since eMulerr simulates a qBittorrent api, it is fully compatible with: 93 | - [Decluttarrr](https://github.com/ManiMatter/decluttarr) 94 | - [eMulerrStalledChecker](https://github.com/Jorman/Scripts/tree/master/eMulerrStalledChecker) 95 | -------------------------------------------------------------------------------- /src/app/data/downloadClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | amuleGetUploads, 3 | amuleGetDownloads, 4 | amuleGetShared, 5 | amuleDoDownload, 6 | amuleDoDelete, 7 | amuleDoReloadShared, 8 | } from "amule/amule" 9 | import { toEd2kLink } from "~/links" 10 | import { unlink } from "node:fs/promises" 11 | import { createJsonDb } from "~/utils/jsonDb" 12 | import { staleWhileRevalidate } from "~/utils/memoize" 13 | 14 | export const metadataDb = createJsonDb< 15 | Record 16 | >("/config/hash-metadata.json", {}) 17 | 18 | export const getDownloadClientFiles = staleWhileRevalidate(async function () { 19 | const uploads = await amuleGetUploads() 20 | const downloads = [...await amuleGetDownloads()] 21 | const shared = (await amuleGetShared()) 22 | .filter( 23 | (f) => !downloads.some((d) => d.hash === f.hash) 24 | ) 25 | .map( 26 | (f) => 27 | ({ 28 | ...f, 29 | eta: 0, 30 | last_seen_complete: 0, 31 | prio: 0, 32 | prio_auto: 0, 33 | progress: 1, 34 | size_done: f.size, 35 | size_xfer: 0, 36 | src_valid: null, 37 | src_count: null, 38 | src_count_xfer: null, 39 | speed: null, 40 | status: 9, 41 | status_str: "downloaded", 42 | }) as const 43 | ) 44 | 45 | const metadata = metadataDb.data 46 | 47 | const files = [ 48 | ...downloads.sort( 49 | (a, b) => 50 | (b.speed > 0 ? 1 : 0) - (a.speed > 0 ? 1 : 0) || 51 | b.progress - a.progress || 52 | b.speed - a.speed 53 | ), 54 | ...shared, 55 | ].map((f) => ({ 56 | ...f, 57 | up_speed: uploads 58 | .filter((u) => u.name === f.name) 59 | .map((u) => u.xfer_speed) 60 | .reduce((prev, curr) => prev + curr, 0), 61 | meta: metadata[f.hash], 62 | })) 63 | 64 | return files 65 | }) 66 | 67 | export async function download( 68 | hash: string, 69 | name: string, 70 | size: number, 71 | category: string 72 | ) { 73 | const ed2kLink = toEd2kLink(hash, name, size) 74 | await amuleDoDownload(ed2kLink) 75 | setCategory(hash, category) 76 | } 77 | 78 | export function setCategory(hash: string, category: string) { 79 | metadataDb.data[hash] = { 80 | addedOn: Date.now(), 81 | category: category, 82 | } 83 | } 84 | 85 | export async function remove(hashes: string[]) { 86 | if (hashes.length) { 87 | const downloads = await amuleGetDownloads() 88 | const shared = await amuleGetShared() 89 | 90 | await Promise.all( 91 | hashes.map(async (hash) => { 92 | const file = 93 | downloads.find((v) => v.hash === hash) ?? 94 | shared.find((v) => v.hash === hash) 95 | 96 | await amuleDoDelete(hash) 97 | 98 | if (file) { 99 | await unlink(`/downloads/complete/${file.name}`).catch(() => void 0) 100 | await unlink(`/tmp/shared/${file.name}`).catch(() => void 0) 101 | } 102 | 103 | delete metadataDb.data[hash] 104 | }) 105 | ) 106 | 107 | await amuleDoReloadShared() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/app/utils/indexers.ts: -------------------------------------------------------------------------------- 1 | import { skipFalsy } from "./array" 2 | import { encode } from "html-entities" 3 | import { buildRFC822Date } from "./time" 4 | import { logger } from "./logger" 5 | import { readableSize } from "./math" 6 | import { searchAndWaitForResults } from "~/data/search" 7 | 8 | export const fakeItem = { 9 | name: "FAKE", 10 | short_name: "FAKE", 11 | hash: "00000000000000000000000000000000", 12 | size: 1, 13 | sources: 1, 14 | present: false, 15 | magnetLink: "http://emulerr/fake", 16 | ed2kLink: "http://emulerr/fake", 17 | } 18 | 19 | export const emptyResponse = () => ` 20 | 21 | 22 | 23 | 24 | ` 25 | 26 | export async function search(q: string) { 27 | const searchResults = await searchAndWaitForResults(q) 28 | const { allowed, skipped } = searchResults.reduce((prev, curr) => { 29 | if (["mp4", "mkv", "avi", "wmv", "mpeg", "mpg"].some((ext) => curr.name.endsWith(`.${ext}`))) { 30 | prev.allowed.push(curr) 31 | } else { 32 | prev.skipped.push(curr) 33 | } 34 | return prev 35 | }, { allowed: [] as typeof searchResults, skipped: [] as typeof searchResults }) 36 | 37 | if (skipped.length > 0) { 38 | logger.debug(`${skipped.length} results excluded with unknown file extensions:`) 39 | skipped.forEach(r => { 40 | logger.debug(`\t- ${r.name} (${readableSize(r.size)})`) 41 | }) 42 | } 43 | 44 | return allowed 45 | } 46 | 47 | export const itemsResponse = ( 48 | searchResults: Awaited>, 49 | categories: number[] 50 | ) => ` 51 | 52 | 53 | 54 | ${searchResults.map( 55 | (item) => ` 56 | 57 | ${encode(item.name)} 58 | ${item.hash}-${encode(item.name)} 59 | ${buildRFC822Date(new Date())} 60 | 61 | 62 | ${categories.map((c) => ``).join("")} 63 | 64 | 65 | 66 | 67 | 68 | 69 | ` 70 | )} 71 | 72 | 73 | ` 74 | 75 | export function group( 76 | arr: T[], 77 | operator: "AND" | "OR", 78 | parenthesis: boolean 79 | ) { 80 | arr = arr.filter(skipFalsy) 81 | 82 | const joined = 83 | operator === "OR" 84 | ? arr.join(` ${operator} `) 85 | : arr 86 | .sort( 87 | // move parenthesis to the end 88 | (a, b) => 89 | (typeof a === "string" && a.startsWith("(") ? 1 : 0) - 90 | (typeof b === "string" && b.startsWith("(") ? 1 : 0) 91 | ) 92 | .reduce( 93 | (prev, curr) => 94 | prev === "" 95 | ? `${curr}` 96 | : prev.endsWith(")") || 97 | (typeof curr === "string" && curr.startsWith("(")) 98 | ? `${prev} AND ${curr}` 99 | : `${prev} ${curr}`, 100 | "" 101 | ) 102 | 103 | if (!parenthesis) { 104 | return joined 105 | } 106 | 107 | return arr.length > 1 ? `(${joined})` : `${arr[0] ?? ""}` 108 | } 109 | -------------------------------------------------------------------------------- /src/amule/amule.conf: -------------------------------------------------------------------------------- 1 | [eMule] 2 | AppVersion=2.3.3 3 | Nick=emulerr 4 | QueueSizePref=50 5 | MaxUpload=1536 6 | MaxDownload=0 7 | SlotAllocation=50 8 | Port=4662 9 | UDPPort=4672 10 | UDPEnable=1 11 | Address= 12 | Autoconnect=1 13 | MaxSourcesPerFile=600 14 | MaxConnections=1000 15 | MaxConnectionsPerFiveSeconds=50 16 | RemoveDeadServer=0 17 | DeadServerRetry=3 18 | ServerKeepAliveTimeout=0 19 | Reconnect=1 20 | Scoresystem=1 21 | Serverlist=1 22 | AddServerListFromServer=0 23 | AddServerListFromClient=0 24 | SafeServerConnect=0 25 | AutoConnectStaticOnly=0 26 | UPnPEnabled=0 27 | UPnPTCPPort=50000 28 | SmartIdCheck=1 29 | ConnectToKad=1 30 | ConnectToED2K=1 31 | TempDir=/downloads/incomplete 32 | IncomingDir=/downloads/complete 33 | ICH=1 34 | AICHTrust=0 35 | CheckDiskspace=1 36 | MinFreeDiskSpace=1 37 | AddNewFilesPaused=0 38 | PreviewPrio=0 39 | ManualHighPrio=0 40 | StartNextFile=0 41 | StartNextFileSameCat=0 42 | StartNextFileAlpha=0 43 | FileBufferSizePref=1400 44 | DAPPref=1 45 | UAPPref=1 46 | AllocateFullFile=1 47 | OSDirectory=/home/amule/.aMule 48 | OnlineSignature=0 49 | OnlineSignatureUpdate=5 50 | EnableTrayIcon=0 51 | MinToTray=0 52 | ConfirmExit=1 53 | StartupMinimized=0 54 | 3DDepth=10 55 | ToolTipDelay=1 56 | ShowOverhead=0 57 | ShowInfoOnCatTabs=1 58 | VerticalToolbar=0 59 | GeoIPEnabled=1 60 | ShowVersionOnTitle=0 61 | VideoPlayer= 62 | StatGraphsInterval=3 63 | statsInterval=30 64 | DownloadCapacity=300 65 | UploadCapacity=100 66 | StatsAverageMinutes=5 67 | VariousStatisticsMaxValue=100 68 | SeeShare=2 69 | FilterLanIPs=1 70 | ParanoidFiltering=1 71 | IPFilterAutoLoad=0 72 | IPFilterURL=http://emuling.gitlab.io/ipfilter.zip 73 | FilterLevel=127 74 | IPFilterSystem=0 75 | FilterMessages=1 76 | FilterAllMessages=0 77 | MessagesFromFriendsOnly=0 78 | MessageFromValidSourcesOnly=1 79 | FilterWordMessages=0 80 | MessageFilter= 81 | ShowMessagesInLog=1 82 | FilterComments=0 83 | CommentFilter= 84 | ShareHiddenFiles=0 85 | AutoSortDownloads=0 86 | NewVersionCheck=0 87 | AdvancedSpamFilter=1 88 | MessageUseCaptchas=1 89 | Language= 90 | SplitterbarPosition=75 91 | YourHostname= 92 | DateTimeFormat=%A, %x, %X 93 | AllcatType=0 94 | ShowAllNotCats=0 95 | SmartIdState=1 96 | DropSlowSources=0 97 | KadNodesUrl=http://upd.emule-security.org/nodes.dat 98 | Ed2kServersUrl=http://emuling.gitlab.io/server.met 99 | # Ed2kServersUrl=http://files.grupots.net/emule/server.met 100 | ShowRatesOnTitle=0 101 | GeoLiteCountryUpdateUrl=http://mailfud.org/geoip-legacy/GeoIP.dat.gz 102 | StatsServerName=Shorty ED2K stats 103 | StatsServerURL=http://ed2k.shortypower.org/?hash= 104 | CreateSparseFiles=1 105 | Notifications=0 106 | [Browser] 107 | OpenPageInTab=1 108 | CustomBrowserString= 109 | [Proxy] 110 | ProxyEnableProxy=0 111 | ProxyType=0 112 | ProxyName= 113 | ProxyPort=1080 114 | ProxyEnablePassword=0 115 | ProxyUser= 116 | ProxyPassword= 117 | [ExternalConnect] 118 | UseSrcSeeds=0 119 | AcceptExternalConnections=1 120 | ECAddress= 121 | ECPort=4712 122 | ECPassword=5ebe2294ecd0e0f08eab7690d2a6ee69 123 | UPnPECEnabled=0 124 | ShowProgressBar=1 125 | ShowPercent=1 126 | UseSecIdent=1 127 | IpFilterClients=1 128 | IpFilterServers=1 129 | TransmitOnlyUploadingClients=0 130 | [WebServer] 131 | Enabled=1 132 | Password=5ebe2294ecd0e0f08eab7690d2a6ee69 133 | PasswordLow= 134 | Port=4711 135 | WebUPnPTCPPort=50001 136 | UPnPWebServerEnabled=0 137 | UseGzip=0 138 | UseLowRightsUser=0 139 | PageRefreshTime=120 140 | Template=AmuleWebUI-Reloaded 141 | Path=amuleweb 142 | [GUI] 143 | HideOnClose=0 144 | [Razor_Preferences] 145 | FastED2KLinksHandler=1 146 | [SkinGUIOptions] 147 | Skin= 148 | [Statistics] 149 | MaxClientVersions=0 150 | [Obfuscation] 151 | IsClientCryptLayerSupported=1 152 | IsCryptLayerRequested=1 153 | IsClientCryptLayerRequired=1 154 | CryptoPaddingLenght=254 155 | CryptoKadUDPKey=138123518 156 | [PowerManagement] 157 | PreventSleepWhileDownloading=0 158 | [UserEvents] 159 | [UserEvents/DownloadCompleted] 160 | CoreEnabled=0 161 | CoreCommand= 162 | GUIEnabled=0 163 | GUICommand= 164 | [UserEvents/NewChatSession] 165 | CoreEnabled=0 166 | CoreCommand= 167 | GUIEnabled=0 168 | GUICommand= 169 | [UserEvents/OutOfDiskSpace] 170 | CoreEnabled=0 171 | CoreCommand= 172 | GUIEnabled=0 173 | GUICommand= 174 | [UserEvents/ErrorOnCompletion] 175 | CoreEnabled=0 176 | CoreCommand= 177 | GUIEnabled=0 178 | GUICommand= 179 | [HTTPDownload] 180 | URL_1=http://upd.emule-security.org/ipfilter.zip 181 | [General] 182 | Count=1 183 | [Cat\#1] 184 | Title=downloads 185 | Incoming=/downloads/complete 186 | Comment=Incoming files being downloaded 187 | Color=16177978 -------------------------------------------------------------------------------- /src/app/routes/api.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "@remix-run/node" 2 | import { logger } from "~/utils/logger" 3 | import { skipFalsy } from "~/utils/array" 4 | import { 5 | emptyResponse, 6 | fakeItem, 7 | group, 8 | itemsResponse, 9 | search, 10 | } from "~/utils/indexers" 11 | 12 | export const loader = (async ({ request }) => { 13 | const content = await handleTorznabRequest(request) 14 | return new Response(`${content}`, { 15 | status: 200, 16 | headers: { 17 | "Content-Type": "application/xml", 18 | "X-Content-Type-Options": "nosniff", 19 | "Cache-Control": "public, max-age=0", 20 | }, 21 | }) 22 | }) satisfies LoaderFunction 23 | 24 | async function handleTorznabRequest(request: Request) { 25 | logger.debug("URL", request.url) 26 | const url = new URL(request.url) 27 | 28 | switch (url.searchParams.get("t")) { 29 | case "caps": 30 | return caps(url) 31 | case "search": 32 | return await rawSearch(url) 33 | case "tvsearch": 34 | return await tvSearch(url) 35 | default: 36 | throw Error("NOT IMPLEMENTED") 37 | } 38 | } 39 | 40 | function caps(_url: URL) { 41 | return ` 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ` 62 | } 63 | 64 | async function rawSearch(url: URL) { 65 | const q = sanitizeQuery(url.searchParams.get("q")) 66 | const offset = url.searchParams.get("offset") 67 | const cat = 68 | url.searchParams 69 | .get("cat") 70 | ?.toString() 71 | ?.split(",") 72 | ?.map((x) => parseInt(x)) ?? [] 73 | 74 | // avoid duplicated entries 75 | if (offset && offset !== "0") { 76 | return emptyResponse() 77 | } 78 | 79 | // rss sync 80 | if (!q) { 81 | return itemsResponse([fakeItem], cat) 82 | } 83 | 84 | const searchResults = await search(q) 85 | return itemsResponse(searchResults, cat) 86 | } 87 | 88 | async function tvSearch(url: URL) { 89 | const q = sanitizeQuery(url.searchParams.get("q")?.toString()) 90 | const season = url.searchParams.get("season")?.toString() 91 | const episode = url.searchParams.get("ep")?.toString() 92 | const offset = url.searchParams.get("offset")?.toString() 93 | const cat = 94 | url.searchParams 95 | .get("cat") 96 | ?.toString() 97 | ?.split(",") 98 | ?.map((x) => parseInt(x)) ?? [] 99 | 100 | // avoid duplicated entries 101 | if (offset && offset !== "0") { 102 | return emptyResponse() 103 | } 104 | 105 | // rss sync 106 | if (!q) { 107 | return itemsResponse([fakeItem], cat) 108 | } 109 | 110 | const episodeQuery = [ 111 | ...new Set( 112 | season && episode 113 | ? ["/", "-"].some((c) => episode.includes(c)) // daily episode 114 | ? [`${season}/${episode}`] 115 | : [ 116 | `${season}x${episode}`, 117 | `${season}x${episode.padStart(2, "0")}`, 118 | `S${season.padStart(2, "0")}E${episode.padStart(2, "0")}`, 119 | `S${season}E${episode}`, 120 | ] 121 | : season 122 | ? season.length === 4 // daily episode 123 | ? [season] 124 | : [`${season}x`, `S${season.padStart(2, "0")}`, `S${season}`] 125 | : [] 126 | ), 127 | ].filter(skipFalsy) 128 | 129 | const episodeFilter = group(episodeQuery, "OR", true) 130 | const query = group([q, episodeFilter], "AND", false) 131 | const searchResults = await search(query) 132 | return itemsResponse(searchResults, cat) 133 | } 134 | 135 | function sanitizeQuery(q: string | undefined | null) { 136 | if (!q) { 137 | return q 138 | } 139 | 140 | return q 141 | .normalize("NFKD") 142 | .replace(/[\u0100-\uFFFF]/g, "") 143 | .replace(/[^\w '-]/g, " ") 144 | .replace(/ +/g, " ") 145 | .trim() 146 | } 147 | -------------------------------------------------------------------------------- /src/app/utils/memoize.ts: -------------------------------------------------------------------------------- 1 | import ExpiryMap from "expiry-map" 2 | import memoize from "memoize" 3 | import pMemoize, { AnyAsyncFunction } from "p-memoize" 4 | import { deepCopy } from "deep-copy-ts" 5 | import stringify from "json-stable-stringify" 6 | import { Mutex } from "async-mutex" 7 | import { deepFreeze, DeepReadonly } from "./state" 8 | export const deterministicStringify = stringify 9 | 10 | class CustomExpiryMap extends ExpiryMap { 11 | constructor( 12 | maxAge: number, 13 | private shouldCache: undefined | ((value: V) => boolean) 14 | ) { 15 | super(maxAge) 16 | } 17 | set(key: K, value: V): this { 18 | if (this.shouldCache && !this.shouldCache(value)) return this 19 | return super.set(key, value) 20 | } 21 | } 22 | 23 | type CacheStorageContent = { 24 | data: ValueType 25 | maxAge: number 26 | } 27 | 28 | export function memoizeSync any>( 29 | fn: T, 30 | timeout: number = 500, 31 | shouldCache?: (value: CacheStorageContent>) => boolean 32 | ): T { 33 | const memoized = memoize(fn, { 34 | cacheKey: deterministicStringify, 35 | maxAge: timeout, 36 | cache: new CustomExpiryMap(timeout, shouldCache), 37 | }) 38 | 39 | return ((...args) => deepCopy(memoized(...args))) as T 40 | } 41 | 42 | export function memoizeAsync( 43 | fn: T, 44 | timeout: number = 500, 45 | shouldCache?: (value: Awaited>) => boolean 46 | ): T { 47 | const memoized = pMemoize(fn, { 48 | cacheKey: deterministicStringify, 49 | cache: new CustomExpiryMap(timeout, shouldCache), 50 | }) 51 | 52 | return ((...args) => memoized(...args).then(deepCopy)) as T 53 | } 54 | 55 | export function staleWhileRevalidate( 56 | fn: (...args: A) => Promise, 57 | options: { 58 | shouldCache?: (value: R) => boolean 59 | stalled?: number 60 | expired?: number, 61 | debug?: boolean 62 | } = {} 63 | ): (...args: A) => Promise> { 64 | const { shouldCache, stalled = 500, expired = stalled * 20 } = options 65 | 66 | const cache = new Map< 67 | string, 68 | { 69 | addedOn: number 70 | value: R 71 | } 72 | >() 73 | 74 | // automatic cleanup 75 | setInterval(() => { 76 | const now = Date.now() 77 | cache.forEach((value, key) => { 78 | if (now - value.addedOn >= expired) { 79 | cache.delete(key) 80 | } 81 | }) 82 | }, 30000) 83 | 84 | const allMutex = new Map() 85 | const updateCache = async (key: string, args: A): Promise => { 86 | if (!allMutex.has(key)) { 87 | allMutex.set(key, new Mutex()) 88 | } 89 | 90 | const mutex = allMutex.get(key)! 91 | return await mutex.runExclusive(async () => { 92 | const now = Date.now() 93 | const cached = cache.get(key) 94 | 95 | if (cached && (now - cached.addedOn) < stalled) { 96 | return cached.value 97 | } 98 | 99 | const value = deepFreeze(await fn(...args)) 100 | 101 | if (!shouldCache || shouldCache(value)) { 102 | // logger.debug(id, "[staleWhileRevalidate] SET") 103 | cache.set(key, { addedOn: Date.now(), value }) 104 | } 105 | 106 | return value 107 | }) 108 | } 109 | 110 | return async (...args: A) => { 111 | const key = deterministicStringify(args) 112 | const now = Date.now() 113 | const cached = cache.get(key) 114 | 115 | if (options.debug) { 116 | console.log({ key, cached }) 117 | } 118 | 119 | if (!cached || now - cached.addedOn >= expired) { 120 | // logger.debug(id, "[staleWhileRevalidate] MISS") 121 | return await updateCache(key, args) 122 | } 123 | 124 | if (now - cached.addedOn >= stalled) { 125 | // logger.debug(id, "[staleWhileRevalidate] STALLED") 126 | void updateCache(key, args) 127 | return cached.value 128 | } 129 | 130 | // logger.debug(id, "[staleWhileRevalidate] HIT") 131 | return cached.value 132 | } 133 | } 134 | 135 | // export function memoizeAsyncGenerator< 136 | // T extends (...args: readonly any[]) => AsyncGenerator, 137 | // R, 138 | // >( 139 | // fn: T, 140 | // timeout: number = 500, 141 | // shouldCache?: (value: AsyncGeneratorReturnType) => boolean 142 | // ): T { 143 | // const cache = new CustomExpiryMap(timeout, shouldCache) 144 | // const cacheKey = deterministicStringify 145 | 146 | // return async function* (...args: Parameters) { 147 | // const key = cacheKey(args) 148 | // const currentCache = cache.get(cacheKey(args)) 149 | 150 | // if (currentCache) { 151 | // return currentCache 152 | // } 153 | 154 | // const res = await (yield* fn(...arguments)) 155 | // cache.set(key, res) 156 | // return res 157 | // } as T 158 | // } -------------------------------------------------------------------------------- /src/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream" 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node" 10 | import { createReadableStreamFromReadable } from "@remix-run/node" 11 | import { RemixServer } from "@remix-run/react" 12 | import { isbot } from "isbot" 13 | import { renderToPipeableStream } from "react-dom/server" 14 | import { initializeJobs } from "../cron/cron" 15 | import { setLogLevel } from "./utils/logger" 16 | 17 | const ABORT_DELAY = 120_000 18 | 19 | export default function handleRequest( 20 | request: Request, 21 | responseStatusCode: number, 22 | responseHeaders: Headers, 23 | remixContext: EntryContext, 24 | // This is ignored so we can keep it in the template for visibility. Feel 25 | // free to delete this parameter in your app if you're not using it! 26 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 27 | loadContext: AppLoadContext 28 | ) { 29 | return isbot(request.headers.get("user-agent") || "") 30 | ? handleBotRequest( 31 | request, 32 | responseStatusCode, 33 | responseHeaders, 34 | remixContext 35 | ) 36 | : handleBrowserRequest( 37 | request, 38 | responseStatusCode, 39 | responseHeaders, 40 | remixContext 41 | ) 42 | } 43 | 44 | function handleBotRequest( 45 | request: Request, 46 | responseStatusCode: number, 47 | responseHeaders: Headers, 48 | remixContext: EntryContext 49 | ) { 50 | return new Promise((resolve, reject) => { 51 | let shellRendered = false 52 | const { pipe, abort } = renderToPipeableStream( 53 | , 58 | { 59 | onAllReady() { 60 | shellRendered = true 61 | const body = new PassThrough() 62 | const stream = createReadableStreamFromReadable(body) 63 | 64 | responseHeaders.set("Content-Type", "text/html") 65 | 66 | resolve( 67 | new Response(stream, { 68 | headers: responseHeaders, 69 | status: responseStatusCode, 70 | }) 71 | ) 72 | 73 | pipe(body) 74 | }, 75 | onShellError(error: unknown) { 76 | reject(error) 77 | }, 78 | onError(error: unknown) { 79 | responseStatusCode = 500 80 | // Log streaming rendering errors from inside the shell. Don't log 81 | // errors encountered during initial shell rendering since they'll 82 | // reject and get logged in handleDocumentRequest. 83 | if (shellRendered) { 84 | console.error(error) 85 | } 86 | }, 87 | } 88 | ) 89 | 90 | setTimeout(abort, ABORT_DELAY) 91 | }) 92 | } 93 | 94 | function handleBrowserRequest( 95 | request: Request, 96 | responseStatusCode: number, 97 | responseHeaders: Headers, 98 | remixContext: EntryContext 99 | ) { 100 | return new Promise((resolve, reject) => { 101 | let shellRendered = false 102 | const { pipe, abort } = renderToPipeableStream( 103 | , 108 | { 109 | onShellReady() { 110 | shellRendered = true 111 | const body = new PassThrough() 112 | const stream = createReadableStreamFromReadable(body) 113 | 114 | responseHeaders.set("Content-Type", "text/html") 115 | responseHeaders.set("X-Content-Type-Options", "nosniff") 116 | 117 | resolve( 118 | new Response(stream, { 119 | headers: responseHeaders, 120 | status: responseStatusCode, 121 | }) 122 | ) 123 | 124 | pipe(body) 125 | }, 126 | onShellError(error: unknown) { 127 | reject(error) 128 | }, 129 | onError(error: unknown) { 130 | responseStatusCode = 500 131 | // Log streaming rendering errors from inside the shell. Don't log 132 | // errors encountered during initial shell rendering since they'll 133 | // reject and get logged in handleDocumentRequest. 134 | if (shellRendered) { 135 | console.error(error) 136 | } 137 | }, 138 | } 139 | ) 140 | 141 | setTimeout(abort, ABORT_DELAY) 142 | }) 143 | } 144 | 145 | /// Initialization code 146 | setLogLevel(process.env.LOG_LEVEL) 147 | initializeJobs() 148 | -------------------------------------------------------------------------------- /src/app/utils/jsonDb.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, chown, mkdir, rename, rm } from "node:fs/promises" 2 | import { readFileSync, existsSync } from "node:fs" 3 | import { dirname } from "path" 4 | import { Mutex } from "async-mutex" 5 | 6 | export type DbType = T extends ReturnType> ? U : never 7 | 8 | export function createJsonDb( 9 | fileName: string, 10 | initialState: Schema, 11 | initializer?: (s: Schema) => Schema 12 | ) { 13 | const state = { 14 | data: initialState 15 | } 16 | 17 | try { 18 | if (existsSync(fileName)) { 19 | state.data = JSON.parse(readFileSync(fileName).toString("utf8"), reviver) as Schema 20 | } else if (existsSync(`${fileName}.new`)) { 21 | state.data = JSON.parse(readFileSync(`${fileName}.new`).toString("utf8"), reviver) as Schema 22 | } 23 | 24 | // convert old objects to Map/Set 25 | if (state.data && initialState instanceof Map && !(state.data instanceof Map)) { 26 | state.data = new Map(Object.entries(state.data)) as Schema 27 | } else if (state.data && initialState instanceof Set && Array.isArray(state.data)) { 28 | state.data = new Set(state.data) as Schema 29 | } 30 | } catch { } 31 | 32 | state.data = initializer ? initializer(state.data) : state.data 33 | 34 | const writeMutex = new Mutex() 35 | let prevContent: string | undefined = undefined 36 | setInterval(async () => { 37 | if (writeMutex.isLocked()) { 38 | return 39 | } 40 | 41 | return await writeMutex.runExclusive(async () => { 42 | const content = JSON.stringify(state.data, replacer) 43 | if (prevContent === content) { 44 | return 45 | } 46 | 47 | prevContent = content 48 | await mkdir(dirname(fileName), { recursive: true }).catch(() => { }) 49 | await writeFile(`${fileName}.new`, content, { encoding: "utf8" }) 50 | await rm(fileName).catch(() => { }) 51 | await rename(`${fileName}.new`, fileName) 52 | await chown( 53 | fileName, 54 | parseInt(process.env.PUID), 55 | parseInt(process.env.PGID) 56 | ) 57 | }) 58 | }, 5000) 59 | 60 | return state 61 | } 62 | 63 | // export function createJsonDb( 64 | // fileName: string, 65 | // initialState: Schema, 66 | // initializer?: (s: Partial) => Schema 67 | // ) { 68 | // let initialized = false 69 | // let lastValue = initialState 70 | 71 | // const initializerMutex = new Mutex() 72 | // const get = async () => { 73 | // await initializerMutex.waitForUnlock() 74 | // if (!initialized) { 75 | // await initializerMutex.runExclusive(async () => { 76 | // initialized = true 77 | 78 | // try { 79 | // lastValue = await readFile(fileName).then( 80 | // (d) => JSON.parse(d.toString("utf8"), reviver) as Schema 81 | // ) 82 | 83 | // // convert old objects to Map/Set 84 | // if (lastValue && initialState instanceof Map && !(lastValue instanceof Map)) { 85 | // lastValue = new Map(Object.entries(lastValue)) as Schema 86 | // } else if (lastValue && initialState instanceof Set && Array.isArray(lastValue)) { 87 | // lastValue = new Set(lastValue) as Schema 88 | // } 89 | // } catch (ex) { 90 | // logger.debug("[jsonDb] defaulting to initialState:", fileName) 91 | // lastValue = initialState 92 | // } 93 | 94 | // if (initializer) { 95 | // lastValue = initializer(lastValue) 96 | // } 97 | // }) 98 | // } 99 | 100 | // return lastValue 101 | // } 102 | 103 | // const setterMutex = new Mutex() 104 | // const set = async ( 105 | // setter: (data: Schema) => Schema | void 106 | // ): Promise => { 107 | // await setterMutex.runExclusive(async () => { 108 | // const previous = await get() 109 | // const updated = setter(previous) ?? previous 110 | 111 | // // populate cache 112 | // lastValue = updated 113 | // void write() 114 | // }) 115 | // } 116 | 117 | // const writterMutex = new Mutex() 118 | // const write = throttle(async () => { 119 | // // TODO: when detecting container shutdown, stop writing to avoid corrupting the JSONs 120 | 121 | // return await writterMutex.runExclusive(async () => { 122 | // const content = deterministicStringify(lastValue, { replacer }) 123 | // await mkdir(dirname(fileName), { recursive: true }).catch(() => { }) 124 | // await writeFile(fileName, content, { encoding: "utf8" }) 125 | // await chown( 126 | // fileName, 127 | // parseInt(process.env.PUID), 128 | // parseInt(process.env.PGID) 129 | // ) 130 | // }) 131 | // }, 5000) 132 | 133 | // return { 134 | // get, 135 | // set, 136 | // } as const 137 | // } 138 | 139 | function replacer(key: string, value: any) { 140 | if (!key && value === undefined) { 141 | return null 142 | } 143 | 144 | if (value instanceof Map) { 145 | return { 146 | dataType: "Map", 147 | value: Array.from(value.entries()), 148 | } 149 | } 150 | 151 | if (value instanceof Set) { 152 | return { 153 | dataType: "Set", 154 | value: Array.from(value.keys()), 155 | } 156 | } 157 | 158 | return value 159 | } 160 | 161 | function reviver(key: string, value: any) { 162 | if (!key && value === null) { 163 | return undefined 164 | } 165 | 166 | if (typeof value === "object" && value !== null) { 167 | if (value.dataType === "Map") { 168 | return new Map(value.value) 169 | } 170 | 171 | if (value.dataType === "Set") { 172 | return new Set(value.value) 173 | } 174 | } 175 | 176 | return value 177 | } -------------------------------------------------------------------------------- /src/app/routes/_shell.search.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction, MetaFunction } from "@remix-run/node" 2 | import { 3 | Form, 4 | json, 5 | useFetcher, 6 | useLoaderData, 7 | useNavigation, 8 | } from "@remix-run/react" 9 | import { readableSize } from "~/utils/math" 10 | import { UserIcon } from "~/icons/userIcon" 11 | import { DownloadIcon } from "~/icons/downloadIcon" 12 | import { ActionOrLoaderReturnType } from "~/utils/types" 13 | import { getCategories } from "~/data/categories" 14 | import { getDownloadClientFiles } from "~/data/downloadClient" 15 | import { searchAndWaitForResults } from "~/data/search" 16 | import { staleWhileRevalidate } from "~/utils/memoize" 17 | 18 | export const meta: MetaFunction = () => [{ title: "eMulerr - Search" }] 19 | 20 | const cachedSearch = staleWhileRevalidate(searchAndWaitForResults, { 21 | stalled: 1000 * 30, 22 | }) 23 | 24 | export const loader = (async ({ request }) => { 25 | const qsp = new URL(request.url).searchParams 26 | const q = qsp.get("q")?.toString()?.trim() 27 | 28 | return json({ 29 | q, 30 | results: await cachedSearch(q), 31 | currentFiles: await getDownloadClientFiles(), 32 | categories: await getCategories(), 33 | }) 34 | }) satisfies LoaderFunction 35 | 36 | export default function Search() { 37 | const { q, results } = useLoaderData() 38 | const { state } = useNavigation() 39 | 40 | return ( 41 | <> 42 |
43 |
44 | 55 | 62 |
63 | {state === "idle" && results && ( 64 | <> 65 |
66 |
Count: {results.length}
67 | 68 | )} 69 |
70 |
71 | {state === "idle" && results && ( 72 |
73 | {results 74 | .sort((a, b) => { 75 | return b.sources - a.sources 76 | }) 77 | .map((r, i) => ( 78 | 79 | ))} 80 |
81 | )} 82 |
83 | 84 | ) 85 | } 86 | 87 | function DownloadResult({ 88 | result: r, 89 | }: { 90 | result: NonNullable["results"]>[0] 91 | }) { 92 | const fetcher = useFetcher() 93 | const { currentFiles, categories } = useLoaderData() 94 | const names = [ 95 | ...new Set( 96 | currentFiles.filter((d) => d.hash === r.hash).map((d) => d.name) 97 | ), 98 | ] 99 | const present = r.present || names.length > 0 100 | 101 | return ( 102 | 107 | 108 | 109 |
110 | 119 |

{r.name}

120 |
121 |
122 | 123 | {readableSize(r.size)} 124 | 125 | 126 | {r.sources} 127 | 128 | {!present ? ( 129 | 163 | ) : ( 164 | 173 | )} 174 |
175 |
176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /src/app/routes/_shell.download-client.tsx: -------------------------------------------------------------------------------- 1 | import { json, LoaderFunction } from "@remix-run/node" 2 | import { useFetcher, useLoaderData } from "@remix-run/react" 3 | import { CategoryPicker } from "~/components/categoryPicker" 4 | import { getCategories } from "~/data/categories" 5 | import { getDownloadClientFiles } from "~/data/downloadClient" 6 | import { DeleteIcon } from "~/icons/deleteIcon" 7 | import { DownIcon } from "~/icons/downIcon" 8 | import { DownloadIcon } from "~/icons/downloadIcon" 9 | import { UpIcon } from "~/icons/upIcon" 10 | import { UserIcon } from "~/icons/userIcon" 11 | import { readableSize, roundToDecimals } from "~/utils/math" 12 | import { readableEta } from "~/utils/time" 13 | 14 | export const loader = (async () => { 15 | const categories = await getCategories() 16 | const files = await getDownloadClientFiles() 17 | 18 | return json({ 19 | files, 20 | categories, 21 | time: new Date(), 22 | }) 23 | }) satisfies LoaderFunction 24 | 25 | export default function Index() { 26 | const { files, categories } = useLoaderData() 27 | const fetcher = useFetcher() 28 | 29 | return ( 30 | <> 31 |
32 | Downloading: {files.length} 33 |
34 |
35 | {files.map((f) => ( 36 |
40 |
41 | 50 |

{f.name}

51 |
52 |
53 |
54 | 55 | 56 | {readableSize(f.up_speed)} 57 | /s 58 | 59 | {f.speed != null && ( 60 | 61 | 62 | {readableSize(f.speed)}/s 63 | 64 | )} 65 | 66 | {" "} 67 | 68 | {f.size_done !== f.size && 69 | `${readableSize(f.size_done)}/${readableSize(f.size)}`} 70 | {f.size_done === f.size && `${readableSize(f.size)}`} 71 | 72 | 73 |
74 |
75 | 80 | {f.src_count_xfer != null && ( 81 | 84 | 85 | {f.src_count_xfer} 86 | 87 | )} 88 |
89 |
90 |
91 |
94 | 95 | {f.status_str === "downloading" && ( 96 | <>{readableEta(f.eta)} - 97 | )} 98 | {f.status_str === "stopped" && <>Waiting - } 99 | {f.status_str === "stalled" && <>Stalled - } 100 | {f.status_str === "completing" && <>Verifying - } 101 | {f.status_str === "downloaded" && <>Done - } 102 | {roundToDecimals(f.progress * 100, 3)}% 103 | 104 |
116 |
117 | { 122 | const confirmation = confirm( 123 | `Are you sure you want to delete?\n\n${f.name}` 124 | ) 125 | if (!confirmation) ev.preventDefault() 126 | }} 127 | > 128 | 129 | 137 | 138 |
139 |
140 | ))} 141 |
142 | 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /src/app/data/search.ts: -------------------------------------------------------------------------------- 1 | import { amuleDoSearch, amuleGetStats } from "amule/amule" 2 | import { toEd2kLink, toMagnetLink } from "~/links" 3 | import { toEntries, groupBy, skipFalsy } from "~/utils/array" 4 | import { logger } from "~/utils/logger" 5 | import { sanitizeFilename, setReleaseGroup } from "~/utils/naming" 6 | import { searchKnown, trackKnown } from "./known" 7 | 8 | export async function searchAndWaitForResults(q: string | undefined, ext?: string) { 9 | if (!q) { 10 | return [] 11 | } 12 | 13 | const stats = await amuleGetStats() 14 | const [amuleResults, localResults] = await Promise.all([ 15 | Promise.all([ 16 | stats.serv_addr ? amuleDoSearch(q, ext, "global") : Promise.resolve([]), 17 | stats.kad_connected ? amuleDoSearch(q, ext, "kad") : Promise.resolve([]), 18 | ]).then((r) => r.flatMap((x) => x).map(postProcessResult)), 19 | searchKnown(q).then((x) => x.map(postProcessResult)), 20 | ]) 21 | const allResults = [...amuleResults, ...localResults] 22 | 23 | // if the same hash+size, sum the sources 24 | const hashGroups = toEntries(groupBy(allResults, (f) => f.hash + f.size)) 25 | hashGroups.forEach(([, results]) => { 26 | let sources = 0 27 | results.forEach((r) => { 28 | sources += r.sources 29 | }) 30 | results.forEach((r) => { 31 | r.sources = sources 32 | }) 33 | }) 34 | 35 | // group same names 36 | const filteredResults = hashGroups 37 | .map(([, results]) => 38 | toEntries(groupBy(results, (r) => r.name)) 39 | .map(([, v]) => v[0]) 40 | .filter(skipFalsy) 41 | ) 42 | .flatMap((r) => r) 43 | 44 | trackKnown(amuleResults) 45 | logger.info(`Search '${q}' finished with ${filteredResults.length} results`) 46 | 47 | return filteredResults 48 | } 49 | 50 | function postProcessResult( 51 | r: 52 | | Awaited>[number] 53 | | Awaited>[number] 54 | ) { 55 | const name = sanitizeFilename(setReleaseGroup(r.name)) 56 | return { 57 | ...r, 58 | name, 59 | ed2kLink: toEd2kLink(r.hash, name, r.size), 60 | magnetLink: toMagnetLink(r.hash, name, r.size), 61 | } 62 | } 63 | 64 | type Query = { 65 | type: "AND" | "OR" | "NOT" 66 | nodes: QueryNode[] 67 | } 68 | 69 | type QueryNode = Query | string 70 | 71 | // I wrote this at 1am, contemplating why the fuck did I think of local searches :) 72 | function parseQuery(q: string): [QueryNode, string] { 73 | let modifier: "NOT" | null = null 74 | let current: Query = { 75 | type: "AND", 76 | nodes: [], 77 | } 78 | 79 | while (q.length > 0) { 80 | // end group 81 | if (q[0] === ")") { 82 | return [current, q.substring(1)] 83 | } 84 | 85 | // new group 86 | if (q[0] === "(") { 87 | const nested = parseQuery(q.substring(1).trim()) 88 | current.nodes.push(nested[0]) 89 | q = nested[1] 90 | continue 91 | } 92 | 93 | if (q.trim().startsWith("NOT")) { 94 | modifier = "NOT" 95 | q = q.substring(q.indexOf("NOT") + 3).trim() 96 | continue 97 | } 98 | 99 | if (q.trim().startsWith("OR")) { 100 | if (current.type !== "OR") { 101 | current = { 102 | type: "OR", 103 | nodes: current.nodes.length > 1 ? [current] : [current.nodes[0]!], 104 | } 105 | } 106 | q = q.substring(q.indexOf("OR") + 2).trim() 107 | continue 108 | } 109 | 110 | if (q.trim().startsWith("AND")) { 111 | if (current.type !== "AND") { 112 | current = { 113 | type: "AND", 114 | nodes: current.nodes.length > 1 ? [current] : [current.nodes[0]!], 115 | } 116 | } 117 | q = q.substring(q.indexOf("AND") + 3).trim() 118 | continue 119 | } 120 | 121 | // not a separator: keyword 122 | let str = "" 123 | while ( 124 | q.length > 0 && 125 | ![",", ";", ".", ":", "-", "_", "'", "/", "!", " ", "(", ")"].includes( 126 | q[0]! 127 | ) 128 | ) { 129 | str += q[0] 130 | q = q.substring(1) 131 | } 132 | if (str) { 133 | switch (modifier) { 134 | case "NOT": 135 | current.nodes.push({ 136 | type: "NOT", 137 | nodes: [str], 138 | }) 139 | break 140 | default: 141 | current.nodes.push(str) 142 | break 143 | } 144 | modifier = null 145 | continue 146 | } 147 | 148 | // its a separator, treat as AND 149 | if (current.type !== "AND" && current.nodes.length > 1) { 150 | current = { 151 | type: "AND", 152 | nodes: [current], 153 | } 154 | } 155 | q = q.substring(1) 156 | } 157 | 158 | return [current, ""] 159 | } 160 | 161 | // print('hola OR adios') 162 | // print('hola adios') 163 | // print('hola AND adios') 164 | // print('hola AND adios bye') 165 | // print('hola OR adios OR (juan AND NOT loco) wow OR NOT isc') 166 | // print('hola OR (juan loco)') 167 | // print('hola OR adios xd') 168 | // print('hola OR adios OR xd') 169 | // print('NOT loco') 170 | // print('hey hola OR adios OR (juan AND NOT loco www) wow OR NOT isc') 171 | 172 | // function print(q: string) { 173 | // console.log(q, JSON.stringify(parseQuery(q)[0], undefined, 2)) 174 | // } 175 | 176 | export function testQuery(query: string, target: string) { 177 | const [q] = parseQuery(query) 178 | return testQueryImpl(q, target) 179 | } 180 | 181 | function testQueryImpl(query: QueryNode, target: string): boolean { 182 | if (typeof query === "string") { 183 | return target.toLowerCase().includes(query.toLowerCase()) 184 | } 185 | 186 | if (query.type === "AND") { 187 | return query.nodes.every((n) => testQueryImpl(n, target)) 188 | } 189 | 190 | if (query.type === "OR") { 191 | return query.nodes.some((n) => testQueryImpl(n, target)) 192 | } 193 | 194 | if (query.type === "NOT") { 195 | return !query.nodes.every((n) => testQueryImpl(n, target)) 196 | } 197 | 198 | return true 199 | } 200 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | volume 2 | tmp 3 | 4 | ## Ignore Visual Studio temporary files, build results, and 5 | ## files generated by popular Visual Studio add-ons. 6 | ## 7 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 8 | 9 | # User-specific files 10 | *.rsuser 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | 16 | # User-specific files (MonoDevelop/Xamarin Studio) 17 | *.userprefs 18 | 19 | # Mono auto generated files 20 | mono_crash.* 21 | 22 | # Build results 23 | [Dd]ebug/ 24 | [Dd]ebugPublic/ 25 | [Rr]elease/ 26 | [Rr]eleases/ 27 | x64/ 28 | x86/ 29 | [Ww][Ii][Nn]32/ 30 | [Aa][Rr][Mm]/ 31 | [Aa][Rr][Mm]64/ 32 | bld/ 33 | [Bb]in/ 34 | [Oo]bj/ 35 | [Ll]og/ 36 | [Ll]ogs/ 37 | 38 | # Visual Studio 2015/2017 cache/options directory 39 | .vs/ 40 | # Uncomment if you have tasks that create the project's static files in wwwroot 41 | #wwwroot/ 42 | 43 | # Visual Studio 2017 auto generated files 44 | Generated\ Files/ 45 | 46 | # MSTest test Results 47 | [Tt]est[Rr]esult*/ 48 | [Bb]uild[Ll]og.* 49 | 50 | # NUnit 51 | *.VisualState.xml 52 | TestResult.xml 53 | nunit-*.xml 54 | 55 | # Build Results of an ATL Project 56 | [Dd]ebugPS/ 57 | [Rr]eleasePS/ 58 | dlldata.c 59 | 60 | # Benchmark Results 61 | BenchmarkDotNet.Artifacts/ 62 | 63 | # .NET Core 64 | project.lock.json 65 | project.fragment.lock.json 66 | artifacts/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.tlog 97 | *.vspscc 98 | *.vssscc 99 | .builds 100 | *.pidb 101 | *.svclog 102 | *.scc 103 | 104 | # Chutzpah Test files 105 | _Chutzpah* 106 | 107 | # Visual C++ cache files 108 | ipch/ 109 | *.aps 110 | *.ncb 111 | *.opendb 112 | *.opensdf 113 | *.sdf 114 | *.cachefile 115 | *.VC.db 116 | *.VC.VC.opendb 117 | 118 | # Visual Studio profiler 119 | *.psess 120 | *.vsp 121 | *.vspx 122 | *.sap 123 | 124 | # Visual Studio Trace Files 125 | *.e2e 126 | 127 | # TFS 2012 Local Workspace 128 | $tf/ 129 | 130 | # Guidance Automation Toolkit 131 | *.gpState 132 | 133 | # ReSharper is a .NET coding add-in 134 | _ReSharper*/ 135 | *.[Rr]e[Ss]harper 136 | *.DotSettings.user 137 | 138 | # TeamCity is a build add-in 139 | _TeamCity* 140 | 141 | # DotCover is a Code Coverage Tool 142 | *.dotCover 143 | 144 | # AxoCover is a Code Coverage Tool 145 | .axoCover/* 146 | !.axoCover/settings.json 147 | 148 | # Coverlet is a free, cross platform Code Coverage Tool 149 | coverage*.json 150 | coverage*.xml 151 | coverage*.info 152 | 153 | # Visual Studio code coverage results 154 | *.coverage 155 | *.coveragexml 156 | 157 | # NCrunch 158 | _NCrunch_* 159 | .*crunch*.local.xml 160 | nCrunchTemp_* 161 | 162 | # MightyMoose 163 | *.mm.* 164 | AutoTest.Net/ 165 | 166 | # Web workbench (sass) 167 | .sass-cache/ 168 | 169 | # Installshield output folder 170 | [Ee]xpress/ 171 | 172 | # DocProject is a documentation generator add-in 173 | DocProject/buildhelp/ 174 | DocProject/Help/*.HxT 175 | DocProject/Help/*.HxC 176 | DocProject/Help/*.hhc 177 | DocProject/Help/*.hhk 178 | DocProject/Help/*.hhp 179 | DocProject/Help/Html2 180 | DocProject/Help/html 181 | 182 | # Click-Once directory 183 | publish/ 184 | 185 | # Publish Web Output 186 | *.[Pp]ublish.xml 187 | *.azurePubxml 188 | # Note: Comment the next line if you want to checkin your web deploy settings, 189 | # but database connection strings (with potential passwords) will be unencrypted 190 | *.pubxml 191 | *.publishproj 192 | 193 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 194 | # checkin your Azure Web App publish settings, but sensitive information contained 195 | # in these scripts will be unencrypted 196 | PublishScripts/ 197 | 198 | # NuGet Packages 199 | *.nupkg 200 | # NuGet Symbol Packages 201 | *.snupkg 202 | # The packages folder can be ignored because of Package Restore 203 | **/[Pp]ackages/* 204 | # except build/, which is used as an MSBuild target. 205 | !**/[Pp]ackages/build/ 206 | # Uncomment if necessary however generally it will be regenerated when needed 207 | #!**/[Pp]ackages/repositories.config 208 | # NuGet v3's project.json files produces more ignorable files 209 | *.nuget.props 210 | *.nuget.targets 211 | 212 | # Microsoft Azure Build Output 213 | csx/ 214 | *.build.csdef 215 | 216 | # Microsoft Azure Emulator 217 | ecf/ 218 | rcf/ 219 | 220 | # Windows Store app package directories and files 221 | AppPackages/ 222 | BundleArtifacts/ 223 | Package.StoreAssociation.xml 224 | _pkginfo.txt 225 | *.appx 226 | *.appxbundle 227 | *.appxupload 228 | 229 | # Visual Studio cache files 230 | # files ending in .cache can be ignored 231 | *.[Cc]ache 232 | # but keep track of directories ending in .cache 233 | !?*.[Cc]ache/ 234 | 235 | # Others 236 | ClientBin/ 237 | ~$* 238 | *~ 239 | *.dbmdl 240 | *.dbproj.schemaview 241 | *.jfm 242 | *.pfx 243 | *.publishsettings 244 | orleans.codegen.cs 245 | 246 | # Including strong name files can present a security risk 247 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 248 | #*.snk 249 | 250 | # Since there are multiple workflows, uncomment next line to ignore bower_components 251 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 252 | #bower_components/ 253 | 254 | # RIA/Silverlight projects 255 | Generated_Code/ 256 | 257 | # Backup & report files from converting an old project file 258 | # to a newer Visual Studio version. Backup files are not needed, 259 | # because we have git ;-) 260 | _UpgradeReport_Files/ 261 | Backup*/ 262 | UpgradeLog*.XML 263 | UpgradeLog*.htm 264 | ServiceFabricBackup/ 265 | *.rptproj.bak 266 | 267 | # SQL Server files 268 | *.mdf 269 | *.ldf 270 | *.ndf 271 | 272 | # Business Intelligence projects 273 | *.rdl.data 274 | *.bim.layout 275 | *.bim_*.settings 276 | *.rptproj.rsuser 277 | *- [Bb]ackup.rdl 278 | *- [Bb]ackup ([0-9]).rdl 279 | *- [Bb]ackup ([0-9][0-9]).rdl 280 | 281 | # Microsoft Fakes 282 | FakesAssemblies/ 283 | 284 | # GhostDoc plugin setting file 285 | *.GhostDoc.xml 286 | 287 | # Node.js Tools for Visual Studio 288 | .ntvs_analysis.dat 289 | node_modules/ 290 | 291 | # Visual Studio 6 build log 292 | *.plg 293 | 294 | # Visual Studio 6 workspace options file 295 | *.opt 296 | 297 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 298 | *.vbw 299 | 300 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 301 | *.vbp 302 | 303 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 304 | *.dsw 305 | *.dsp 306 | 307 | # Visual Studio 6 technical files 308 | *.ncb 309 | *.aps 310 | 311 | # Visual Studio LightSwitch build output 312 | **/*.HTMLClient/GeneratedArtifacts 313 | **/*.DesktopClient/GeneratedArtifacts 314 | **/*.DesktopClient/ModelManifest.xml 315 | **/*.Server/GeneratedArtifacts 316 | **/*.Server/ModelManifest.xml 317 | _Pvt_Extensions 318 | 319 | # Paket dependency manager 320 | .paket/paket.exe 321 | paket-files/ 322 | 323 | # FAKE - F# Make 324 | .fake/ 325 | 326 | # CodeRush personal settings 327 | .cr/personal 328 | 329 | # Python Tools for Visual Studio (PTVS) 330 | __pycache__/ 331 | *.pyc 332 | 333 | # Cake - Uncomment if you are using it 334 | # tools/** 335 | # !tools/packages.config 336 | 337 | # Tabs Studio 338 | *.tss 339 | 340 | # Telerik's JustMock configuration file 341 | *.jmconfig 342 | 343 | # BizTalk build output 344 | *.btp.cs 345 | *.btm.cs 346 | *.odx.cs 347 | *.xsd.cs 348 | 349 | # OpenCover UI analysis results 350 | OpenCover/ 351 | 352 | # Azure Stream Analytics local run output 353 | ASALocalRun/ 354 | 355 | # MSBuild Binary and Structured Log 356 | *.binlog 357 | 358 | # NVidia Nsight GPU debugger configuration file 359 | *.nvuser 360 | 361 | # MFractors (Xamarin productivity tool) working folder 362 | .mfractor/ 363 | 364 | # Local History for Visual Studio 365 | .localhistory/ 366 | 367 | # Visual Studio History (VSHistory) files 368 | .vshistory/ 369 | 370 | # BeatPulse healthcheck temp database 371 | healthchecksdb 372 | 373 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 374 | MigrationBackup/ 375 | 376 | # Ionide (cross platform F# VS Code tools) working folder 377 | .ionide/ 378 | 379 | # Fody - auto-generated XML schema 380 | FodyWeavers.xsd 381 | 382 | # VS Code files for those working on multiple tools 383 | .vscode/* 384 | !.vscode/settings.json 385 | !.vscode/tasks.json 386 | !.vscode/launch.json 387 | !.vscode/extensions.json 388 | *.code-workspace 389 | 390 | # Local History for Visual Studio Code 391 | .history/ 392 | 393 | # Windows Installer files from build outputs 394 | *.cab 395 | *.msi 396 | *.msix 397 | *.msm 398 | *.msp 399 | 400 | # JetBrains Rider 401 | *.sln.iml -------------------------------------------------------------------------------- /src/amule/api.php: -------------------------------------------------------------------------------- 1 | $category) { 7 | if ($index > 0) { echo ","; } 8 | echo '"'.$category.'":'.$index; 9 | } 10 | echo "}"; 11 | break; 12 | case "stats": 13 | $stats = amule_get_stats(); 14 | echo "{"; 15 | echo '"id":'.num_value($stats["id"]).','; 16 | echo '"serv_addr":'.str_value($stats["serv_addr"]).','; 17 | echo '"serv_name":'.str_value($stats["serv_name"]).','; 18 | echo '"serv_users":'.num_value($stats["serv_users"]).','; 19 | echo '"kad_connected":'.bool_value($stats["kad_connected"] == "1").','; 20 | echo '"kad_firewalled":'.bool_value($stats["kad_firewalled"] == "1").','; 21 | echo '"speed_up":'.num_value($stats["speed_up"]).','; 22 | echo '"speed_down":'.num_value($stats["speed_down"]).','; 23 | echo '"speed_limit_up":'.num_value($stats["speed_limit_up"]).','; 24 | echo '"speed_limit_down":'.num_value($stats["speed_limit_down"]); 25 | echo "}"; 26 | break; 27 | case "options": 28 | echo_array(amule_get_options()); 29 | break; 30 | case "downloads": 31 | $arr = amule_load_vars("downloads"); 32 | echo "["; 33 | foreach ($arr as $index => $item) { 34 | if ($index > 0) { echo ","; } 35 | echo "{"; 36 | echo '"name":"'.$item->name.'",'; 37 | echo '"short_name":"'.$item->short_name.'",'; 38 | echo '"hash":"'.$item->hash.'",'; 39 | echo '"link":"'.$item->link.'",'; 40 | echo '"category":'.$item->category.','; 41 | echo '"status":'.$item->status.','; 42 | echo '"size":'.$item->size.','; 43 | echo '"size_done":'.$item->size_done.','; 44 | echo '"size_xfer":'.$item->size_xfer.','; 45 | echo '"speed":'.$item->speed.','; 46 | echo '"src_count":'.$item->src_count.','; 47 | echo '"src_count_not_curr":'.$item->src_count_not_curr.','; 48 | echo '"src_count_a4af":'.$item->src_count_a4af.','; 49 | echo '"src_count_xfer":'.$item->src_count_xfer.','; 50 | echo '"prio":'.$item->prio.','; 51 | echo '"prio_auto":'.$item->prio_auto.','; 52 | echo '"last_seen_complete":'.$item->last_seen_complete; 53 | echo "}"; 54 | } 55 | echo "]"; 56 | break; 57 | case "uploads": 58 | $arr = amule_load_vars("uploads"); 59 | echo "["; 60 | foreach ($arr as $index => $item) { 61 | if ($index > 0) { echo ","; } 62 | echo "{"; 63 | echo '"name":"'.$item->name.'",'; 64 | echo '"short_name":"'.$item->short_name.'",'; 65 | echo '"xfer_up":'.$item->xfer_up.','; 66 | echo '"xfer_down":'.$item->xfer_down.','; 67 | echo '"xfer_speed":'.$item->xfer_speed; 68 | echo "}"; 69 | } 70 | echo "]"; 71 | break; 72 | case "shared": 73 | $arr = amule_load_vars("shared"); 74 | echo "["; 75 | foreach ($arr as $index => $item) { 76 | if ($index > 0) { echo ","; } 77 | echo "{"; 78 | echo '"name":"'.$item->name.'",'; 79 | echo '"short_name":"'.$item->short_name.'",'; 80 | echo '"hash":"'.$item->hash.'",'; 81 | echo '"size":'.$item->size.','; 82 | echo '"link":"'.$item->link.'",'; 83 | echo '"xfer":'.$item->xfer.','; 84 | echo '"xfer_all":'.$item->xfer_all.','; 85 | echo '"req":'.$item->req.','; 86 | echo '"req_all":'.$item->req_all.','; 87 | echo '"accept":'.$item->accept.','; 88 | echo '"accept_all":'.$item->accept_all.','; 89 | echo '"prio":'.$item->prio.','; 90 | echo '"prio_auto":'.$item->prio_auto; 91 | echo "}"; 92 | } 93 | echo "]"; 94 | break; 95 | case "searchresult": 96 | $arr = amule_load_vars("searchresult"); 97 | echo "["; 98 | foreach ($arr as $index => $item) { 99 | if ($index > 0) { echo ","; } 100 | echo "{"; 101 | echo '"name":"'.$item->name.'",'; 102 | echo '"short_name":"'.$item->short_name.'",'; 103 | echo '"hash":"'.$item->hash.'",'; 104 | echo '"size":'.$item->size.','; 105 | echo '"sources":'.$item->sources.','; 106 | echo '"present":'.$item->present; 107 | echo "}"; 108 | } 109 | echo "]"; 110 | break; 111 | case "servers": 112 | $arr = amule_load_vars("servers"); 113 | echo "["; 114 | foreach ($arr as $index => $item) { 115 | if ($index > 0) { echo ","; } 116 | echo "{"; 117 | echo '"name":"'.$item->name.'",'; 118 | echo '"desc":"'.$item->desc.'",'; 119 | echo '"addr":"'.$item->addr.'",'; 120 | echo '"users":'.$item->users.','; 121 | echo '"ip":"'.$item->ip.'",'; 122 | echo '"port":'.$item->port.','; 123 | echo '"maxusers":'.$item->maxusers.','; 124 | echo '"files":'.$item->files; 125 | echo "}"; 126 | } 127 | echo "]"; 128 | break; 129 | case "version": 130 | echo amule_get_version(); 131 | break; 132 | } 133 | 134 | switch ($HTTP_GET_VARS["do"]) { 135 | case "search": 136 | $q = $HTTP_GET_VARS["q"]; 137 | $ext = $HTTP_GET_VARS["ext"]; 138 | $searchType = $HTTP_GET_VARS["searchType"]; 139 | $minSize = $HTTP_GET_VARS["minSize"]; 140 | amule_do_search_start_cmd($q, $ext, "", $searchType, "", $minSize, 0); 141 | echo '{}'; 142 | break; 143 | case "download": 144 | $cat = amule_get_categories(); 145 | $link = $HTTP_GET_VARS["link"]; 146 | $category = $HTTP_GET_VARS["category"]; 147 | amule_do_ed2k_download_cmd($link, $category); 148 | echo '{}'; 149 | break; 150 | case "cancel": 151 | $hash = $HTTP_GET_VARS["hash"]; 152 | amule_do_download_cmd($hash, 'cancel'); 153 | echo '{}'; 154 | break; 155 | case "resume": 156 | $hash = $HTTP_GET_VARS["hash"]; 157 | amule_do_download_cmd($hash, 'resume'); 158 | echo '{}'; 159 | break; 160 | case "reload-shared": 161 | amule_do_reload_shared_cmd(); 162 | echo '{}'; 163 | break; 164 | case "reconnect": 165 | echo '{ TODO: 1 }'; 166 | break; 167 | } 168 | 169 | function str_value($value) { 170 | if ($value == "" || $value == null) { 171 | return 'null'; 172 | } 173 | 174 | return '"'.$value.'"'; 175 | } 176 | 177 | function num_value($value) { 178 | if ($value == "" || $value == null) { 179 | return 'null'; 180 | } 181 | 182 | return $value; 183 | } 184 | 185 | function bool_value($value) { 186 | if ($value) { 187 | return 'true'; 188 | } 189 | 190 | return 'false'; 191 | } 192 | 193 | function echo_array($object) { 194 | if ($object == "Array") { 195 | if (count($object) == 0) { 196 | echo "[]"; 197 | return; 198 | } 199 | 200 | // real array 201 | if ($object[0] != "") { 202 | echo "["; 203 | foreach($object as $value) { 204 | echo_array($value); 205 | echo ","; 206 | } 207 | echo "]"; 208 | return; 209 | } 210 | 211 | // key/value 212 | echo "{"; 213 | foreach($object as $key => $value) { 214 | if ($key == 0) continue; // php sucks 215 | echo '"'.$key.'":'; 216 | echo_array($value); 217 | echo ","; 218 | } 219 | echo "}"; 220 | return; 221 | } 222 | 223 | echo '"' . $object . '"'; 224 | return; 225 | } 226 | ?> -------------------------------------------------------------------------------- /src/app/routes/_shell.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, LoaderFunction } from "@remix-run/node" 2 | import { 3 | NavLink, 4 | NavLinkProps, 5 | json, 6 | useFetcher, 7 | useLoaderData, 8 | useNavigate, 9 | useOutlet, 10 | } from "@remix-run/react" 11 | import { PropsWithChildren, useState } from "react" 12 | import { restartAmule, amuleGetStats } from "amule/amule" 13 | import { useRevalidate } from "~/utils/useRevalidate" 14 | import { readableSize } from "~/utils/math" 15 | import { twMerge } from "tailwind-merge" 16 | import { DownloadIcon } from "~/icons/downloadIcon" 17 | import { SearchIcon } from "~/icons/searchIcon" 18 | import { UpIcon } from "~/icons/upIcon" 19 | import { DownIcon } from "~/icons/downIcon" 20 | import { AddIcon } from "~/icons/addIcon" 21 | import { getCategories } from "~/data/categories" 22 | import { getDownloadClientFiles } from "~/data/downloadClient" 23 | 24 | export const action = (async ({ request }) => { 25 | void restartAmule().catch(() => {}) 26 | return null 27 | }) satisfies ActionFunction 28 | 29 | export const loader = (async () => { 30 | const stats = await amuleGetStats() 31 | const downloads = await getDownloadClientFiles() 32 | const ed2kPort = process.env.ED2K_PORT 33 | const version = process.env.IMG_VER 34 | 35 | return json({ 36 | version, 37 | stats, 38 | speed_up: stats.speed_up ?? 0, 39 | speed_down: stats.speed_down ?? 0, 40 | ed2kPort, 41 | downloads, 42 | time: new Date(), 43 | categories: await getCategories(), 44 | }) 45 | }) satisfies LoaderFunction 46 | 47 | export default function Layout() { 48 | const fetcher = useFetcher() 49 | const data = useLoaderData() 50 | const outlet = useOutlet() 51 | const navigate = useNavigate() 52 | const [menuHidden, setMenuHidden] = useState(true) 53 | 54 | useRevalidate(true, 1000) 55 | 56 | return ( 57 | <> 58 |
59 | 60 | logo 61 |
eMulerr
62 |
63 | 69 |
70 |
71 |
72 | 76 | 77 | {readableSize(data.speed_up)}/s 78 | 79 | 83 | 84 | {readableSize(data.speed_down)}/s 85 | 86 |
87 | {/* 0 ? "text-buffer" : "text-error"} flex items-center gap-px text-sm`} 89 | title="Buffer" 90 | > 91 | 92 | {data.bufferCount} 93 | */} 94 |
95 |
96 | 115 | eD2k 116 | 117 | 134 | KAD 135 | 136 | { 140 | const confirmation = confirm( 141 | `Are you sure you want to reconnect?` 142 | ) 143 | if (!confirmation) ev.preventDefault() 144 | }} 145 | > 146 | 153 | 154 |
155 |
156 |
setMenuHidden(true)} 160 | >
161 | 226 |
{outlet}
227 | 228 | ) 229 | } 230 | 231 | function StyledNavLink({ ...props }: Omit) { 232 | return ( 233 | 239 | ) 240 | } 241 | 242 | function StatusPill({ 243 | state, 244 | title, 245 | children, 246 | ...props 247 | }: PropsWithChildren<{ 248 | state: "info" | "ok" | "warn" | "error" 249 | title: Record 250 | }>) { 251 | const styles: Record = { 252 | info: "bg-slate-700 border-slate-500", 253 | ok: "bg-green-700 border-green-500", 254 | warn: "bg-yellow-700 border-yellow-500", 255 | error: "bg-red-600 border-red-400", 256 | } 257 | 258 | return ( 259 | 264 | {children} 265 | 266 | ) 267 | } 268 | 269 | function RestartIcon() { 270 | return ( 271 | 284 | ) 285 | } 286 | 287 | function MenuIcon() { 288 | return ( 289 | 302 | ) 303 | } 304 | -------------------------------------------------------------------------------- /src/amule/amule.tsx: -------------------------------------------------------------------------------- 1 | const pass = "secret" 2 | const host = "http://127.0.0.1:4711" 3 | 4 | import { promisify } from "util" 5 | import { exec } from "child_process" 6 | import { decode } from "html-entities" 7 | import { AmuleCategory } from "./amule.types" 8 | import { Mutex } from "async-mutex" 9 | import { staleWhileRevalidate } from "~/utils/memoize" 10 | import { logger } from "~/utils/logger" 11 | import { z } from "zod" 12 | import { wait } from "~/utils/time" 13 | 14 | async function fetchTimeout(url: string, init: RequestInit, ms: number) { 15 | const controller = new AbortController() 16 | const promise = fetch(url, { ...init, signal: controller.signal }) 17 | const timeout = setTimeout(() => controller.abort(), ms) 18 | return await promise.finally(() => clearTimeout(timeout)) 19 | } 20 | 21 | async function fetchAmuleApiRaw( 22 | url: string | URL, 23 | init: RequestInit, 24 | retry = true 25 | ) { 26 | try { 27 | return await fetchTimeout(url.toString(), init, 30000) 28 | } catch { 29 | await restartAmule() 30 | 31 | if (retry) { 32 | return await fetchAmuleApiRaw(url, init, false) 33 | } else { 34 | throw "Unable to connect to amule" 35 | } 36 | } 37 | } 38 | 39 | async function fetchAmuleApi(url: string | URL): Promise 40 | async function fetchAmuleApi( 41 | url: string | URL, 42 | zodType: z.ZodType 43 | ): Promise> 44 | async function fetchAmuleApi( 45 | url: string | URL, 46 | zodType: z.ZodType = z.any() 47 | ) { 48 | const response = await fetchAmuleApiRaw(url, await getAuth(), true) 49 | const json = await response.json() 50 | return zodType.parse(json) 51 | } 52 | 53 | async function getAuth() { 54 | const cookie = await fetchAmuleApiRaw(`${host}/?pass=${pass}`, {}).then( 55 | (r) => r.headers.get("Set-Cookie")! 56 | ) 57 | 58 | return { 59 | headers: { 60 | cookie, 61 | }, 62 | } satisfies RequestInit 63 | } 64 | 65 | const restartMutex = new Mutex() 66 | export async function restartAmule() { 67 | if (restartMutex.isLocked()) { 68 | await restartMutex.waitForUnlock() 69 | return 70 | } 71 | 72 | await restartMutex.runExclusive(async () => { 73 | logger.info("Restarting amule...") 74 | await promisify(exec)("kill $(pidof amuleweb) || true") 75 | await promisify(exec)("kill $(pidof amuled) || true") 76 | await wait(30000) 77 | }) 78 | } 79 | 80 | // API 81 | 82 | const searchTypes = { 83 | local: "0", 84 | global: "1", 85 | kad: "2", 86 | } 87 | 88 | async function amuleDoSearchImpl( 89 | query: string, 90 | ext: string | undefined, 91 | type: keyof typeof searchTypes 92 | ) { 93 | const searchUrl = new URL(`${host}/api.php`) 94 | searchUrl.searchParams.set("do", "search") 95 | searchUrl.searchParams.set("q", query) 96 | searchUrl.searchParams.set("ext", ext ?? "") 97 | searchUrl.searchParams.set("searchType", searchTypes[type]) 98 | searchUrl.searchParams.set("minSize", (100 * 1012 * 1024).toString(10)) 99 | 100 | return await fetchAmuleApi(searchUrl) 101 | } 102 | 103 | export async function amuleDoDownload(link: string) { 104 | const downloadUrl = new URL(`${host}/api.php`) 105 | downloadUrl.searchParams.set("do", "download") 106 | downloadUrl.searchParams.set("link", link) 107 | downloadUrl.searchParams.set("category", AmuleCategory.downloads.toString(10)) 108 | 109 | return await fetchAmuleApi(downloadUrl) 110 | } 111 | 112 | export async function amuleDoDelete(hash: string) { 113 | const url = new URL(`${host}/api.php`) 114 | url.searchParams.set("do", "cancel") 115 | url.searchParams.set("hash", hash) 116 | 117 | return await fetchAmuleApi(url) 118 | } 119 | 120 | export async function amuleDoResume(hash: string) { 121 | const url = new URL(`${host}/api.php`) 122 | url.searchParams.set("do", "resume") 123 | url.searchParams.set("hash", hash) 124 | 125 | return await fetchAmuleApi(url) 126 | } 127 | 128 | export async function amuleDoReloadShared() { 129 | const url = new URL(`${host}/api.php`) 130 | url.searchParams.set("do", "reload-shared") 131 | 132 | await fetchAmuleApi(url) 133 | } 134 | 135 | async function amuleGetSearchResults() { 136 | const searchResult = await fetchAmuleApi( 137 | `${host}/api.php?get=searchresult`, 138 | z.array( 139 | z.object({ 140 | name: z.string(), 141 | short_name: z.string(), 142 | hash: z.string(), 143 | size: z.number(), 144 | sources: z.number(), 145 | present: z.number(), 146 | }) 147 | ) 148 | ).then((r) => 149 | r.map((o) => ({ 150 | ...o, 151 | name: decode(o.name), 152 | short_name: decode(o.short_name), 153 | hash: o.hash.toUpperCase(), 154 | present: !!o.present, 155 | sources: Math.max(o.sources, 1), 156 | })) 157 | ) 158 | 159 | return searchResult 160 | } 161 | 162 | export const amuleGetShared = staleWhileRevalidate( 163 | async function () { 164 | const shared = await fetchAmuleApi( 165 | `${host}/api.php?get=shared`, 166 | z.array( 167 | z.object({ 168 | name: z.string(), 169 | short_name: z.string(), 170 | hash: z.string(), 171 | size: z.number(), 172 | link: z.string(), 173 | xfer: z.number(), 174 | xfer_all: z.number(), 175 | req: z.number(), 176 | req_all: z.number(), 177 | accept: z.number(), 178 | accept_all: z.number(), 179 | }) 180 | ) 181 | ).then((r) => 182 | r.map((o) => ({ 183 | ...o, 184 | name: decode(o.name), 185 | short_name: decode(o.short_name), 186 | hash: o.hash.toUpperCase(), 187 | })) 188 | ) 189 | 190 | return shared 191 | }, 192 | { 193 | stalled: 250, 194 | } 195 | ) 196 | 197 | export const amuleGetDownloads = staleWhileRevalidate(async function () { 198 | const downloads = await fetchAmuleApi( 199 | `${host}/api.php?get=downloads`, 200 | z.array( 201 | z.object({ 202 | name: z.string(), 203 | short_name: z.string(), 204 | hash: z.string(), 205 | link: z.string(), 206 | category: z.nativeEnum(AmuleCategory), 207 | status: z.number(), 208 | size: z.number(), 209 | size_done: z.number(), 210 | size_xfer: z.number(), 211 | speed: z.number(), 212 | src_count: z.number(), 213 | src_count_not_curr: z.number(), 214 | src_count_a4af: z.number(), 215 | src_count_xfer: z.number(), 216 | prio: z.number(), 217 | prio_auto: z.number(), 218 | last_seen_complete: z.number(), 219 | }) 220 | ) 221 | ).then((r) => 222 | r.map((d) => { 223 | const progress = d.size > 0 ? d.size_done / d.size : 0 224 | const o = { 225 | ...d, 226 | name: decode(d.name), 227 | short_name: decode(d.short_name), 228 | hash: d.hash.toUpperCase(), 229 | amuleCategory: d.category, 230 | progress, 231 | eta: d.speed > 0 ? (d.size - d.size_done) / d.speed : 8640000, 232 | src_valid: d.src_count - d.src_count_not_curr, 233 | status_str: (() => { 234 | switch (d.status) { 235 | case 0: 236 | case 1: 237 | case 2: 238 | case 3: 239 | case 10: 240 | return d.src_count_xfer > 0 241 | ? ("downloading" as const) 242 | : progress < 1 243 | ? ("stalled" as const) 244 | : "completing" 245 | case 4: 246 | case 5: 247 | case 6: 248 | return "error" as const 249 | case 7: 250 | return "stopped" as const 251 | case 8: 252 | return "completing" as const 253 | case 9: 254 | return "downloaded" as const 255 | default: 256 | return "stalled" as const 257 | } 258 | })(), 259 | } 260 | const { category, ...good } = o 261 | return good 262 | }) 263 | ) 264 | 265 | return downloads 266 | }) 267 | 268 | export const amuleGetUploads = staleWhileRevalidate(async function () { 269 | const uploads = await fetchAmuleApi( 270 | `${host}/api.php?get=uploads`, 271 | z.array( 272 | z.object({ 273 | name: z.string(), 274 | short_name: z.string(), 275 | xfer_up: z.number(), 276 | xfer_down: z.number(), 277 | xfer_speed: z.number(), 278 | }) 279 | ) 280 | ).then((r) => 281 | r.map((d) => ({ 282 | ...d, 283 | name: decode(d.name), 284 | short_name: decode(d.short_name), 285 | })) 286 | ) 287 | 288 | return uploads 289 | }) 290 | 291 | export const amuleGetServers = staleWhileRevalidate(async function () { 292 | return await fetchAmuleApi( 293 | `${host}/api.php?get=servers`, 294 | z.array( 295 | z.object({ 296 | name: z.string(), 297 | desc: z.string(), 298 | addr: z.string(), 299 | users: z.number(), 300 | ip: z.string(), 301 | port: z.number(), 302 | maxusers: z.number(), 303 | files: z.number(), 304 | }) 305 | ) 306 | ) 307 | }) 308 | 309 | export const amuleGetStats = staleWhileRevalidate(async function () { 310 | return await fetchAmuleApi( 311 | `${host}/api.php?get=stats`, 312 | z.object({ 313 | id: z.number().nullish(), 314 | serv_addr: z.string().nullish(), 315 | serv_name: z.string().nullish(), 316 | serv_users: z.number().nullish(), 317 | kad_connected: z.boolean(), 318 | kad_firewalled: z.boolean(), 319 | speed_up: z.number().nullish(), 320 | speed_down: z.number().nullish(), 321 | speed_limit_up: z.number().nullish(), 322 | speed_limit_down: z.number().nullish(), 323 | }) 324 | ) 325 | }) 326 | 327 | export const amuleGetCategories = staleWhileRevalidate( 328 | async function getCategories() { 329 | return await fetchAmuleApi( 330 | `${host}/api.php?get=categories`, 331 | z.record(z.string(), z.number()) 332 | ) 333 | } 334 | ) 335 | 336 | const searchMutex = new Mutex() 337 | export const amuleDoSearch = staleWhileRevalidate( 338 | async function ( 339 | q: string, 340 | ext: string | undefined, 341 | type: keyof typeof searchTypes 342 | ) { 343 | return await searchMutex.runExclusive(async () => { 344 | logger.info(`[network] Searching ${type}${ext ? ` (${ext})` : ""}: ${q}`) 345 | await amuleDoSearchImpl(q, ext, type) 346 | let allResults: Awaited> = [] 347 | 348 | let retries = 6 349 | while (retries > 0) { 350 | await new Promise((r) => setTimeout(r, 2500)) 351 | const currentResults = await amuleGetSearchResults() 352 | 353 | if (currentResults.length === 0) { 354 | logger.debug( 355 | `[network] Searching ${type}: no items found, searching more...` 356 | ) 357 | --retries 358 | continue 359 | } 360 | 361 | if (allResults.length == currentResults.length) { 362 | logger.debug(`[network] Searching ${type}: found same items`) 363 | allResults = currentResults 364 | --retries 365 | continue 366 | } 367 | 368 | allResults = currentResults 369 | logger.debug( 370 | `[network] Searching ${type}: found new items, searching more...` 371 | ) 372 | retries = 2 373 | } 374 | 375 | logger.info( 376 | `[network] Searching ${type}${ext ? ` (${ext})` : ""}: finished with`, 377 | allResults.length, 378 | "items" 379 | ) 380 | return allResults 381 | }) 382 | }, 383 | { 384 | stalled: 1000 * 60 * 5, 385 | expired: 1000 * 60 * 5, 386 | shouldCache: (v) => v.length > 0, 387 | } 388 | ) 389 | -------------------------------------------------------------------------------- /src/amule/native-amule.ts.o: -------------------------------------------------------------------------------- 1 | import * as net from 'net'; 2 | 3 | class Response { 4 | [x: string]: any; 5 | public header: number; 6 | public totalSizeOfRequest: number = 0; 7 | public opCode = null; 8 | public typeEcOp: number; 9 | public tagCountInResponse: number; 10 | public opCodeLabel: string; 11 | public children: Response[] = []; 12 | public nameEcTag: number; 13 | public value: any; 14 | public length: number; 15 | public sourceCount = 0; 16 | public sourceCountXfer: number = 0; 17 | public status: number = 0; 18 | } 19 | 20 | export class AMuleCli { 21 | private isConnected: Boolean = false; 22 | private offset: number = 0; // use internally to read bit stream from server 23 | private arrayBuffers = [];// used to build requests 24 | private recurcifInBuildTagArrayBuffer = 0; 25 | private responseOpcode = 0;// op code given in the server 26 | 27 | private ip: string;// server address 28 | private port: number;// server port 29 | private md5Password: string; // md5 value of the password 30 | private solt: string = '';// solt number (sessions id) 31 | private md5Function;// function to md5() 32 | private textDecoder; 33 | private stringDecoder; 34 | 35 | constructor(ip: string, port: number, password: string, md5Function) { 36 | this.ip = ip; 37 | this.port = port; 38 | this.md5Function = md5Function; 39 | // must be the same as ECPassword in .aMule/amule.conf 40 | this.md5Password = this.md5(password); 41 | } 42 | 43 | private md5(str: string) { 44 | return this.md5Function(str); 45 | } 46 | 47 | public setTextDecoder(textDecoder) { 48 | this.textDecoder = textDecoder; 49 | } 50 | 51 | public setStringDecoder(stringDecoder) { 52 | this.stringDecoder = stringDecoder; 53 | } 54 | 55 | /** 56 | * from amule ECCodes.h code 57 | */ 58 | private ECCodes = { 59 | EC_CURRENT_PROTOCOL_VERSION: 0x0204, 60 | EC_OP_AUTH_REQ: 0x02, 61 | EC_OP_GET_SHARED_FILES: 0x10 62 | }; 63 | private ECOpCodes = { 64 | EC_OP_STRINGS: 0x06, 65 | EC_TAGTYPE_UINT16: 0x03, 66 | EC_TAGTYPE_CUMSTOM: 1, 67 | EC_TAGTYPE_UINT8: 2, // defined in ECTagTypes.h 68 | EC_TAGTYPE_HASH16: 0x09, 69 | EC_OP_AUTH_FAIL: 0x03, 70 | EC_OP_AUTH_OK: 0x04, 71 | EC_OP_SEARCH_START: 0x26, 72 | EC_OP_SEARCH_STOP: 0x27, 73 | EC_OP_SEARCH_RESULTS: 0x28, 74 | EC_OP_SEARCH_PROGRESS: 0x29, 75 | EC_OP_DOWNLOAD_SEARCH_RESULT: 0x2A 76 | }; 77 | private ECTagNames = { 78 | EC_TAG_CLIENT_NAME: 0x0100, 79 | EC_TAG_CLIENT_VERSION: 0x0101, 80 | EC_TAG_PROTOCOL_VERSION: 0x0002, 81 | EC_TAG_PASSWD_HASH: 0x0001 82 | }; 83 | private ProtocolVersion = { 84 | EC_CURRENT_PROTOCOL_VERSION: 0x0204 85 | }; 86 | private EC_SEARCH_TYPE = { 87 | EC_SEARCH_LOCA: 0x00, 88 | EC_SEARCH_GLOBAL: 0x01, 89 | EC_SEARCH_KAD: 0x02, 90 | EC_SEARCH_WEB: 0x03 91 | }; 92 | private EC_TAG_SEARCHFILE = { 93 | EC_TAG_SEARCH_TYPE: 0x0701, //1793/2? 94 | EC_TAG_SEARCH_NAME: 0x0702, 95 | EC_TAG_SEARCH_MIN_SIZE: 0x0703, 96 | EC_TAG_SEARCH_MAX_SIZE: 0x0704, 97 | EC_TAG_SEARCH_FILE_TYPE: 0x0705, 98 | EC_TAG_SEARCH_EXTENSION: 0x0706, 99 | EC_TAG_SEARCH_AVAILABILITY: 0x0707, 100 | EC_TAG_SEARCH_STATUS: 0x0708, 101 | EC_TAG_SEARCH_PARENT: 0x0709 102 | }; 103 | 104 | /** 105 | * Used internally to build a request 106 | */ 107 | private _buildTagArrayBuffer(ecTag: number, ecOp: number, value, children) { 108 | this.recurcifInBuildTagArrayBuffer++; 109 | var tagLength = 0; 110 | var dv = new DataView(new ArrayBuffer(Uint16Array.BYTES_PER_ELEMENT)); 111 | dv.setUint16(0, ecTag, false);// name 112 | this.arrayBuffers.push(dv.buffer); 113 | tagLength += Uint16Array.BYTES_PER_ELEMENT; 114 | 115 | dv = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)); 116 | dv.setUint8(0, ecOp);// type 117 | this.arrayBuffers.push(dv.buffer); 118 | tagLength += Uint8Array.BYTES_PER_ELEMENT; 119 | 120 | var lengthDataView = new DataView(new ArrayBuffer(Uint32Array.BYTES_PER_ELEMENT)); 121 | this.arrayBuffers.push(lengthDataView.buffer); 122 | // data length is going to be set after the children are created 123 | tagLength += Uint32Array.BYTES_PER_ELEMENT; 124 | 125 | var childrenTagsLength = 0; 126 | if ((ecTag & 0x01) !== 0 && children === null) { 127 | // if tag has no child 128 | dv = new DataView(new ArrayBuffer(Uint16Array.BYTES_PER_ELEMENT)); 129 | dv.setUint16(0, 0, false); 130 | this.arrayBuffers.push(dv.buffer); 131 | if (this.recurcifInBuildTagArrayBuffer < 2) { 132 | tagLength += Uint16Array.BYTES_PER_ELEMENT; 133 | } 134 | } 135 | else if (children) {// if tag has a child 136 | dv = new DataView(new ArrayBuffer(Uint16Array.BYTES_PER_ELEMENT)); 137 | this.arrayBuffers.push(dv.buffer); 138 | tagLength += Uint16Array.BYTES_PER_ELEMENT; 139 | for (var m = 0; m < children.length; m++) { 140 | // console.log("child " + children[m].ecTag + " " + children[m].ecOp + " " + children[m].value); 141 | childrenTagsLength += this._buildTagArrayBuffer(children[m].ecTag, children[m].ecOp, children[m].value, null); 142 | // console.log("childrenTagsLength : " + childrenTagsLength); 143 | } 144 | dv.setUint16(0, children.length, false); 145 | } 146 | 147 | // set length after children are created 148 | if (ecOp === this.ECOpCodes.EC_TAGTYPE_UINT16) {// length 149 | lengthDataView.setUint32(0, 2 + childrenTagsLength, false); 150 | } 151 | else if (ecOp === this.ECOpCodes.EC_TAGTYPE_UINT8) { 152 | lengthDataView.setUint32(0, 1 + childrenTagsLength, false); 153 | } 154 | else if (ecOp === this.ECOpCodes.EC_TAGTYPE_HASH16) { 155 | lengthDataView.setUint32(0, value.length / 2 + childrenTagsLength, false); 156 | } 157 | else { 158 | lengthDataView.setUint32(0, parseInt(value.length + childrenTagsLength), false); 159 | } 160 | 161 | // set content 162 | if (ecOp === this.ECOpCodes.EC_OP_STRINGS) { 163 | for (var i = 0; i < value.length; i++) { 164 | var dv = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)); 165 | dv.setUint8(0, value[i].charCodeAt(0)); 166 | this.arrayBuffers.push(dv.buffer); 167 | tagLength += Uint8Array.BYTES_PER_ELEMENT; 168 | } 169 | } 170 | else if (ecOp === this.ECOpCodes.EC_TAGTYPE_UINT8) { 171 | var dv = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)); 172 | dv.setUint8(0, value); 173 | this.arrayBuffers.push(dv.buffer); 174 | tagLength += Uint8Array.BYTES_PER_ELEMENT; 175 | } 176 | else if (ecOp === this.ECOpCodes.EC_TAGTYPE_HASH16) { 177 | for (let i = 0; i < value.length; i = i + 2) { 178 | const dv = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)); 179 | const hashValue = parseInt(value[i] + value[i + 1], 16); 180 | dv.setUint8(0, hashValue); 181 | this.arrayBuffers.push(dv.buffer); 182 | tagLength += Uint8Array.BYTES_PER_ELEMENT; 183 | } 184 | } 185 | else if (ecOp === this.ECOpCodes.EC_TAGTYPE_UINT16) { 186 | var dv = new DataView(new ArrayBuffer(Uint16Array.BYTES_PER_ELEMENT)); 187 | dv.setUint16(0, value, false); 188 | this.arrayBuffers.push(dv.buffer); 189 | tagLength += Uint16Array.BYTES_PER_ELEMENT; 190 | } 191 | this.recurcifInBuildTagArrayBuffer--; 192 | return tagLength; 193 | }; 194 | 195 | /** 196 | * Build request headers 197 | */ 198 | private _setHeadersToRequest(opCode: number) { 199 | let dv = new DataView(new ArrayBuffer(Uint32Array.BYTES_PER_ELEMENT)); 200 | // set flags, normal === 32 (34 pour amule-gui) 201 | dv.setUint32(0, 32, false); 202 | this.arrayBuffers.push(dv.buffer); 203 | // packet body length, will be set at the end 204 | this.arrayBuffers.push(new ArrayBuffer(Uint32Array.BYTES_PER_ELEMENT)); 205 | dv = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)); 206 | dv.setUint8(0, opCode);// op code 207 | this.arrayBuffers.push(dv.buffer); 208 | // tag count, will be set at the end 209 | this.arrayBuffers.push(new ArrayBuffer(Uint16Array.BYTES_PER_ELEMENT)); 210 | }; 211 | 212 | /** 213 | * Build a ArrayBuffer from the array of DataView, set body length in bytes 214 | * and tag count. 215 | * 216 | * @returns {ArrayBuffer} 217 | */ 218 | private _finalizeRequest(tagCount): ArrayBuffer { 219 | // calculating the buffer length in bytes 220 | let bufferLength = 0; 221 | for (let i = 0; i < this.arrayBuffers.length; i++) { 222 | bufferLength = bufferLength + this.arrayBuffers[i].byteLength; 223 | } 224 | // creating ArrayBuffer with all the DataViews above 225 | const buffer = new ArrayBuffer(bufferLength); 226 | let offset = 0; 227 | for (let i = 0; i < this.arrayBuffers.length; i++) { 228 | for (let j = 0; j < this.arrayBuffers[i].byteLength; j++) { 229 | const fromArrayView = new Uint8Array(this.arrayBuffers[i], j, 1); 230 | const toArrayView = new Uint8Array(buffer, j + offset, 1); 231 | toArrayView.set(fromArrayView); 232 | } 233 | offset = offset + this.arrayBuffers[i].byteLength; 234 | } 235 | this.arrayBuffers = []; 236 | // set body length 237 | const bodyLengthDataView = new DataView(buffer, Uint32Array.BYTES_PER_ELEMENT, Uint32Array.BYTES_PER_ELEMENT); 238 | bodyLengthDataView.setUint32(0, buffer.byteLength - Uint32Array.BYTES_PER_ELEMENT * 2, false); 239 | // console.log('> body length: '+ (buffer.byteLength - Uint32Array.BYTES_PER_ELEMENT * 2)); 240 | // set tag count 241 | const tagNumberDataView = new DataView(buffer, Uint32Array.BYTES_PER_ELEMENT * 2 + Uint8Array.BYTES_PER_ELEMENT, Uint16Array.BYTES_PER_ELEMENT); 242 | tagNumberDataView.setUint16(0, tagCount, false); 243 | return buffer; 244 | }; 245 | 246 | /** 247 | * The first request trigger a 8 bytes number to be associate with the 248 | * session (the salt number). 249 | */ 250 | private getAuthRequest1(): ArrayBuffer { 251 | this._setHeadersToRequest(2);//EC_OP_AUTH_REQ 252 | let tagCount = 0; 253 | this._buildTagArrayBuffer(this.ECTagNames.EC_TAG_CLIENT_NAME * 2, this.ECOpCodes.EC_OP_STRINGS, "amule-js\0", null); 254 | tagCount++; 255 | this._buildTagArrayBuffer(this.ECTagNames.EC_TAG_CLIENT_VERSION * 2, this.ECOpCodes.EC_OP_STRINGS, "1.0\0", null); 256 | tagCount++; 257 | this._buildTagArrayBuffer(4, this.ECOpCodes.EC_TAGTYPE_UINT16, this.ProtocolVersion.EC_CURRENT_PROTOCOL_VERSION, null); 258 | tagCount++; 259 | return this._finalizeRequest(tagCount); 260 | }; 261 | 262 | /** 263 | * When the solt number (aka session id) is given by the server, we can auth 264 | */ 265 | private _getAuthRequest2(): ArrayBuffer { 266 | this._setHeadersToRequest(80); 267 | let tagCount = 0; 268 | let passwd = this.md5(this.md5Password + this.md5(this.solt)); 269 | this._buildTagArrayBuffer(2, this.ECOpCodes.EC_TAGTYPE_HASH16, passwd, null); 270 | tagCount++; 271 | return this._finalizeRequest(tagCount); 272 | }; 273 | 274 | /** 275 | * < EC_OP_SEARCH_START opCode:38 size:38 (compressed: 30) 276 | * EC_TAG_SEARCH_TYPE tagName:1793 dataType:2 dataLen:1 = EC_SEARCH_LOCAL 277 | * EC_TAG_SEARCH_NAME tagName:1794 dataType:6 dataLen:10 = keywords 2017 278 | * EC_TAG_SEARCH_FILE_TYPE tagName:1797 dataType:6 dataLen:1 = 279 | * > EC_OP_STRINGS opCode:6 size:59 (compressed: 54) 280 | * EC_TAG_STRING tagName:0 dataType:6 dataLen:49 = Search in progress. Refetch results in a moment! 281 | * 282 | * or 283 | * 284 | * < EC_OP_SEARCH_START opCode:38 size:38 (compressed: 30) 285 | * EC_TAG_SEARCH_TYPE tagName:1793 dataType:2 dataLen:1 = EC_SEARCH_KAD 286 | * EC_TAG_SEARCH_NAME tagName:1794 dataType:6 dataLen:10 = keywords 2017 287 | * EC_TAG_SEARCH_FILE_TYPE tagName:1797 dataType:6 dataLen:1 = 288 | * 289 | * > EC_OP_FAILED opCode:5 size:61 290 | * EC_TAG_STRING tagName:0 dataType:6 dataLen:51 = eD2k search can't be done if eD2k is not connected 291 | */ 292 | private _getSearchStartRequest(q: string, searchType): ArrayBuffer { 293 | this._setHeadersToRequest(this.ECOpCodes.EC_OP_SEARCH_START); //38 294 | let tagCount = 0; 295 | const children = [{ 296 | ecTag: 1794 * 2, 297 | ecOp: this.ECOpCodes.EC_OP_STRINGS, // 6 298 | value: q + "\0" 299 | }, { 300 | ecTag: this.EC_TAG_SEARCHFILE.EC_TAG_SEARCH_FILE_TYPE, // 1797*2 301 | ecOp: this.ECOpCodes.EC_OP_STRINGS, 302 | value: "\0" 303 | }, { 304 | ecTag: this.EC_TAG_SEARCHFILE.EC_TAG_SEARCH_EXTENSION, 305 | ecOp: this.ECOpCodes.EC_OP_STRINGS, 306 | value: "mp4\0" 307 | }]; 308 | 309 | //this.EC_SEARCH_TYPE.EC_SEARCH_KAD EC_SEARCH_LOCA 310 | this._buildTagArrayBuffer(this.EC_TAG_SEARCHFILE.EC_TAG_SEARCH_TYPE, this.ECOpCodes.EC_TAGTYPE_UINT8, searchType, children); 311 | tagCount++; 312 | return this._finalizeRequest(tagCount); 313 | }; 314 | 315 | /** 316 | * < EC_OP_SEARCH_PROGRESS opCode:41 size:3 (compressed: 2) 317 | * > EC_OP_SEARCH_PROGRESS opCode:41 size:11 (compressed: 8) 318 | * EC_TAG_SEARCH_STATUS tagName:1800 dataType:2 dataLen:1 = 0 319 | */ 320 | private _isSearchFinished(): ArrayBuffer { 321 | this._setHeadersToRequest(this.ECOpCodes.EC_OP_SEARCH_PROGRESS); //41 322 | return this._finalizeRequest(0); 323 | }; 324 | 325 | /** 326 | * 327 | */ 328 | private getSharedFilesRequest(): ArrayBuffer { 329 | this._setHeadersToRequest(this.ECCodes.EC_OP_GET_SHARED_FILES); 330 | return this._finalizeRequest(0); 331 | }; 332 | 333 | /** 334 | * 335 | */ 336 | private getSearchResultRequest(): ArrayBuffer { 337 | this._setHeadersToRequest(this.ECOpCodes.EC_OP_SEARCH_RESULTS); 338 | let tagCount = 0; 339 | this._buildTagArrayBuffer(8, this.ECOpCodes.EC_TAGTYPE_UINT8, this.EC_SEARCH_TYPE.EC_SEARCH_LOCA, null); 340 | tagCount++; 341 | return this._finalizeRequest(tagCount); 342 | }; 343 | 344 | private getDownloadsRequest(): ArrayBuffer { 345 | this._setHeadersToRequest(13); // EC_OP_GET_DLOAD_QUEUE 346 | return this._finalizeRequest(0); 347 | }; 348 | 349 | /** 350 | * < EC_OP_DOWNLOAD_SEARCH_RESULT opCode:42 size:36 (compressed: 28) 351 | * EC_TAG_PARTFILE tagName:768 dataType:9 dataLen:16 = 26E4413971DF1EC89AC3B91A4A02402F 352 | * EC_TAG_PARTFILE_CAT tagName:783 dataType:2 dataLen:1 = 0 353 | * > EC_OP_STRINGS opCode:6 size:3 (compressed: 2) 354 | */ 355 | private downloadRequest(e): ArrayBuffer { 356 | this._setHeadersToRequest(this.ECOpCodes.EC_OP_DOWNLOAD_SEARCH_RESULT);//42 357 | let tagCount = 0; 358 | const children = [{ 359 | ecTag: 783 * 2, 360 | ecOp: this.ECOpCodes.EC_TAGTYPE_UINT8, //2 361 | value: 0 362 | }]; 363 | 364 | // if has children => +1 365 | this._buildTagArrayBuffer(768 * 2 + 1, this.ECOpCodes.EC_TAGTYPE_HASH16, e.partfile_hash, children); 366 | tagCount++; 367 | return this._finalizeRequest(tagCount); 368 | }; 369 | 370 | /** 371 | * < EC_OP_SET_PREFERENCES opCode:64 size:20 (compressed: 14) 372 | * EC_TAG_PREFS_CONNECTIONS tagName:4864 dataType:1 dataLen:0 = empty 373 | * EC_TAG_CONN_MAX_DL tagName:4867 dataType:2 dataLen:1 = 0 374 | * or 375 | * < EC_OP_SET_PREFERENCES opCode:64 size:20 (compressed: 14) 376 | * EC_TAG_PREFS_CONNECTIONS tagName:4864 dataType:1 dataLen:0 = empty 377 | * EC_TAG_CONN_MAX_UL tagName:4868 dataType:2 dataLen:1 = 0 378 | */ 379 | private getSetMaxBandwithRequest(tag: number, limit: number): ArrayBuffer { 380 | this._setHeadersToRequest(64); 381 | let tagCount = 0; 382 | const children = [{ 383 | ecTag: tag * 2, 384 | ecOp: this.ECOpCodes.EC_TAGTYPE_UINT8, //2 385 | value: limit 386 | }]; 387 | 388 | // if has children => +1 389 | this._buildTagArrayBuffer(4864 * 2 + 1, 1, null, children); 390 | tagCount++; 391 | return this._finalizeRequest(tagCount); 392 | }; 393 | 394 | /** 395 | * 396 | */ 397 | private simpleRequest(opCode: number): ArrayBuffer { 398 | this._setHeadersToRequest(opCode); 399 | return this._finalizeRequest(0); 400 | }; 401 | 402 | /** 403 | * EC_OP_STAT_REQ == 10 for a short summary 404 | * EC_OP_GET_UPDATE == 82 for the list of dl and ul files 405 | * 406 | * < EC_OP_STAT_REQ opCode:10 size:11 (compressed: 6) 407 | * EC_TAG_DETAIL_LEVEL tagName:4 dataType:2 dataLen:1 = EC_DETAIL_INC_UPDATE 408 | * > EC_OP_STATS opCode:12 size:316 (compressed: 215) 409 | * EC_TAG_STATS_UP_OVERHEAD tagName:516 dataType:2 dataLen:1 = 197 410 | * EC_TAG_STATS_DOWN_OVERHEAD tagName:517 dataType:2 dataLen:1 = 164 411 | * EC_TAG_STATS_BANNED_COUNT tagName:519 dataType:2 dataLen:1 = 0 412 | * ... 413 | */ 414 | private getStatsRequest(EC_OP = 10): ArrayBuffer { 415 | this._setHeadersToRequest(EC_OP); // EC_OP_STAT_REQ == 10 416 | let tagCount = 0; 417 | const EC_TAG_DETAIL_LEVEL = 4; 418 | const EC_DETAIL_INC_UPDATE = 4; 419 | this._buildTagArrayBuffer(EC_TAG_DETAIL_LEVEL * 2, this.ECOpCodes.EC_TAGTYPE_UINT8, EC_DETAIL_INC_UPDATE, null); 420 | tagCount++; 421 | return this._finalizeRequest(tagCount); 422 | }; 423 | 424 | /** 425 | * < EC_OP_GET_PREFERENCES opCode:63 size:20 (compressed: 13) 426 | * EC_TAG_DETAIL_LEVEL tagName:4 dataType:2 dataLen:1 = EC_DETAIL_UPDATE 427 | * EC_TAG_SELECT_PREFS tagName:4096 dataType:3 dataLen:2 = 15103 428 | * > EC_OP_SET_PREFERENCES opCode:64 size:683 (compressed: 540) 429 | * EC_TAG_DETAIL_LEVEL tagName:4 dataType:2 dataLen:1 = EC_DETAIL_UPDATE 430 | * EC_TAG_PREFS_GENERAL tagName:4608 dataType:1 dataLen:0 = empty 431 | * EC_TAG_USER_NICK tagName:4609 dataType:6 dataLen:13 = name 432 | */ 433 | private getPreferencesRequest(): ArrayBuffer { 434 | this._setHeadersToRequest(63); 435 | let tagCount = 0; 436 | const EC_TAG_DETAIL_LEVEL = 4; 437 | const EC_DETAIL_UPDATE = 3; 438 | this._buildTagArrayBuffer(EC_TAG_DETAIL_LEVEL * 2, this.ECOpCodes.EC_TAGTYPE_UINT8, EC_DETAIL_UPDATE, null); 439 | tagCount++; 440 | this._buildTagArrayBuffer(4096 * 2, this.ECOpCodes.EC_TAGTYPE_UINT16, 15103, null); 441 | tagCount++; 442 | return this._finalizeRequest(tagCount); 443 | }; 444 | 445 | /** 446 | * < EC_OP_PARTFILE_DELETE opCode:29 size:26 (compressed: 22) 447 | * EC_TAG_PARTFILE tagName:768 dataType:9 dataLen:16 = EA63C3774DF2EB871EFA3AC58543B66F 448 | */ 449 | private getCancelDownloadRequest(e): ArrayBuffer { 450 | this._setHeadersToRequest(29); // EC_OP_PARTFILE_DELETE 451 | this._buildTagArrayBuffer(768 * 2, this.ECOpCodes.EC_TAGTYPE_HASH16, e.partfile_hash, null); 452 | return this._finalizeRequest(1); 453 | }; 454 | 455 | private readSalt(buffer: ArrayBuffer): number { 456 | let offset: number = Uint32Array.BYTES_PER_ELEMENT * 2; 457 | let dataView: DataView = new DataView(buffer, offset, Uint8Array.BYTES_PER_ELEMENT); 458 | this.responseOpcode = dataView.getUint8(0); 459 | // console.log("response opcode : " + this.responseOpcode); 460 | offset = offset + Uint8Array.BYTES_PER_ELEMENT; 461 | // console.log("response tag count : " + dataView.getUint16(0, false)); 462 | offset = offset + Uint16Array.BYTES_PER_ELEMENT; 463 | // console.log("response tag # name (ecTag) : " + dataView.getUint16(0, false)); 464 | offset = offset + Uint16Array.BYTES_PER_ELEMENT; 465 | // console.log("response tag # type (ecOp): " + dataView.getUint8(0, false)); 466 | offset = offset + Uint8Array.BYTES_PER_ELEMENT; 467 | // console.log("response tag # length : " + dataView.getUint32(0, false)); 468 | offset = offset + Uint32Array.BYTES_PER_ELEMENT; 469 | 470 | if (this.responseOpcode === 79) { 471 | const dv: DataView = new DataView(buffer, offset, 8);// 8 bytes 472 | for (let i: number = 0; i < 8; i++) { 473 | let c: string = dv.getUint8(i).toString(16).toUpperCase(); 474 | if (c.length < 2 && i !== 0) { 475 | c = '0' + c; 476 | } 477 | this.solt += c; 478 | } 479 | offset = offset + 8; 480 | } 481 | return this.responseOpcode; 482 | }; 483 | 484 | private readBuffer(buffer: ArrayBuffer, byteNumberToRead: number, littleEndian = false): number { 485 | let val = null; 486 | const dataView = new DataView(buffer, this.offset, byteNumberToRead); 487 | if (byteNumberToRead === 1) { 488 | val = dataView.getUint8(0); 489 | } else if (byteNumberToRead === 2) { 490 | val = dataView.getUint16(0, littleEndian); 491 | } else if (byteNumberToRead === 4) { 492 | val = dataView.getUint32(0, littleEndian); 493 | } 494 | this.offset += byteNumberToRead; 495 | return val; 496 | }; 497 | 498 | private readBufferChildren(buffer: ArrayBuffer, res: Response, recursivity = 1): Response { 499 | res.children = []; 500 | 501 | for (let j = 0; j < res.tagCountInResponse; j++) { 502 | 503 | const child = new Response(); 504 | child.nameEcTag = this.readBuffer(buffer, 2) 505 | child.typeEcOp = this.readBuffer(buffer, 1) 506 | child.length = this.readBuffer(buffer, 4) // length without ectag, ecOp, length and tag count BUT with children length 507 | child.tagCountInResponse = 0 508 | child.value = '' 509 | 510 | res.length -= (7 + child.length); // remove header length + length 511 | 512 | if (child.nameEcTag % 2) {// if name (ecTag) is odd there is a child count 513 | child.nameEcTag = (child.nameEcTag - 1) / 2; 514 | child.tagCountInResponse = this.readBuffer(buffer, 2); 515 | res.length -= 2; 516 | } else { 517 | child.nameEcTag = child.nameEcTag / 2; 518 | } 519 | this.readBufferChildren(buffer, child, recursivity + 1); 520 | res.children.push(child); 521 | } 522 | if (recursivity > 1) { 523 | res.value = this.readValueOfANode(res, buffer); 524 | } 525 | return res; 526 | }; 527 | 528 | /** 529 | * Read the value of a node according to its type (typeEcOp) and size in the buffer 530 | */ 531 | private readValueOfANode(child2: Response, buffer: ArrayBuffer) { 532 | if (!child2.length) { 533 | return ''; 534 | } 535 | if (child2.typeEcOp === this.ECOpCodes.EC_TAGTYPE_UINT8) { // 2 536 | child2.value = this.readBuffer(buffer, child2.length); 537 | } 538 | else if (child2.typeEcOp === this.ECOpCodes.EC_TAGTYPE_UINT16) { // 3 539 | child2.value = this.readBuffer(buffer, child2.length); 540 | } 541 | else if (child2.typeEcOp === 4) { // integer 542 | child2.value = this.readBuffer(buffer, child2.length); 543 | } 544 | else if (child2.typeEcOp === 5) { // integer 8 bytes 545 | let high = this.readBuffer(buffer, 4); 546 | let low = this.readBuffer(buffer, 4); 547 | let n = high * 4294967296.0 + low; 548 | if (low < 0) n += 4294967296; 549 | child2.value = n; 550 | } 551 | else if (child2.typeEcOp === this.ECOpCodes.EC_OP_STRINGS) { // 6 552 | if (!this.textDecoder && typeof this.stringDecoder === 'undefined') { 553 | console.log("you won't be able to read special utf-8 char"); 554 | } 555 | let uint8array: number[] = []; 556 | for (let m = 0; m < child2.length; m++) { 557 | let intValue = this.readBuffer(buffer, 1); 558 | if (intValue > 0x80) {// wired utf-8 char 559 | uint8array.push(intValue); 560 | } else if (uint8array.length > 0) {// end of wired utf-8 char 561 | if (this.textDecoder) { // browser 562 | child2.value += this.textDecoder.decode(new Uint8Array(uint8array)); 563 | } else if (this.stringDecoder) {// nodeJs 564 | child2.value += this.stringDecoder.write(Buffer.from(uint8array)); 565 | } 566 | uint8array = []; 567 | child2.value += '' + String.fromCharCode(intValue); // work for all javascript engine 568 | } else { 569 | child2.value += '' + String.fromCharCode(intValue); 570 | } 571 | } 572 | if (child2.value) { 573 | child2.value = child2.value.substring(0, child2.value.length - 1); 574 | } 575 | } 576 | else if (child2.typeEcOp === this.ECOpCodes.EC_TAGTYPE_HASH16) { //9 577 | for (var m = 0; m < child2.length; m = m + 2) { 578 | let c = this.readBuffer(buffer, 2).toString(16); 579 | c = ('0000' + c).slice(-4); 580 | child2.value += c; 581 | } 582 | if (child2.value.length != 32) { 583 | console.log('HASH is false: ' + child2.value + ' -- ' + + child2.value.length); 584 | } 585 | } 586 | else { 587 | // console.log('WARNING: not read : child2.typeEcOp = ' + child2.typeEcOp); 588 | // TODO 589 | // wrong but we do it to read the buffer 590 | for (var m = 0; m < child2.length; m++) { 591 | child2.value += "" + this.readBuffer(buffer, 1); 592 | } 593 | } 594 | return child2.value; 595 | } 596 | 597 | private _readHeader(buffer: ArrayBuffer): Response { 598 | this.offset = 0; 599 | let response = new Response(); 600 | response.header = this.readBuffer(buffer, 4); 601 | // length (total minus header and response Length) 602 | let responseLength = this.readBuffer(buffer, 4); 603 | response.totalSizeOfRequest = responseLength + 8; 604 | // children response length (total minus header, response length, opcode, tag count) 605 | response.length = responseLength - 3; 606 | response.opCode = this.readBuffer(buffer, 1); 607 | response.tagCountInResponse = this.readBuffer(buffer, 2); 608 | return response; 609 | } 610 | 611 | private EC_TAG_MAPPING = [{ EC_TAG_PARTFILE_NAME: 769 }, { EC_TAG_PARTFILE_SIZE_DONE: 774 }, { EC_TAG_PARTFILE_SPEED: 775 }, { EC_TAG_PARTFILE_LAST_SEEN_COMP: 785 }, 612 | { EC_TAG_PARTFILE_LAST_RECV: 784 }, { EC_TAG_PARTFILE_COMMENTS: 790 }, { EC_TAG_PARTFILE_HASH: 798 }, { EC_TAG_PARTFILE_SIZE_FULL: 771 }, { EC_TAG_PARTFILE_ED2K_LINK: 782 }, 613 | { EC_TAG_PARTFILE_SOURCE_COUNT: 778 }, { EC_TAG_PARTFILE_SOURCE_COUNT_XFER: 781 }, { EC_TAG_PARTFILE_STATUS: 776 }, 614 | { EC_TAG_KNOWNFILE_FILENAME: 1032 }, { EC_TAG_KNOWNFILE_XFERRED_ALL: 1026 }, { EC_TAG_KNOWNFILE_REQ_COUNT_ALL: 1028 }, 615 | { EC_TAG_PARTFILE_LAST_SEEN_COMP: 785 }, { EC_TAG_PARTFILE_LAST_RECV: 784 }, { EC_TAG_PREFS_GENERAL: 4608 }, { EC_TAG_USER_NICK: 4609 }, { EC_TAG_USER_HASH: 4610 }, 616 | { EC_TAG_PREFS_CONNECTIONS: 4864 }, { EC_TAG_CONN_MAX_UL: 4868 }, { EC_TAG_CONN_MAX_DL: 4867 }, { EC_TAG_CLIENT: 1536 }, { EC_TAG_CLIENT_NAME: 256 }, 617 | { EC_TAG_CLIENT_HASH: 1539 }, { EC_TAG_CLIENT_USER_ID: 1566 }, { EC_TAG_CLIENT_SOFTWARE: 1537 }, { EC_TAG_CLIENT_SOFT_VER_STR: 1557 }, 618 | { EC_TAG_CLIENT_USER_IP: 1552 }, { EC_TAG_CLIENT_USER_PORT: 1553 }, { EC_TAG_CLIENT_DISABLE_VIEW_SHARED: 1563 }, { EC_TAG_CLIENT_OS_INFO: 1577 }, 619 | { EC_TAG_SERVER: 1280 }, { EC_TAG_STATS_LOGGER_MESSAGE: 525 }, { EC_TAG_STATS_TOTAL_SENT_BYTES: 536 }, { EC_TAG_STATS_TOTAL_RECEIVED_BYTES: 537 }, 620 | { EC_TAG_STATS_UL_SPEED: 512 }, { EC_TAG_STATS_DL_SPEED: 513 }, { EC_TAG_STATS_UL_SPEED_LIMIT: 514 }, { EC_TAG_STATS_DL_SPEED_LIMIT: 515 }, 621 | { EC_TAG_PREFS_DIRECTORIES: 6656 }, { EC_TAG_DIRECTORIES_INCOMING: 6657 }, { EC_TAG_DIRECTORIES_TEMP: 6658 } 622 | ]; 623 | 624 | private _formatResultsList(response: Response): Response { 625 | response.children.map(e => { 626 | this.EC_TAG_MAPPING.map(REF => { 627 | Object.keys(REF).map(key => { 628 | if (!e.children.length && e.nameEcTag === REF[key]) { 629 | response[key.split('EC_TAG_')[1].toLowerCase()] = e.value; 630 | } 631 | }); 632 | }); 633 | if (!e.length) { 634 | delete e.value; 635 | } 636 | delete e.length; 637 | delete e.tagCountInResponse; 638 | if (e.children && !e.children.length) { 639 | delete e.children; 640 | } else { 641 | this._formatResultsList(e); 642 | } 643 | }); 644 | this.EC_TAG_MAPPING.map(REF => { 645 | Object.keys(REF).map(key => { 646 | if (response.nameEcTag === REF[key]) { 647 | response['label'] = key.split('EC_TAG_')[1].toLowerCase(); 648 | } 649 | }); 650 | }); 651 | if (response['knownfile_xferred_all']) { 652 | response['sharedRatio'] = response['knownfile_xferred_all'] / response['partfile_size_full']; 653 | } 654 | if (response['partfile_size_done']) { 655 | response['completeness'] = Math.floor(response['partfile_size_done'] * 10000 / response['partfile_size_full']) / 100; 656 | } 657 | return response; 658 | } 659 | 660 | private readResultsList(buffer: ArrayBuffer): Response { 661 | let response = this._readHeader(buffer); 662 | switch (response.opCode) { 663 | case 1: response.opCodeLabel = 'EC_OP_NOOP'; break; 664 | case 5: response.opCodeLabel = 'EC_OP_FAILED'; break; 665 | case 31: response.opCodeLabel = 'EC_OP_DLOAD_QUEUE'; break; 666 | case 40: response.opCodeLabel = 'EC_OP_SEARCH_RESULTS'; break; 667 | default: ; 668 | } 669 | this.readBufferChildren(buffer, response); 670 | this._formatResultsList(response); 671 | return response; 672 | }; 673 | 674 | private client; // node socket 675 | private socketId; // chrome API socket id 676 | 677 | private toBuffer(ab: ArrayBuffer): Buffer { 678 | return new Buffer(new Uint8Array(ab)); 679 | } 680 | 681 | private toArrayBuffer(buf: ArrayBuffer): ArrayBuffer { 682 | return new Uint8Array(buf).buffer; 683 | } 684 | 685 | /** 686 | * Create a TCP socket with the server. 687 | */ 688 | private initConnToServer(ip: string, port: number): Promise { 689 | return new Promise((resolve, reject) => { 690 | if (typeof chrome !== 'undefined') { 691 | console.log("using chrome API"); 692 | chrome.sockets.tcp.create({}, r => { 693 | this.socketId = r.socketId; 694 | chrome.sockets.tcp.connect(r.socketId, ip, port, resolve); 695 | }); 696 | } else { 697 | this.client = new net.Socket(); // return a Node socket 698 | this.client.connect(port, ip); 699 | this.client.once('connect', resolve); 700 | } 701 | }); 702 | }; 703 | 704 | private sendToServer_simple(data: ArrayBuffer): Promise { 705 | return new Promise((resolve, reject) => { 706 | if (typeof chrome !== 'undefined') { 707 | chrome.sockets.tcp.send(this.socketId, data, r => { }); 708 | chrome.sockets.tcp.onReceive.addListener(receiveInfo => resolve(receiveInfo.data)); 709 | } else { 710 | this.client.write(this.toBuffer(data)); 711 | this.client.once('data', data => resolve(this.toArrayBuffer(data))); 712 | } 713 | }); 714 | }; 715 | 716 | private sendToServer(data: ArrayBuffer): Promise { 717 | return new Promise((resolve, reject) => { 718 | 719 | let buf = [], totalSizeOfRequest, frequency = 100, timeout = 200, count = 0; 720 | 721 | if (typeof chrome !== 'undefined') { 722 | chrome.sockets.tcp.send(this.socketId, data, r => { }); 723 | chrome.sockets.tcp.onReceive.addListener(r => buf.push(r.data)); 724 | } else { 725 | this.client.write(this.toBuffer(data)); 726 | this.client.on('data', data => buf.push(this.toArrayBuffer(data))); 727 | } 728 | 729 | const intervalId = setInterval(() => { 730 | if (buf[0]) { 731 | totalSizeOfRequest = this._readHeader(buf[0]).totalSizeOfRequest; 732 | let bl = 0; 733 | buf.forEach(b => { 734 | bl += b.byteLength; 735 | }); 736 | if (bl >= totalSizeOfRequest) { 737 | const buffer = new ArrayBuffer(bl); 738 | let o = 0; 739 | buf.forEach(b => { 740 | for (let j = 0; j < b.byteLength; j++) { 741 | let fromArrayView = new Uint8Array(b, j, 1); 742 | let toArrayView = new Uint8Array(buffer, j + o, 1); 743 | toArrayView.set(fromArrayView); 744 | } 745 | o = o + b.byteLength; 746 | }); 747 | clearInterval(intervalId); 748 | if (this.client) { 749 | this.client.removeAllListeners('data') 750 | } 751 | resolve(buffer); 752 | } 753 | } 754 | if (count++ > timeout) { 755 | clearInterval(intervalId); 756 | throw 'time out expired for this TCP request'; 757 | } 758 | }, frequency); 759 | }).then(data => this.readResultsList(data)) 760 | }; 761 | 762 | public connect(): Promise { 763 | return this.initConnToServer(this.ip, this.port).then(() => { 764 | return this.sendToServer_simple(this.getAuthRequest1()); 765 | }).then(data => { 766 | this.readSalt(data); 767 | return this.sendToServer_simple(this._getAuthRequest2()); 768 | }).then(data => { 769 | if (this.readSalt(data) === 4) { 770 | this.isConnected = true 771 | return 'You are successfuly connected to amule' 772 | } 773 | else { 774 | throw 'You are NOT connected to amule' 775 | } 776 | }).catch(err => { 777 | throw '\n\nYou are NOT connected to amule: ' + err 778 | }); 779 | }; 780 | 781 | private filterResultList(list: Response, q: string, strict: boolean): Response[] { 782 | if (strict && list.children) { 783 | list.children = list.children.filter(e => { 784 | let isPresent = true; 785 | const fileName = e['partfile_name'] 786 | q.split(' ').map(r => { 787 | if (fileName && fileName.toLowerCase().indexOf(r.toLowerCase()) === -1) { 788 | isPresent = false; 789 | } 790 | }); 791 | return isPresent; 792 | }); 793 | } 794 | return list.children; 795 | } 796 | 797 | 798 | /** 799 | * Search on the server 800 | * 801 | */ 802 | public __search(q: string, network: number, strict = true): Promise { 803 | q = q.trim(); 804 | return this.sendToServer(this._getSearchStartRequest(q, network)).then(res => { 805 | return new Promise((resolve, reject) => { 806 | if (network === this.EC_SEARCH_TYPE.EC_SEARCH_KAD) { 807 | let timeout = 120, frequency = 2000, count = 0, isSearchFinished = false; 808 | const intervalId = setInterval(() => { 809 | if (isSearchFinished) { 810 | clearInterval(intervalId); 811 | this.fetchSearch().then(list => resolve(this.filterResultList(list, q, strict))) 812 | } 813 | this.sendToServer(this._isSearchFinished()).then(res => { 814 | if (res.children && res.children[0] && res.children[0].value !== 0) { 815 | isSearchFinished = true; 816 | } 817 | }); 818 | if (count++ > timeout) { 819 | console.error('time out expired to fetch result'); 820 | clearInterval(intervalId); 821 | } 822 | }, frequency); 823 | } else if (network === this.EC_SEARCH_TYPE.EC_SEARCH_LOCA) { 824 | // TODO to improve 825 | setTimeout(() => { 826 | this.fetchSearch().then(list => resolve(this.filterResultList(list, q, strict))) 827 | }, 1500); 828 | } 829 | }); 830 | }); 831 | } 832 | 833 | public _clean(str: string) { 834 | return str 835 | .replace(new RegExp('é', 'g'), 'e') 836 | .replace(new RegExp('�€', 'g'), 'A') 837 | .replace(new RegExp('è', 'g'), 'e') 838 | } 839 | 840 | /** 841 | * Perform a search on the amule server. 842 | * 843 | * @param q 844 | * @param network 845 | */ 846 | public search(q: string, network: number = this.EC_SEARCH_TYPE.EC_SEARCH_KAD) { 847 | return this.getSharedFiles().then(allFiles => { 848 | return this.getDownloads().then(dlFiles => { 849 | return this.__search(q, network).then(list => { 850 | allFiles.map(f => list.map(s => s.partfile_hash === f.partfile_hash ? s.present = true : false)); 851 | dlFiles.map(f => list.map(s => s.partfile_hash === f.partfile_hash ? s.currentDl = true : false)); 852 | list.map(e => e.partfile_name = this._clean(e.partfile_name)) 853 | return list 854 | }); 855 | }); 856 | }); 857 | } 858 | 859 | public fetchSearch(): Promise { 860 | return this.sendToServer(this.getSearchResultRequest()); 861 | } 862 | 863 | /** 864 | * get all the files being currently downloaded 865 | */ 866 | public getDownloads(): Promise { 867 | return this.sendToServer(this.getDownloadsRequest()).then(elements => { 868 | if (elements.children) { 869 | elements.children.map(f => { 870 | ['partfile_last_recv', 'partfile_last_seen_comp'].map(key => { 871 | if (f[key]) { 872 | f[key + '_f'] = new Date(f[key] * 1000); 873 | } 874 | }); 875 | ['partfile_speed', 'completeness'].map(key => { 876 | if (!f[key]) { 877 | f[key] = 0; 878 | } 879 | }); 880 | delete f.children; 881 | }); 882 | } 883 | 884 | return elements.children; 885 | }); 886 | } 887 | 888 | /** 889 | * download a file in the search list 890 | * 891 | * @param e file to download (must have a hash) 892 | */ 893 | public download(e): Promise { 894 | return this.sendToServer_simple(this.downloadRequest(e)); 895 | } 896 | 897 | /** 898 | * return the list of shared files 899 | */ 900 | public getSharedFiles(): Promise { 901 | return this.sendToServer(this.getSharedFilesRequest()).then(elements => { 902 | elements.children.map(f => { 903 | ['knownfile_req_count_all', 'sharedRatio'].map(key => { 904 | if (!f[key]) { 905 | f[key] = 0; 906 | } 907 | }); 908 | delete f.children; 909 | // remove files being currently downloaded 910 | if (f['knownfile_filename'] && f['knownfile_filename'].endsWith('.part')) { 911 | let index = elements.children.indexOf(f); 912 | elements.children.splice(index, 1); 913 | } 914 | }); 915 | return elements.children; 916 | }); 917 | } 918 | 919 | /** 920 | * 921 | */ 922 | public getDetailUpdate(): Promise { 923 | return this.sendToServer(this.getStatsRequest(82)); 924 | } 925 | 926 | /** 927 | * EC_OP_CLEAR_COMPLETED 928 | */ 929 | public clearCompleted(): Promise { 930 | return this.sendToServer_simple(this.simpleRequest(0x53)); 931 | } 932 | public getStatistiques(): Promise { 933 | return this.sendToServer(this.getStatsRequest(10)); 934 | } 935 | public setMaxDownload(limit): Promise { 936 | return this.sendToServer_simple(this.getSetMaxBandwithRequest(4867, limit)); 937 | } 938 | public setMaxUpload(limit): Promise { 939 | return this.sendToServer_simple(this.getSetMaxBandwithRequest(4868, limit)); 940 | } 941 | public cancelDownload(e): Promise { 942 | return this.sendToServer_simple(this.getCancelDownloadRequest(e)); 943 | } 944 | 945 | /** 946 | * get user preferences (EC_OP_GET_PREFERENCES) 947 | */ 948 | public getPreferences(): Promise { 949 | return this.sendToServer(this.getPreferencesRequest()); 950 | } 951 | 952 | /** 953 | * reload shared files list (EC_OP_SHAREDFILES_RELOAD) 954 | */ 955 | public reloadSharedFiles(): Promise { 956 | return this.sendToServer_simple(this.simpleRequest(35)); 957 | } 958 | 959 | /** 960 | * get the date. Example: "2017-11" 961 | */ 962 | public getMonth(): string { 963 | const dateObj = new Date(), 964 | month = dateObj.getUTCMonth() + 1, 965 | monthStr: string = ('00' + month).slice(-2), 966 | year: number = dateObj.getUTCFullYear(); 967 | return year + '-' + monthStr; 968 | } 969 | 970 | /** 971 | * @param funcs array of function with promises to execute 972 | */ 973 | public promiseSerial(funcs: Array<() => Promise>) { 974 | return funcs.reduce((promise, func) => 975 | promise.then(result => func().then(x => result.concat(x))), 976 | Promise.resolve([])); 977 | } 978 | } --------------------------------------------------------------------------------