Compare commits

..

5 Commits

5 changed files with 203 additions and 99 deletions

View File

@@ -3,7 +3,7 @@ dev:
go run . go run .
build: build:
docker build -t git.zatch.ru/tsivinsky/tvqueue:latest . docker build -t git.tsivinsky.com/tsivinsky/tvqueue:latest .
push: push:
docker push git.zatch.ru/tsivinsky/tvqueue:latest docker push git.tsivinsky.com/tsivinsky/tvqueue:latest

10
main.go
View File

@@ -373,7 +373,15 @@ func main() {
return 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) http.Error(w, err.Error(), 500)
return return
} }

View File

@@ -1,23 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ItemDetails } from "../api/useItemsQuery"; import type { ItemDetails } from "../api/useItemsQuery";
import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery"; import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery";
import { import {
ArrowsClockwiseIcon, ArrowsClockwiseIcon,
CaretDownIcon, CaretDownIcon,
CaretUpIcon, CaretUpIcon,
CheckCircleIcon,
DownloadSimpleIcon,
TrashIcon, TrashIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
import { useDeleteItemMutation } from "../api/useDeleteItemMutation"; import { useDeleteItemMutation } from "../api/useDeleteItemMutation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { humanFileSize } from "../utils/humanFileSize";
import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation";
import { useRefreshItemMutation } from "../api/useRefreshItemMutation"; import { useRefreshItemMutation } from "../api/useRefreshItemMutation";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { Torrent } from "./Torrent";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@@ -33,12 +29,21 @@ export const Item = ({ item }: ItemProps) => {
const { data: torrents } = useItemTorrentsQuery(item.id, open); const { data: torrents } = useItemTorrentsQuery(item.id, open);
const deleteMutation = useDeleteItemMutation(); const deleteMutation = useDeleteItemMutation();
const downloadMutation = useDownloadTorrentMutation();
const refreshMutation = useRefreshItemMutation(); const refreshMutation = useRefreshItemMutation();
const deleteTorrentMutation = useDeleteTorrentMutation();
const Icon = open ? CaretUpIcon : CaretDownIcon; 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(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const itemId = params.get("item"); const itemId = params.get("item");
@@ -49,10 +54,6 @@ export const Item = ({ item }: ItemProps) => {
} }
}, [item]); }, [item]);
const handleDownloadTorrent = (torrentId: number) => {
downloadMutation.mutate({ torrentId });
};
const handleDelete = () => { const handleDelete = () => {
if (!confirm("Do you want to delete this item?")) return; 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 ( return (
<div className="border-t border-b border-neutral-900"> <div className="border-t border-b border-neutral-900">
<div <div
@@ -112,6 +97,15 @@ export const Item = ({ item }: ItemProps) => {
</div> </div>
{open && ( {open && (
<div> <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 className="flex items-center gap-2 mb-2">
<div> <div>
Last Refresh:{" "} Last Refresh:{" "}
@@ -140,51 +134,9 @@ export const Item = ({ item }: ItemProps) => {
<TrashIcon size={20} /> Delete item <TrashIcon size={20} /> Delete item
</button> </button>
</div> </div>
{torrents && torrents.length > 0 ? ( {filteredTorrents && filteredTorrents.length > 0 ? (
torrents?.map((torrent) => ( filteredTorrents.map((torrent) => (
<div <Torrent key={torrent.id} itemId={item.id} torrent={torrent} />
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>
)) ))
) : ( ) : (
<span>No torrents yet</span> <span>No torrents yet</span>
@@ -194,26 +146,3 @@ export const Item = ({ item }: ItemProps) => {
</div> </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 "";
}
};

View 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] || "";
};

View File

@@ -1,10 +1,61 @@
export const categories = { export const categories = {
1000: "Console", 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", 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", 3000: "Audio",
3010: "Audio/MP3",
3020: "Audio/Video",
3030: "Audio/Audiobook",
3040: "Audio/Lossless",
3050: "Audio/Other",
4000: "PC", 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", 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", 6000: "XXX",
6010: "XXX/DVD",
6060: "XXX/ImageSet",
7000: "Books", 7000: "Books",
7010: "Books/Mags",
7020: "Books/EBook",
7030: "Books/Comics",
7040: "Books/Technical",
7050: "Books/Other",
8000: "Other", 8000: "Other",
8010: "Other/Misc",
}; };