fixes, orval, downloader functioning again
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import HomeLayout from "./layouts/HomeLayout";
|
||||
import ChatLayout from "./layouts/ChatLayout";
|
||||
import ChatLayout from "./layouts/social/Chat";
|
||||
|
||||
import Downloader from "./pages/downloader/Downloader";
|
||||
import Home from "./pages/home/home";
|
||||
@@ -14,8 +14,6 @@ import PortfolioPage from "./pages/portfolio/PortfolioPage";
|
||||
import ContactPage from "./pages/contact/ContactPage";
|
||||
import ScrollToTop from "./components/common/ScrollToTop";
|
||||
|
||||
|
||||
import AuthLayout from "./layouts/AuthLayout";
|
||||
import LogoutPage from "./pages/social/account/Logout";
|
||||
import LoginPage from "./pages/social/account/Login";
|
||||
import RegisterPage from "./pages/social/account/Register";
|
||||
@@ -44,7 +42,7 @@ export default function App() {
|
||||
|
||||
</Route>
|
||||
|
||||
<Route path="auth/" element={<AuthLayout />}>
|
||||
<Route path="auth/" element={<PrivateRoute />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegisterPage />} />
|
||||
<Route path="logout" element={<LogoutPage />} />
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ApiSchemaRetrieveLang = {
|
||||
hi: "hi",
|
||||
hr: "hr",
|
||||
hsb: "hsb",
|
||||
ht: "ht",
|
||||
hu: "hu",
|
||||
hy: "hy",
|
||||
ia: "ia",
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { ShippingMethodEnum } from "./shippingMethodEnum";
|
||||
import type { State1f6Enum } from "./state1f6Enum";
|
||||
import type { StateF41Enum } from "./stateF41Enum";
|
||||
import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead";
|
||||
|
||||
export interface CarrierRead {
|
||||
readonly shipping_method: ShippingMethodEnum;
|
||||
readonly state: State1f6Enum;
|
||||
readonly state: StateF41Enum;
|
||||
readonly zasilkovna: readonly ZasilkovnaPacketRead[];
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly shipping_price: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CartItem {
|
||||
readonly product_price: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
quantity?: number;
|
||||
readonly subtotal: string;
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface DeutschePostOrder {
|
||||
/**
|
||||
* Weight in grams
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
shipment_gross_weight: number;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface DiscountCode {
|
||||
active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
usage_limit?: number | null;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v8.8.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export interface DownloadRequest {
|
||||
/** Video/Playlist URL to download from supported platforms */
|
||||
url: string;
|
||||
/** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */
|
||||
ext?: string;
|
||||
/**
|
||||
* Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected.
|
||||
* @nullable
|
||||
*/
|
||||
video_quality?: number | null;
|
||||
/**
|
||||
* Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected.
|
||||
* @nullable
|
||||
*/
|
||||
audio_quality?: number | null;
|
||||
/**
|
||||
* For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded.
|
||||
* @nullable
|
||||
*/
|
||||
selected_videos?: number[] | null;
|
||||
/**
|
||||
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
|
||||
* @nullable
|
||||
*/
|
||||
subtitles?: string | null;
|
||||
/** Embed subtitles into the video file (requires mkv or mp4 container) */
|
||||
embed_subtitles?: boolean;
|
||||
/** Embed thumbnail as cover art in the file */
|
||||
embed_thumbnail?: boolean;
|
||||
/** Extract audio only, ignoring video quality settings */
|
||||
extract_audio?: boolean;
|
||||
/**
|
||||
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
|
||||
* @nullable
|
||||
*/
|
||||
cookies?: string | null;
|
||||
}
|
||||
@@ -44,9 +44,7 @@ export * from "./deutschePostOrder";
|
||||
export * from "./deutschePostOrderStateEnum";
|
||||
export * from "./deutschePostTracking";
|
||||
export * from "./discountCode";
|
||||
export * from "./downloadErrorResponse";
|
||||
export * from "./downloaderStats";
|
||||
export * from "./downloadRequest";
|
||||
export * from "./errorResponse";
|
||||
export * from "./gopayCreatePayment201";
|
||||
export * from "./gopayGetStatus200";
|
||||
@@ -133,12 +131,13 @@ export * from "./reviewSerializerPublic";
|
||||
export * from "./roleEnum";
|
||||
export * from "./shippingMethodEnum";
|
||||
export * from "./siteConfiguration";
|
||||
export * from "./state1f6Enum";
|
||||
export * from "./stateCdfEnum";
|
||||
export * from "./statusD4fEnum";
|
||||
export * from "./state9b5Enum";
|
||||
export * from "./stateF41Enum";
|
||||
export * from "./status0b2Enum";
|
||||
export * from "./tagAttach";
|
||||
export * from "./tags";
|
||||
export * from "./timeseriesPoint";
|
||||
export * from "./tokenError";
|
||||
export * from "./topUrl";
|
||||
export * from "./trackingURL";
|
||||
export * from "./transferInit";
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { ShippingMethodEnum } from "./shippingMethodEnum";
|
||||
import type { State1f6Enum } from "./state1f6Enum";
|
||||
import type { StateF41Enum } from "./stateF41Enum";
|
||||
import type { ZasilkovnaPacket } from "./zasilkovnaPacket";
|
||||
|
||||
export interface OrderCarrier {
|
||||
shipping_method?: ShippingMethodEnum;
|
||||
readonly state: State1f6Enum;
|
||||
readonly state: StateF41Enum;
|
||||
readonly zasilkovna: readonly ZasilkovnaPacket[];
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly shipping_price: string;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface OrderMini {
|
||||
readonly id: number;
|
||||
readonly status: StatusD4fEnum;
|
||||
readonly status: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price: string;
|
||||
readonly created_at: Date;
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { CarrierRead } from "./carrierRead";
|
||||
import type { OrderItemRead } from "./orderItemRead";
|
||||
import type { PaymentRead } from "./paymentRead";
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface OrderRead {
|
||||
readonly id: number;
|
||||
readonly status: StatusD4fEnum;
|
||||
readonly status: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price: string;
|
||||
/** Order currency - captured from site configuration at order creation and never changes */
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder {
|
||||
/**
|
||||
* Weight in grams
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
shipment_gross_weight?: number;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface PatchedDiscountCode {
|
||||
active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
usage_limit?: number | null;
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { CarrierRead } from "./carrierRead";
|
||||
import type { OrderItemRead } from "./orderItemRead";
|
||||
import type { PaymentRead } from "./paymentRead";
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface PatchedOrderRead {
|
||||
readonly id?: number;
|
||||
readonly status?: StatusD4fEnum;
|
||||
readonly status?: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price?: string;
|
||||
/** Order currency - captured from site configuration at order creation and never changes */
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PatchedProduct {
|
||||
url?: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
is_active?: boolean;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PatchedVATRate {
|
||||
name?: string;
|
||||
/**
|
||||
* VAT rate as percentage (e.g. 19.00 for 19%)
|
||||
* @pattern ^-?\d{0,1}(?:\.\d{0,4})?$
|
||||
* @pattern ^-?\d{0,2}(?:\.\d{0,4})?$
|
||||
*/
|
||||
rate?: string;
|
||||
/** VAT rate as decimal (e.g., 0.19 for 19%) */
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface PostVote {
|
||||
post: number;
|
||||
readonly user: number;
|
||||
/**
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -32768
|
||||
* @maximum 32767
|
||||
*/
|
||||
vote: VoteEnum;
|
||||
readonly created_at: Date;
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Product {
|
||||
url: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
is_active?: boolean;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ProductMiniForWishlist {
|
||||
is_active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* `RETURNING` - Posláno zpátky
|
||||
* `RETURNED` - Vráceno
|
||||
*/
|
||||
export type StateCdfEnum = (typeof StateCdfEnum)[keyof typeof StateCdfEnum];
|
||||
export type State9b5Enum = (typeof State9b5Enum)[keyof typeof State9b5Enum];
|
||||
|
||||
export const StateCdfEnum = {
|
||||
export const State9b5Enum = {
|
||||
WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
|
||||
PENDING: "PENDING",
|
||||
SENDED: "SENDED",
|
||||
@@ -10,9 +10,9 @@
|
||||
* `delivered` - Doručeno
|
||||
* `ready_to_pickup` - Připraveno k vyzvednutí
|
||||
*/
|
||||
export type State1f6Enum = (typeof State1f6Enum)[keyof typeof State1f6Enum];
|
||||
export type StateF41Enum = (typeof StateF41Enum)[keyof typeof StateF41Enum];
|
||||
|
||||
export const State1f6Enum = {
|
||||
export const StateF41Enum = {
|
||||
ordered: "ordered",
|
||||
shipped: "shipped",
|
||||
delivered: "delivered",
|
||||
@@ -11,9 +11,9 @@
|
||||
* `refunding` - Vrácení v procesu
|
||||
* `refunded` - Vráceno
|
||||
*/
|
||||
export type StatusD4fEnum = (typeof StatusD4fEnum)[keyof typeof StatusD4fEnum];
|
||||
export type Status0b2Enum = (typeof Status0b2Enum)[keyof typeof Status0b2Enum];
|
||||
|
||||
export const StatusD4fEnum = {
|
||||
export const Status0b2Enum = {
|
||||
created: "created",
|
||||
cancelled: "cancelled",
|
||||
completed: "completed",
|
||||
@@ -4,6 +4,6 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export interface DownloadErrorResponse {
|
||||
export interface TokenError {
|
||||
error: string;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface VATRate {
|
||||
name: string;
|
||||
/**
|
||||
* VAT rate as percentage (e.g. 19.00 for 19%)
|
||||
* @pattern ^-?\d{0,1}(?:\.\d{0,4})?$
|
||||
* @pattern ^-?\d{0,2}(?:\.\d{0,4})?$
|
||||
*/
|
||||
rate: string;
|
||||
/** VAT rate as decimal (e.g., 0.19 for 19%) */
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StateCdfEnum } from "./stateCdfEnum";
|
||||
import type { State9b5Enum } from "./state9b5Enum";
|
||||
|
||||
export interface ZasilkovnaPacket {
|
||||
readonly id: number;
|
||||
readonly created_at: Date;
|
||||
/**
|
||||
* Číslo zásilky v Packetě (vraceno od API od Packety)
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -2147483648
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
packet_id?: number | null;
|
||||
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacket {
|
||||
* @nullable
|
||||
*/
|
||||
readonly barcode: string | null;
|
||||
readonly state: StateCdfEnum;
|
||||
readonly state: State9b5Enum;
|
||||
/** Hmotnost zásilky v gramech */
|
||||
readonly weight: number;
|
||||
/** Seznam 2 routing stringů pro vrácení zásilky */
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StateCdfEnum } from "./stateCdfEnum";
|
||||
import type { State9b5Enum } from "./state9b5Enum";
|
||||
|
||||
export interface ZasilkovnaPacketRead {
|
||||
readonly id: number;
|
||||
readonly created_at: Date;
|
||||
/**
|
||||
* Číslo zásilky v Packetě (vraceno od API od Packety)
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -2147483648
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
packet_id?: number | null;
|
||||
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead {
|
||||
* @nullable
|
||||
*/
|
||||
readonly barcode: string | null;
|
||||
readonly state: StateCdfEnum;
|
||||
readonly state: State9b5Enum;
|
||||
/** Hmotnost zásilky v gramech */
|
||||
readonly weight: number;
|
||||
/** Seznam 2 routing stringů pro vrácení zásilky */
|
||||
|
||||
@@ -20,11 +20,13 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
ApiDownloaderDownloadCreateParams,
|
||||
ApiDownloaderDownloadFileCreateParams,
|
||||
ApiDownloaderDownloadFileRetrieveParams,
|
||||
ApiDownloaderDownloadRetrieveParams,
|
||||
DownloadErrorResponse,
|
||||
DownloadRequest,
|
||||
DownloaderStats,
|
||||
ErrorResponse,
|
||||
TokenError,
|
||||
VideoInfoResponse,
|
||||
} from "./models";
|
||||
|
||||
@@ -214,60 +216,35 @@ export function useApiDownloaderDownloadRetrieve<
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
Download video/playlist with optional quality constraints and container format conversion.
|
||||
|
||||
**For Playlists:**
|
||||
- Returns a ZIP file containing all selected videos
|
||||
- Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5])
|
||||
- If `selected_videos` is not provided, all videos in the playlist will be downloaded
|
||||
|
||||
**Quality Parameters (optional):**
|
||||
- If not specified, yt-dlp will automatically select the best available quality.
|
||||
- `video_quality`: Maximum video height in pixels (e.g., 1080, 720, 480).
|
||||
- `audio_quality`: Maximum audio bitrate in kbps (e.g., 320, 192, 128).
|
||||
|
||||
**Format/Extension:**
|
||||
- Any format supported by ffmpeg (mp4, mkv, webm, avi, mov, flv, m4a, mp3, etc.).
|
||||
- Defaults to 'mp4' if not specified.
|
||||
- The conversion is handled automatically by ffmpeg in the background.
|
||||
|
||||
**Advanced Options:**
|
||||
- `subtitles`: Download subtitles (language codes like 'en,cs' or 'all')
|
||||
- `embed_subtitles`: Embed subtitles into video file
|
||||
- `embed_thumbnail`: Embed thumbnail as cover art
|
||||
- `extract_audio`: Extract audio only (ignores video quality)
|
||||
- `cookies`: Browser cookies for age-restricted content (Netscape format)
|
||||
|
||||
* @summary Download video or playlist from URL
|
||||
* Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.
|
||||
* @summary Download file via signed token
|
||||
*/
|
||||
export const apiDownloaderDownloadCreate = (
|
||||
downloadRequest: DownloadRequest,
|
||||
params: ApiDownloaderDownloadCreateParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return publicMutator<Blob>({
|
||||
url: `/api/downloader/download/`,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: downloadRequest,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getApiDownloaderDownloadCreateMutationOptions = <
|
||||
TError = DownloadErrorResponse,
|
||||
TError = TokenError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
|
||||
TError,
|
||||
{ data: DownloadRequest },
|
||||
{ params: ApiDownloaderDownloadCreateParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
|
||||
TError,
|
||||
{ data: DownloadRequest },
|
||||
{ params: ApiDownloaderDownloadCreateParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["apiDownloaderDownloadCreate"];
|
||||
@@ -281,11 +258,11 @@ export const getApiDownloaderDownloadCreateMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
|
||||
{ data: DownloadRequest }
|
||||
{ params: ApiDownloaderDownloadCreateParams }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
const { params } = props ?? {};
|
||||
|
||||
return apiDownloaderDownloadCreate(data);
|
||||
return apiDownloaderDownloadCreate(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -294,21 +271,21 @@ export const getApiDownloaderDownloadCreateMutationOptions = <
|
||||
export type ApiDownloaderDownloadCreateMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>
|
||||
>;
|
||||
export type ApiDownloaderDownloadCreateMutationBody = DownloadRequest;
|
||||
export type ApiDownloaderDownloadCreateMutationError = DownloadErrorResponse;
|
||||
|
||||
export type ApiDownloaderDownloadCreateMutationError = TokenError;
|
||||
|
||||
/**
|
||||
* @summary Download video or playlist from URL
|
||||
* @summary Download file via signed token
|
||||
*/
|
||||
export const useApiDownloaderDownloadCreate = <
|
||||
TError = DownloadErrorResponse,
|
||||
TError = TokenError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
|
||||
TError,
|
||||
{ data: DownloadRequest },
|
||||
{ params: ApiDownloaderDownloadCreateParams },
|
||||
TContext
|
||||
>;
|
||||
},
|
||||
@@ -316,7 +293,7 @@ export const useApiDownloaderDownloadCreate = <
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
|
||||
TError,
|
||||
{ data: DownloadRequest },
|
||||
{ params: ApiDownloaderDownloadCreateParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
@@ -324,6 +301,279 @@ export const useApiDownloaderDownloadCreate = <
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
*
|
||||
Fetch detailed information about a video or playlist from supported platforms.
|
||||
|
||||
**Supported platforms:** YouTube, TikTok, Vimeo, Twitter, Instagram, Facebook, Reddit, and many more.
|
||||
|
||||
**Returns:**
|
||||
For single videos:
|
||||
- Video title, duration, and thumbnail
|
||||
- Available video qualities/resolutions
|
||||
- Available audio formats
|
||||
|
||||
For playlists:
|
||||
- Array of videos with the same info structure as single videos
|
||||
- Each video includes title, duration, thumbnail, and available qualities
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
GET /api/downloader/download/?url=https://youtube.com/watch?v=VIDEO_ID
|
||||
GET /api/downloader/download/?url=https://youtube.com/playlist?list=PLAYLIST_ID
|
||||
```
|
||||
|
||||
* @summary Get video info from URL
|
||||
*/
|
||||
export const apiDownloaderDownloadFileRetrieve = (
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return publicMutator<VideoInfoResponse>({
|
||||
url: `/api/downloader/download/file/`,
|
||||
method: "GET",
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getApiDownloaderDownloadFileRetrieveQueryKey = (
|
||||
params?: ApiDownloaderDownloadFileRetrieveParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/downloader/download/file/`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getApiDownloaderDownloadFileRetrieveQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError = ErrorResponse,
|
||||
>(
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getApiDownloaderDownloadFileRetrieveQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
|
||||
> = ({ signal }) => apiDownloaderDownloadFileRetrieve(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
};
|
||||
|
||||
export type ApiDownloaderDownloadFileRetrieveQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
|
||||
>;
|
||||
export type ApiDownloaderDownloadFileRetrieveQueryError = ErrorResponse;
|
||||
|
||||
export function useApiDownloaderDownloadFileRetrieve<
|
||||
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError = ErrorResponse,
|
||||
>(
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
options: {
|
||||
query: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useApiDownloaderDownloadFileRetrieve<
|
||||
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError = ErrorResponse,
|
||||
>(
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useApiDownloaderDownloadFileRetrieve<
|
||||
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError = ErrorResponse,
|
||||
>(
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
/**
|
||||
* @summary Get video info from URL
|
||||
*/
|
||||
|
||||
export function useApiDownloaderDownloadFileRetrieve<
|
||||
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError = ErrorResponse,
|
||||
>(
|
||||
params: ApiDownloaderDownloadFileRetrieveParams,
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
} {
|
||||
const queryOptions = getApiDownloaderDownloadFileRetrieveQueryOptions(
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||
TData,
|
||||
TError
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.
|
||||
* @summary Download file via signed token
|
||||
*/
|
||||
export const apiDownloaderDownloadFileCreate = (
|
||||
params: ApiDownloaderDownloadFileCreateParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return publicMutator<Blob>({
|
||||
url: `/api/downloader/download/file/`,
|
||||
method: "POST",
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getApiDownloaderDownloadFileCreateMutationOptions = <
|
||||
TError = TokenError,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
|
||||
TError,
|
||||
{ params: ApiDownloaderDownloadFileCreateParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
|
||||
TError,
|
||||
{ params: ApiDownloaderDownloadFileCreateParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["apiDownloaderDownloadFileCreate"];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
|
||||
{ params: ApiDownloaderDownloadFileCreateParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return apiDownloaderDownloadFileCreate(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ApiDownloaderDownloadFileCreateMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>
|
||||
>;
|
||||
|
||||
export type ApiDownloaderDownloadFileCreateMutationError = TokenError;
|
||||
|
||||
/**
|
||||
* @summary Download file via signed token
|
||||
*/
|
||||
export const useApiDownloaderDownloadFileCreate = <
|
||||
TError = TokenError,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
|
||||
TError,
|
||||
{ params: ApiDownloaderDownloadFileCreateParams },
|
||||
TContext
|
||||
>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
|
||||
TError,
|
||||
{ params: ApiDownloaderDownloadFileCreateParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getApiDownloaderDownloadFileCreateMutationOptions(options),
|
||||
queryClient,
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Vrací agregované statistiky z tabulky DownloaderRecord.
|
||||
* @summary Get aggregated downloader statistics
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.8.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export type ApiDownloaderDownloadCreateParams = {
|
||||
/**
|
||||
* Signed token containing file info
|
||||
* @minLength 1
|
||||
*/
|
||||
token: string;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.8.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export type ApiDownloaderDownloadFileCreateParams = {
|
||||
/**
|
||||
* Signed token containing file info
|
||||
* @minLength 1
|
||||
*/
|
||||
token: string;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval v8.8.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export type ApiDownloaderDownloadFileRetrieveParams = {
|
||||
/**
|
||||
* Video/Playlist URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform.
|
||||
* @minLength 1
|
||||
*/
|
||||
url: string;
|
||||
};
|
||||
@@ -4,12 +4,12 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { ShippingMethodEnum } from "./shippingMethodEnum";
|
||||
import type { State1f6Enum } from "./state1f6Enum";
|
||||
import type { StateF41Enum } from "./stateF41Enum";
|
||||
import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead";
|
||||
|
||||
export interface CarrierRead {
|
||||
readonly shipping_method: ShippingMethodEnum;
|
||||
readonly state: State1f6Enum;
|
||||
readonly state: StateF41Enum;
|
||||
readonly zasilkovna: readonly ZasilkovnaPacketRead[];
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly shipping_price: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface CartItem {
|
||||
readonly product_price: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
quantity?: number;
|
||||
readonly subtotal: string;
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface DeutschePostOrder {
|
||||
/**
|
||||
* Weight in grams
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
shipment_gross_weight: number;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface DiscountCode {
|
||||
active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
usage_limit?: number | null;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Generated by orval v8.8.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export interface DownloadRequest {
|
||||
/** Video/Playlist URL to download from supported platforms */
|
||||
url: string;
|
||||
/** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */
|
||||
ext?: string;
|
||||
/**
|
||||
* Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected.
|
||||
* @nullable
|
||||
*/
|
||||
video_quality?: number | null;
|
||||
/**
|
||||
* Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected.
|
||||
* @nullable
|
||||
*/
|
||||
audio_quality?: number | null;
|
||||
/**
|
||||
* For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded.
|
||||
* @nullable
|
||||
*/
|
||||
selected_videos?: number[] | null;
|
||||
/**
|
||||
* Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles
|
||||
* @nullable
|
||||
*/
|
||||
subtitles?: string | null;
|
||||
/** Embed subtitles into the video file (requires mkv or mp4 container) */
|
||||
embed_subtitles?: boolean;
|
||||
/** Embed thumbnail as cover art in the file */
|
||||
embed_thumbnail?: boolean;
|
||||
/** Extract audio only, ignoring video quality settings */
|
||||
extract_audio?: boolean;
|
||||
/**
|
||||
* Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'
|
||||
* @nullable
|
||||
*/
|
||||
cookies?: string | null;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export * from "./apiCommerceDiscountCodesListParams";
|
||||
export * from "./apiCommerceOrdersListParams";
|
||||
export * from "./apiCommerceProductImagesListParams";
|
||||
export * from "./apiCommerceProductsListParams";
|
||||
export * from "./apiDownloaderDownloadCreateParams";
|
||||
export * from "./apiDownloaderDownloadFileCreateParams";
|
||||
export * from "./apiDownloaderDownloadFileRetrieveParams";
|
||||
export * from "./apiDownloaderDownloadRetrieveParams";
|
||||
export * from "./apiChoicesRetrieve200";
|
||||
export * from "./apiChoicesRetrieve200Item";
|
||||
@@ -30,9 +33,7 @@ export * from "./deutschePostOrder";
|
||||
export * from "./deutschePostOrderStateEnum";
|
||||
export * from "./deutschePostTracking";
|
||||
export * from "./discountCode";
|
||||
export * from "./downloadErrorResponse";
|
||||
export * from "./downloaderStats";
|
||||
export * from "./downloadRequest";
|
||||
export * from "./errorResponse";
|
||||
export * from "./hub";
|
||||
export * from "./hubPermission";
|
||||
@@ -116,12 +117,13 @@ export * from "./reviewSerializerPublic";
|
||||
export * from "./roleEnum";
|
||||
export * from "./shippingMethodEnum";
|
||||
export * from "./siteConfiguration";
|
||||
export * from "./state1f6Enum";
|
||||
export * from "./stateCdfEnum";
|
||||
export * from "./statusD4fEnum";
|
||||
export * from "./state9b5Enum";
|
||||
export * from "./stateF41Enum";
|
||||
export * from "./status0b2Enum";
|
||||
export * from "./tagAttach";
|
||||
export * from "./tags";
|
||||
export * from "./timeseriesPoint";
|
||||
export * from "./tokenError";
|
||||
export * from "./topUrl";
|
||||
export * from "./trackingURL";
|
||||
export * from "./transferInit";
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { ShippingMethodEnum } from "./shippingMethodEnum";
|
||||
import type { State1f6Enum } from "./state1f6Enum";
|
||||
import type { StateF41Enum } from "./stateF41Enum";
|
||||
import type { ZasilkovnaPacket } from "./zasilkovnaPacket";
|
||||
|
||||
export interface OrderCarrier {
|
||||
shipping_method?: ShippingMethodEnum;
|
||||
readonly state: State1f6Enum;
|
||||
readonly state: StateF41Enum;
|
||||
readonly zasilkovna: readonly ZasilkovnaPacket[];
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly shipping_price: string;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface OrderMini {
|
||||
readonly id: number;
|
||||
readonly status: StatusD4fEnum;
|
||||
readonly status: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price: string;
|
||||
readonly created_at: Date;
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { CarrierRead } from "./carrierRead";
|
||||
import type { OrderItemRead } from "./orderItemRead";
|
||||
import type { PaymentRead } from "./paymentRead";
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface OrderRead {
|
||||
readonly id: number;
|
||||
readonly status: StatusD4fEnum;
|
||||
readonly status: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price: string;
|
||||
/** Order currency - captured from site configuration at order creation and never changes */
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder {
|
||||
/**
|
||||
* Weight in grams
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
shipment_gross_weight?: number;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface PatchedDiscountCode {
|
||||
active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
usage_limit?: number | null;
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { CarrierRead } from "./carrierRead";
|
||||
import type { OrderItemRead } from "./orderItemRead";
|
||||
import type { PaymentRead } from "./paymentRead";
|
||||
import type { StatusD4fEnum } from "./statusD4fEnum";
|
||||
import type { Status0b2Enum } from "./status0b2Enum";
|
||||
|
||||
export interface PatchedOrderRead {
|
||||
readonly id?: number;
|
||||
readonly status?: StatusD4fEnum;
|
||||
readonly status?: Status0b2Enum;
|
||||
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
|
||||
readonly total_price?: string;
|
||||
/** Order currency - captured from site configuration at order creation and never changes */
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PatchedProduct {
|
||||
url?: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
is_active?: boolean;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PatchedVATRate {
|
||||
name?: string;
|
||||
/**
|
||||
* VAT rate as percentage (e.g. 19.00 for 19%)
|
||||
* @pattern ^-?\d{0,1}(?:\.\d{0,4})?$
|
||||
* @pattern ^-?\d{0,2}(?:\.\d{0,4})?$
|
||||
*/
|
||||
rate?: string;
|
||||
/** VAT rate as decimal (e.g., 0.19 for 19%) */
|
||||
|
||||
@@ -10,8 +10,8 @@ export interface PostVote {
|
||||
post: number;
|
||||
readonly user: number;
|
||||
/**
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -32768
|
||||
* @maximum 32767
|
||||
*/
|
||||
vote: VoteEnum;
|
||||
readonly created_at: Date;
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Product {
|
||||
url: string;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
is_active?: boolean;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ProductMiniForWishlist {
|
||||
is_active?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 9223372036854776000
|
||||
* @maximum 2147483647
|
||||
*/
|
||||
stock?: number;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
* `RETURNING` - Posláno zpátky
|
||||
* `RETURNED` - Vráceno
|
||||
*/
|
||||
export type StateCdfEnum = (typeof StateCdfEnum)[keyof typeof StateCdfEnum];
|
||||
export type State9b5Enum = (typeof State9b5Enum)[keyof typeof State9b5Enum];
|
||||
|
||||
export const StateCdfEnum = {
|
||||
export const State9b5Enum = {
|
||||
WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
|
||||
PENDING: "PENDING",
|
||||
SENDED: "SENDED",
|
||||
@@ -10,9 +10,9 @@
|
||||
* `delivered` - Doručeno
|
||||
* `ready_to_pickup` - Připraveno k vyzvednutí
|
||||
*/
|
||||
export type State1f6Enum = (typeof State1f6Enum)[keyof typeof State1f6Enum];
|
||||
export type StateF41Enum = (typeof StateF41Enum)[keyof typeof StateF41Enum];
|
||||
|
||||
export const State1f6Enum = {
|
||||
export const StateF41Enum = {
|
||||
ordered: "ordered",
|
||||
shipped: "shipped",
|
||||
delivered: "delivered",
|
||||
@@ -11,9 +11,9 @@
|
||||
* `refunding` - Vrácení v procesu
|
||||
* `refunded` - Vráceno
|
||||
*/
|
||||
export type StatusD4fEnum = (typeof StatusD4fEnum)[keyof typeof StatusD4fEnum];
|
||||
export type Status0b2Enum = (typeof Status0b2Enum)[keyof typeof Status0b2Enum];
|
||||
|
||||
export const StatusD4fEnum = {
|
||||
export const Status0b2Enum = {
|
||||
created: "created",
|
||||
cancelled: "cancelled",
|
||||
completed: "completed",
|
||||
@@ -4,6 +4,6 @@
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
|
||||
export interface DownloadErrorResponse {
|
||||
export interface TokenError {
|
||||
error: string;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface VATRate {
|
||||
name: string;
|
||||
/**
|
||||
* VAT rate as percentage (e.g. 19.00 for 19%)
|
||||
* @pattern ^-?\d{0,1}(?:\.\d{0,4})?$
|
||||
* @pattern ^-?\d{0,2}(?:\.\d{0,4})?$
|
||||
*/
|
||||
rate: string;
|
||||
/** VAT rate as decimal (e.g., 0.19 for 19%) */
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StateCdfEnum } from "./stateCdfEnum";
|
||||
import type { State9b5Enum } from "./state9b5Enum";
|
||||
|
||||
export interface ZasilkovnaPacket {
|
||||
readonly id: number;
|
||||
readonly created_at: Date;
|
||||
/**
|
||||
* Číslo zásilky v Packetě (vraceno od API od Packety)
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -2147483648
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
packet_id?: number | null;
|
||||
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacket {
|
||||
* @nullable
|
||||
*/
|
||||
readonly barcode: string | null;
|
||||
readonly state: StateCdfEnum;
|
||||
readonly state: State9b5Enum;
|
||||
/** Hmotnost zásilky v gramech */
|
||||
readonly weight: number;
|
||||
/** Seznam 2 routing stringů pro vrácení zásilky */
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Do not edit manually.
|
||||
* OpenAPI spec version: 0.0.0
|
||||
*/
|
||||
import type { StateCdfEnum } from "./stateCdfEnum";
|
||||
import type { State9b5Enum } from "./state9b5Enum";
|
||||
|
||||
export interface ZasilkovnaPacketRead {
|
||||
readonly id: number;
|
||||
readonly created_at: Date;
|
||||
/**
|
||||
* Číslo zásilky v Packetě (vraceno od API od Packety)
|
||||
* @minimum -9223372036854776000
|
||||
* @maximum 9223372036854776000
|
||||
* @minimum -2147483648
|
||||
* @maximum 2147483647
|
||||
* @nullable
|
||||
*/
|
||||
packet_id?: number | null;
|
||||
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead {
|
||||
* @nullable
|
||||
*/
|
||||
readonly barcode: string | null;
|
||||
readonly state: StateCdfEnum;
|
||||
readonly state: State9b5Enum;
|
||||
/** Hmotnost zásilky v gramech */
|
||||
readonly weight: number;
|
||||
/** Seznam 2 routing stringů pro vrácení zásilky */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function Ad() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading || isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="text-4xl select-none">🎁</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<p className="font-semibold text-gray-800">
|
||||
Create a free account — download files up to{' '}
|
||||
<span className="text-blue-600 font-bold">2 GB per file!</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Registered users get higher file size limits, even better than most tools out there.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 whitespace-nowrap transition-colors"
|
||||
>
|
||||
Sign up free
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
546
frontend/src/components/downloader/Downloader.tsx
Normal file
546
frontend/src/components/downloader/Downloader.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
apiDownloaderDownloadRetrieve,
|
||||
} from '@/api/generated/public/downloader';
|
||||
import { type VideoInfoResponse } from '@/api/generated/public/models';
|
||||
import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa';
|
||||
import Ad from './Ad';
|
||||
import Statistics from './Statistics';
|
||||
import { publicApi } from '@/api/publicClient';
|
||||
|
||||
// Common file extensions supported by ffmpeg
|
||||
const FILE_EXTENSIONS = [
|
||||
{ value: 'mp4', label: 'MP4 (H.264 + AAC, most compatible)' },
|
||||
{ value: 'mkv', label: 'MKV (Flexible, lossless container)' },
|
||||
{ value: 'webm', label: 'WebM (VP9/AV1 + Opus)' },
|
||||
{ value: 'avi', label: 'AVI (Older format)' },
|
||||
{ value: 'mov', label: 'MOV (Apple-friendly)' },
|
||||
{ value: 'flv', label: 'FLV (Legacy)' },
|
||||
{ value: 'ogg', label: 'OGG (Audio/Video)' },
|
||||
];
|
||||
|
||||
export default function Downloader() {
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [videoInfo, setVideoInfo] = useState<null | VideoInfoResponse>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadStatus, setDownloadStatus] = useState<string>('');
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState<null | { error: string }>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Basic download options
|
||||
const [selectedVideoQuality, setSelectedVideoQuality] = useState<string>('');
|
||||
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
|
||||
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
|
||||
|
||||
// Playlist selection
|
||||
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
|
||||
|
||||
// Advanced options
|
||||
const [subtitles, setSubtitles] = useState<string>('');
|
||||
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
|
||||
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
|
||||
const [cookies, setCookies] = useState<string>('');
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Helper functions for playlist selection
|
||||
const toggleVideoSelection = (videoIndex: number) => {
|
||||
setSelectedVideos(prev =>
|
||||
prev.includes(videoIndex)
|
||||
? prev.filter(i => i !== videoIndex)
|
||||
: [...prev, videoIndex]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAllVideos = () => {
|
||||
if (videoInfo?.videos) {
|
||||
setSelectedVideos(videoInfo.videos.map((_, index) => index + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const deselectAllVideos = () => {
|
||||
setSelectedVideos([]);
|
||||
};
|
||||
|
||||
// Cleanup WebSocket on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const getAllVideoQualities = () => {
|
||||
if (!videoInfo?.videos) return [];
|
||||
const allQualities = new Set<string>();
|
||||
videoInfo.videos.forEach(video => {
|
||||
video.video_resolutions.forEach(quality => allQualities.add(quality));
|
||||
});
|
||||
return Array.from(allQualities).sort((a, b) => {
|
||||
const aNum = parseInt(a.replace('p', ''));
|
||||
const bNum = parseInt(b.replace('p', ''));
|
||||
return bNum - aNum;
|
||||
});
|
||||
};
|
||||
|
||||
const getAllAudioQualities = () => {
|
||||
if (!videoInfo?.videos) return [];
|
||||
const allQualities = new Set<string>();
|
||||
videoInfo.videos.forEach(video => {
|
||||
video.audio_resolutions.forEach(quality => allQualities.add(quality));
|
||||
});
|
||||
return Array.from(allQualities).sort((a, b) => {
|
||||
const aNum = parseInt(a.replace('kbps', ''));
|
||||
const bNum = parseInt(b.replace('kbps', ''));
|
||||
return bNum - aNum;
|
||||
});
|
||||
};
|
||||
|
||||
async function retrieveVideoInfo() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setVideoInfo(null);
|
||||
setSelectedVideos([]);
|
||||
try {
|
||||
const info = await apiDownloaderDownloadRetrieve({ url: videoUrl });
|
||||
setVideoInfo(info);
|
||||
// If it's a playlist, select all videos by default
|
||||
if (info.is_playlist && info.videos) {
|
||||
setSelectedVideos(info.videos.map((_, index) => index + 1));
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.error || err.message;
|
||||
setError({ error: errorMessage });
|
||||
console.error('Retrieve video info error:', err);
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (!videoUrl) {
|
||||
setError({ error: 'Please enter a URL first' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
setDownloadStatus('Connecting…');
|
||||
setDownloadProgress(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const videoQuality = selectedVideoQuality
|
||||
? parseInt(selectedVideoQuality.replace('p', ''))
|
||||
: undefined;
|
||||
const audioQuality = selectedAudioQuality
|
||||
? parseInt(selectedAudioQuality.replace('kbps', ''))
|
||||
: undefined;
|
||||
|
||||
// Connect to WebSocket
|
||||
const wsUrl = `ws://localhost:8000/ws/downloader/`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
setDownloadStatus('Starting…');
|
||||
// Send download parameters
|
||||
ws.send(JSON.stringify({
|
||||
url: videoUrl,
|
||||
ext: selectedExtension,
|
||||
video_quality: videoQuality,
|
||||
audio_quality: audioQuality,
|
||||
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
|
||||
subtitles: subtitles || null,
|
||||
embed_subtitles: embedSubtitles,
|
||||
embed_thumbnail: embedThumbnail,
|
||||
cookies: cookies || null,
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'status') {
|
||||
setDownloadStatus(data.message);
|
||||
} else if (data.type === 'progress') {
|
||||
setDownloadStatus(data.message);
|
||||
setDownloadProgress(data.percent);
|
||||
} else if (data.type === 'done') {
|
||||
|
||||
setDownloadStatus('Finalizing download...');
|
||||
setDownloadProgress(100);
|
||||
|
||||
// Přidáme responseType: 'blob', aby Axios/Fetch vrátil soubor správně
|
||||
publicApi.post('/api/downloader/download/file/',
|
||||
{ token: data.token },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
.then((response) => {
|
||||
// 1. Vytvoření dočasné URL z přijatého Blobu
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
|
||||
// 2. Extrakce názvu souboru z hlaviček (pokud ho backend posílá)
|
||||
let filename = `${data.filename || 'video'}.${selectedExtension}`;
|
||||
|
||||
// 3. Vytvoření neviditelného odkazu pro spuštění stahování
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename); // Zajišťuje stáhnutí místo otevření
|
||||
document.body.appendChild(link);
|
||||
|
||||
// 4. Simulace kliknutí
|
||||
link.click();
|
||||
|
||||
// 5. Úklid po stažení
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setDownloadStatus('Download complete!');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to download blob:', err);
|
||||
reject(new Error('Failed to retrieve the file after processing.'));
|
||||
});
|
||||
|
||||
} else if (data.type === 'error') {
|
||||
reject(new Error(data.message));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (wsRef.current === ws) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to download video';
|
||||
setError({ error: errorMessage });
|
||||
} finally {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsDownloading(false);
|
||||
setDownloadStatus('');
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full max-w-4xl mx-auto">
|
||||
<Ad />
|
||||
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FaLink className="text-gray-500" />
|
||||
<span className="text-sm font-medium">Video URL</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
|
||||
className="w-full p-3 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={retrieveVideoInfo}
|
||||
disabled={isLoading || !videoUrl}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Retrieve Options'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
|
||||
Error: {error.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoInfo && (
|
||||
<div className="mt-8 mx-4 p-6 border rounded">
|
||||
{videoInfo.is_playlist ? (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
📋 {videoInfo.playlist_title || 'Playlist'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{videoInfo.playlist_count} videos found
|
||||
</p>
|
||||
|
||||
{/* Playlist Video Selection */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={selectAllVideos}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAllVideos}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded">
|
||||
{videoInfo.videos.map((video, index) => {
|
||||
const videoNumber = index + 1;
|
||||
return (
|
||||
<div key={video.id} className="p-3 border-b last:border-b-0 hover:bg-gray-50">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVideos.includes(videoNumber)}
|
||||
onChange={() => toggleVideoSelection(videoNumber)}
|
||||
className="mt-1 w-4 h-4"
|
||||
/>
|
||||
<div className="flex gap-3 flex-1">
|
||||
{video.thumbnail && (
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
className="w-20 h-15 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{videoNumber}. {video.title}
|
||||
</div>
|
||||
{video.duration && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Quality: {video.video_resolutions.join(', ') || 'N/A'} |
|
||||
Audio: {video.audio_resolutions.join(', ') || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
{selectedVideos.length} of {videoInfo.videos.length} videos selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
🎥 {videoInfo.videos[0]?.title || 'Video'}
|
||||
</h2>
|
||||
|
||||
{videoInfo.videos[0]?.thumbnail && (
|
||||
<img
|
||||
src={videoInfo.videos[0].thumbnail}
|
||||
alt={videoInfo.videos[0].title}
|
||||
className="mt-4 w-1/3 max-w-sm rounded shadow mx-auto block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{videoInfo.videos[0]?.duration && (
|
||||
<p className="mt-4 text-gray-600 text-center">
|
||||
Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality and Format Selection */}
|
||||
<div className="mt-8 mx-2 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Video Quality Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaVideo className="text-gray-500" />
|
||||
Video Quality (optional)
|
||||
</label>
|
||||
<select
|
||||
value={selectedVideoQuality}
|
||||
onChange={(e) => setSelectedVideoQuality(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
<option value="">Best available</option>
|
||||
{getAllVideoQualities().map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Audio Quality Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaVolumeUp className="text-gray-500" />
|
||||
Audio Quality (optional)
|
||||
</label>
|
||||
<select
|
||||
value={selectedAudioQuality}
|
||||
onChange={(e) => setSelectedAudioQuality(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
<option value="">Best available</option>
|
||||
{getAllAudioQualities().map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* File Extension Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaFile className="text-gray-500" />
|
||||
File Format
|
||||
</label>
|
||||
<select
|
||||
value={selectedExtension}
|
||||
onChange={(e) => setSelectedExtension(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
{FILE_EXTENSIONS.map((ext) => (
|
||||
<option key={ext.value} value={ext.value}>
|
||||
{ext.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
|
||||
className="mt-8 mx-2 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDownloading ? downloadStatus || 'Downloading…' : (
|
||||
videoInfo.is_playlist
|
||||
? `Download Selected (${selectedVideos.length}) as ZIP`
|
||||
: 'Download Video'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDownloading && downloadStatus && (
|
||||
<div className="mt-3 mx-2">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600 mb-2">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
{downloadStatus}
|
||||
</div>
|
||||
{downloadProgress !== null && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoInfo.is_playlist && selectedVideos.length === 0 && (
|
||||
<p className="mt-4 mx-2 text-sm text-red-600">
|
||||
Please select at least one video to download
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<div className="mt-10 mx-2 border-t pt-8">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-blue-500 hover:text-blue-700 font-medium flex items-center gap-2 mx-2"
|
||||
>
|
||||
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-6 space-y-6 p-6 rounded mx-2">
|
||||
{/* Subtitles */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaFont className="text-gray-500" />
|
||||
Subtitles (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subtitles}
|
||||
onChange={(e) => setSubtitles(e.target.value)}
|
||||
placeholder="e.g., 'en', 'en,cs', or 'all'"
|
||||
className="w-full p-2 border rounded"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Language codes (e.g., 'en', 'cs') or 'all' for all available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={embedSubtitles}
|
||||
onChange={(e) => setEmbedSubtitles(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={embedThumbnail}
|
||||
onChange={(e) => setEmbedThumbnail(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Embed Thumbnail</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Cookies for Age-Restricted Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaCookie className="text-gray-500" />
|
||||
Cookies (for age-restricted content)
|
||||
</label>
|
||||
<textarea
|
||||
value={cookies}
|
||||
onChange={(e) => setCookies(e.target.value)}
|
||||
placeholder="Paste cookies in Netscape format here..."
|
||||
rows={4}
|
||||
className="w-full p-2 border rounded font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Export cookies from your browser using extensions like "Get cookies.txt" or "cookies.txt".
|
||||
Login to YouTube/Google first, then export cookies in Netscape format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Statistics />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
interface SliceData {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface PiechartProps {
|
||||
data: SliceData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||||
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
||||
];
|
||||
|
||||
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function slicePath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||
const start = polarToCartesian(cx, cy, r, startAngle);
|
||||
const end = polarToCartesian(cx, cy, r, endAngle);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`;
|
||||
}
|
||||
|
||||
export default function Piechart({ data, title }: PiechartProps) {
|
||||
const total = data.reduce((sum, d) => sum + d.count, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
let currentAngle = 0;
|
||||
const slices = data.map((d, i) => {
|
||||
const angle = (d.count / total) * 360;
|
||||
const start = currentAngle;
|
||||
const end = currentAngle + angle;
|
||||
currentAngle = end;
|
||||
return { ...d, startAngle: start, endAngle: end, color: COLORS[i % COLORS.length] };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{title && <h3 className="text-sm font-semibold text-gray-600 uppercase tracking-wide">{title}</h3>}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
<svg width="180" height="180" viewBox="0 0 200 200">
|
||||
{slices.map((s) => (
|
||||
<path
|
||||
key={s.label}
|
||||
d={slicePath(100, 100, 90, s.startAngle, s.endAngle)}
|
||||
fill={s.color}
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<title>{s.label}: {s.count}</title>
|
||||
</path>
|
||||
))}
|
||||
</svg>
|
||||
<ul className="space-y-2">
|
||||
{slices.map((s) => (
|
||||
<li key={s.label} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
<span className="text-gray-700 capitalize">{s.label}</span>
|
||||
<span className="text-gray-400 ml-1">({s.count})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useApiDownloaderStatsRetrieve } from '@/api/generated/public/downloader';
|
||||
import Piechart from './Piechart';
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null) return 'N/A';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Statistics() {
|
||||
const { data, isLoading, isError } = useApiDownloaderStatsRetrieve();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-12 border-t pt-10 text-center text-gray-400 text-sm animate-pulse">
|
||||
Loading statistics...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) return null;
|
||||
|
||||
const pieData = data.downloads_by_platform.map((p) => ({
|
||||
label: p.platform,
|
||||
count: p.count,
|
||||
}));
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Total Downloads', value: data.total_downloads.toLocaleString() },
|
||||
{ label: 'Success Rate', value: `${data.success_rate.toFixed(1)}%` },
|
||||
{ label: 'Avg File Size', value: formatBytes(data.avg_file_size) },
|
||||
{ label: 'Avg Duration', value: formatDuration(data.avg_length_of_media) },
|
||||
{ label: 'Most Common Format', value: data.most_common_format?.toUpperCase() ?? 'N/A' },
|
||||
{ label: 'Video Downloads', value: data.video_count.toLocaleString() },
|
||||
{ label: 'Audio Only', value: data.audio_only_count.toLocaleString() },
|
||||
{ label: 'Total Media Time', value: formatDuration(data.total_length_of_media) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-12 border-t pt-10">
|
||||
<h2 className="text-xl font-bold mb-6 text-gray-800">Downloader Statistics</h2>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-10">
|
||||
{statCards.map((s) => (
|
||||
<div key={s.label} className="p-4 bg-gray-50 rounded-lg border text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{s.value}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pieData.length > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<Piechart data={pieData} title="Downloads by Platform" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
export default function ChatLayout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="bg-gray-800 text-white p-4">
|
||||
<h1 className="text-xl font-bold">Chat</h1>
|
||||
</header>
|
||||
<main className="flex-1 p-4">
|
||||
nothing now
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,452 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { apiDownloaderDownloadRetrieve, apiDownloaderDownloadCreate } from '@/api/generated/public/downloader';
|
||||
import { type VideoInfoResponse, /*type VideoInfo*/ } from '@/api/generated/public/models';
|
||||
import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa';
|
||||
import Downloader from '@/components/downloader/Downloader';
|
||||
|
||||
// Common file extensions supported by ffmpeg
|
||||
const FILE_EXTENSIONS = [
|
||||
{ value: 'mp4', label: 'MP4 (H.264 + AAC, most compatible)' },
|
||||
{ value: 'mkv', label: 'MKV (Flexible, lossless container)' },
|
||||
{ value: 'webm', label: 'WebM (VP9/AV1 + Opus)' },
|
||||
{ value: 'avi', label: 'AVI (Older format)' },
|
||||
{ value: 'mov', label: 'MOV (Apple-friendly)' },
|
||||
{ value: 'flv', label: 'FLV (Legacy)' },
|
||||
{ value: 'ogg', label: 'OGG (Audio/Video)' },
|
||||
];
|
||||
|
||||
export default function Downloader() {
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [videoInfo, setVideoInfo] = useState<null | VideoInfoResponse>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [error, setError] = useState<null | { error: string }>(null);
|
||||
|
||||
// Basic download options
|
||||
const [selectedVideoQuality, setSelectedVideoQuality] = useState<string>('');
|
||||
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
|
||||
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
|
||||
|
||||
// Playlist selection
|
||||
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
|
||||
|
||||
// Advanced options (removed start_time, end_time, playlist_items)
|
||||
const [subtitles, setSubtitles] = useState<string>('');
|
||||
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
|
||||
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
|
||||
const [cookies, setCookies] = useState<string>('');
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Helper functions for playlist selection
|
||||
const toggleVideoSelection = (videoIndex: number) => {
|
||||
setSelectedVideos(prev =>
|
||||
prev.includes(videoIndex)
|
||||
? prev.filter(i => i !== videoIndex)
|
||||
: [...prev, videoIndex]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAllVideos = () => {
|
||||
if (videoInfo?.videos) {
|
||||
setSelectedVideos(videoInfo.videos.map((_, index) => index + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const deselectAllVideos = () => {
|
||||
setSelectedVideos([]);
|
||||
};
|
||||
|
||||
// Get all available qualities from all videos for consistent UI
|
||||
const getAllVideoQualities = () => {
|
||||
if (!videoInfo?.videos) return [];
|
||||
const allQualities = new Set<string>();
|
||||
videoInfo.videos.forEach(video => {
|
||||
video.video_resolutions.forEach(quality => allQualities.add(quality));
|
||||
});
|
||||
return Array.from(allQualities).sort((a, b) => {
|
||||
const aNum = parseInt(a.replace('p', ''));
|
||||
const bNum = parseInt(b.replace('p', ''));
|
||||
return bNum - aNum; // Sort descending
|
||||
});
|
||||
};
|
||||
|
||||
const getAllAudioQualities = () => {
|
||||
if (!videoInfo?.videos) return [];
|
||||
const allQualities = new Set<string>();
|
||||
videoInfo.videos.forEach(video => {
|
||||
video.audio_resolutions.forEach(quality => allQualities.add(quality));
|
||||
});
|
||||
return Array.from(allQualities).sort((a, b) => {
|
||||
const aNum = parseInt(a.replace('kbps', ''));
|
||||
const bNum = parseInt(b.replace('kbps', ''));
|
||||
return bNum - aNum; // Sort descending
|
||||
});
|
||||
};
|
||||
|
||||
async function retrieveVideoInfo() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setVideoInfo(null);
|
||||
setSelectedVideos([]); // Reset selected videos
|
||||
try {
|
||||
const info = await apiDownloaderDownloadRetrieve({ url: videoUrl });
|
||||
setVideoInfo(info);
|
||||
// If it's a playlist, select all videos by default
|
||||
if (info.is_playlist && info.videos) {
|
||||
setSelectedVideos(info.videos.map((_, index) => index + 1));
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle axios errors properly
|
||||
const errorMessage = err.response?.data?.error || err.message
|
||||
setError({ error: errorMessage });
|
||||
console.error('Retrieve video info error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (!videoUrl) {
|
||||
setError({ error: 'Please enter a URL first' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Parse quality values (remove 'p' and 'kbps' suffixes)
|
||||
const videoQuality = selectedVideoQuality
|
||||
? parseInt(selectedVideoQuality.replace('p', ''))
|
||||
: undefined;
|
||||
const audioQuality = selectedAudioQuality
|
||||
? parseInt(selectedAudioQuality.replace('kbps', ''))
|
||||
: undefined;
|
||||
|
||||
// Make the download request with all parameters
|
||||
const response = await apiDownloaderDownloadCreate({
|
||||
url: videoUrl,
|
||||
ext: selectedExtension,
|
||||
video_quality: videoQuality || null,
|
||||
audio_quality: audioQuality || null,
|
||||
// Playlist selection
|
||||
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
|
||||
// Advanced options
|
||||
subtitles: subtitles || null,
|
||||
embed_subtitles: embedSubtitles,
|
||||
embed_thumbnail: embedThumbnail,
|
||||
cookies: cookies || null,
|
||||
});
|
||||
|
||||
// The response should be a Blob, trigger download
|
||||
const blob = new Blob([response as any], { type: 'application/octet-stream' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
// Better filename based on content type
|
||||
if (videoInfo?.is_playlist) {
|
||||
const playlistTitle = videoInfo.playlist_title || 'playlist';
|
||||
a.download = `${playlistTitle.replace(/[<>:"/\\|?*]/g, '_').trim()}.zip`;
|
||||
} else {
|
||||
const title = videoInfo?.videos?.[0]?.title || 'video';
|
||||
a.download = `${title.replace(/[<>:"/\\|?*]/g, '_').trim()}.${selectedExtension}`;
|
||||
}
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.error || err.message || 'Failed to download video';
|
||||
setError({ error: errorMessage });
|
||||
console.error('Download error:', err);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FaLink className="text-gray-500" />
|
||||
<span className="text-sm font-medium">Video URL</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
|
||||
className="w-full p-3 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={retrieveVideoInfo}
|
||||
disabled={isLoading || !videoUrl}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Retrieve Options'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
|
||||
Error: {error.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoInfo && (
|
||||
<div className="mt-8 mx-4 p-6 border rounded">
|
||||
{videoInfo.is_playlist ? (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
📋 {videoInfo.playlist_title || 'Playlist'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{videoInfo.playlist_count} videos found
|
||||
</p>
|
||||
|
||||
{/* Playlist Video Selection */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={selectAllVideos}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAllVideos}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded">
|
||||
{videoInfo.videos.map((video, index) => {
|
||||
const videoNumber = index + 1;
|
||||
return (
|
||||
<div key={video.id} className="p-3 border-b last:border-b-0 hover:bg-gray-50">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVideos.includes(videoNumber)}
|
||||
onChange={() => toggleVideoSelection(videoNumber)}
|
||||
className="mt-1 w-4 h-4"
|
||||
/>
|
||||
<div className="flex gap-3 flex-1">
|
||||
{video.thumbnail && (
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
className="w-20 h-15 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{videoNumber}. {video.title}
|
||||
</div>
|
||||
{video.duration && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Quality: {video.video_resolutions.join(', ') || 'N/A'} |
|
||||
Audio: {video.audio_resolutions.join(', ') || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
{selectedVideos.length} of {videoInfo.videos.length} videos selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
🎥 {videoInfo.videos[0]?.title || 'Video'}
|
||||
</h2>
|
||||
|
||||
{videoInfo.videos[0]?.thumbnail && (
|
||||
<img
|
||||
src={videoInfo.videos[0].thumbnail}
|
||||
alt={videoInfo.videos[0].title}
|
||||
className="mt-4 w-1/3 max-w-sm rounded shadow mx-auto block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{videoInfo.videos[0]?.duration && (
|
||||
<p className="mt-4 text-gray-600 text-center">
|
||||
Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality and Format Selection */}
|
||||
<div className="mt-8 mx-2 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Video Quality Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaVideo className="text-gray-500" />
|
||||
Video Quality (optional)
|
||||
</label>
|
||||
<select
|
||||
value={selectedVideoQuality}
|
||||
onChange={(e) => setSelectedVideoQuality(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
<option value="">Best available</option>
|
||||
{getAllVideoQualities().map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Audio Quality Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaVolumeUp className="text-gray-500" />
|
||||
Audio Quality (optional)
|
||||
</label>
|
||||
<select
|
||||
value={selectedAudioQuality}
|
||||
onChange={(e) => setSelectedAudioQuality(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
<option value="">Best available</option>
|
||||
{getAllAudioQualities().map((res) => (
|
||||
<option key={res} value={res}>{res}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* File Extension Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaFile className="text-gray-500" />
|
||||
File Format
|
||||
</label>
|
||||
<select
|
||||
value={selectedExtension}
|
||||
onChange={(e) => setSelectedExtension(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
{FILE_EXTENSIONS.map((ext) => (
|
||||
<option key={ext.value} value={ext.value}>
|
||||
{ext.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
|
||||
className="mt-8 mx-2 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDownloading ? 'Downloading...' : (
|
||||
videoInfo.is_playlist
|
||||
? `Download Selected (${selectedVideos.length}) as ZIP`
|
||||
: 'Download Video'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{videoInfo.is_playlist && selectedVideos.length === 0 && (
|
||||
<p className="mt-4 mx-2 text-sm text-red-600">
|
||||
Please select at least one video to download
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<div className="mt-10 mx-2 border-t pt-8">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-blue-500 hover:text-blue-700 font-medium flex items-center gap-2 mx-2"
|
||||
>
|
||||
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-6 space-y-6 p-6 rounded mx-2">
|
||||
{/* Subtitles */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaFont className="text-gray-500" />
|
||||
Subtitles (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subtitles}
|
||||
onChange={(e) => setSubtitles(e.target.value)}
|
||||
placeholder="e.g., 'en', 'en,cs', or 'all'"
|
||||
className="w-full p-2 border rounded"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Language codes (e.g., 'en', 'cs') or 'all' for all available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={embedSubtitles}
|
||||
onChange={(e) => setEmbedSubtitles(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={embedThumbnail}
|
||||
onChange={(e) => setEmbedThumbnail(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">Embed Thumbnail</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Cookies for Age-Restricted Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FaCookie className="text-gray-500" />
|
||||
Cookies (for age-restricted content)
|
||||
</label>
|
||||
<textarea
|
||||
value={cookies}
|
||||
onChange={(e) => setCookies(e.target.value)}
|
||||
placeholder="Paste cookies in Netscape format here..."
|
||||
rows={4}
|
||||
className="w-full p-2 border rounded font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Export cookies from your browser using extensions like "Get cookies.txt" or "cookies.txt".
|
||||
Login to YouTube/Google first, then export cookies in Netscape format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default Downloader;
|
||||
Reference in New Issue
Block a user