fix: adds global networks

This commit is contained in:
Tom Wheeler
2025-09-08 00:15:07 +12:00
parent 51976d4686
commit bda6bf5df6
9 changed files with 459 additions and 41 deletions

View File

@@ -91,13 +91,13 @@ class FlixPatrolAPI extends ExternalAPI {
*/
public async getPlatformTop10(
platform: string,
region = 'world',
region = 'global',
requestedMediaType?: 'movie' | 'tv' | 'both'
): Promise<FlixPatrolPlatformData> {
try {
// Construct URL based on region
let url: string;
if (region === 'world' || region === 'global') {
if (region === 'global') {
url = '/top10';
} else {
// Use current date for streaming overview
@@ -258,8 +258,8 @@ class FlixPatrolAPI extends ExternalAPI {
public async getAvailablePlatformsForCountry(
country: string
): Promise<FlixPatrolPlatformOption[]> {
// For global/world, return our static list
if (country === 'world' || country === 'global') {
// For global, return our static list
if (country === 'global') {
return this.getGlobalPlatformOptions();
}
@@ -364,33 +364,85 @@ class FlixPatrolAPI extends ExternalAPI {
let platformSection = null;
for (const heading of headings) {
const headingText = heading.textContent || '';
// Handle both formats:
// Country-specific: "PLATFORM TOP 10" (e.g., "NETFLIX TOP 10")
// Global: "TOP Movies on PLATFORM" (e.g., "TOP Movies on Netflix")
let actualPlatformName = null;
if (headingText.toLowerCase().includes('top 10')) {
// Extract the actual platform name from the heading for comparison
// Country-specific format: "PLATFORM TOP 10"
const match = headingText.match(/^(.+?)\s+TOP 10/i);
if (match) {
const actualPlatformName = match[1].trim();
const normalizedActual = actualPlatformName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/\+/g, '')
.replace(/&/g, 'and')
.replace(/[^a-z0-9-]/g, '');
actualPlatformName = match[1].trim();
}
} else if (
headingText.toLowerCase().includes('top movies on') ||
headingText.toLowerCase().includes('top tv shows on')
) {
// Global format: Check if heading contains any of our mapped platform names
const possibleNames = this.mapPlatformIdToFlixPatrolName(platformName);
// Compare normalized platform names
if (normalizedActual === platformName.toLowerCase()) {
logger.debug(`Found platform section: ${headingText}`, {
label: 'FlixPatrol API',
platform,
headingText,
actualPlatformName,
normalizedActual,
platformName,
});
platformSection = heading;
// Instead of parsing, just check if the heading contains our platform names
for (const possibleName of possibleNames) {
if (headingText.toLowerCase().includes(possibleName.toLowerCase())) {
actualPlatformName = possibleName; // Use the mapped name directly
break;
}
}
}
if (actualPlatformName) {
const normalizedActual = actualPlatformName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/\+/g, '')
.replace(/&/g, 'and')
.replace(/[^a-z0-9-]/g, '');
let isMatch = false;
// For country-specific pages (with "TOP 10"), use the original logic
if (headingText.toLowerCase().includes('top 10')) {
const normalizedPlatform = platformName
.toLowerCase()
.replace(/_/g, '-'); // Keep the original underscore-to-dash conversion
isMatch = normalizedActual === normalizedPlatform;
} else {
// For global pages, use the new mapping logic
const possibleNames =
this.mapPlatformIdToFlixPatrolName(platformName);
isMatch = possibleNames.some((name) => {
const normalizedName = name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/\+/g, '')
.replace(/&/g, 'and')
.replace(/[^a-z0-9-]/g, '');
return normalizedActual === normalizedName;
});
}
// Compare normalized platform names
if (isMatch) {
logger.debug(`Found platform section: ${headingText}`, {
label: 'FlixPatrol API',
platform,
headingText,
actualPlatformName,
normalizedActual,
platformName,
format: headingText.toLowerCase().includes('top 10')
? 'country'
: 'global',
matchingMethod: headingText.toLowerCase().includes('top 10')
? 'original'
: 'mapping',
});
platformSection = heading;
break;
}
}
}
if (!platformSection) {
@@ -403,6 +455,11 @@ class FlixPatrolAPI extends ExternalAPI {
return result;
}
// For global pages, use the simpler card-table parsing
if (region === 'global') {
return this.parseGlobalPlatformData(platformSection, result, platform);
}
// Extract platform logo information from the platform section
const platformLogo = await this.extractPlatformLogo(platformSection);
if (platformLogo) {
@@ -799,7 +856,13 @@ class FlixPatrolAPI extends ExternalAPI {
const platformName = this.extractPlatformNameFromSubtype(platform);
return platformName
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
@@ -811,6 +874,27 @@ class FlixPatrolAPI extends ExternalAPI {
return platform.replace(/_top_10$/, '');
}
/**
* Map our platform IDs to FlixPatrol HTML platform names
*/
private mapPlatformIdToFlixPatrolName(platformId: string): string[] {
// Only platforms actually found in FlixPatrol /top10 page test data
const mappings: { [key: string]: string[] } = {
netflix: ['Netflix'],
hbo: ['HBO'],
disney: ['Disney+'], // "TOP Movies on Disney+ on September 6, 2025"
amazon_prime: ['Amazon Prime'], // "TOP Movies on Amazon Prime on September 6, 2025"
'amazon-prime': ['Amazon Prime'],
apple_tv: ['Apple'], // "TOP Movies on Apple on September 6, 2025"
'apple-tv': ['Apple'],
paramount: ['Paramount+'], // "TOP TV Shows on Paramount+ on September 6, 2025"
amazon: ['Amazon'], // "TOP Movies on Amazon on September 6, 2025" (different from Prime)
};
const normalized = platformId.toLowerCase().replace(/_/g, '-');
return mappings[normalized] || [platformId];
}
/**
* Identify what type of content each table contains by analyzing the text structure
*/
@@ -1059,10 +1143,8 @@ class FlixPatrolAPI extends ExternalAPI {
}
});
// Always include 'world' as the global option
countries.add('world');
const result = Array.from(countries).sort();
// Always include 'global' as the global option at the top
const result = ['global', ...Array.from(countries).sort()];
logger.debug(`Total unique countries found: ${result.length}`, {
label: 'FlixPatrol API',
@@ -1333,6 +1415,196 @@ class FlixPatrolAPI extends ExternalAPI {
return [];
}
}
/**
* Parse global platform data using card-table structure
* Simple method focused only on global pages to avoid breaking country logic
*/
private parseGlobalPlatformData(
platformSection: Element,
result: FlixPatrolPlatformData,
platform: string
): FlixPatrolPlatformData {
logger.debug(`Parsing global platform data for ${platform}`, {
label: 'FlixPatrol API',
platform,
});
// The card tables exist in the document, but not directly after headings
// Search the entire document for card-table elements and associate them with platforms
const document = platformSection.ownerDocument;
const allCardTables = document?.querySelectorAll('table.card-table') || [];
logger.debug(
`Found ${allCardTables.length} total card tables in document`,
{
label: 'FlixPatrol API',
platform,
}
);
// Find all global platform headings (exclude country breakdown)
const allHeadings = Array.from(document?.querySelectorAll('h2') || []);
const globalPlatformHeadings = allHeadings.filter((h) => {
const text = h.textContent?.toLowerCase() || '';
return (
(text.includes('top movies on') || text.includes('top tv shows on')) &&
!text.includes('by country')
);
});
// Group headings by platform (Movies + TV pairs)
const platformGroups: { movies: Element | null; tv: Element | null }[] = [];
const platforms: string[] = [];
globalPlatformHeadings.forEach((heading) => {
const text = heading.textContent?.toLowerCase() || '';
// Extract platform name from heading like "TOP Movies on Netflix on September 6, 2025"
const platformMatch = text.match(
/top (?:movies|tv shows) on (.+?) on \w+/
);
if (platformMatch) {
const platformName = platformMatch[1].trim();
let platformGroup = platformGroups.find(
(_, index) => platforms[index] === platformName
);
if (!platformGroup) {
platforms.push(platformName);
platformGroup = { movies: null, tv: null };
platformGroups.push(platformGroup);
}
if (text.includes('movies')) {
platformGroup.movies = heading;
} else if (text.includes('tv shows')) {
platformGroup.tv = heading;
}
}
});
// Find which platform group our section belongs to
const currentPlatformGroupIndex = platformGroups.findIndex(
(group) =>
group.movies === platformSection || group.tv === platformSection
);
if (currentPlatformGroupIndex >= 0) {
// Each platform gets 2 sequential tables from the global card-table list
// Filter out country breakdown tables (they have many rows, typically >50)
const globalCardTables = Array.from(allCardTables).filter((table) => {
const rows = table.querySelectorAll('tr');
return rows.length <= 20; // Global platform tables have ~10 rows each
});
const startTableIndex = currentPlatformGroupIndex * 2;
const endTableIndex = startTableIndex + 2;
logger.debug(
`Platform ${platform} should use tables ${startTableIndex}-${
endTableIndex - 1
}`,
{
label: 'FlixPatrol API',
platform,
currentPlatformGroupIndex,
totalPlatformGroups: platformGroups.length,
globalCardTablesCount: globalCardTables.length,
platformName: platforms[currentPlatformGroupIndex],
}
);
for (
let i = startTableIndex;
i < endTableIndex && i < globalCardTables.length;
i++
) {
const table = globalCardTables[i];
const items = this.parseCardTable(table);
// For global pages, each platform typically has 2 tables: Movies then TV Shows
// Determine the content type based on table position within the platform's tables
const tablePositionInPlatform = i - startTableIndex;
const isMovieTable = tablePositionInPlatform % 2 === 0; // Even indices = Movies, Odd = TV
if (isMovieTable) {
result.movies.push(
...items.map((item) => ({ ...item, type: 'movie' as const }))
);
} else {
result.tvShows.push(
...items.map((item) => ({ ...item, type: 'tv' as const }))
);
}
logger.debug(`Processed table ${i} for ${platform}`, {
label: 'FlixPatrol API',
platform,
tableIndex: i,
tablePositionInPlatform,
isMovieTable,
itemsCount: items.length,
contentType: isMovieTable ? 'movies' : 'tv',
});
}
logger.debug(`Parsed platform data for ${platform}`, {
label: 'FlixPatrol API',
platform,
movieCount: result.movies.length,
tvCount: result.tvShows.length,
tablesUsed: `${startTableIndex}-${endTableIndex - 1}`,
});
}
return result;
}
/**
* Parse a single card-table element to extract ranking items
*/
private parseCardTable(table: Element): FlixPatrolListItem[] {
const items: FlixPatrolListItem[] = [];
const rows = table.querySelectorAll('tr');
rows.forEach((row, index) => {
const cells = row.querySelectorAll('td');
if (cells.length >= 3) {
const rankText = cells[0].textContent?.trim() || '';
const titleElement = cells[1].querySelector('a');
const pointsText = cells[2].textContent?.trim() || '';
// Extract rank number
const rankMatch = rankText.match(/(\d+)/);
const rank = rankMatch ? parseInt(rankMatch[1], 10) : index + 1;
// Extract title
const title =
titleElement?.textContent?.trim() ||
cells[1].textContent?.trim() ||
'';
// Extract FlixPatrol URL
const flixpatrolUrl = titleElement?.getAttribute('href') || undefined;
// Extract points
const points = pointsText;
if (title) {
items.push({
rank,
title,
points,
flixpatrolUrl,
type: 'movie', // Default - will be determined by context or backend
});
}
}
});
return items;
}
}
export default FlixPatrolAPI;

View File

@@ -837,7 +837,12 @@ export class NetworksCollectionSync extends BaseCollectionSync {
* Extract clean platform name from subtype for branding
*/
private extractPlatformNameFromSubtype(subtype: string): string {
return subtype.replace(/_top_10$/, '');
// Remove "_top_10" suffix and normalize to match poster generation system
const platformName = subtype.replace(/_top_10$/, '');
// Convert underscores to hyphens for poster generation compatibility
// This ensures platform names match the SERVICE_LOGO_MAP in posterGeneration.ts
return platformName.replace(/_/g, '-');
}
/**

View File

@@ -644,7 +644,15 @@ export class TemplateEngine {
return platform
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
.split(' ')
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
}

View File

@@ -744,6 +744,8 @@ collectionsRoutes.post('/create', isAuthenticated(), async (req, res) => {
const settings = getSettings();
const { IdGenerator } = await import('@server/utils/idGenerator');
// Cache warming removed - caused double requests and rate limiting issues
// Extract libraryIds from request - support both single libraryId and multiple libraryIds
const libraryIds = req.body.libraryIds
? Array.isArray(req.body.libraryIds)

View File

@@ -32,12 +32,16 @@ interface NetworksConfigSectionProps {
errors: FormikErrors<CollectionFormConfig>;
touched: FormikTouched<CollectionFormConfig>;
isVisible?: boolean;
getTemplatePresets?: (
values: CollectionFormConfig
) => { label: string; value: string }[];
}
const NetworksConfigSection = ({
values,
setFieldValue,
isVisible = true,
getTemplatePresets,
}: NetworksConfigSectionProps) => {
const intl = useIntl();
@@ -90,19 +94,33 @@ const NetworksConfigSection = ({
setFieldValue('subtype', '');
}
}}
disabled={isLoadingCountries}
disabled={false}
>
<option value="">
{isLoadingCountries
? intl.formatMessage(messages.loadingCountries)
: intl.formatMessage(messages.selectCountry)}
<option value="">{intl.formatMessage(messages.selectCountry)}</option>
{/* Global option - always available */}
<option value="global">Global</option>
{/* Separator */}
<option disabled style={{ borderTop: '1px solid #4a5568' }}>
</option>
{Array.isArray(countries) &&
countries.map((country) => (
<option key={country.value} value={country.value}>
{country.label}
</option>
))}
{/* Loading state or countries */}
{isLoadingCountries ? (
<option disabled>
{intl.formatMessage(messages.loadingCountries)}
</option>
) : (
Array.isArray(countries) &&
countries
.filter((country) => country.value !== 'global') // Exclude global since it's shown above
.map((country) => (
<option key={country.value} value={country.value}>
{country.label}
</option>
))
)}
</Field>
{countriesError && (
<p className="mt-1 text-xs text-red-400">
@@ -127,6 +145,21 @@ const NetworksConfigSection = ({
name="subtype"
className="w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
disabled={isLoadingPlatforms}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const newPlatform = e.target.value;
setFieldValue('subtype', newPlatform);
// Auto-select first template option when platform is selected (same as other collection types)
if (newPlatform && getTemplatePresets) {
setTimeout(() => {
const tempValues = { ...values, subtype: newPlatform };
const presets = getTemplatePresets(tempValues);
if (presets.length > 0) {
setFieldValue('template', presets[0].value);
}
}, 100); // Same delay as other collection types
}
}}
>
<option value="">
{isLoadingPlatforms

View File

@@ -133,6 +133,7 @@ const TemplateSection = ({
fetchedTitles,
detectedMediaTypes
);
return templatePresets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}

View File

@@ -1271,6 +1271,67 @@ const CollectionFormConfigForm = ({
}
}
// Networks collection presets
if (values.type === 'networks') {
if (values.subtype) {
// Get platform name from subtype for display
// Handle cases like "netflix_top_10" -> "Netflix"
// and "disney-plus" -> "Disney Plus"
const platformName = values.subtype
.split('_')[0] // Take first part before underscore (removes "_top_10" etc)
.split('-') // Split on dashes
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
return [
{
label: `Top 10 {mediaType}s on ${platformName}`,
value: `Top 10 {mediaType}s on ${platformName}`,
},
{
label: `Popular on ${platformName}`,
value: `Popular on ${platformName}`,
},
{
label: `${platformName} Top 10 {mediaType}s`,
value: `${platformName} Top 10 {mediaType}s`,
},
{
label: `${platformName} Top {mediaType}s`,
value: `${platformName} Top {mediaType}s`,
},
{
label: `Top {mediaType}s on ${platformName}`,
value: `Top {mediaType}s on ${platformName}`,
},
{
label: `${platformName} Trending {mediaType}s`,
value: `${platformName} Trending {mediaType}s`,
},
{
label: `Best of ${platformName}`,
value: `Best of ${platformName}`,
},
{ label: 'Custom', value: 'custom' },
];
} else {
// No platform selected yet
return [
{
label: 'Select a Platform First',
value: 'select-platform',
},
{ label: 'Custom', value: 'custom' },
];
}
}
// Fallback for unknown types
return [
{
@@ -1610,6 +1671,7 @@ const CollectionFormConfigForm = ({
errors={errors as FormikErrors<CollectionFormConfig>}
touched={touched as FormikTouched<CollectionFormConfig>}
isVisible={true}
getTemplatePresets={getTemplatePresets}
/>
)}

View File

@@ -891,6 +891,23 @@ const AllCollectionsView: React.FC = () => {
default:
return subtype;
}
case 'networks':
// Format platform names like "netflix_top_10" -> "Netflix"
// and "neon-tv" -> "Neon TV"
return subtype
.split('_')[0] // Take first part before underscore (removes "_top_10" etc)
.split('-') // Split on dashes
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return (
word.charAt(0).toUpperCase() +
word.slice(1)
);
})
.join(' ');
default:
return subtype;
}
@@ -909,6 +926,8 @@ const AllCollectionsView: React.FC = () => {
? 'Tautulli'
: config.type === 'overseerr'
? 'Overseerr'
: config.type === 'networks'
? 'Networks'
: config.type || '';
const subtypeLabel = getSubtypeLabel(

View File

@@ -562,6 +562,20 @@ const SortableItem = ({
default:
return subtype;
}
case 'networks':
// Format platform names like "netflix_top_10" -> "Netflix"
// and "neon-tv" -> "Neon TV"
return subtype
.split('_')[0] // Take first part before underscore (removes "_top_10" etc)
.split('-') // Split on dashes
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
default:
return subtype;
}
@@ -580,6 +594,8 @@ const SortableItem = ({
? 'Tautulli'
: collection.type === 'overseerr'
? 'Overseerr'
: collection.type === 'networks'
? 'Networks'
: collection.type || '';
const subtypeLabel = getSubtypeLabel(