Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions app/components/EditTorrentModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,22 @@

<!-- Body -->
<div class="p-6 space-y-6">
<!-- Torrent Name (read-only) -->
<!-- Torrent Name -->
<div class="space-y-2">
<label
class="text-[10px] font-bold uppercase tracking-widest text-text-muted ml-1"
>Torrent Name</label
>
<div
class="input w-full !py-2 text-xs font-medium bg-bg-tertiary/50 text-text-secondary cursor-not-allowed"
>
{{ torrent.name }}
</div>
<input
v-model="name"
type="text"
maxlength="255"
class="input w-full !py-2 text-xs font-medium"
/>
</div>

<MediaIdsFields v-model="mediaIds" />

<!-- Category Select -->
<div class="space-y-2">
<label
Expand Down Expand Up @@ -224,6 +227,9 @@ interface TorrentData {
name: string;
description: string | null;
categoryId: string | null;
imdbId: string | null;
tmdbId: number | null;
tvdbId: number | null;
}

const props = defineProps<{
Expand All @@ -238,6 +244,19 @@ const emit = defineEmits<{

const textareaRef = ref<HTMLTextAreaElement | null>(null);
const selectedCategoryId = ref('');
const name = ref('');
const mediaIds = ref({ imdbId: '', tmdbId: '', tvdbId: '' });

function loadForm(torrent: TorrentData) {
selectedCategoryId.value = torrent.categoryId || '';
description.value = torrent.description || '';
name.value = torrent.name;
mediaIds.value = {
imdbId: torrent.imdbId ?? '',
tmdbId: torrent.tmdbId?.toString() ?? '',
tvdbId: torrent.tvdbId?.toString() ?? '',
};
}
const description = ref('');
const isPreview = ref(false);
const isSaving = ref(false);
Expand All @@ -253,10 +272,7 @@ const renderedDescription = computed(() => {
watch(
() => props.torrent,
(torrent) => {
if (torrent) {
selectedCategoryId.value = torrent.categoryId || '';
description.value = torrent.description || '';
}
if (torrent) loadForm(torrent);
},
{ immediate: true }
);
Expand All @@ -266,8 +282,7 @@ watch(
() => props.isOpen,
(isOpen) => {
if (isOpen && props.torrent) {
selectedCategoryId.value = props.torrent.categoryId || '';
description.value = props.torrent.description || '';
loadForm(props.torrent);
isPreview.value = false;
error.value = null;
}
Expand Down Expand Up @@ -372,8 +387,10 @@ async function save() {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.value.trim() || undefined,
description: description.value,
categoryId: selectedCategoryId.value || null,
...mediaIds.value,
}),
}).then(async (res) => {
if (!res.ok) {
Expand Down
46 changes: 46 additions & 0 deletions app/components/MediaIdsFields.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<template>
<div class="space-y-2">
<label
class="text-[10px] font-bold uppercase tracking-widest text-text-muted ml-1"
>External IDs (optional)</label
>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input
v-model="model.imdbId"
type="text"
class="input w-full !py-2 text-xs font-mono"
placeholder="IMDb (tt0133093 or URL)"
aria-label="IMDb ID"
/>
<input
v-model="model.tmdbId"
type="text"
inputmode="numeric"
class="input w-full !py-2 text-xs font-mono"
placeholder="TMDb (603 or URL)"
aria-label="TMDb ID"
/>
<input
v-model="model.tvdbId"
type="text"
inputmode="numeric"
class="input w-full !py-2 text-xs font-mono"
placeholder="TheTVDB (81189)"
aria-label="TheTVDB ID"
/>
</div>
<p class="text-[10px] text-text-muted ml-1">
Used by Sonarr / Radarr to match releases. Paste an ID or the page URL.
</p>
</div>
</template>

<script setup lang="ts">
export interface MediaIdsModel {
imdbId: string;
tmdbId: string;
tvdbId: string;
}

const model = defineModel<MediaIdsModel>({ required: true });
</script>
88 changes: 88 additions & 0 deletions app/components/UploadTorrentModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,51 @@
</div>
</div>

<!-- Personal announce URL (#42) -->
<div class="space-y-2">
<label
class="text-[10px] font-bold uppercase tracking-widest text-text-muted ml-1"
>Your announce URL</label
>
<div class="flex gap-2">
<input
:value="announceUrl ?? 'Loading…'"
type="text"
readonly
class="input w-full !py-2 text-xs font-mono"
aria-label="Announce URL"
@focus="($event.target as HTMLInputElement).select()"
/>
<button
type="button"
class="btn btn-secondary !py-2 !px-3 text-xs shrink-0"
:disabled="!announceUrl"
:title="announceCopied ? 'Copied' : 'Copy announce URL'"
@click="copyAnnounce"
>
<Icon :name="announceCopied ? 'ph:check-bold' : 'ph:copy-bold'" />
</button>
</div>
<p class="text-[10px] text-text-muted ml-1">
Keep it private: it contains your passkey.
</p>
</div>

<!-- Custom name (#42) -->
<div class="space-y-2">
<label
class="text-[10px] font-bold uppercase tracking-widest text-text-muted ml-1"
>Display name (optional)</label
>
<input
v-model="customName"
type="text"
maxlength="255"
class="input w-full !py-2 text-xs"
placeholder="Defaults to the name inside the .torrent"
/>
</div>

<!-- Category Select -->
<div class="space-y-2">
<label
Expand All @@ -101,6 +146,8 @@
</select>
</div>

<MediaIdsFields v-model="mediaIds" />

<!-- Description -->
<div class="space-y-2">
<div class="flex items-center justify-between ml-1">
Expand Down Expand Up @@ -336,6 +383,39 @@ const isUploading = ref(false);
const result = ref<TorrentResult | null>(null);
const error = ref<string | null>(null);
const copied = ref(false);
const customName = ref('');
const mediaIds = ref({ imdbId: '', tmdbId: '', tvdbId: '' });
const announceUrl = ref<string | null>(null);
const announceCopied = ref(false);
const config = useRuntimeConfig();

// Passkey is not in the client session: fetch it when the modal opens
watch(
() => props.isOpen,
async (open) => {
if (!open || announceUrl.value) return;
try {
const { passkey } = await $fetch<{ passkey: string }>('/api/auth/passkey');
const url = new URL(config.public.trackerHttpUrl as string);
url.searchParams.set('passkey', passkey);
announceUrl.value = url.toString();
} catch {
announceUrl.value = null;
}
},
{ immediate: true }
);

async function copyAnnounce() {
if (!announceUrl.value) return;
try {
await navigator.clipboard.writeText(announceUrl.value);
announceCopied.value = true;
setTimeout(() => (announceCopied.value = false), 2000);
} catch {
error.value = 'Failed to copy to clipboard';
}
}

const { data: categories } = await useFetch('/api/categories');

Expand Down Expand Up @@ -373,6 +453,8 @@ function reset() {
selectedFile.value = null;
selectedCategoryId.value = '';
description.value = '';
customName.value = '';
mediaIds.value = { imdbId: '', tmdbId: '', tvdbId: '' };
isPreview.value = false;
result.value = null;
error.value = null;
Expand Down Expand Up @@ -483,6 +565,12 @@ async function upload() {
if (description.value) {
formData.append('description', description.value);
}
if (customName.value.trim()) {
formData.append('name', customName.value.trim());
}
for (const [key, value] of Object.entries(mediaIds.value)) {
if (value.trim()) formData.append(key, value.trim());
}

const response = await $fetch<TorrentResult>('/api/torrents', {
method: 'POST',
Expand Down
41 changes: 39 additions & 2 deletions app/pages/torrents/[hash].vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,28 @@
<h2 class="text-2xl font-bold text-text-primary tracking-tight">
{{ torrent.name }}
</h2>
<!-- Category Badge -->
<div v-if="torrent.category" class="mt-2">
<!-- Category Badge + external database links (#47) -->
<div
v-if="torrent.category || externalLinks.length"
class="mt-2 flex flex-wrap items-center gap-2"
>
<span
v-if="torrent.category"
class="text-[10px] font-bold bg-bg-tertiary border border-border px-2 py-1 rounded-sm text-text-secondary uppercase tracking-wider"
>
{{ torrent.category.name }}
</span>
<a
v-for="link in externalLinks"
:key="link.label"
:href="link.href"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] font-bold border border-border px-2 py-1 rounded-sm text-text-secondary uppercase tracking-wider hover:text-white hover:border-white/30 transition-colors inline-flex items-center gap-1"
>
{{ link.label }}
<Icon name="ph:arrow-square-out-bold" />
</a>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
Expand Down Expand Up @@ -305,6 +320,7 @@ interface Category {
id: string;
name: string;
slug: string;
newznabId?: number | null;
}

interface TorrentDetail {
Expand All @@ -316,6 +332,9 @@ interface TorrentDetail {
uploaderId: string | null;
categoryId: string | null;
category: Category | null;
imdbId: string | null;
tmdbId: number | null;
tvdbId: number | null;
createdAt: string;
stats: {
seeders: number;
Expand Down Expand Up @@ -362,8 +381,26 @@ const editableTorrent = computed(() => ({
name: torrent.value?.name || '',
description: torrent.value?.description || null,
categoryId: torrent.value?.categoryId || null,
imdbId: torrent.value?.imdbId ?? null,
tmdbId: torrent.value?.tmdbId ?? null,
tvdbId: torrent.value?.tvdbId ?? null,
}));

const externalLinks = computed(() => {
const t = torrent.value;
if (!t) return [];
const links: { label: string; href: string }[] = [];
if (t.imdbId) links.push({ label: 'IMDb', href: imdbUrl(t.imdbId) });
if (t.tmdbId) {
// Torznab TV categories are 5000-5999
const newznabId = t.category?.newznabId ?? 0;
const kind = t.tvdbId || (newznabId >= 5000 && newznabId < 6000) ? 'tv' : 'movie';
links.push({ label: 'TMDb', href: tmdbUrl(t.tmdbId, kind) });
}
if (t.tvdbId) links.push({ label: 'TheTVDB', href: tvdbUrl(t.tvdbId) });
return links;
});

const renderedDescription = computed(() => {
return renderMarkdown(torrent.value?.description);
});
Expand Down
29 changes: 27 additions & 2 deletions server/api/torrents/[hash]/index.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import { torrents, categories } from '../../../db/schema';
import { requireAuthSession } from '../../../utils/adminAuth';
import { rateLimit, RATE_LIMITS } from '../../../utils/rateLimit';
import { z } from 'zod';
import { parseMediaIds } from '../../../../shared/utils/mediaIds';

const mediaIdSchema = z.union([z.string().max(200), z.number()]).nullable().optional();

const patchSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
imdbId: mediaIdSchema,
tmdbId: mediaIdSchema,
tvdbId: mediaIdSchema,
description: z.string().max(10000).nullable().optional(),
categoryId: z.union([z.uuid(), z.literal('')]).nullable().optional(),
});
Expand Down Expand Up @@ -61,7 +68,14 @@ export default defineEventHandler(async (event) => {
if (!parsedBody.success) {
throw createError({ statusCode: 400, message: 'Invalid request body' });
}
const { description, categoryId } = parsedBody.data;
const { description, categoryId, name } = parsedBody.data;
const media = parseMediaIds(parsedBody.data);
if (media.invalid.length > 0) {
throw createError({
statusCode: 400,
message: `Invalid ${media.invalid.join(', ')}`,
});
}

// Validate categoryId if provided
if (categoryId !== undefined && categoryId !== null && categoryId !== '') {
Expand All @@ -79,9 +93,17 @@ export default defineEventHandler(async (event) => {

// Build update object
const updateData: {
name?: string;
description?: string | null;
categoryId?: string | null;
} = {};
imdbId?: string | null;
tmdbId?: number | null;
tvdbId?: number | null;
} = { ...media.ids };

if (name !== undefined) {
updateData.name = name;
}

if (description !== undefined) {
updateData.description = description || null;
Expand Down Expand Up @@ -116,6 +138,9 @@ export default defineEventHandler(async (event) => {
description: updated!.description,
categoryId: updated!.categoryId,
category: updated!.category,
imdbId: updated!.imdbId,
tmdbId: updated!.tmdbId,
tvdbId: updated!.tvdbId,
},
};
});
Loading