Compare commits
5 Commits
e293f68c4a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1509f03681 | |||
| 5fda73a258 | |||
| b92a1216f0 | |||
| 7aa0e8fd30 | |||
| bedb50d524 |
4
Justfile
4
Justfile
@@ -3,7 +3,7 @@ dev:
|
||||
go run .
|
||||
|
||||
build:
|
||||
docker build -t git.zatch.ru/tsivinsky/tvqueue:latest .
|
||||
docker build -t git.tsivinsky.com/tsivinsky/tvqueue:latest .
|
||||
|
||||
push:
|
||||
docker push git.zatch.ru/tsivinsky/tvqueue:latest
|
||||
docker push git.tsivinsky.com/tsivinsky/tvqueue:latest
|
||||
|
||||
10
main.go
10
main.go
@@ -373,7 +373,15 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := torrentClient.AddTorrentFromMemory(data, map[string]string{}); err != nil {
|
||||
opts := make(map[string]string)
|
||||
if category, ok := os.LookupEnv("QBITTORRENT_CATEGORY"); ok {
|
||||
opts["category"] = category
|
||||
}
|
||||
if savePath, ok := os.LookupEnv("QBITTORRENT_SAVEPATH"); ok {
|
||||
opts["savepath"] = savePath
|
||||
}
|
||||
|
||||
if err := torrentClient.AddTorrentFromMemory(data, opts); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ItemDetails } from "../api/useItemsQuery";
|
||||
import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery";
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadSimpleIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
|
||||
import { useDeleteItemMutation } from "../api/useDeleteItemMutation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { humanFileSize } from "../utils/humanFileSize";
|
||||
import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation";
|
||||
import { useRefreshItemMutation } from "../api/useRefreshItemMutation";
|
||||
import { Loader } from "./Loader";
|
||||
import { Torrent } from "./Torrent";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -33,12 +29,21 @@ export const Item = ({ item }: ItemProps) => {
|
||||
const { data: torrents } = useItemTorrentsQuery(item.id, open);
|
||||
|
||||
const deleteMutation = useDeleteItemMutation();
|
||||
const downloadMutation = useDownloadTorrentMutation();
|
||||
const refreshMutation = useRefreshItemMutation();
|
||||
const deleteTorrentMutation = useDeleteTorrentMutation();
|
||||
|
||||
const Icon = open ? CaretUpIcon : CaretDownIcon;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredTorrents = useMemo(() => {
|
||||
if (!search) return torrents;
|
||||
return torrents?.filter((torrent) => {
|
||||
const terms = search.split(" ");
|
||||
const foundTerms = terms.filter((term) => torrent.title.includes(term));
|
||||
return foundTerms.length === terms.length;
|
||||
});
|
||||
}, [search, torrents]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const itemId = params.get("item");
|
||||
@@ -49,10 +54,6 @@ export const Item = ({ item }: ItemProps) => {
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const handleDownloadTorrent = (torrentId: number) => {
|
||||
downloadMutation.mutate({ torrentId });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("Do you want to delete this item?")) return;
|
||||
|
||||
@@ -83,22 +84,6 @@ export const Item = ({ item }: ItemProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteTorrent = (torrentId: number) => {
|
||||
deleteTorrentMutation.mutate(
|
||||
{
|
||||
id: torrentId,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["items", item.id, "torrents"],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-b border-neutral-900">
|
||||
<div
|
||||
@@ -112,6 +97,15 @@ export const Item = ({ item }: ItemProps) => {
|
||||
</div>
|
||||
{open && (
|
||||
<div>
|
||||
<div className="my-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search torrents..."
|
||||
className="w-full outline-none py-1 px-2 rounded border-2 border-transparent focus:border-neutral-900"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div>
|
||||
Last Refresh:{" "}
|
||||
@@ -140,51 +134,9 @@ export const Item = ({ item }: ItemProps) => {
|
||||
<TrashIcon size={20} /> Delete item
|
||||
</button>
|
||||
</div>
|
||||
{torrents && torrents.length > 0 ? (
|
||||
torrents?.map((torrent) => (
|
||||
<div
|
||||
key={torrent.id}
|
||||
className="flex justify-between items-center hover:bg-neutral-200 group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<a
|
||||
href={torrent.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{torrent.title}
|
||||
</a>{" "}
|
||||
[{formatCategory(torrent.category)}] [{torrent.indexer}]
|
||||
</span>
|
||||
{torrent.downloaded && (
|
||||
<span title="Torrent files downloaded">
|
||||
<CheckCircleIcon size={20} color="green" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="hidden group-hover:inline text-[#b00420] cursor-pointer"
|
||||
onClick={() => handleDeleteTorrent(torrent.id)}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Seeds: {torrent.seeders}</span>
|
||||
<span>Peers: {torrent.peers}</span>
|
||||
<span>
|
||||
PubDate: {dayjs(torrent.pubdate).format("DD.MM.YYYY")} at{" "}
|
||||
{dayjs(torrent.pubdate).format("HH:mm")}
|
||||
</span>
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDownloadTorrent(torrent.id)}
|
||||
>
|
||||
<DownloadSimpleIcon size={24} />
|
||||
</button>
|
||||
<span>{humanFileSize(torrent.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTorrents && filteredTorrents.length > 0 ? (
|
||||
filteredTorrents.map((torrent) => (
|
||||
<Torrent key={torrent.id} itemId={item.id} torrent={torrent} />
|
||||
))
|
||||
) : (
|
||||
<span>No torrents yet</span>
|
||||
@@ -194,26 +146,3 @@ export const Item = ({ item }: ItemProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatCategory = (category: number): string => {
|
||||
switch (category) {
|
||||
case 1000:
|
||||
return "Console";
|
||||
case 2000:
|
||||
return "Movies";
|
||||
case 3000:
|
||||
return "Audio";
|
||||
case 4000:
|
||||
return "PC";
|
||||
case 5000:
|
||||
return "TV";
|
||||
case 6000:
|
||||
return "XXX";
|
||||
case 7000:
|
||||
return "Books";
|
||||
case 8000:
|
||||
return "Other";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
116
web/src/components/Torrent.tsx
Normal file
116
web/src/components/Torrent.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import type { ItemTorrent } from "../api/useItemTorrentsQuery";
|
||||
import { categories } from "../lib/categories";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadSimpleIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import dayjs from "dayjs";
|
||||
import { humanFileSize } from "../utils/humanFileSize";
|
||||
import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation";
|
||||
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export type TorrentProps = {
|
||||
itemId: number;
|
||||
torrent: ItemTorrent;
|
||||
};
|
||||
|
||||
export const Torrent = ({ itemId, torrent }: TorrentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const downloadMutation = useDownloadTorrentMutation();
|
||||
const deleteMutation = useDeleteTorrentMutation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const ChevronIcon = open ? CaretUpIcon : CaretDownIcon;
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadMutation.mutate({ torrentId: torrent.id });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
id: torrent.id,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["items", itemId, "torrents"],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
className="flex items-center gap-2 hover:bg-neutral-200 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<p>{torrent.title}</p>
|
||||
{torrent.downloaded && (
|
||||
<span title="Torrent files downloaded">
|
||||
<CheckCircleIcon size={20} color="green" />
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span>[{formatCategory(torrent.category)}]</span>
|
||||
<span>{humanFileSize(torrent.size)}</span>
|
||||
<span>
|
||||
<ChevronIcon size={20} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="p-2 bg-neutral-100 rounded-md text-[15px]">
|
||||
<p>Indexer: {torrent.indexer}</p>
|
||||
<p>Seeders: {torrent.seeders}</p>
|
||||
<p>Peers: {torrent.peers}</p>
|
||||
<p>
|
||||
Published: {dayjs(torrent.pubdate).format("DD.MM.YYYY")} at{" "}
|
||||
{dayjs(torrent.pubdate).format("HH:mm")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<a
|
||||
href={torrent.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ArrowSquareOutIcon size={18} /> Open
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer flex items-center gap-1"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadSimpleIcon size={18} /> Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[#b00420] cursor-pointer flex items-center gap-1"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<TrashIcon size={18} /> Delete torrent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatCategory = (category: number): string => {
|
||||
return categories[category as keyof typeof categories] || "";
|
||||
};
|
||||
@@ -1,10 +1,61 @@
|
||||
export const categories = {
|
||||
1000: "Console",
|
||||
1010: "Console/NDS",
|
||||
1020: "Console/PSP",
|
||||
1030: "Console/Wii",
|
||||
1040: "Console/XBox",
|
||||
1050: "Console/XBox 360",
|
||||
1080: "Console/PS3",
|
||||
1090: "Console/Other",
|
||||
1110: "Console/3DS",
|
||||
1120: "Console/PS Vita",
|
||||
1180: "Console/PS4",
|
||||
|
||||
2000: "Movies",
|
||||
2010: "Movies/Foreign",
|
||||
2020: "Movies/Other",
|
||||
2030: "Movies/SD",
|
||||
2040: "Movies/HD",
|
||||
2045: "Movies/UHD",
|
||||
2060: "Movies/3D",
|
||||
2070: "Movies/DVD",
|
||||
|
||||
3000: "Audio",
|
||||
3010: "Audio/MP3",
|
||||
3020: "Audio/Video",
|
||||
3030: "Audio/Audiobook",
|
||||
3040: "Audio/Lossless",
|
||||
3050: "Audio/Other",
|
||||
|
||||
4000: "PC",
|
||||
4010: "PC/0day",
|
||||
4030: "PC/Mac",
|
||||
4040: "PC/Mobile-Other",
|
||||
4050: "PC/Games",
|
||||
4060: "PC/Mobile-IOS",
|
||||
4070: "PC/Mobile-Android",
|
||||
|
||||
5000: "TV",
|
||||
5020: "TV/Foreign",
|
||||
5030: "TV/SD",
|
||||
5040: "TV/HD",
|
||||
5045: "TV/UHD",
|
||||
5050: "TV/Other",
|
||||
5060: "TV/Sport",
|
||||
5070: "TV/Anime",
|
||||
5080: "TV/Documentary",
|
||||
|
||||
6000: "XXX",
|
||||
6010: "XXX/DVD",
|
||||
6060: "XXX/ImageSet",
|
||||
|
||||
7000: "Books",
|
||||
7010: "Books/Mags",
|
||||
7020: "Books/EBook",
|
||||
7030: "Books/Comics",
|
||||
7040: "Books/Technical",
|
||||
7050: "Books/Other",
|
||||
|
||||
8000: "Other",
|
||||
8010: "Other/Misc",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user