chore(release): merge develop into latest

This commit is contained in:
Tom Wheeler
2026-01-15 17:25:51 +13:00
7 changed files with 164 additions and 250 deletions
+52 -33
View File
@@ -6,14 +6,11 @@ on:
- latest
jobs:
semantic-release:
name: Tag and release latest version
version-check:
name: Check for new version
runs-on: ubuntu-22.04
permissions:
contents: write
issues: write
pull-requests: write
packages: write
outputs:
new_release_published: ${{ steps.semantic.outputs.new_release_published }}
new_release_version: ${{ steps.semantic.outputs.new_release_version }}
@@ -31,17 +28,19 @@ jobs:
env:
HUSKY: 0
run: yarn
- name: Release
- name: Check version (dry-run)
id: semantic
uses: cycjimmy/semantic-release-action@v4
with:
dry_run: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
needs: semantic-release
if: needs.semantic-release.outputs.new_release_published == 'true'
needs: version-check
if: needs.version-check.outputs.new_release_published == 'true'
permissions:
packages: write
contents: read
@@ -56,6 +55,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Update version in package.json
run: npm version ${{ needs.version-check.outputs.new_release_version }} --no-git-tag-version
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -95,7 +97,7 @@ jobs:
name: Create multi-platform manifest
runs-on: ubuntu-22.04
needs:
- semantic-release
- version-check
- build_and_push
permissions:
packages: write
@@ -129,7 +131,7 @@ jobs:
run: |
docker buildx imagetools create \
-t ghcr.io/agregarr/agregarr:latest \
-t ghcr.io/agregarr/agregarr:v${{ needs.semantic-release.outputs.new_release_version }} \
-t ghcr.io/agregarr/agregarr:v${{ needs.version-check.outputs.new_release_version }} \
$(printf 'ghcr.io/agregarr/agregarr@sha256:%s ' *)
- name: Install regctl
@@ -140,45 +142,62 @@ jobs:
- name: Copy to Docker Hub
run: |
/tmp/regctl image copy ghcr.io/agregarr/agregarr:latest agregarr/agregarr:latest
/tmp/regctl image copy ghcr.io/agregarr/agregarr:v${{ needs.semantic-release.outputs.new_release_version }} agregarr/agregarr:v${{ needs.semantic-release.outputs.new_release_version }}
/tmp/regctl image copy ghcr.io/agregarr/agregarr:v${{ needs.version-check.outputs.new_release_version }} agregarr/agregarr:v${{ needs.version-check.outputs.new_release_version }}
- name: Inspect images
run: |
docker buildx imagetools inspect ghcr.io/agregarr/agregarr:latest
docker buildx imagetools inspect agregarr/agregarr:latest
github-release:
name: Create GitHub Release
release:
name: Create release
runs-on: ubuntu-22.04
needs:
- semantic-release
- version-check
- merge
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: v${{ needs.semantic-release.outputs.new_release_version }}
fetch-depth: 0
- name: Get release notes from tag
id: notes
run: |
# Get the release notes from CHANGELOG.md for this version
VERSION="v${{ needs.semantic-release.outputs.new_release_version }}"
echo "Creating release for ${VERSION}"
# Extract notes between this version and the previous one
NOTES=$(awk "/^## \[${VERSION#v}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md)
# Write to file for gh release
echo "$NOTES" > /tmp/release-notes.md
- name: Create GitHub Release
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- name: Install dependencies
env:
HUSKY: 0
run: yarn
- name: Release
uses: cycjimmy/semantic-release-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
discord:
name: Discord Notification
runs-on: ubuntu-22.04
needs:
- version-check
- release
steps:
- name: Send Discord Notification
run: |
gh release create "v${{ needs.semantic-release.outputs.new_release_version }}" \
--title "v${{ needs.semantic-release.outputs.new_release_version }}" \
--notes-file /tmp/release-notes.md
curl -H "Content-Type: application/json" \
-d '{
"content": "@everyone",
"embeds": [{
"title": "🎉 Agregarr v${{ needs.version-check.outputs.new_release_version }} Released!",
"description": "A new version of Agregarr is now available!\n\n**[📝 View Release Notes](${{ github.server_url }}/${{ github.repository }}/releases/tag/v${{ needs.version-check.outputs.new_release_version }})**",
"color": 15105570,
"fields": [
{"name": "🐳 Update your Docker image", "value": "```docker pull agregarr/agregarr:latest```", "inline": false}
],
"thumbnail": {"url": "https://raw.githubusercontent.com/agregarr/agregarr/develop/public/android-chrome-512x512.png"}
}]
}' \
${{ secrets.DISCORD_WEBHOOK_LATEST }}
+83 -214
View File
@@ -1,7 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { ImdbAxiosClient } from '@server/lib/collections/utils/ImdbAxiosClient';
import logger from '@server/logger';
import { JSDOM } from 'jsdom';
/**
* IMDb List Item interface
@@ -55,36 +53,16 @@ export interface ImdbTop250Result {
/**
* IMDb API client for fetching lists and popular content
*
* Note: IMDb doesn't have a public API for lists, so this uses web scraping
* for public IMDb lists. This is a best-effort implementation.
* Uses the shared ImdbAxiosClient which handles AWS WAF challenges
* and maintains cookies for reliable access to IMDb.
*/
class ImdbAPI extends ExternalAPI {
class ImdbAPI {
// Cache for Top 250 lists (refreshed periodically)
private top250MoviesCache: Map<string, number> = new Map();
private top250TvCache: Map<string, number> = new Map();
private top250LastRefresh: { movies?: number; tv?: number } = {};
private readonly TOP250_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
constructor() {
super(
'https://www.imdb.com',
{},
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'text/html; charset=utf-8',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
Connection: 'keep-alive',
},
nodeCache: cacheManager.getCache('imdb').data,
}
);
}
/**
* Get a predefined IMDb top list
*/
@@ -98,41 +76,46 @@ class ImdbAPI extends ExternalAPI {
switch (listType) {
case ImdbTopList.TOP_250_MOVIES:
url = '/chart/top/';
url = 'https://www.imdb.com/chart/top/';
expectedType = 'movie';
break;
case ImdbTopList.TOP_250_ENGLISH_MOVIES:
url = '/chart/top-english-movies/';
url = 'https://www.imdb.com/chart/top-english-movies/';
expectedType = 'movie';
break;
case ImdbTopList.TOP_250_TV:
url = '/chart/toptv/';
url = 'https://www.imdb.com/chart/toptv/';
expectedType = 'tv';
break;
case ImdbTopList.POPULAR_MOVIES:
url = '/chart/moviemeter/';
url = 'https://www.imdb.com/chart/moviemeter/';
expectedType = 'movie';
break;
case ImdbTopList.POPULAR_TV:
url = '/chart/tvmeter/';
url = 'https://www.imdb.com/chart/tvmeter/';
expectedType = 'tv';
break;
case ImdbTopList.MOST_POPULAR_MOVIES:
url = '/chart/boxoffice/';
url = 'https://www.imdb.com/chart/boxoffice/';
expectedType = 'movie';
break;
case ImdbTopList.MOST_POPULAR_TV:
url = '/chart/tvpopular/';
url = 'https://www.imdb.com/chart/tvpopular/';
expectedType = 'tv';
break;
default:
throw new Error(`Unknown IMDb top list type: ${listType}`);
}
const html = await this.get<string>(url, undefined, 30000);
// Use the shared ImdbAxiosClient with WAF handling
const axios = await ImdbAxiosClient.getInstance();
const response = await axios.get(url, { timeout: 30000 });
const html = response.data as string;
return this.parseTopListHtml(html, expectedType, limit);
} catch (error) {
logger.error(`Failed to fetch IMDb top list ${listType}:`, {
label: 'IMDb API',
error: error instanceof Error ? error.message : 'Unknown error',
listType,
stack: error instanceof Error ? error.stack : undefined,
@@ -145,205 +128,91 @@ class ImdbAPI extends ExternalAPI {
}
}
/**
* Get a custom IMDb list by URL
*/
public async getCustomList(
listUrl: string,
limit = 9999
): Promise<ImdbListItem[]> {
try {
// Extract list ID from URL
const listMatch = listUrl.match(/\/list\/(ls\d+)/);
if (!listMatch) {
throw new Error('Invalid IMDb list URL format');
}
const listId = listMatch[1];
const url = `/list/${listId}/`;
const html = await this.get<string>(url, undefined, 30000);
return this.parseCustomListHtml(html, limit);
} catch (error) {
logger.error(`Failed to fetch IMDb custom list ${listUrl}:`, {
error: error instanceof Error ? error.message : 'Unknown error',
listUrl,
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(
`Failed to fetch IMDb custom list: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
/**
* Parse HTML for top lists (Top 250, Popular, etc.)
* Uses JSON-LD structured data which IMDb provides for all chart pages.
*/
private parseTopListHtml(
html: string,
expectedType: 'movie' | 'tv',
limit: number
): ImdbListItem[] {
const dom = new JSDOM(html);
const document = dom.window.document;
const items: ImdbListItem[] = [];
const items = this.parseJsonLd(html, expectedType, limit);
// Different selectors for different chart types
const itemSelectors = [
'.cli-item', // Top 250 movies/TV
'.titleColumn', // Some chart pages
'.ipc-title-link-wrapper', // Newer layout
'.titleListItem', // Fallback
];
let itemElements: NodeListOf<Element> | null = null;
for (const selector of itemSelectors) {
itemElements = document.querySelectorAll(selector);
if (itemElements.length > 0) break;
if (items.length > 0) {
logger.debug('Parsed IMDb list from JSON-LD', {
label: 'IMDb API',
itemCount: items.length,
expectedType,
});
} else {
logger.warn('No items found in IMDb JSON-LD data', {
label: 'IMDb API',
expectedType,
});
}
if (!itemElements || itemElements.length === 0) {
logger.warn('No items found in IMDb top list HTML');
return [];
}
return items;
}
for (let i = 0; i < Math.min(itemElements.length, limit); i++) {
const element = itemElements[i];
const imdbId = this.extractImdbId(element);
const title = this.extractTitle(element);
const year = this.extractYear(element);
/**
* Parse JSON-LD structured data from IMDb page
* IMDb includes ItemList schema with all items - much more reliable than HTML scraping
*/
private parseJsonLd(
html: string,
expectedType: 'movie' | 'tv',
limit: number
): ImdbListItem[] {
try {
// Look for ItemList JSON-LD script
const jsonLdMatch = html.match(
/<script type="application\/ld\+json">(\{"@type":"ItemList"[^<]+)<\/script>/
);
if (!jsonLdMatch) {
return [];
}
const data = JSON.parse(jsonLdMatch[1]) as {
itemListElement?: {
item?: {
url?: string;
name?: string;
};
}[];
};
const itemListElement = data.itemListElement || [];
const items: ImdbListItem[] = [];
for (let i = 0; i < Math.min(itemListElement.length, limit); i++) {
const listItem = itemListElement[i];
const movie = listItem.item;
if (!movie?.url || !movie?.name) continue;
// Extract IMDb ID from URL (e.g., https://www.imdb.com/title/tt0111161/)
const urlMatch = movie.url.match(/\/title\/(tt\d+)/);
if (!urlMatch) continue;
const imdbId = urlMatch[1];
if (imdbId && title) {
items.push({
imdbId,
title,
year,
title: movie.name,
type: expectedType,
});
}
return items;
} catch (error) {
logger.debug('Failed to parse JSON-LD from IMDb page', {
label: 'IMDb API',
error: error instanceof Error ? error.message : String(error),
});
return [];
}
return items;
}
/**
* Parse HTML for custom user lists
*/
private parseCustomListHtml(html: string, limit: number): ImdbListItem[] {
const dom = new JSDOM(html);
const document = dom.window.document;
const items: ImdbListItem[] = [];
const itemElements = document.querySelectorAll('.lister-item, .ipc-title');
for (let i = 0; i < Math.min(itemElements.length, limit); i++) {
const element = itemElements[i];
const imdbId = this.extractImdbId(element);
const title = this.extractTitle(element);
const year = this.extractYear(element);
const type = this.inferType(element);
if (imdbId && title) {
items.push({
imdbId,
title,
year,
type,
});
}
}
return items;
}
/**
* Extract IMDb ID from an element
*/
private extractImdbId(element: Element): string | null {
// Look for links with IMDb title patterns
const linkSelectors = ['a[href*="/title/"]', '[href*="/title/"]'];
for (const selector of linkSelectors) {
const link = element.querySelector(selector) || element.closest(selector);
if (link) {
const href = link.getAttribute('href');
const match = href?.match(/\/title\/(tt\d+)/);
if (match) return match[1];
}
}
return null;
}
/**
* Extract title from an element
*/
private extractTitle(element: Element): string | null {
const titleSelectors = [
'.cli-title a',
'.titleColumn a',
'.ipc-title__text',
'.titleListItem .title a',
'h3 a',
'.title a',
'a',
];
for (const selector of titleSelectors) {
const titleElement = element.querySelector(selector);
if (titleElement?.textContent?.trim()) {
return titleElement.textContent.trim();
}
}
return null;
}
/**
* Extract year from an element
*/
private extractYear(element: Element): number | undefined {
const yearSelectors = [
'.cli-title-metadata .cli-title-metadata-item:first-child',
'.secondaryInfo',
'.lister-item-year',
'.year',
];
for (const selector of yearSelectors) {
const yearElement = element.querySelector(selector);
if (yearElement?.textContent) {
const yearMatch = yearElement.textContent.match(/(\d{4})/);
if (yearMatch) {
return parseInt(yearMatch[1], 10);
}
}
}
return undefined;
}
/**
* Infer media type from element content
*/
private inferType(element: Element): 'movie' | 'tv' {
const text = element.textContent?.toLowerCase() || '';
// Look for TV indicators
if (
text.includes('tv series') ||
text.includes('tv mini series') ||
text.includes('episode') ||
text.includes('season')
) {
return 'tv';
}
// Default to movie
return 'movie';
}
/**
@@ -606,6 +606,7 @@ export class CollectionSyncService {
createPlaceholdersForMissing: config.createPlaceholdersForMissing,
placeholderDaysAhead: config.placeholderDaysAhead,
placeholderReleasedDays: config.placeholderReleasedDays,
includeAllReleasedItems: config.includeAllReleasedItems,
applyOverlaysDuringSync: config.applyOverlaysDuringSync,
};
@@ -458,6 +458,7 @@ const CollectionSettings = ({
createPlaceholdersForMissing: config.createPlaceholdersForMissing,
placeholderDaysAhead: config.placeholderDaysAhead,
placeholderReleasedDays: config.placeholderReleasedDays,
includeAllReleasedItems: config.includeAllReleasedItems,
tautulliStatType: config.tautulliStatType,
minimumPlays: config.minimumPlays,
searchMissingMovies: config.searchMissingMovies,
+23 -3
View File
@@ -765,7 +765,7 @@
"components.Settings.testTraktConnection": "Tester la connexion",
"components.Collections.FormSections.selectRootFolder": "Sélectionner le dossier racine...",
"components.Settings.OverseerrModal.testFirstRootFolders": "Sélectionnez d'abord un serveur",
"components.Collections.Forms.configureDownloads": "Configurer les fichiers fictifs",
"components.Collections.Forms.configureDownloads": "Configurer les téléchargements",
"components.OverlayEditor.segmentValue": "Contenu du texte",
"components.PostersView.syncOverlaysConfirm": "Confirmer?",
"components.PostersView.localDescription": "Utilisez des affiches personnalisées à partir de dossiers organisés. Placez les images dans la structure de dossiers indiquée ci-dessous. Peut être alimenté avec des affiches Plex. En cas de fichier introuvable, TMDB est utilisé comme solution de secours.",
@@ -1511,7 +1511,7 @@
"components.Collections.FormSections.tvShows": "Séries",
"components.Collections.FormSections.tmdbUrlExamples": "Exemples : Collection (https://www.themoviedb.org/collection/12345), Liste (https://www.themoviedb.org/list/310), Chaîne (https://www.themoviedb.org/network/213), Société (https://www.themoviedb.org/company/7505/movie ou /tv)",
"components.Collections.FormSections.to": "À",
"components.Collections.FormSections.traktUrlExamples": "Exemples : https://trakt.tv/users/nomutilisateur/lists/nomdelaliste ou https://trakt.tv/lists/official/jurassic-park-collection",
"components.Collections.FormSections.traktUrlExamples": "Exemples : https://trakt.tv/users/nomutilisateur/lists/nomdelaliste ou https://app.trakt.tv/users/nomutilisateur/lists/nomdelaliste",
"components.Collections.FormSections.useSeparator": "Utiliser un séparateur",
"components.Collections.FormSections.useSeparatorHelp": "Créer une collection séparateur simple pour regrouper vos collections auto {type}.",
"components.Collections.FormSections.userRequestCollectionsRestricted": "Les collections «demandes utilisateur» sont limitées à la visibilité «Onglet Bibliothèque uniquement» à cause dun bug Plex qui ne respecte pas les restrictions de labels sur les écrans Accueil/Recommandé. Les collections «Franchises TMDB» et les «Collections auto par réalisateurs» de la bibliothèque Plex sont masquées pour ne pas encombrer les écrans Accueil/Recommandé.",
@@ -1548,5 +1548,25 @@
"components.Collections.Forms.franchiseTemplateNote": "<strong>Remarque :</strong> votre modèle de titre doit inclure <code>« {franchiseName} »</code> (ex. <code>« {franchiseName} »</code> ou « Films de la franchise <code>{franchiseName}</code> »).",
"components.Collections.Forms.imdbRatingAsc": "Note IMDb (plus faible à la plus élevée)",
"components.Collections.Forms.imdbRatingDesc": "Note IMDb (plus élevée à la plus faible)",
"components.Collections.Forms.itemOrder": "Ordre des éléments"
"components.Collections.Forms.itemOrder": "Ordre des éléments",
"components.Collections.Forms.days": "jours",
"components.Collections.Forms.includeAllReleasedItems": "Inclure tous les éléments déjà diffusés",
"components.Collections.Forms.includeAllReleasedItemsHelp": "Créer des fichiers fictifs pour tous les éléments, quelle que soit leur date de diffusion",
"components.Collections.Forms.limitCollectionItems": "Limiter la collection à ce nombre d’éléments{smartCollectionNote}",
"components.Collections.Forms.limitCollectionItemsSmartNote": " (sapplique aux collections intelligentes)",
"components.Collections.Forms.onlyRecentlyReleased": "Inclure uniquement les éléments diffusés depuis",
"components.Collections.Forms.onlyRecentlyReleasedHelp": "Créer des fichiers fictifs uniquement pour les éléments diffusés dans le nombre de jours indiqué",
"components.Collections.Forms.overseerrUserCollectionsDescription": "Crée une collection pour chaque utilisateur Overseerr à partir de ses demandes, et utilise des étiquettes et des restrictions pour que seul lutilisateur demandeur puisse voir ses demandes. Comme le propriétaire du serveur ne peut pas être restreint, toutes les collections lui seront visibles.",
"components.Collections.Forms.placeholderCreation": "Création de fichiers fictifs",
"components.Collections.Forms.placeholderCreationHelp": "Crée des fichiers fictifs dans Plex pour les éléments pas encore disponibles, avec des overlays de compte à rebours indiquant les dates de sortie/diffusion.",
"components.Collections.Forms.placeholderFoldersNotConfigured": "Les {pluralLibrary} suivantes {pluralNeed} des dossiers racine de fichiers fictifs configurés : <strong>{namesText}</strong>. Configurez les dossiers de fichiers fictifs pour {libraryCount} dans Paramètres > Téléchargements.",
"components.Collections.Forms.pleaseFixErrors": "Veuillez corriger les erreurs suivantes :",
"components.Collections.Forms.randomOrder": "Ordre aléatoire (mélangé à chaque synchro)",
"components.Collections.Forms.randomizeHomeOrder": "Ordre de l'accueil aléatoire",
"components.Collections.Forms.releaseDateAsc": "Date de diffusion (de la plus ancienne à la plus récente)",
"components.Collections.Forms.releaseDateDesc": "Date de diffusion (de la plus récente à la plus ancienne)",
"components.Collections.Forms.releasedItems": "Éléments déjà diffusés",
"components.Collections.Forms.reverseOrder": "Ordre inversé",
"components.Collections.Forms.shuffleHubCollectionHelp": "Si activé, la position de ce {itemType} sera mélangée aléatoirement avec les autres collections ayant cette option activée à chaque synchro. Une synchro personnalisée du mélange peut être définie depuis l'onglet « Tâches » des paramètres.",
"components.Collections.Forms.shufflePositionHelp": "Si activé, la position de cette collection sera mélangée aléatoirement avec les autres collections ayant cette option activée à chaque synchro. Une synchro personnalisée du mélange peut être définie depuis l'onglet « Tâches » des paramètres."
}
+3
View File
@@ -175,6 +175,9 @@ export const saveIndividualConfigs = async (
...(collectionConfig.placeholderReleasedDays !== undefined && {
placeholderReleasedDays: collectionConfig.placeholderReleasedDays,
}),
...(collectionConfig.includeAllReleasedItems !== undefined && {
includeAllReleasedItems: collectionConfig.includeAllReleasedItems,
}),
...(collectionConfig.tautulliStatType && {
tautulliStatType: collectionConfig.tautulliStatType,
}),
+1
View File
@@ -224,6 +224,7 @@ export const linkCollectionConfig = async (
masterConfig.createPlaceholdersForMissing,
placeholderDaysAhead: masterConfig.placeholderDaysAhead,
placeholderReleasedDays: masterConfig.placeholderReleasedDays,
includeAllReleasedItems: masterConfig.includeAllReleasedItems,
tautulliStatType: masterConfig.tautulliStatType,
isMultiSource: masterConfig.isMultiSource,
sources: masterConfig.sources,