feat: Introduce adult content browsing with dynamic tag management, aggregated categories, and a video content grid.

This commit is contained in:
kuekhaoyang
2025-11-29 22:39:45 +08:00
parent 7489ff6887
commit da36da655c
8 changed files with 702 additions and 7 deletions
+117
View File
@@ -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 }
);
}
}
+152
View File
@@ -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 }
);
}
}