Add upload to show functionality in MediaPanel: introduce file selection for uploading media files directly to a show, enhancing the upload store to support show associations. Update translations for new upload options and UI elements.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Upload } from 'lucide-react'
|
import { ListPlus, Upload } from 'lucide-react'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||||
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||||
@@ -9,6 +9,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteMedia, listMedia } from './api'
|
import { deleteMedia, listMedia } from './api'
|
||||||
|
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -43,7 +44,9 @@ export function MediaPanel() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -92,6 +95,22 @@ export function MediaPanel() {
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputShow}
|
||||||
|
type="file"
|
||||||
|
accept="video/*,.mkv,.avi,.ts"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||||
|
<ListPlus className="h-4 w-4" />
|
||||||
|
{t('admin.media.uploadToShow')}
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
{t('admin.media.upload')}
|
{t('admin.media.upload')}
|
||||||
@@ -99,6 +118,10 @@ export function MediaPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filesForShow && (
|
||||||
|
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="crt-panel overflow-x-auto rounded-md">
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/ui/dialog'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
|
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||||
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
|
export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
const [showId, setShowId] = useState('')
|
||||||
|
const [seasonStr, setSeasonStr] = useState('')
|
||||||
|
const [regexStr, setRegexStr] = useState('')
|
||||||
|
|
||||||
|
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||||
|
|
||||||
|
const regexOk = isValidRegex(regexStr)
|
||||||
|
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||||
|
|
||||||
|
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||||
|
const previews = useMemo(() => {
|
||||||
|
const opts = {
|
||||||
|
seasonOverride: seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||||
|
episodeRegex: regexOk ? regexStr : null,
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
.map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) }))
|
||||||
|
.sort(compareParsed)
|
||||||
|
}, [files, seasonOverride, regexStr, regexOk])
|
||||||
|
|
||||||
|
const recognized = previews.filter((p) => p.parsed.episode != null).length
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (!showId) return
|
||||||
|
void enqueue(
|
||||||
|
previews.map((p) => p.file),
|
||||||
|
{ showId },
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('admin.media.toShowSubtitle', { count: files.length })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-1">
|
||||||
|
<Label>{t('admin.media.toShowShow')}</Label>
|
||||||
|
<Select value={showId} onValueChange={setShowId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('admin.media.toShowPick')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shows?.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
|
value={seasonStr}
|
||||||
|
onChange={(e) => setSeasonStr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="^(\d+)"
|
||||||
|
value={regexStr}
|
||||||
|
onChange={(e) => setRegexStr(e.target.value)}
|
||||||
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.media.toShowHint')}
|
||||||
|
{!regexOk && (
|
||||||
|
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.media.toShowRecognized', { recognized, total: files.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="crt-panel max-h-64 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||||
|
{previews.map((p) => {
|
||||||
|
const label = formatSeasonEpisode(p.parsed)
|
||||||
|
return (
|
||||||
|
<li key={p.name} className="flex items-center gap-3 px-3 py-1.5">
|
||||||
|
{label ? (
|
||||||
|
<Badge>{label}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="truncate" title={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={!showId} onClick={confirm}>
|
||||||
|
{t('admin.media.toShowConfirm')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export type ParseOptions = {
|
||||||
|
/** Ручной сезон — перебивает распознанный/дефолтный. */
|
||||||
|
seasonOverride?: number | null
|
||||||
|
/** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */
|
||||||
|
episodeRegex?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedEpisode = { season: number | null; episode: number | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пытается распознать сезон/серию из имени файла. Сначала встроенные шаблоны (SxxEyy, NxNN), затем —
|
||||||
|
* пользовательский regex (перебивает серию, а при двух группах и сезон), в конце — ручной сезон.
|
||||||
|
* Если серия распознана, а сезон нет — сезон считается первым.
|
||||||
|
*/
|
||||||
|
export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpisode {
|
||||||
|
let season: number | null = null
|
||||||
|
let episode: number | null = null
|
||||||
|
|
||||||
|
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
|
||||||
|
if (se) {
|
||||||
|
season = Number(se[1])
|
||||||
|
episode = Number(se[2])
|
||||||
|
} else {
|
||||||
|
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
|
||||||
|
if (nx) {
|
||||||
|
season = Number(nx[1])
|
||||||
|
episode = Number(nx[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRegex = opts?.episodeRegex?.trim()
|
||||||
|
if (rawRegex) {
|
||||||
|
try {
|
||||||
|
const match = name.match(new RegExp(rawRegex, 'i'))
|
||||||
|
if (match) {
|
||||||
|
if (match.length >= 3 && match[1] != null && match[2] != null) {
|
||||||
|
season = Number(match[1])
|
||||||
|
episode = Number(match[2])
|
||||||
|
} else if (match[1] != null) {
|
||||||
|
episode = Number(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// невалидный regex — просто игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.seasonOverride != null) season = opts.seasonOverride
|
||||||
|
if (episode != null && season == null) season = 1
|
||||||
|
|
||||||
|
if (episode != null && !Number.isFinite(episode)) episode = null
|
||||||
|
if (season != null && !Number.isFinite(season)) season = null
|
||||||
|
return { season, episode }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
/** «S14E17» либо null, если серия не распознана. */
|
||||||
|
export function formatSeasonEpisode(parsed: ParsedEpisode): string | null {
|
||||||
|
if (parsed.episode == null) return null
|
||||||
|
return `S${pad2(parsed.season ?? 1)}E${pad2(parsed.episode)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет корректность пользовательского regex (для подсветки ошибки в UI). */
|
||||||
|
export function isValidRegex(pattern: string): boolean {
|
||||||
|
if (!pattern.trim()) return true
|
||||||
|
try {
|
||||||
|
new RegExp(pattern)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сортировка по (сезон, серия); нераспознанные — в конец по имени. */
|
||||||
|
export function compareParsed(
|
||||||
|
a: { name: string; parsed: ParsedEpisode },
|
||||||
|
b: { name: string; parsed: ParsedEpisode },
|
||||||
|
): number {
|
||||||
|
const ae = a.parsed.episode
|
||||||
|
const be = b.parsed.episode
|
||||||
|
if (ae != null && be != null) {
|
||||||
|
return (a.parsed.season ?? 1) - (b.parsed.season ?? 1) || ae - be
|
||||||
|
}
|
||||||
|
if (ae != null) return -1
|
||||||
|
if (be != null) return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { queryClient } from '@/shared/api/query-client'
|
import { queryClient } from '@/shared/api/query-client'
|
||||||
|
import { addEpisode } from '@/features/admin/shows/api'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { listMedia, uploadMedia } from './api'
|
import { listMedia, uploadMedia } from './api'
|
||||||
|
|
||||||
export type UploadItem = {
|
export type UploadItem = {
|
||||||
@@ -14,16 +16,19 @@ type UploadStore = {
|
|||||||
active: boolean
|
active: boolean
|
||||||
minimized: boolean
|
minimized: boolean
|
||||||
skipped: number
|
skipped: number
|
||||||
enqueue: (files: File[]) => Promise<void>
|
enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
|
||||||
cancel: (id: string) => void
|
cancel: (id: string) => void
|
||||||
cancelAll: () => void
|
cancelAll: () => void
|
||||||
toggleMinimize: () => void
|
toggleMinimize: () => void
|
||||||
dismiss: () => void
|
dismiss: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). */
|
||||||
|
export type EnqueueOptions = { showId?: string }
|
||||||
|
|
||||||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||||||
let counter = 0
|
let counter = 0
|
||||||
const queue: { id: string; file: File }[] = []
|
const queue: { id: string; file: File; showId?: string }[] = []
|
||||||
const controllers = new Map<string, AbortController>()
|
const controllers = new Map<string, AbortController>()
|
||||||
let running = false
|
let running = false
|
||||||
|
|
||||||
@@ -43,9 +48,23 @@ async function pump() {
|
|||||||
controllers.set(job.id, controller)
|
controllers.set(job.id, controller)
|
||||||
patch(job.id, { status: 'uploading', percent: 0 })
|
patch(job.id, { status: 'uploading', percent: 0 })
|
||||||
try {
|
try {
|
||||||
await uploadMedia(job.file, (percent) => patch(job.id, { percent }), controller.signal)
|
const created = await uploadMedia(
|
||||||
|
job.file,
|
||||||
|
(percent) => patch(job.id, { percent }),
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
patch(job.id, { status: 'done', percent: 100 })
|
patch(job.id, { status: 'done', percent: 100 })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||||
|
|
||||||
|
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
|
||||||
|
if (job.showId) {
|
||||||
|
try {
|
||||||
|
await addEpisode(job.showId, created.id)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||||
|
} catch {
|
||||||
|
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой.
|
// Отмена (AbortError) — тихо: элемент уже убран из списка. Прочее — помечаем ошибкой.
|
||||||
if (!(error instanceof DOMException && error.name === 'AbortError'))
|
if (!(error instanceof DOMException && error.name === 'AbortError'))
|
||||||
@@ -65,7 +84,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
|
|
||||||
enqueue: async (files) => {
|
enqueue: async (files, options) => {
|
||||||
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
|
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
|
||||||
let existing = new Set<string>()
|
let existing = new Set<string>()
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +109,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
|||||||
|
|
||||||
const newItems: UploadItem[] = toAdd.map((file) => {
|
const newItems: UploadItem[] = toAdd.map((file) => {
|
||||||
const id = `u${++counter}`
|
const id = `u${++counter}`
|
||||||
queue.push({ id, file })
|
queue.push({ id, file, showId: options?.showId })
|
||||||
return { id, name: file.name, percent: 0, status: 'queued' }
|
return { id, name: file.name, percent: 0, status: 'queued' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ const resources = {
|
|||||||
media: {
|
media: {
|
||||||
title: 'Медиа',
|
title: 'Медиа',
|
||||||
upload: 'Загрузить',
|
upload: 'Загрузить',
|
||||||
|
uploadToShow: 'Загрузить в шоу',
|
||||||
|
toShowTitle: 'Загрузить и добавить в шоу',
|
||||||
|
toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.',
|
||||||
|
toShowShow: 'Шоу',
|
||||||
|
toShowPick: 'Выберите шоу',
|
||||||
|
toShowSeason: 'Сезон (вручную)',
|
||||||
|
toShowAuto: 'авто',
|
||||||
|
toShowRegex: 'Regex серии',
|
||||||
|
toShowRegexInvalid: 'некорректный regex',
|
||||||
|
toShowHint:
|
||||||
|
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
|
||||||
|
toShowPreview: 'Что распознаем',
|
||||||
|
toShowRecognized: 'распознано {{recognized}} из {{total}}',
|
||||||
|
toShowUnknown: '—',
|
||||||
|
toShowConfirm: 'Загрузить и добавить',
|
||||||
uploaded: 'Файл загружен, идёт обработка',
|
uploaded: 'Файл загружен, идёт обработка',
|
||||||
uploadedCount: 'Загружено файлов: {{count}}',
|
uploadedCount: 'Загружено файлов: {{count}}',
|
||||||
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
||||||
@@ -359,6 +374,21 @@ const resources = {
|
|||||||
media: {
|
media: {
|
||||||
title: 'Media',
|
title: 'Media',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
|
uploadToShow: 'Upload to show',
|
||||||
|
toShowTitle: 'Upload and add to show',
|
||||||
|
toShowSubtitle: '{{count}} file(s) selected. After upload they are added as episodes to the chosen show.',
|
||||||
|
toShowShow: 'Show',
|
||||||
|
toShowPick: 'Pick a show',
|
||||||
|
toShowSeason: 'Season (manual)',
|
||||||
|
toShowAuto: 'auto',
|
||||||
|
toShowRegex: 'Episode regex',
|
||||||
|
toShowRegexInvalid: 'invalid regex',
|
||||||
|
toShowHint:
|
||||||
|
'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.',
|
||||||
|
toShowPreview: 'What we detect',
|
||||||
|
toShowRecognized: '{{recognized}} of {{total}} recognized',
|
||||||
|
toShowUnknown: '—',
|
||||||
|
toShowConfirm: 'Upload and add',
|
||||||
uploaded: 'File uploaded, processing started',
|
uploaded: 'File uploaded, processing started',
|
||||||
uploadedCount: 'Uploaded files: {{count}}',
|
uploadedCount: 'Uploaded files: {{count}}',
|
||||||
uploadingCount: 'Uploading {{done}}/{{total}}',
|
uploadingCount: 'Uploading {{done}}/{{total}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user