mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 17:23:43 +08:00
feat: Introduce adult content browsing with dynamic tag management, aggregated categories, and a video content grid.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const categoryParam = searchParams.get('category') || ''; // Format: "sourceId:typeId" or just "typeId" or empty
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
|
||||
try {
|
||||
// Parse category parameter which might contain multiple sources
|
||||
// Format: "source1:id1,source2:id2" or just "typeId" (legacy)
|
||||
const sourceMap = new Map<string, string>(); // sourceId -> typeId
|
||||
|
||||
if (categoryParam) {
|
||||
const parts = categoryParam.split(',');
|
||||
parts.forEach(part => {
|
||||
if (part.includes(':')) {
|
||||
const [sId, tId] = part.split(':');
|
||||
sourceMap.set(sId, tId);
|
||||
} else {
|
||||
// Legacy format: just typeId, assume first enabled source
|
||||
const firstSource = ADULT_SOURCES.find(s => s.enabled);
|
||||
if (firstSource) {
|
||||
sourceMap.set(firstSource.id, part);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which sources to fetch
|
||||
// If specific sources requested via category, use those
|
||||
// Otherwise (e.g. "Recommend"), use all enabled sources
|
||||
let targetSources = [];
|
||||
if (sourceMap.size > 0) {
|
||||
targetSources = ADULT_SOURCES.filter(s => sourceMap.has(s.id) && s.enabled);
|
||||
} else {
|
||||
targetSources = ADULT_SOURCES.filter(s => s.enabled);
|
||||
}
|
||||
|
||||
if (targetSources.length === 0) {
|
||||
return NextResponse.json({ videos: [], error: 'No enabled sources' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Fetch from all target sources concurrently
|
||||
const fetchPromises = targetSources.map(async (source) => {
|
||||
try {
|
||||
const url = new URL(source.baseUrl);
|
||||
url.searchParams.set('ac', 'detail');
|
||||
url.searchParams.set('pg', page.toString());
|
||||
|
||||
// Set category parameter if specific type requested for this source
|
||||
if (sourceMap.has(source.id)) {
|
||||
url.searchParams.set('t', sourceMap.get(source.id)!);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
next: { revalidate: 1800 }, // Cache for 30 minutes
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
return (data.list || []).map((item: any) => ({
|
||||
vod_id: item.vod_id,
|
||||
vod_name: item.vod_name,
|
||||
vod_pic: item.vod_pic,
|
||||
vod_remarks: item.vod_remarks,
|
||||
type_name: item.type_name,
|
||||
source: source.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch from ${source.name}:`, error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(fetchPromises);
|
||||
|
||||
// Interleave results: [A1, B1, C1, A2, B2, C2, ...]
|
||||
const interleavedVideos = [];
|
||||
const maxLen = Math.max(...results.map(r => r.length));
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j][i]) {
|
||||
interleavedVideos.push(results[j][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit after interleaving
|
||||
// Note: Since we fetch 'limit' from EACH source, the total could be huge
|
||||
// We should slice the final result. However, 'limit' param usually means per page.
|
||||
// If we want consistent pagination, we should return all fetched items (limit * sources)
|
||||
// But to respect the client's requested limit roughly, we can slice.
|
||||
// Actually, for "load more" to work properly with multiple sources,
|
||||
// we should probably return everything we fetched for this "page" index.
|
||||
|
||||
return NextResponse.json({ videos: interleavedVideos });
|
||||
} catch (error) {
|
||||
console.error('Category content error:', error);
|
||||
return NextResponse.json(
|
||||
{ videos: [], error: 'Failed to fetch category content' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
|
||||
|
||||
export const revalidate = 3600; // Cache for 1 hour
|
||||
|
||||
interface Category {
|
||||
type_id: number;
|
||||
type_name: string;
|
||||
}
|
||||
|
||||
interface SourceCategories {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const enabledSources = ADULT_SOURCES.filter(s => s.enabled);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledSources.map(async (source) => {
|
||||
try {
|
||||
const url = new URL(source.baseUrl);
|
||||
url.searchParams.set('ac', 'list');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
next: { revalidate: 3600 }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
categories: data.class || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch categories from ${source.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const allTags = [];
|
||||
|
||||
// Add "Recommend" tag first
|
||||
allTags.push({
|
||||
id: 'recommend',
|
||||
label: '今日推荐',
|
||||
value: ''
|
||||
});
|
||||
|
||||
// Map to store merged categories: type_name -> Array of "sourceId:typeId"
|
||||
// Using a more complex structure to support fuzzy matching
|
||||
interface MergedCategory {
|
||||
label: string;
|
||||
values: string[];
|
||||
}
|
||||
const mergedCategories: MergedCategory[] = [];
|
||||
|
||||
// Helper to clean label for comparison (remove common noise words)
|
||||
const cleanLabel = (label: string) => {
|
||||
return label.replace(/[视频片区专]/g, '');
|
||||
};
|
||||
|
||||
// Helper to check if two labels match fuzzily (>= 2 chars overlap)
|
||||
const isFuzzyMatch = (label1: string, label2: string) => {
|
||||
const clean1 = cleanLabel(label1);
|
||||
const clean2 = cleanLabel(label2);
|
||||
|
||||
// If either is too short after cleaning, require exact match
|
||||
if (clean1.length < 2 || clean2.length < 2) {
|
||||
return clean1 === clean2;
|
||||
}
|
||||
|
||||
// Check for 2+ char overlap
|
||||
let overlapCount = 0;
|
||||
const set1 = new Set(clean1.split(''));
|
||||
for (const char of clean2) {
|
||||
if (set1.has(char)) {
|
||||
overlapCount++;
|
||||
}
|
||||
}
|
||||
return overlapCount >= 2;
|
||||
};
|
||||
|
||||
// Process results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const { sourceId, categories } = result.value;
|
||||
|
||||
categories.forEach((cat: Category) => {
|
||||
const typeName = cat.type_name.trim();
|
||||
const value = `${sourceId}:${cat.type_id}`;
|
||||
|
||||
// Try to find a fuzzy match in existing categories
|
||||
let matched = false;
|
||||
for (const existing of mergedCategories) {
|
||||
if (isFuzzyMatch(existing.label, typeName)) {
|
||||
existing.values.push(value);
|
||||
// Update label to the longer one if the new one is longer (usually more descriptive)
|
||||
// Or keep the shorter one? Let's keep the one that is "cleaner" or just the first one.
|
||||
// Let's stick to the first one for stability.
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
mergedCategories.push({
|
||||
label: typeName,
|
||||
values: [value]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Convert merged categories to tags
|
||||
mergedCategories.forEach((cat) => {
|
||||
// Create a unique ID based on the label (using base64 to be safe)
|
||||
const id = Buffer.from(cat.label).toString('base64');
|
||||
|
||||
allTags.push({
|
||||
id,
|
||||
label: cat.label,
|
||||
value: cat.values.join(',') // Join multiple sources with comma
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({ tags: allTags });
|
||||
} catch (error) {
|
||||
console.error('Failed to aggregate categories:', error);
|
||||
return NextResponse.json(
|
||||
{ tags: [], error: 'Failed to fetch categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user