ddys-nextjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/README.zh-CN.md +196 -0
- package/examples/app-router/app/api/ddys/[route]/route.ts +1 -0
- package/examples/app-router/app/api/ddys/diagnostics/route.ts +1 -0
- package/examples/app-router/app/api/ddys/request/route.ts +1 -0
- package/examples/app-router/app/api/ddys/revalidate/route.ts +1 -0
- package/examples/app-router/app/ddys/calendar/page.tsx +5 -0
- package/examples/app-router/app/ddys/collections/page.tsx +5 -0
- package/examples/app-router/app/ddys/diagnostics/page.tsx +5 -0
- package/examples/app-router/app/ddys/genres/page.tsx +5 -0
- package/examples/app-router/app/ddys/hot/page.tsx +5 -0
- package/examples/app-router/app/ddys/latest/page.tsx +5 -0
- package/examples/app-router/app/ddys/layout.tsx +13 -0
- package/examples/app-router/app/ddys/movie/[slug]/page.tsx +15 -0
- package/examples/app-router/app/ddys/movie/[slug]/sources/page.tsx +12 -0
- package/examples/app-router/app/ddys/movies/page.tsx +5 -0
- package/examples/app-router/app/ddys/regions/page.tsx +5 -0
- package/examples/app-router/app/ddys/request/page.tsx +8 -0
- package/examples/app-router/app/ddys/search/page.tsx +5 -0
- package/examples/app-router/app/ddys/shares/page.tsx +5 -0
- package/examples/app-router/app/ddys/types/page.tsx +5 -0
- package/examples/app-router/app/manifest.ts +9 -0
- package/examples/app-router/app/robots.ts +5 -0
- package/examples/app-router/app/sitemap.ts +7 -0
- package/next.config.example.mjs +9 -0
- package/package.json +105 -0
- package/public/images/icon-16.png +0 -0
- package/public/images/icon-192.png +0 -0
- package/public/images/icon-32.png +0 -0
- package/public/images/icon-512.png +0 -0
- package/public/images/logo.png +0 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/request.ts +26 -0
- package/src/actions/revalidate.ts +15 -0
- package/src/client/client.ts +194 -0
- package/src/client/config.ts +111 -0
- package/src/client/error.ts +15 -0
- package/src/client/index.ts +13 -0
- package/src/components/card.tsx +38 -0
- package/src/components/client.ts +3 -0
- package/src/components/diagnostics.tsx +35 -0
- package/src/components/grid.tsx +27 -0
- package/src/components/index.ts +8 -0
- package/src/components/movie-detail.tsx +40 -0
- package/src/components/request-form.tsx +50 -0
- package/src/components/search.tsx +41 -0
- package/src/components/sources.tsx +27 -0
- package/src/components/utils.ts +50 -0
- package/src/components/view.tsx +50 -0
- package/src/index.ts +2 -0
- package/src/metadata/index.ts +273 -0
- package/src/route-handlers/diagnostics.ts +61 -0
- package/src/route-handlers/index.ts +10 -0
- package/src/route-handlers/proxy.ts +42 -0
- package/src/route-handlers/request.ts +46 -0
- package/src/route-handlers/revalidate.ts +31 -0
- package/src/server/cache.ts +45 -0
- package/src/server/client.ts +15 -0
- package/src/server/config.ts +49 -0
- package/src/server/index.ts +11 -0
- package/src/server/request-service.ts +105 -0
- package/src/styles/ddys.css +228 -0
- package/src/types/ddys.ts +99 -0
- package/src/utils/security.ts +125 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface DdysRequestFormProps {
|
|
6
|
+
action?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
honeypotField?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DdysRequestForm({ action = '/api/ddys/request', token = '', honeypotField = 'ddys_website' }: DdysRequestFormProps) {
|
|
12
|
+
const [status, setStatus] = useState('');
|
|
13
|
+
const [error, setError] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
const form = event.currentTarget;
|
|
18
|
+
setStatus('Submitting...');
|
|
19
|
+
setError(false);
|
|
20
|
+
const response = await fetch(action, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: new FormData(form),
|
|
23
|
+
credentials: 'same-origin',
|
|
24
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
25
|
+
});
|
|
26
|
+
const json = await response.json().catch(() => ({ success: false, message: 'Invalid JSON response.' }));
|
|
27
|
+
if (json.success) {
|
|
28
|
+
setStatus('Request submitted.');
|
|
29
|
+
form.reset();
|
|
30
|
+
} else {
|
|
31
|
+
setError(true);
|
|
32
|
+
setStatus(json.message || `Request failed with HTTP ${response.status}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<form className="ddys-next-request-form" onSubmit={submit}>
|
|
38
|
+
<input type="hidden" name="ddys_token" value={token} />
|
|
39
|
+
<label className="ddys-next-honeypot">Website<input type="text" name={honeypotField} tabIndex={-1} autoComplete="off" /></label>
|
|
40
|
+
<label>Title<input type="text" name="title" maxLength={255} required /></label>
|
|
41
|
+
<label>Year<input type="number" name="year" min={1900} max={2099} /></label>
|
|
42
|
+
<label>Type<select name="type"><option value=""></option><option value="movie">Movie</option><option value="series">Series</option><option value="variety">Variety</option><option value="anime">Anime</option></select></label>
|
|
43
|
+
<label>Douban ID<input type="text" name="douban_id" maxLength={30} /></label>
|
|
44
|
+
<label>IMDb ID<input type="text" name="imdb_id" maxLength={30} /></label>
|
|
45
|
+
<label>Description<textarea name="description" maxLength={1000} /></label>
|
|
46
|
+
<button type="submit">Submit request</button>
|
|
47
|
+
<p className={`ddys-next-status${error ? ' is-error' : ''}`} role="status">{status}</p>
|
|
48
|
+
</form>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from 'react';
|
|
4
|
+
import type { DdysItem } from '../types/ddys';
|
|
5
|
+
import { DdysGrid } from './grid';
|
|
6
|
+
|
|
7
|
+
export interface DdysSearchProps {
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function DdysSearch({ endpoint = '/api/ddys/search' }: DdysSearchProps) {
|
|
12
|
+
const [items, setItems] = useState<DdysItem[]>([]);
|
|
13
|
+
const [status, setStatus] = useState('');
|
|
14
|
+
|
|
15
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
const form = event.currentTarget;
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
for (const [key, value] of new FormData(form).entries()) {
|
|
20
|
+
if (typeof value === 'string') params.set(key, value);
|
|
21
|
+
}
|
|
22
|
+
setStatus('Searching...');
|
|
23
|
+
const response = await fetch(`${endpoint}?${params.toString()}`, { credentials: 'same-origin' });
|
|
24
|
+
const json = await response.json().catch(() => ({ data: [] }));
|
|
25
|
+
const data = Array.isArray(json.data) ? json.data : Array.isArray(json.data?.data) ? json.data.data : [];
|
|
26
|
+
setItems(data);
|
|
27
|
+
setStatus(data.length ? '' : 'No DDYS content found.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="ddys-next-search-block">
|
|
32
|
+
<form className="ddys-next-search" onSubmit={submit}>
|
|
33
|
+
<input type="search" name="q" placeholder="Search DDYS" required />
|
|
34
|
+
<select name="type" defaultValue="movie"><option value="movie">Movie</option><option value="share">Share</option><option value="request">Request</option></select>
|
|
35
|
+
<button type="submit">Search</button>
|
|
36
|
+
</form>
|
|
37
|
+
{status ? <p className="ddys-next-status" role="status">{status}</p> : null}
|
|
38
|
+
{items.length ? <DdysGrid items={items} /> : null}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DdysResource } from '../types/ddys';
|
|
2
|
+
import { resourceParts } from './utils';
|
|
3
|
+
|
|
4
|
+
export interface DdysSourcesProps {
|
|
5
|
+
groups: Record<string, DdysResource[]>;
|
|
6
|
+
allowedProtocols?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DdysSources({ groups, allowedProtocols = ['http:', 'https:', 'magnet:', 'ed2k:', 'thunder:'] }: DdysSourcesProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="ddys-next-sources">
|
|
12
|
+
{Object.entries(groups).map(([name, resources]) => (
|
|
13
|
+
<section className="ddys-next-source-group" key={name}>
|
|
14
|
+
<h3>{name}</h3>
|
|
15
|
+
{resources.map((resource, index) => {
|
|
16
|
+
const links = resourceParts(resource, allowedProtocols);
|
|
17
|
+
return (
|
|
18
|
+
<p className="ddys-next-resource" key={index}>
|
|
19
|
+
{links.length ? links.map((link) => <a href={link.href} target="_blank" rel="noopener noreferrer" key={link.href}>{link.label}</a>) : 'Resource'}
|
|
20
|
+
</p>
|
|
21
|
+
);
|
|
22
|
+
})}
|
|
23
|
+
</section>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DdysItem, DdysResource } from '../types/ddys';
|
|
2
|
+
import { isAllowedResourceUrl, safeMediaUrl } from '../utils/security';
|
|
3
|
+
|
|
4
|
+
export function itemTitle(item: DdysItem): string {
|
|
5
|
+
return String(item.title || item.name || item.cn_name || item.en_name || item.username || item.search_keyword || 'Untitled');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function itemPoster(item: DdysItem): string {
|
|
9
|
+
return safeMediaUrl(item.poster || item.cover || item.image || item.avatar);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function itemSummary(item: DdysItem): string {
|
|
13
|
+
return String(item.description || item.intro || item.summary || item.note || item.content || item.bio || '').replace(/<[^>]*>/g, '').slice(0, 160);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function itemUrl(item: DdysItem, siteBaseUrl = 'https://ddys.io'): string {
|
|
17
|
+
const url = String(item.url || item.link || item.href || '');
|
|
18
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
19
|
+
if (url.startsWith('/')) return `${siteBaseUrl.replace(/\/+$/, '')}${url}`;
|
|
20
|
+
if (item.slug) return `${siteBaseUrl.replace(/\/+$/, '')}/movie/${encodeURIComponent(item.slug)}`;
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function itemMeta(item: DdysItem): string[] {
|
|
25
|
+
const meta: string[] = [];
|
|
26
|
+
for (const key of ['year', 'type', 'type_code', 'region', 'quality', 'episode', 'status', 'resource_type'] as const) {
|
|
27
|
+
const value = item[key];
|
|
28
|
+
if (Array.isArray(value)) meta.push(value.join(', '));
|
|
29
|
+
else if (value !== undefined && value !== null && value !== '') meta.push(String(value));
|
|
30
|
+
}
|
|
31
|
+
if (item.rating) meta.push(`Rating ${item.rating}`);
|
|
32
|
+
return meta;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resourceParts(resource: DdysResource, protocols: readonly string[]) {
|
|
36
|
+
const title = String(resource.title || resource.name || resource.label || resource.download_type || resource.type || resource.quality || 'Resource');
|
|
37
|
+
const raw = String(resource.url || resource.link || resource.href || '');
|
|
38
|
+
return raw.split('#').flatMap((part, index, all) => {
|
|
39
|
+
part = part.trim();
|
|
40
|
+
if (!part) return [];
|
|
41
|
+
let label = all.length > 1 ? `${title} ${index + 1}` : title;
|
|
42
|
+
let href = part;
|
|
43
|
+
if (part.includes('$')) {
|
|
44
|
+
const pieces = part.split('$');
|
|
45
|
+
label = pieces[0] || title;
|
|
46
|
+
href = pieces.slice(1).join('$');
|
|
47
|
+
}
|
|
48
|
+
return isAllowedResourceUrl(href, protocols) ? [{ label, href }] : [];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DdysDisplayOptions, DdysItem } from '../types/ddys';
|
|
2
|
+
import { createDdysServerClient } from '../server/client';
|
|
3
|
+
import { nextFetchOptions } from '../server/cache';
|
|
4
|
+
import type { DdysConfigInput } from '../client/config';
|
|
5
|
+
import { DdysGrid } from './grid';
|
|
6
|
+
import { DdysMovieDetail } from './movie-detail';
|
|
7
|
+
|
|
8
|
+
export interface DdysViewProps {
|
|
9
|
+
view: string;
|
|
10
|
+
params?: Record<string, string | number | undefined>;
|
|
11
|
+
display?: DdysDisplayOptions;
|
|
12
|
+
config?: DdysConfigInput;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function DdysView({ view, params = {}, display, config }: DdysViewProps) {
|
|
16
|
+
const client = createDdysServerClient(config);
|
|
17
|
+
switch (view) {
|
|
18
|
+
case 'latest': return <DdysGrid items={await asItems(client.latest(params, nextFetchOptions('/latest', client.config)))} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
19
|
+
case 'hot': return <DdysGrid items={await asItems(client.hot(params, nextFetchOptions('/hot', client.config)))} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
20
|
+
case 'movies': return <DdysGrid items={(await client.movies(params, nextFetchOptions('/movies', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
21
|
+
case 'search': return <DdysGrid items={(await client.search(params, nextFetchOptions('/search', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
22
|
+
case 'collections': return <DdysGrid items={(await client.collections(params, nextFetchOptions('/collections', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
23
|
+
case 'shares': return <DdysGrid items={(await client.shares(params, nextFetchOptions('/shares', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
24
|
+
case 'requests': return <DdysGrid items={(await client.requests(params, nextFetchOptions('/requests', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
25
|
+
case 'activities': return <DdysGrid items={(await client.activities(params, nextFetchOptions('/activities', client.config))).data as DdysItem[]} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
26
|
+
case 'movie': return <DdysMovieDetail movie={await client.movie(String(params.slug || ''), nextFetchOptions(`/movies/${params.slug}`, client.config)) as DdysItem} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
27
|
+
case 'types': return <DdysGrid items={dictionary(await client.types(nextFetchOptions('/types', client.config)))} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
28
|
+
case 'genres': return <DdysGrid items={dictionary(await client.genres(nextFetchOptions('/genres', client.config)))} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
29
|
+
case 'regions': return <DdysGrid items={dictionary(await client.regions(nextFetchOptions('/regions', client.config)))} display={display} siteBaseUrl={client.config.siteBaseUrl} />;
|
|
30
|
+
default: return <div className="ddys-next-empty">Unsupported DDYS view.</div>;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function asItems(payload: Promise<unknown>): Promise<DdysItem[]> {
|
|
35
|
+
const data = await payload;
|
|
36
|
+
if (Array.isArray(data)) return data as DdysItem[];
|
|
37
|
+
if (data && typeof data === 'object' && Array.isArray((data as { data?: unknown }).data)) return (data as { data: DdysItem[] }).data;
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dictionary(payload: unknown): DdysItem[] {
|
|
42
|
+
if (Array.isArray(payload)) return payload.map((item) => typeof item === 'object' ? item as DdysItem : { title: String(item) });
|
|
43
|
+
if (payload && typeof payload === 'object') {
|
|
44
|
+
return Object.entries(payload as Record<string, unknown>).map(([code, value]) => {
|
|
45
|
+
if (value && typeof value === 'object') return { code, ...(value as Record<string, unknown>) } as DdysItem;
|
|
46
|
+
return { title: String(value), code } as DdysItem;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import type { Metadata, MetadataRoute } from 'next';
|
|
4
|
+
import type { DdysConfig, DdysConfigInput } from '../client/config';
|
|
5
|
+
import type { DdysItem } from '../types/ddys';
|
|
6
|
+
import { createDdysServerClient } from '../server/client';
|
|
7
|
+
import { nextFetchOptions } from '../server/cache';
|
|
8
|
+
import { getDdysConfigFromEnv } from '../server/config';
|
|
9
|
+
import { itemPoster, itemSummary, itemTitle, itemUrl } from '../components/utils';
|
|
10
|
+
|
|
11
|
+
type SitemapEntry = MetadataRoute.Sitemap[number];
|
|
12
|
+
type SitemapChangeFrequency = NonNullable<SitemapEntry['changeFrequency']>;
|
|
13
|
+
|
|
14
|
+
export interface DdysMetadataOptions {
|
|
15
|
+
config?: DdysConfigInput;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
path?: string;
|
|
19
|
+
siteName?: string;
|
|
20
|
+
titleTemplate?: string;
|
|
21
|
+
images?: string[];
|
|
22
|
+
robots?: Metadata['robots'];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DdysMovieMetadataOptions extends DdysMetadataOptions {
|
|
26
|
+
fallbackTitle?: string;
|
|
27
|
+
fallbackDescription?: string;
|
|
28
|
+
fallbackImage?: string;
|
|
29
|
+
throwOnError?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DdysSitemapOptions {
|
|
33
|
+
config?: DdysConfigInput;
|
|
34
|
+
basePath?: string;
|
|
35
|
+
staticPaths?: string[];
|
|
36
|
+
includeLatest?: boolean;
|
|
37
|
+
latestLimit?: number;
|
|
38
|
+
changeFrequency?: SitemapChangeFrequency;
|
|
39
|
+
priority?: number;
|
|
40
|
+
moviePriority?: number;
|
|
41
|
+
throwOnError?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DdysRobotsOptions {
|
|
45
|
+
config?: DdysConfigInput;
|
|
46
|
+
userAgent?: string | string[];
|
|
47
|
+
allow?: string | string[];
|
|
48
|
+
disallow?: string | string[];
|
|
49
|
+
sitemap?: string | string[];
|
|
50
|
+
host?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DdysManifestOptions {
|
|
54
|
+
config?: DdysConfigInput;
|
|
55
|
+
name?: string;
|
|
56
|
+
shortName?: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
startUrl?: string;
|
|
59
|
+
scope?: string;
|
|
60
|
+
display?: MetadataRoute.Manifest['display'];
|
|
61
|
+
themeColor?: string;
|
|
62
|
+
backgroundColor?: string;
|
|
63
|
+
iconBasePath?: string;
|
|
64
|
+
icons?: MetadataRoute.Manifest['icons'];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createDdysMetadata(options: DdysMetadataOptions = {}): Metadata {
|
|
68
|
+
return metadataFromConfig(getDdysConfigFromEnv(options.config), options);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createDdysMovieMetadata(slug: string, options: DdysMovieMetadataOptions = {}): Promise<Metadata> {
|
|
72
|
+
const client = createDdysServerClient(options.config);
|
|
73
|
+
const encodedSlug = encodeURIComponent(String(slug));
|
|
74
|
+
const path = options.path ?? `/ddys/movie/${encodedSlug}`;
|
|
75
|
+
try {
|
|
76
|
+
const movie = await client.movie(slug, nextFetchOptions(`/movies/${encodedSlug}`, client.config)) as DdysItem;
|
|
77
|
+
const title = itemTitle(movie);
|
|
78
|
+
const description = itemSummary(movie) || options.description || `${title} - DDYS`;
|
|
79
|
+
const poster = itemPoster(movie) || options.fallbackImage;
|
|
80
|
+
const url = options.path || itemUrl(movie, client.config.siteBaseUrl) || path;
|
|
81
|
+
return metadataFromConfig(client.config, {
|
|
82
|
+
...options,
|
|
83
|
+
title,
|
|
84
|
+
description,
|
|
85
|
+
path: url,
|
|
86
|
+
images: poster ? [poster, ...(options.images ?? [])] : options.images
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (options.throwOnError) throw error;
|
|
90
|
+
return metadataFromConfig(client.config, {
|
|
91
|
+
...options,
|
|
92
|
+
title: options.fallbackTitle ?? options.title ?? 'DDYS Movie',
|
|
93
|
+
description: options.fallbackDescription ?? options.description ?? 'DDYS movie details.',
|
|
94
|
+
path,
|
|
95
|
+
images: options.fallbackImage ? [options.fallbackImage, ...(options.images ?? [])] : options.images
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function createDdysSitemap(options: DdysSitemapOptions = {}): Promise<MetadataRoute.Sitemap> {
|
|
101
|
+
const client = createDdysServerClient(options.config);
|
|
102
|
+
const config = client.config;
|
|
103
|
+
const basePath = normalizePath(options.basePath ?? '/ddys');
|
|
104
|
+
const now = new Date();
|
|
105
|
+
const staticPaths = options.staticPaths ?? [
|
|
106
|
+
basePath,
|
|
107
|
+
joinPath(basePath, 'latest'),
|
|
108
|
+
joinPath(basePath, 'hot'),
|
|
109
|
+
joinPath(basePath, 'movies'),
|
|
110
|
+
joinPath(basePath, 'search'),
|
|
111
|
+
joinPath(basePath, 'calendar'),
|
|
112
|
+
joinPath(basePath, 'collections'),
|
|
113
|
+
joinPath(basePath, 'shares'),
|
|
114
|
+
joinPath(basePath, 'request')
|
|
115
|
+
];
|
|
116
|
+
const entries: MetadataRoute.Sitemap = staticPaths.flatMap((path) => sitemapEntry(config.siteBaseUrl, path, now, options.changeFrequency ?? 'hourly', options.priority ?? 0.7));
|
|
117
|
+
|
|
118
|
+
if (options.includeLatest !== false) {
|
|
119
|
+
try {
|
|
120
|
+
const latest = await client.latest({ limit: options.latestLimit ?? 24 }, nextFetchOptions('/latest', config));
|
|
121
|
+
for (const item of asItems(latest)) {
|
|
122
|
+
if (!item.slug) continue;
|
|
123
|
+
entries.push(...sitemapEntry(config.siteBaseUrl, joinPath(basePath, `movie/${encodeURIComponent(item.slug)}`), now, 'daily', options.moviePriority ?? 0.8));
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (options.throwOnError) throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return dedupeSitemap(entries);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createDdysRobots(options: DdysRobotsOptions = {}): MetadataRoute.Robots {
|
|
134
|
+
const config = getDdysConfigFromEnv(options.config);
|
|
135
|
+
return {
|
|
136
|
+
rules: {
|
|
137
|
+
userAgent: options.userAgent ?? '*',
|
|
138
|
+
allow: options.allow ?? '/',
|
|
139
|
+
disallow: options.disallow ?? ['/api/ddys/diagnostics', '/api/ddys/revalidate']
|
|
140
|
+
},
|
|
141
|
+
sitemap: options.sitemap ?? absoluteUrl(config.siteBaseUrl, '/sitemap.xml'),
|
|
142
|
+
host: options.host ?? config.siteBaseUrl
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createDdysManifest(options: DdysManifestOptions = {}): MetadataRoute.Manifest {
|
|
147
|
+
const config = getDdysConfigFromEnv(options.config);
|
|
148
|
+
const iconBasePath = normalizePath(options.iconBasePath ?? '/images');
|
|
149
|
+
return {
|
|
150
|
+
name: options.name ?? 'DDYS',
|
|
151
|
+
short_name: options.shortName ?? 'DDYS',
|
|
152
|
+
description: options.description ?? 'DDYS API powered movie and video experience.',
|
|
153
|
+
start_url: options.startUrl ?? '/ddys',
|
|
154
|
+
scope: options.scope ?? '/',
|
|
155
|
+
display: options.display ?? 'standalone',
|
|
156
|
+
background_color: options.backgroundColor ?? '#0f172a',
|
|
157
|
+
theme_color: options.themeColor ?? '#0f172a',
|
|
158
|
+
icons: options.icons ?? [
|
|
159
|
+
{ src: joinPath(iconBasePath, 'icon-192.png'), sizes: '192x192', type: 'image/png' },
|
|
160
|
+
{ src: joinPath(iconBasePath, 'icon-512.png'), sizes: '512x512', type: 'image/png' }
|
|
161
|
+
]
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createDdysMovieJsonLd(movie: DdysItem, configInput: DdysConfigInput = {}) {
|
|
166
|
+
const config = getDdysConfigFromEnv(configInput);
|
|
167
|
+
const image = itemPoster(movie);
|
|
168
|
+
const url = itemUrl(movie, config.siteBaseUrl);
|
|
169
|
+
return stripEmpty({
|
|
170
|
+
'@context': 'https://schema.org',
|
|
171
|
+
'@type': 'Movie',
|
|
172
|
+
name: itemTitle(movie),
|
|
173
|
+
description: itemSummary(movie),
|
|
174
|
+
image,
|
|
175
|
+
url,
|
|
176
|
+
datePublished: movie.year ? String(movie.year) : undefined,
|
|
177
|
+
genre: Array.isArray(movie.genre) ? movie.genre.join(', ') : movie.genre,
|
|
178
|
+
countryOfOrigin: Array.isArray(movie.region) ? movie.region.join(', ') : movie.region,
|
|
179
|
+
aggregateRating: movie.rating ? {
|
|
180
|
+
'@type': 'AggregateRating',
|
|
181
|
+
ratingValue: String(movie.rating),
|
|
182
|
+
bestRating: '10'
|
|
183
|
+
} : undefined
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function metadataFromConfig(config: DdysConfig, options: DdysMetadataOptions): Metadata {
|
|
188
|
+
const siteName = options.siteName ?? 'DDYS';
|
|
189
|
+
const title = options.title ?? siteName;
|
|
190
|
+
const description = options.description ?? 'DDYS API powered movie and video experience.';
|
|
191
|
+
const canonical = absoluteUrl(config.siteBaseUrl, options.path ?? '/ddys');
|
|
192
|
+
const images = normalizeImages(config.siteBaseUrl, options.images);
|
|
193
|
+
return {
|
|
194
|
+
metadataBase: new URL(config.siteBaseUrl),
|
|
195
|
+
title: {
|
|
196
|
+
default: title,
|
|
197
|
+
template: options.titleTemplate ?? `%s | ${siteName}`
|
|
198
|
+
},
|
|
199
|
+
applicationName: siteName,
|
|
200
|
+
description,
|
|
201
|
+
alternates: canonical ? { canonical } : undefined,
|
|
202
|
+
openGraph: {
|
|
203
|
+
title,
|
|
204
|
+
description,
|
|
205
|
+
url: canonical,
|
|
206
|
+
siteName,
|
|
207
|
+
type: 'website',
|
|
208
|
+
images
|
|
209
|
+
},
|
|
210
|
+
twitter: {
|
|
211
|
+
card: images.length ? 'summary_large_image' : 'summary',
|
|
212
|
+
title,
|
|
213
|
+
description,
|
|
214
|
+
images
|
|
215
|
+
},
|
|
216
|
+
robots: options.robots ?? {
|
|
217
|
+
index: true,
|
|
218
|
+
follow: true
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeImages(baseUrl: string, images: string[] = []) {
|
|
224
|
+
return images.flatMap((image) => {
|
|
225
|
+
const url = absoluteUrl(baseUrl, image);
|
|
226
|
+
return url ? [url] : [];
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sitemapEntry(baseUrl: string, path: string, lastModified: Date, changeFrequency: SitemapChangeFrequency, priority: number): MetadataRoute.Sitemap {
|
|
231
|
+
const url = absoluteUrl(baseUrl, path);
|
|
232
|
+
return url ? [{ url, lastModified, changeFrequency, priority }] : [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function dedupeSitemap(entries: MetadataRoute.Sitemap): MetadataRoute.Sitemap {
|
|
236
|
+
const seen = new Set<string>();
|
|
237
|
+
return entries.filter((entry) => {
|
|
238
|
+
if (seen.has(entry.url)) return false;
|
|
239
|
+
seen.add(entry.url);
|
|
240
|
+
return true;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function asItems(payload: unknown): DdysItem[] {
|
|
245
|
+
if (Array.isArray(payload)) return payload as DdysItem[];
|
|
246
|
+
if (payload && typeof payload === 'object' && Array.isArray((payload as { data?: unknown }).data)) return (payload as { data: DdysItem[] }).data;
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function absoluteUrl(baseUrl: string, value: string): string | undefined {
|
|
251
|
+
const text = String(value || '').trim();
|
|
252
|
+
if (!text) return undefined;
|
|
253
|
+
try {
|
|
254
|
+
return new URL(text, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizePath(path: string): string {
|
|
261
|
+
const clean = String(path || '/').trim();
|
|
262
|
+
return clean.startsWith('/') ? clean.replace(/\/+$/, '') || '/' : `/${clean.replace(/\/+$/, '')}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function joinPath(basePath: string, segment: string): string {
|
|
266
|
+
const base = normalizePath(basePath);
|
|
267
|
+
const cleanSegment = String(segment || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
268
|
+
return cleanSegment ? `${base === '/' ? '' : base}/${cleanSegment}` : base;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function stripEmpty(input: Record<string, unknown>) {
|
|
272
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
|
273
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DDYS_VERSION, type DdysConfigInput } from '../client/config';
|
|
2
|
+
import { DdysError } from '../client/error';
|
|
3
|
+
import { createDdysServerClient } from '../server/client';
|
|
4
|
+
import { getDdysConfigFromEnv, safeDdysConfig } from '../server/config';
|
|
5
|
+
|
|
6
|
+
export interface DdysDiagnosticsRouteOptions {
|
|
7
|
+
config?: DdysConfigInput;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createDdysDiagnosticsRouteHandler(options: DdysDiagnosticsRouteOptions = {}) {
|
|
11
|
+
return async function GET() {
|
|
12
|
+
const config = getDdysConfigFromEnv(options.config);
|
|
13
|
+
if (!config.diagnostics.enabled) {
|
|
14
|
+
return Response.json({ success: false, message: 'DDYS diagnostics is disabled.' }, { status: 403 });
|
|
15
|
+
}
|
|
16
|
+
return Response.json({
|
|
17
|
+
success: true,
|
|
18
|
+
data: {
|
|
19
|
+
version: DDYS_VERSION,
|
|
20
|
+
runtime: typeof EdgeRuntime === 'string' ? 'edge' : 'node',
|
|
21
|
+
next: 'app-router',
|
|
22
|
+
config: safeDdysConfig(config),
|
|
23
|
+
views: [
|
|
24
|
+
'movies', 'latest', 'hot', 'search', 'suggest', 'calendar',
|
|
25
|
+
'movie', 'sources', 'related', 'comments',
|
|
26
|
+
'collections', 'collection', 'shares', 'share',
|
|
27
|
+
'requests', 'activities', 'user', 'types', 'genres', 'regions'
|
|
28
|
+
],
|
|
29
|
+
routeHandlers: [
|
|
30
|
+
'/api/ddys/[route]',
|
|
31
|
+
'/api/ddys/request',
|
|
32
|
+
'/api/ddys/diagnostics',
|
|
33
|
+
'/api/ddys/revalidate'
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDdysDiagnosticsTestRouteHandler(options: DdysDiagnosticsRouteOptions = {}) {
|
|
41
|
+
return async function POST() {
|
|
42
|
+
const config = getDdysConfigFromEnv(options.config);
|
|
43
|
+
if (!config.diagnostics.enabled) {
|
|
44
|
+
return Response.json({ success: false, message: 'DDYS diagnostics is disabled.' }, { status: 403 });
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const payload = await createDdysServerClient(options.config).get('/latest', { limit: 1 }, { noCache: true });
|
|
48
|
+
return Response.json({ success: true, data: payload });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return Response.json({
|
|
51
|
+
success: false,
|
|
52
|
+
message: error instanceof DdysError ? error.message : 'DDYS diagnostics test failed.'
|
|
53
|
+
}, { status: 500 });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare const EdgeRuntime: string | undefined;
|
|
59
|
+
|
|
60
|
+
export const ddysDiagnosticsGET = createDdysDiagnosticsRouteHandler();
|
|
61
|
+
export const ddysDiagnosticsTestPOST = createDdysDiagnosticsTestRouteHandler();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createDdysProxyRouteHandler, ddysProxyGET, type DdysProxyRouteOptions } from './proxy';
|
|
2
|
+
export { createDdysRequestRouteHandler, ddysRequestPOST, type DdysRequestRouteOptions } from './request';
|
|
3
|
+
export {
|
|
4
|
+
createDdysDiagnosticsRouteHandler,
|
|
5
|
+
createDdysDiagnosticsTestRouteHandler,
|
|
6
|
+
ddysDiagnosticsGET,
|
|
7
|
+
ddysDiagnosticsTestPOST,
|
|
8
|
+
type DdysDiagnosticsRouteOptions
|
|
9
|
+
} from './diagnostics';
|
|
10
|
+
export { createDdysRevalidateRouteHandler, ddysRevalidatePOST, type DdysRevalidateRouteOptions } from './revalidate';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DdysError } from '../client/error';
|
|
2
|
+
import type { DdysConfigInput } from '../client/config';
|
|
3
|
+
import type { DdysRouteHandlerContext } from '../types/ddys';
|
|
4
|
+
import { createDdysServerClient } from '../server/client';
|
|
5
|
+
|
|
6
|
+
export interface DdysProxyRouteOptions {
|
|
7
|
+
config?: DdysConfigInput;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createDdysProxyRouteHandler(options: DdysProxyRouteOptions = {}) {
|
|
11
|
+
return async function GET(request: Request, context: DdysRouteHandlerContext = {}) {
|
|
12
|
+
const params = await context.params;
|
|
13
|
+
const route = params?.route ?? new URL(request.url).searchParams.get('route') ?? '';
|
|
14
|
+
const query = Object.fromEntries(new URL(request.url).searchParams.entries());
|
|
15
|
+
try {
|
|
16
|
+
const client = createDdysServerClient(options.config);
|
|
17
|
+
if (!client.config.proxy.enabled) return json({ success: false, message: 'DDYS proxy is disabled.' }, 404);
|
|
18
|
+
const payload = await client.proxy(route, query);
|
|
19
|
+
return json(payload);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return errorJson(error);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ddysProxyGET = createDdysProxyRouteHandler();
|
|
27
|
+
|
|
28
|
+
function json(payload: unknown, status = 200) {
|
|
29
|
+
return Response.json(payload, {
|
|
30
|
+
status,
|
|
31
|
+
headers: {
|
|
32
|
+
'Cache-Control': 'private, no-store'
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorJson(error: unknown) {
|
|
38
|
+
if (error instanceof DdysError) {
|
|
39
|
+
return json({ success: false, message: error.message, status: error.status }, error.status >= 400 ? error.status : 500);
|
|
40
|
+
}
|
|
41
|
+
return json({ success: false, message: error instanceof Error ? error.message : 'DDYS proxy failed.' }, 500);
|
|
42
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DdysConfigInput } from '../client/config';
|
|
2
|
+
import { getDdysConfigFromEnv } from '../server/config';
|
|
3
|
+
import { submitDdysRequest } from '../server/request-service';
|
|
4
|
+
import { formDataToObject } from '../utils/security';
|
|
5
|
+
|
|
6
|
+
export interface DdysRequestRouteOptions {
|
|
7
|
+
config?: DdysConfigInput;
|
|
8
|
+
identity?: (request: Request) => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createDdysRequestRouteHandler(options: DdysRequestRouteOptions = {}) {
|
|
12
|
+
return async function POST(request: Request) {
|
|
13
|
+
const config = getDdysConfigFromEnv(options.config);
|
|
14
|
+
const formData = await request.formData();
|
|
15
|
+
const input = formDataToObject(formData);
|
|
16
|
+
const identity = options.identity?.(request) ?? identityFromRequest(request);
|
|
17
|
+
try {
|
|
18
|
+
const payload = await submitDdysRequest(input, config, {
|
|
19
|
+
identity,
|
|
20
|
+
token: input.ddys_token
|
|
21
|
+
});
|
|
22
|
+
return Response.json({ success: true, data: payload });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return Response.json({
|
|
25
|
+
success: false,
|
|
26
|
+
message: error instanceof Error ? error.message : 'DDYS request submission failed.'
|
|
27
|
+
}, { status: statusFor(error) });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ddysRequestPOST = createDdysRequestRouteHandler();
|
|
33
|
+
|
|
34
|
+
function identityFromRequest(request: Request): string {
|
|
35
|
+
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
36
|
+
const realIp = request.headers.get('x-real-ip');
|
|
37
|
+
return forwarded || realIp || 'anonymous';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function statusFor(error: unknown): number {
|
|
41
|
+
if (!(error instanceof Error)) return 500;
|
|
42
|
+
if (/disabled/i.test(error.message)) return 403;
|
|
43
|
+
if (/token|submission|invalid/i.test(error.message)) return 400;
|
|
44
|
+
if (/too many/i.test(error.message)) return 429;
|
|
45
|
+
return 500;
|
|
46
|
+
}
|