mirror of
https://github.com/agregarr/agregarr.git
synced 2026-05-04 09:00:12 -05:00
fix(custom-lists): refactor validation to include SSE messaging. fix fetch title for imdb
fix #344
This commit is contained in:
+35
-45
@@ -6395,61 +6395,51 @@ paths:
|
||||
'500':
|
||||
description: Failed to generate template preview
|
||||
/collections/fetch-title:
|
||||
post:
|
||||
summary: Quick validation and title extraction from external collection URL
|
||||
description: Performs quick validation (first 10 items) and extracts the title from external collection URLs (Trakt, TMDB, IMDb, Letterboxd). Returns immediately with basic media type detection for fast UX. For comprehensive media type analysis, use the /collections/detect-media-type endpoint.
|
||||
get:
|
||||
summary: Fetch title with real-time progress updates via SSE
|
||||
description: Fetches title from external collection URL with Server-Sent Events for real-time progress updates. Particularly useful for IMDb which requires WAF challenge solving (10-20 seconds on first request).
|
||||
tags:
|
||||
- collections-utility
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
example: 'https://trakt.tv/users/username/lists/my-list'
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
trakt,
|
||||
tmdb,
|
||||
imdb,
|
||||
mdblist,
|
||||
letterboxd,
|
||||
anilist,
|
||||
myanimelist,
|
||||
]
|
||||
example: 'trakt'
|
||||
required:
|
||||
- url
|
||||
- type
|
||||
parameters:
|
||||
- in: query
|
||||
name: url
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'https://www.imdb.com/list/ls123456789/'
|
||||
description: External collection URL
|
||||
- in: query
|
||||
name: type
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [trakt, tmdb, imdb, letterboxd, mdblist, anilist]
|
||||
example: 'imdb'
|
||||
description: Collection source type
|
||||
responses:
|
||||
'200':
|
||||
description: Title fetched successfully
|
||||
description: Server-Sent Events stream with progress updates
|
||||
content:
|
||||
application/json:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: 'success'
|
||||
title:
|
||||
type: string
|
||||
example: 'My List'
|
||||
mediaType:
|
||||
type: string
|
||||
enum: [movie, tv, both]
|
||||
example: 'movie'
|
||||
type: string
|
||||
description: SSE messages with progress updates and final result
|
||||
example: |
|
||||
data: {"stage":"connecting","message":"Connecting to IMDb..."}
|
||||
|
||||
data: {"stage":"challenge","message":"Solving IMDb bot protection challenge... (first request takes 10-20 seconds)"}
|
||||
|
||||
data: {"stage":"parsing","message":"Extracting list information..."}
|
||||
|
||||
data: {"stage":"complete","message":"Complete!"}
|
||||
|
||||
data: {"status":"success","title":"My IMDb List","mediaType":"movie","contentTypes":["movies"]}
|
||||
'400':
|
||||
description: Bad request - invalid URL or unsupported type
|
||||
description: Bad request - missing or invalid parameters
|
||||
'429':
|
||||
description: Too many requests - rate limited
|
||||
'500':
|
||||
description: Failed to fetch title
|
||||
description: Internal server error
|
||||
/collections/detect-media-type:
|
||||
post:
|
||||
summary: Comprehensive media type detection from external collection URL
|
||||
|
||||
+329
-199
@@ -5,49 +5,73 @@ import {
|
||||
buildTraktRedirectUri,
|
||||
persistTraktTokens,
|
||||
} from '@server/utils/traktAuth';
|
||||
import { Router } from 'express';
|
||||
import { Router, type Response } from 'express';
|
||||
import { rateLimiter, validateExternalUrl } from './collections';
|
||||
|
||||
const fetchTitleRoutes = Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/collections/fetch-title
|
||||
* Fetch title from external collection URL
|
||||
* Helper to send SSE progress updates
|
||||
*/
|
||||
fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
function sendProgress(res: Response, stage: string, message: string) {
|
||||
res.write(`data: ${JSON.stringify({ stage, message })}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/collections/fetch-title
|
||||
* Fetch title from external collection URL with SSE progress updates
|
||||
*/
|
||||
fetchTitleRoutes.get('/', isAuthenticated(), async (req, res) => {
|
||||
const { url, type } = req.query;
|
||||
|
||||
if (!url || !type || typeof url !== 'string' || typeof type !== 'string') {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'URL and type are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Setup SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
try {
|
||||
const { url, type } = req.body;
|
||||
|
||||
if (!url || !type) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'URL and type are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check rate limiting (per user)
|
||||
const userId = req.user?.id?.toString() || req.ip || 'anonymous';
|
||||
if (!rateLimiter.isAllowed(userId)) {
|
||||
return res.status(429).json({
|
||||
status: 'error',
|
||||
message: 'Too many requests. Please wait before trying again.',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Too many requests. Please wait before trying again.',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
sendProgress(res, 'validating', 'Validating URL...');
|
||||
|
||||
// Validate and sanitize the URL
|
||||
const validation = validateExternalUrl(url, type);
|
||||
if (!validation.isValid) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: validation.error,
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: validation.error,
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (!validation.sanitizedUrl) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'URL sanitization failed',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'URL sanitization failed',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
const sanitizedUrl = validation.sanitizedUrl;
|
||||
|
||||
@@ -57,16 +81,21 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
|
||||
switch (type) {
|
||||
case 'trakt': {
|
||||
sendProgress(res, 'connecting', 'Connecting to Trakt...');
|
||||
|
||||
const TraktAPI = (await import('@server/api/trakt')).default;
|
||||
const settings = getSettings();
|
||||
|
||||
const clientId = settings.trakt.clientId || settings.trakt.apiKey;
|
||||
const redirectUri = buildTraktRedirectUri(settings, req);
|
||||
if (!clientId) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Trakt is not configured',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Trakt is not configured',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const traktClient = new TraktAPI({
|
||||
@@ -82,13 +111,15 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
// Get list metadata to extract real title, then validate with items
|
||||
try {
|
||||
// First get the real list title from metadata
|
||||
sendProgress(res, 'metadata', 'Fetching list metadata...');
|
||||
const listMetadata = await traktClient.getListMetadata(sanitizedUrl);
|
||||
title = listMetadata.name || 'Trakt List';
|
||||
|
||||
// Then validate list accessibility with first 10 items
|
||||
const listData = await traktClient.getCustomList(sanitizedUrl, 10);
|
||||
// Then validate list accessibility with first 100 items
|
||||
sendProgress(res, 'analyzing', 'Analyzing list content...');
|
||||
const listData = await traktClient.getCustomList(sanitizedUrl, 100);
|
||||
if (listData && listData.length >= 0) {
|
||||
// Quick media type detection from first 10 items
|
||||
// Comprehensive media type detection from first 100 items
|
||||
if (listData.length > 0) {
|
||||
const hasMovies = listData.some(
|
||||
(item) => item.type === 'movie' || item.movie
|
||||
@@ -113,15 +144,20 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid Trakt list URL or list not accessible',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Invalid Trakt list URL or list not accessible',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tmdb': {
|
||||
sendProgress(res, 'connecting', 'Connecting to TMDb...');
|
||||
|
||||
const TheMovieDb = (await import('@server/api/themoviedb')).default;
|
||||
const tmdbClient = new TheMovieDb();
|
||||
|
||||
@@ -141,6 +177,8 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
/themoviedb\.org\/company\/(\d+)(?:-[^/]+)?\/(movie|tv)/
|
||||
);
|
||||
|
||||
sendProgress(res, 'fetching', 'Fetching data...');
|
||||
|
||||
if (collectionMatch) {
|
||||
const collectionId = parseInt(collectionMatch[1]);
|
||||
const collection = await tmdbClient.getCollection({ collectionId });
|
||||
@@ -183,50 +221,117 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
title = company.name;
|
||||
mediaType = companyMediaType === 'movie' ? 'movie' : 'tv';
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message:
|
||||
'Invalid TMDB URL format. Expected: collection, list, network, or company URL',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message:
|
||||
'Invalid TMDB URL format. Expected: collection, list, network, or company URL',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message:
|
||||
'Invalid TMDB collection/list/network/company ID or not found',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message:
|
||||
'Invalid TMDB collection/list/network/company ID or not found',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'imdb': {
|
||||
// For IMDb, we'll need to scrape the title from the page
|
||||
const axios = (await import('axios')).default;
|
||||
sendProgress(res, 'connecting', 'Connecting to IMDb...');
|
||||
|
||||
const { ImdbAxiosClient } = await import(
|
||||
'@server/lib/collections/utils/ImdbAxiosClient'
|
||||
);
|
||||
const axios = await ImdbAxiosClient.getInstance();
|
||||
|
||||
try {
|
||||
const urlMatch = sanitizedUrl.match(/imdb\.com\/list\/(ls\d+)/);
|
||||
if (!urlMatch) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid IMDb list URL format',
|
||||
});
|
||||
const listMatch = sanitizedUrl.match(/imdb\.com\/list\/(ls\d+)/);
|
||||
const watchlistMatch = sanitizedUrl.match(
|
||||
/imdb\.com\/user\/(ur\d+)\/watchlist/
|
||||
);
|
||||
if (!listMatch && !watchlistMatch) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message:
|
||||
'Invalid IMDb URL format. Expected list or watchlist URL',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
sendProgress(
|
||||
res,
|
||||
'challenge',
|
||||
'Solving IMDb bot protection challenge... (first request takes 10-20 seconds)'
|
||||
);
|
||||
|
||||
const response = await axios.get(sanitizedUrl, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
},
|
||||
timeout: 15000,
|
||||
timeout: 30000, // Longer timeout for WAF challenge
|
||||
});
|
||||
|
||||
// Extract title from HTML
|
||||
const titleMatch = response.data.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
let extractedTitle = titleMatch[1].replace(' - IMDb', '').trim();
|
||||
sendProgress(res, 'parsing', 'Extracting list information...');
|
||||
|
||||
// Extract __NEXT_DATA__ for accurate parsing
|
||||
const nextDataMatch = response.data.match(
|
||||
/<script id="__NEXT_DATA__"[^>]*>(.*?)<\/script>/s
|
||||
);
|
||||
|
||||
if (!nextDataMatch) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Could not parse IMDb list data',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const nextData = JSON.parse(nextDataMatch[1]);
|
||||
|
||||
// Get list data (works for both lists and watchlists)
|
||||
let listData =
|
||||
nextData?.props?.pageProps?.mainColumnData?.list
|
||||
?.titleListItemSearch;
|
||||
|
||||
if (!listData || !listData.edges) {
|
||||
listData =
|
||||
nextData?.props?.pageProps?.mainColumnData?.predefinedList
|
||||
?.titleListItemSearch;
|
||||
}
|
||||
|
||||
if (!listData || !listData.edges) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Could not find list items in IMDb data',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Extract title - use list name if available, otherwise from page title
|
||||
let rawTitle =
|
||||
nextData?.props?.pageProps?.mainColumnData?.list?.name ||
|
||||
nextData?.props?.pageProps?.mainColumnData?.predefinedList?.name;
|
||||
|
||||
if (!rawTitle) {
|
||||
const titleMatch = response.data.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
rawTitle = titleMatch[1].replace(' - IMDb', '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTitle) {
|
||||
// Decode HTML entities (same as RandomListManager and Letterboxd)
|
||||
extractedTitle = extractedTitle
|
||||
title = rawTitle
|
||||
.replace(/‎/g, '') // Remove left-to-right mark
|
||||
.replace(/‏/g, '') // Remove right-to-left mark
|
||||
.replace(/•/g, '•') // Replace bullet entity with actual bullet
|
||||
@@ -239,74 +344,44 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
.replace(/&/g, '&') // Replace ampersand (do this last)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
title = extractedTitle;
|
||||
}
|
||||
|
||||
// Try to detect media type from the page content by analyzing list items
|
||||
const htmlContent = response.data;
|
||||
|
||||
// Try multiple approaches to find list items
|
||||
let listItemMatches = htmlContent.match(
|
||||
/<li[^>]*class="[^"]*ipc-metadata-list-summary-item[^"]*"[^>]*>.*?<\/li>/gs
|
||||
);
|
||||
|
||||
// If the first pattern doesn't work, try alternative patterns
|
||||
if (!listItemMatches) {
|
||||
listItemMatches =
|
||||
htmlContent.match(
|
||||
/<div[^>]*class="[^"]*titleColumn[^"]*"[^>]*>.*?<\/div>/gs
|
||||
) ||
|
||||
htmlContent.match(
|
||||
/<div[^>]*class="[^"]*list[^"]*item[^"]*"[^>]*>.*?<\/div>/gs
|
||||
) ||
|
||||
[];
|
||||
}
|
||||
|
||||
// Analyze items for media type using structured data
|
||||
let movieCount = 0;
|
||||
let showCount = 0;
|
||||
let episodeCount = 0;
|
||||
const unknownTypes: string[] = [];
|
||||
|
||||
// Analyze up to 1000 items to determine media type accurately
|
||||
listItemMatches.slice(0, 1000).forEach((item: string) => {
|
||||
// Look for title type indicators in the structured data or metadata
|
||||
const lowerItem = item.toLowerCase();
|
||||
for (const edge of listData.edges) {
|
||||
const titleTypeId = edge.listItem?.titleType?.id;
|
||||
|
||||
// Check for movie indicators
|
||||
if (
|
||||
lowerItem.includes('titletype-movie') ||
|
||||
lowerItem.includes('feature') ||
|
||||
lowerItem.includes('film') ||
|
||||
lowerItem.includes('"@type":"movie"') ||
|
||||
lowerItem.includes('(movie)') ||
|
||||
lowerItem.includes('feature film') ||
|
||||
lowerItem.includes('short film')
|
||||
) {
|
||||
if (titleTypeId === 'movie') {
|
||||
movieCount++;
|
||||
}
|
||||
// Check for episode indicators (more specific than shows)
|
||||
else if (
|
||||
lowerItem.includes('tv episode') ||
|
||||
lowerItem.includes('"@type":"episode"') ||
|
||||
lowerItem.includes('"@type":"tvepisode"') ||
|
||||
lowerItem.includes('(tv episode)') ||
|
||||
(lowerItem.includes('season') && lowerItem.includes('episode'))
|
||||
) {
|
||||
} else if (titleTypeId === 'tvEpisode') {
|
||||
episodeCount++;
|
||||
}
|
||||
// Check for TV show indicators (but not episodes)
|
||||
else if (
|
||||
lowerItem.includes('titletype-tv') ||
|
||||
lowerItem.includes('tv series') ||
|
||||
lowerItem.includes('tv mini-series') ||
|
||||
lowerItem.includes('tv movie') ||
|
||||
lowerItem.includes('"@type":"tvseries"') ||
|
||||
lowerItem.includes('(tv series)') ||
|
||||
lowerItem.includes('television')
|
||||
} else if (
|
||||
titleTypeId === 'tvSeries' ||
|
||||
titleTypeId === 'tvMiniSeries' ||
|
||||
titleTypeId === 'tvShort' ||
|
||||
titleTypeId === 'tvSpecial'
|
||||
) {
|
||||
showCount++;
|
||||
} else if (titleTypeId) {
|
||||
// Track unknown types for debugging
|
||||
if (!unknownTypes.includes(titleTypeId)) {
|
||||
unknownTypes.push(titleTypeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log unknown types for debugging
|
||||
if (unknownTypes.length > 0) {
|
||||
logger.warn('Unknown IMDb titleType IDs found', {
|
||||
label: 'Collections API',
|
||||
unknownTypes,
|
||||
url: sanitizedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine media type and content types based on what we found
|
||||
contentTypes = [];
|
||||
@@ -316,6 +391,24 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
|
||||
const totalTvContent = showCount + episodeCount;
|
||||
|
||||
logger.info('IMDb media type detection', {
|
||||
label: 'Collections API',
|
||||
url: sanitizedUrl,
|
||||
totalItems: listData.edges.length,
|
||||
movieCount,
|
||||
showCount,
|
||||
episodeCount,
|
||||
unknownTypesCount: unknownTypes.length,
|
||||
detectedMediaType:
|
||||
contentTypes.length > 1
|
||||
? 'mixed'
|
||||
: movieCount > 0 && totalTvContent === 0
|
||||
? 'movie'
|
||||
: totalTvContent > 0 && movieCount === 0
|
||||
? 'tv'
|
||||
: 'mixed/default',
|
||||
});
|
||||
|
||||
if (contentTypes.length > 1) {
|
||||
mediaType = 'mixed';
|
||||
} else if (movieCount > 0 && totalTvContent === 0) {
|
||||
@@ -325,42 +418,30 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
} else if (movieCount > 0 && totalTvContent > 0) {
|
||||
mediaType = 'mixed';
|
||||
} else {
|
||||
// Fallback: try to detect from page title or description
|
||||
const lowerContent = htmlContent.toLowerCase();
|
||||
if (
|
||||
lowerContent.includes('movie list') ||
|
||||
lowerContent.includes('film list')
|
||||
) {
|
||||
mediaType = 'movie';
|
||||
contentTypes = ['movies'];
|
||||
} else if (
|
||||
lowerContent.includes('tv list') ||
|
||||
lowerContent.includes('television list') ||
|
||||
lowerContent.includes('series list')
|
||||
) {
|
||||
mediaType = 'tv';
|
||||
contentTypes = ['shows'];
|
||||
} else {
|
||||
mediaType = 'movie'; // Default when we can't determine
|
||||
contentTypes = ['movies'];
|
||||
}
|
||||
mediaType = 'movie'; // Default when we can't determine
|
||||
contentTypes = ['movies'];
|
||||
}
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error.code === 'ECONNABORTED' || error.message?.includes('timeout');
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching IMDb list. The list page may be loading slowly. Please try again.'
|
||||
: 'Could not fetch IMDb list title. Please verify the URL is correct and the list is publicly accessible.',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching IMDb list. The list page may be loading slowly. Please try again.'
|
||||
: 'Could not fetch IMDb list title. Please verify the URL is correct and the list is publicly accessible.',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'letterboxd': {
|
||||
// For Letterboxd, we'll need to scrape the title from the page
|
||||
const axios = (await import('axios')).default;
|
||||
sendProgress(res, 'connecting', 'Connecting to Letterboxd...');
|
||||
|
||||
const letterboxdAxios = (await import('axios')).default;
|
||||
|
||||
try {
|
||||
const watchlistMatch = sanitizedUrl.match(
|
||||
@@ -374,13 +455,18 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
);
|
||||
|
||||
if (!watchlistMatch && !listMatch && !filmsMatch) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid Letterboxd URL format',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Invalid Letterboxd URL format',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const response = await axios.get(sanitizedUrl, {
|
||||
sendProgress(res, 'fetching', 'Fetching list information...');
|
||||
|
||||
const response = await letterboxdAxios.get(sanitizedUrl, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
@@ -388,6 +474,8 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
sendProgress(res, 'parsing', 'Extracting list title...');
|
||||
|
||||
if (watchlistMatch) {
|
||||
const username = watchlistMatch[1]
|
||||
.replace(/-/g, ' ')
|
||||
@@ -446,21 +534,29 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error.code === 'ECONNABORTED' || error.message?.includes('timeout');
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching Letterboxd list. The list page may be loading slowly. Please try again.'
|
||||
: 'Could not fetch Letterboxd list title. Please verify the URL is correct and the list is publicly accessible.',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching Letterboxd list'
|
||||
: 'Could not fetch Letterboxd list title',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'anilist': {
|
||||
// Fetch the AniList page and extract the HTML title
|
||||
const axios = (await import('axios')).default;
|
||||
sendProgress(res, 'connecting', 'Connecting to AniList...');
|
||||
|
||||
const anilistAxios = (await import('axios')).default;
|
||||
|
||||
try {
|
||||
const response = await axios.get(sanitizedUrl, {
|
||||
sendProgress(res, 'fetching', 'Fetching list information...');
|
||||
|
||||
const response = await anilistAxios.get(sanitizedUrl, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
@@ -468,6 +564,8 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
sendProgress(res, 'parsing', 'Extracting list title...');
|
||||
|
||||
const titleMatch = response.data.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
// Strip common suffixes like " - AniList"
|
||||
@@ -486,26 +584,33 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
} catch (error) {
|
||||
const isTimeout =
|
||||
error.code === 'ECONNABORTED' || error.message?.includes('timeout');
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching AniList list. The list page may be loading slowly. Please try again.'
|
||||
: 'Could not fetch AniList list title. Please verify the URL is correct and the list is publicly accessible.',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: isTimeout
|
||||
? 'Request timed out while fetching AniList list'
|
||||
: 'Could not fetch AniList list title',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mdblist': {
|
||||
sendProgress(res, 'connecting', 'Connecting to MDBList...');
|
||||
|
||||
const MDBListAPI = (await import('@server/api/mdblist')).default;
|
||||
const settings = getSettings();
|
||||
|
||||
if (!settings.mdblist.apiKey) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'MDBList API key not configured',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'MDBList API key not configured',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const mdblistClient = new MDBListAPI(settings.mdblist.apiKey);
|
||||
@@ -514,15 +619,20 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
// Parse URL to get username and list name
|
||||
const parsedUrl = mdblistClient.parseListUrl(sanitizedUrl);
|
||||
if (!parsedUrl) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid MDBList URL format',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Invalid MDBList URL format',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Get list metadata to extract title
|
||||
// Try two approaches: first try getting by username (for other users' public lists),
|
||||
// then fallback to getting own lists (for private lists or when username endpoint fails)
|
||||
sendProgress(res, 'fetching', 'Fetching list metadata...');
|
||||
|
||||
if (
|
||||
parsedUrl.type === 'user' &&
|
||||
parsedUrl.username &&
|
||||
@@ -571,12 +681,14 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate list accessibility and get data with first 10 items
|
||||
// Validate list accessibility and get data with first 100 items
|
||||
sendProgress(res, 'analyzing', 'Analyzing list content...');
|
||||
|
||||
const listData = await mdblistClient.getCustomList(sanitizedUrl, {
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Quick media type detection from first 10 items
|
||||
// Comprehensive media type detection from first 100 items
|
||||
const movies = listData.movies || [];
|
||||
const shows = listData.shows || [];
|
||||
|
||||
@@ -588,44 +700,62 @@ fetchTitleRoutes.post('/', isAuthenticated(), async (req, res) => {
|
||||
mediaType = 'tv';
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid MDBList list URL or list not accessible',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Invalid MDBList list URL or list not accessible',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Unsupported collection type',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Unsupported collection type',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Could not extract title from URL',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Could not extract title from URL',
|
||||
})}\n\n`
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
title: title,
|
||||
mediaType: mediaType,
|
||||
contentTypes: contentTypes,
|
||||
});
|
||||
sendProgress(res, 'complete', 'Complete!');
|
||||
|
||||
// Send final result
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'success',
|
||||
title,
|
||||
mediaType,
|
||||
contentTypes,
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
logger.error('Error fetching collection title', {
|
||||
label: 'Collections API',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'Internal server error while fetching title',
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Internal server error while fetching title',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ interface CustomUrlSectionProps {
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => Promise<void>;
|
||||
titleFetchProgress?: {
|
||||
trakt?: string;
|
||||
tmdb?: string;
|
||||
imdb?: string;
|
||||
letterboxd?: string;
|
||||
mdblist?: string;
|
||||
anilist?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const CustomUrlSection = ({
|
||||
@@ -60,6 +68,7 @@ const CustomUrlSection = ({
|
||||
fetchLetterboxdTitle,
|
||||
fetchMdblistTitle,
|
||||
fetchAnilistTitle,
|
||||
titleFetchProgress,
|
||||
}: CustomUrlSectionProps) => {
|
||||
const intl = useIntl();
|
||||
const [isLoadingTitle, setIsLoadingTitle] = useState({
|
||||
@@ -96,6 +105,9 @@ const CustomUrlSection = ({
|
||||
} else if (type === 'anilist' && fetchAnilistTitle) {
|
||||
await fetchAnilistTitle(url, setFieldValue);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error is already handled by the fetch functions (toasts shown)
|
||||
// Just silently catch here to prevent unhandled rejection
|
||||
} finally {
|
||||
setIsLoadingTitle((prev) => ({ ...prev, [type]: false }));
|
||||
}
|
||||
@@ -138,6 +150,11 @@ const CustomUrlSection = ({
|
||||
component="div"
|
||||
className="mt-1 text-sm text-red-500"
|
||||
/>
|
||||
{titleFetchProgress?.trakt && (
|
||||
<p className="mt-1 text-sm text-orange-400">
|
||||
{titleFetchProgress.trakt}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Examples: https://trakt.tv/users/username/lists/listname or
|
||||
https://trakt.tv/lists/official/jurassic-park-collection
|
||||
@@ -183,6 +200,11 @@ const CustomUrlSection = ({
|
||||
component="div"
|
||||
className="mt-1 text-sm text-red-500"
|
||||
/>
|
||||
{titleFetchProgress?.tmdb && (
|
||||
<p className="mt-1 text-sm text-orange-400">
|
||||
{titleFetchProgress.tmdb}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Examples: Collection (https://www.themoviedb.org/collection/12345),
|
||||
List (https://www.themoviedb.org/list/310), Network
|
||||
@@ -209,7 +231,7 @@ const CustomUrlSection = ({
|
||||
type="url"
|
||||
id="imdbCustomListUrl"
|
||||
name="imdbCustomListUrl"
|
||||
placeholder="https://www.imdb.com/list/ls123456789/"
|
||||
placeholder="https://www.imdb.com/list/ls123456789/ or https://www.imdb.com/user/ur12345678/watchlist"
|
||||
className="flex-1 rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
{fetchImdbTitle && (
|
||||
@@ -230,9 +252,14 @@ const CustomUrlSection = ({
|
||||
component="div"
|
||||
className="mt-1 text-sm text-red-500"
|
||||
/>
|
||||
{titleFetchProgress?.imdb && (
|
||||
<p className="mt-1 text-sm text-orange-400">
|
||||
{titleFetchProgress.imdb}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Example: https://www.imdb.com/list/ls123456789/ or
|
||||
https://www.imdb.com/user/ur12345678/lists/
|
||||
Examples: List (https://www.imdb.com/list/ls123456789/) or Watchlist
|
||||
(https://www.imdb.com/user/ur12345678/watchlist)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ interface LibrarySelectionSectionProps {
|
||||
isEnhancedForm?: boolean;
|
||||
isVisible?: boolean;
|
||||
filteredLibraries?: Library[];
|
||||
detectedMediaType?: 'movie' | 'tv' | 'both';
|
||||
detectedMediaType?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
isDetectingMediaType?: boolean;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const LibrarySelectionSection = ({
|
||||
}
|
||||
|
||||
// Show success message if both types detected
|
||||
if (detectedMediaType === 'both') {
|
||||
if (detectedMediaType === 'both' || detectedMediaType === 'mixed') {
|
||||
return {
|
||||
message: 'List contains both Movies and TV Shows.',
|
||||
type: 'success',
|
||||
|
||||
@@ -425,7 +425,7 @@ const MultiSourceConfigSection = ({
|
||||
});
|
||||
}, [sources, setFieldValue]);
|
||||
|
||||
// Validate a source URL using the existing /fetch-title endpoint
|
||||
// Validate a source URL using SSE endpoint
|
||||
const validateSourceUrl = React.useCallback(
|
||||
async (sourceId: string, url: string, type: string) => {
|
||||
if (!url?.trim()) return;
|
||||
@@ -443,50 +443,82 @@ const MultiSourceConfigSection = ({
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/collections/fetch-title', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url, type }),
|
||||
});
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=${type}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.message || `Failed to validate ${type} URL`
|
||||
);
|
||||
}
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
// Update validation state with results
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: true,
|
||||
title: data.title || null,
|
||||
mediaType: data.mediaType || null,
|
||||
contentTypes: data.contentTypes || [],
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
eventSource.close();
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
// Update validation state with error
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
title: null,
|
||||
mediaType: null,
|
||||
contentTypes: [],
|
||||
error: data.message || 'Validation failed',
|
||||
},
|
||||
}));
|
||||
eventSource.close();
|
||||
reject(new Error(data.message));
|
||||
}
|
||||
// Ignore progress messages - just wait for success/error
|
||||
} catch (parseError) {
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
title: null,
|
||||
mediaType: null,
|
||||
contentTypes: [],
|
||||
error: 'Failed to parse response',
|
||||
},
|
||||
}));
|
||||
eventSource.close();
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
// Update validation state with results
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: true,
|
||||
title: data.title || null,
|
||||
mediaType: data.mediaType || null,
|
||||
contentTypes: data.contentTypes || [],
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
// Update validation state with error
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
title: null,
|
||||
mediaType: null,
|
||||
contentTypes: [],
|
||||
error: error instanceof Error ? error.message : 'Validation failed',
|
||||
},
|
||||
}));
|
||||
}
|
||||
eventSource.onerror = () => {
|
||||
setSourceValidations((prev) => ({
|
||||
...prev,
|
||||
[sourceId]: {
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
title: null,
|
||||
mediaType: null,
|
||||
contentTypes: [],
|
||||
error: `Connection error while validating ${type} URL`,
|
||||
},
|
||||
}));
|
||||
eventSource.close();
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ interface FetchedTitles {
|
||||
}
|
||||
|
||||
interface DetectedMediaTypes {
|
||||
[key: string]: 'movie' | 'tv' | 'both' | null;
|
||||
[key: string]: 'movie' | 'tv' | 'both' | 'mixed' | null;
|
||||
}
|
||||
|
||||
interface TemplateSectionProps {
|
||||
|
||||
@@ -130,20 +130,12 @@ const CollectionFormConfigForm = ({
|
||||
}>({});
|
||||
|
||||
const [detectedMediaTypes, setDetectedMediaTypes] = useState<{
|
||||
trakt?: 'movie' | 'tv' | 'both';
|
||||
tmdb?: 'movie' | 'tv' | 'both';
|
||||
imdb?: 'movie' | 'tv' | 'both';
|
||||
letterboxd?: 'movie' | 'tv' | 'both';
|
||||
mdblist?: 'movie' | 'tv' | 'both';
|
||||
anilist?: 'movie' | 'tv' | 'both';
|
||||
}>({});
|
||||
|
||||
const [detectingMediaTypes, setDetectingMediaTypes] = useState<{
|
||||
trakt?: boolean;
|
||||
tmdb?: boolean;
|
||||
imdb?: boolean;
|
||||
letterboxd?: boolean;
|
||||
mdblist?: boolean;
|
||||
trakt?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
tmdb?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
imdb?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
letterboxd?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
mdblist?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
anilist?: 'movie' | 'tv' | 'both' | 'mixed';
|
||||
}>({});
|
||||
|
||||
const [, setFetchingTitle] = useState<{
|
||||
@@ -155,6 +147,15 @@ const CollectionFormConfigForm = ({
|
||||
anilist?: boolean;
|
||||
}>({});
|
||||
|
||||
const [titleFetchProgress, setTitleFetchProgress] = useState<{
|
||||
trakt?: string;
|
||||
tmdb?: string;
|
||||
imdb?: string;
|
||||
letterboxd?: string;
|
||||
mdblist?: string;
|
||||
anilist?: string;
|
||||
}>({});
|
||||
|
||||
// State for confirmation - MUST be before any early returns to avoid React Hooks violation
|
||||
const [unlinkConfirmState, setUnlinkConfirmState] = useState(false);
|
||||
const [linkConfirmState, setLinkConfirmState] = useState(false);
|
||||
@@ -507,8 +508,8 @@ const CollectionFormConfigForm = ({
|
||||
schema
|
||||
.required('IMDb list URL is required')
|
||||
.matches(
|
||||
/imdb\.com\/list\/ls\d+/,
|
||||
'Please enter a valid IMDb list URL (e.g., https://www.imdb.com/list/ls123456789/)'
|
||||
/(imdb\.com\/list\/ls\d+|imdb\.com\/user\/ur\d+\/watchlist)/,
|
||||
'Please enter a valid IMDb list or watchlist URL (e.g., https://www.imdb.com/list/ls123456789/ or https://www.imdb.com/user/ur12345678/watchlist)'
|
||||
),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
@@ -760,354 +761,514 @@ const CollectionFormConfigForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Comprehensive media type detection function
|
||||
const detectMediaType = async (
|
||||
url: string,
|
||||
type: 'trakt' | 'tmdb' | 'imdb' | 'letterboxd'
|
||||
) => {
|
||||
try {
|
||||
setDetectingMediaTypes((prev) => ({ ...prev, [type]: true }));
|
||||
const response = await fetch(`/api/v1/collections/detect-media-type`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({ ...prev, [type]: data.mediaType }));
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - media type detection is optional
|
||||
} finally {
|
||||
setDetectingMediaTypes((prev) => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Title fetching functions
|
||||
// Title fetching functions (SSE endpoints now handle media type detection)
|
||||
const fetchTraktTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: true }));
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: undefined }));
|
||||
|
||||
// Step 1: Quick title fetch and validation (first 10 items)
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'trakt' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch Trakt list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, trakt: data.title }));
|
||||
|
||||
// Set initial media type from first 10 items if available
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({ ...prev, trakt: data.mediaType }));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
|
||||
// Step 2: Start comprehensive media type detection in background
|
||||
setDetectingMediaTypes((prev) => ({ ...prev, trakt: true }));
|
||||
detectMediaType(url, 'trakt');
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch Trakt list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=trakt`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Success - update state and call callbacks
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, trakt: data.title }));
|
||||
|
||||
// Set initial media type from first 10 items if available
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
trakt: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
|
||||
// Note: SSE endpoint now analyzes 100 items and returns accurate media type
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: undefined }));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
// Error - show toast and cleanup
|
||||
addToast(data.message || 'Failed to fetch Trakt list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: undefined }));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
// Progress update
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: data.message }));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching Trakt list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, trakt: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, trakt: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTmdbTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: true }));
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'tmdb' }),
|
||||
});
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: undefined }));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch TMDB title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, tmdb: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({ ...prev, tmdb: data.mediaType }));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch TMDB title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=tmdb`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Success - update state and call callbacks
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, tmdb: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
tmdb: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: undefined }));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
// Error - show toast and cleanup
|
||||
addToast(data.message || 'Failed to fetch TMDB title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: undefined }));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
// Progress update
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: data.message }));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching TMDB title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, tmdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, tmdb: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchImdbTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: true }));
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: undefined }));
|
||||
|
||||
// Step 1: Quick title fetch and validation
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'imdb' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch IMDb list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, imdb: data.title }));
|
||||
|
||||
// Set initial media type if available
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({ ...prev, imdb: data.mediaType }));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Step 2: Start comprehensive media type detection in background
|
||||
detectMediaType(url, 'imdb');
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch IMDb list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=imdb`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Success - update state and call callbacks
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, imdb: data.title }));
|
||||
|
||||
// Set initial media type if available
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
imdb: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Note: SSE endpoint now analyzes 100 items and returns accurate media type
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: undefined }));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
// Error - show toast and cleanup
|
||||
addToast(data.message || 'Failed to fetch IMDb list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: undefined }));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
// Progress update
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: data.message }));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching IMDb list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, imdb: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, imdb: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchLetterboxdTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: true }));
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'letterboxd' }),
|
||||
});
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, letterboxd: undefined }));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch Letterboxd list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, letterboxd: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
letterboxd: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch Letterboxd list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=letterboxd`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, letterboxd: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
letterboxd: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: false }));
|
||||
setTitleFetchProgress((prev) => ({
|
||||
...prev,
|
||||
letterboxd: undefined,
|
||||
}));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
addToast(data.message || 'Failed to fetch Letterboxd list title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: false }));
|
||||
setTitleFetchProgress((prev) => ({
|
||||
...prev,
|
||||
letterboxd: undefined,
|
||||
}));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
setTitleFetchProgress((prev) => ({
|
||||
...prev,
|
||||
letterboxd: data.message,
|
||||
}));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, letterboxd: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching Letterboxd list title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, letterboxd: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, letterboxd: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMdblistTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: true }));
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'mdblist' }),
|
||||
});
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, mdblist: undefined }));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch MDBList title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, mdblist: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
mdblist: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch MDBList title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=mdblist`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, mdblist: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
mdblist: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, mdblist: undefined }));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
addToast(data.message || 'Failed to fetch MDBList title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, mdblist: undefined }));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
setTitleFetchProgress((prev) => ({
|
||||
...prev,
|
||||
mdblist: data.message,
|
||||
}));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, mdblist: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching MDBList title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, mdblist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, mdblist: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAnilistTitle = async (
|
||||
url: string,
|
||||
setFieldValue?: (field: string, value: string) => void
|
||||
) => {
|
||||
try {
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: true }));
|
||||
const response = await fetch(`/api/v1/collections/fetch-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, type: 'anilist' }),
|
||||
});
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: true }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, anilist: undefined }));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
addToast(errorData.message || 'Failed to fetch AniList title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, anilist: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
anilist: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-select first template option when title is fetched
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch AniList title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=anilist`
|
||||
);
|
||||
} finally {
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: false }));
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (data.title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, anilist: data.title }));
|
||||
if (data.mediaType) {
|
||||
setDetectedMediaTypes((prev) => ({
|
||||
...prev,
|
||||
anilist: data.mediaType,
|
||||
}));
|
||||
}
|
||||
|
||||
if (setFieldValue) {
|
||||
setTimeout(() => {
|
||||
setFieldValue('template', data.title);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, anilist: undefined }));
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
addToast(data.message || 'Failed to fetch AniList title', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, anilist: undefined }));
|
||||
reject(new Error(data.message));
|
||||
} else {
|
||||
setTitleFetchProgress((prev) => ({
|
||||
...prev,
|
||||
anilist: data.message,
|
||||
}));
|
||||
}
|
||||
} catch (parseError) {
|
||||
addToast('Failed to parse server response', {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, anilist: undefined }));
|
||||
reject(parseError);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
addToast(
|
||||
'Connection error while fetching AniList title. Please check your connection and try again.',
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
eventSource.close();
|
||||
setFetchingTitle((prev) => ({ ...prev, anilist: false }));
|
||||
setTitleFetchProgress((prev) => ({ ...prev, anilist: undefined }));
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// getVisibilityOptions will be defined inside the Formik render function
|
||||
@@ -2198,6 +2359,7 @@ const CollectionFormConfigForm = ({
|
||||
fetchLetterboxdTitle={fetchLetterboxdTitle}
|
||||
fetchMdblistTitle={fetchMdblistTitle}
|
||||
fetchAnilistTitle={fetchAnilistTitle}
|
||||
titleFetchProgress={titleFetchProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2278,15 +2440,7 @@ const CollectionFormConfigForm = ({
|
||||
|
||||
return undefined;
|
||||
})()}
|
||||
isDetectingMediaType={(() => {
|
||||
// Return detecting state for custom lists
|
||||
if (values.subtype === 'custom') {
|
||||
return detectingMediaTypes?.[
|
||||
values.type as keyof typeof detectingMediaTypes
|
||||
];
|
||||
}
|
||||
return false;
|
||||
})()}
|
||||
isDetectingMediaType={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ interface UseTitleFetchingReturn {
|
||||
) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
lastError: string | null;
|
||||
progressMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +289,7 @@ export const useTitleFetching = ({
|
||||
}: UseTitleFetchingOptions = {}): UseTitleFetchingReturn => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const [progressMessage, setProgressMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchTitle = useCallback(
|
||||
async (
|
||||
@@ -304,48 +306,70 @@ export const useTitleFetching = ({
|
||||
|
||||
setIsLoading(true);
|
||||
setLastError(null);
|
||||
setProgressMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/collections/fetch-title', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url, type }),
|
||||
});
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const eventSource = new EventSource(
|
||||
`/api/v1/collections/fetch-title?url=${encodeURIComponent(
|
||||
url
|
||||
)}&type=${type}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Failed to fetch ${type} title`);
|
||||
}
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
// Success - update form fields and call success callback
|
||||
if (setFieldValue && data.title) {
|
||||
setFieldValue('template', data.title);
|
||||
|
||||
if (data.title) {
|
||||
// Update form field if setFieldValue is provided
|
||||
if (setFieldValue) {
|
||||
setFieldValue('template', data.title);
|
||||
// Set detected media type if available
|
||||
if (data.mediaType) {
|
||||
setFieldValue('mediaType', data.mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
// Set detected media type if available
|
||||
if (data.mediaType) {
|
||||
setFieldValue('mediaType', data.mediaType);
|
||||
onSuccess?.(data.title, data.mediaType, data.contentTypes);
|
||||
eventSource.close();
|
||||
setIsLoading(false);
|
||||
setProgressMessage(null);
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
// Error - set error message and call error callback
|
||||
const errorMessage =
|
||||
data.message || `Failed to fetch ${type} title`;
|
||||
setLastError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
eventSource.close();
|
||||
setIsLoading(false);
|
||||
setProgressMessage(null);
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
// Progress update - update progress message
|
||||
setProgressMessage(data.message || '');
|
||||
}
|
||||
} catch (parseError) {
|
||||
const errorMessage = 'Failed to parse server response';
|
||||
setLastError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
eventSource.close();
|
||||
setIsLoading(false);
|
||||
setProgressMessage(null);
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
};
|
||||
|
||||
onSuccess?.(data.title, data.mediaType, data.contentTypes);
|
||||
} else {
|
||||
throw new Error(`No title found for ${type} URL`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to fetch ${type} title`;
|
||||
setLastError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
eventSource.onerror = () => {
|
||||
const errorMessage = 'Connection error while fetching title';
|
||||
setLastError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
eventSource.close();
|
||||
setIsLoading(false);
|
||||
setProgressMessage(null);
|
||||
reject(new Error(errorMessage));
|
||||
};
|
||||
});
|
||||
},
|
||||
[onSuccess, onError]
|
||||
);
|
||||
@@ -393,6 +417,7 @@ export const useTitleFetching = ({
|
||||
fetchMdblistTitle,
|
||||
isLoading,
|
||||
lastError,
|
||||
progressMessage,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ const customUrlValidations = {
|
||||
schema
|
||||
.required('IMDb list URL is required')
|
||||
.matches(
|
||||
/imdb\.com\/list\/ls\d+/,
|
||||
'Please enter a valid IMDb list URL (e.g., https://www.imdb.com/list/ls123456789/)'
|
||||
/(imdb\.com\/list\/ls\d+|imdb\.com\/user\/ur\d+\/watchlist)/,
|
||||
'Please enter a valid IMDb list or watchlist URL (e.g., https://www.imdb.com/list/ls123456789/ or https://www.imdb.com/user/ur12345678/watchlist)'
|
||||
),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user