Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,10 @@ dist
*.save
*.save.*
temp/

# Local addon runtime data
addon/data/.migration-completed
addon/data/*.cache
addon/data/db.sqlite
addon/data/db.sqlite-shm
addon/data/db.sqlite-wal
56 changes: 46 additions & 10 deletions addon/lib/getCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require("dotenv").config();
import { getGenreList } from "./getGenreList.js";
import { getLanguages } from "./getLanguages.js";
import { fetchMDBListItems, parseMDBListItems, fetchMDBListBatchMediaInfo, fetchMDBListUpNext, parseMDBListUpNextItems } from "../utils/mdbList.js";
import { fetchMDBListItems, fetchMDBListItemsBySlug, parseMDBListItems, fetchMDBListBatchMediaInfo, fetchMDBListUpNext, parseMDBListUpNextItems, resolveMDBListUnifiedCatalogIdentity } from "../utils/mdbList.js";
import { fetchStremThruCatalog, parseStremThruItems } from "../utils/stremthru.js";
import { fetchTraktWatchlistItems, fetchTraktFavoritesItems, fetchTraktRecommendationsItems, fetchTraktListItems, fetchTraktListItemsById, parseTraktItems, fetchTraktMostFavoritedItems, fetchTraktCalendarShows, fetchTraktSearchItems, getTraktAccessToken } from "../utils/traktUtils.js";
import { fetchSimklTrendingItems, fetchSimklWatchlistItems, parseSimklItems, getSimklToken, fetchSimklCalendarItems, fetchSimklGenreItems, fetchSimklDvdReleases } from "../utils/simklUtils.js";
Expand Down Expand Up @@ -60,22 +60,23 @@ function applyAgeRatingFilter(metas: any[], type: string, config: any): any[] {
'NC-17': 'TV-MA'
};

const isTvRating = type === 'series';
const finalUserRating = isTvRating ? (movieToTvMap[config.ageRating] || config.ageRating) : config.ageRating;
const ratingHierarchy = isTvRating ? tvRatingHierarchy : movieRatingHierarchy;
const userRatingIndex = ratingHierarchy.indexOf(finalUserRating);
const filteredMetas = metas.filter(meta => {
const effectiveType = type === 'all' ? meta.type : type;
const isTvRating = effectiveType === 'series';
const finalUserRating = isTvRating ? (movieToTvMap[config.ageRating] || config.ageRating) : config.ageRating;
const ratingHierarchy = isTvRating ? tvRatingHierarchy : movieRatingHierarchy;
const userRatingIndex = ratingHierarchy.indexOf(finalUserRating);

if (userRatingIndex === -1) {
return metas;
}
if (userRatingIndex === -1) {
return true;
}

const filteredMetas = metas.filter(meta => {
let cert: string | null = null;

if (meta.app_extras?.certification) {
cert = meta.app_extras.certification;
} else {
logger.debug(`[StremThru] ${type} ${meta.name}: No certification data available`);
logger.debug(`[StremThru] ${effectiveType} ${meta.name}: No certification data available`);
}

// If rating is PG-13 or lower, exclude NR content as it could be inappropriate
Expand Down Expand Up @@ -746,6 +747,41 @@ async function getTmdbAndMdbListCatalog(type: string, id: string, genre: string,
if (genreSlug !== genre) {
logger.debug(`Converted genre "${genre}" to slug "${genreSlug}"`);
}

const unifiedSlugIdentity = resolveMDBListUnifiedCatalogIdentity(catalogConfig, id);
if (unifiedSlugIdentity) {
logger.info(`Fetching MDBList unified catalog by slug: ${unifiedSlugIdentity.username}/${unifiedSlugIdentity.listSlug}, Genre: ${genre}, Page: ${page}`);

const response = await fetchMDBListItemsBySlug(
unifiedSlugIdentity.username,
unifiedSlugIdentity.listSlug,
config.apiKeys?.mdblist || process.env.MDBLIST_API_KEY || '',
language,
page,
sort,
order,
genreSlug,
catalogConfig?.cacheTTL
);

if (response.totalItems !== undefined && response.totalPages !== undefined) {
const pageInfo = `page ${page}/${response.totalPages}`;
const itemInfo = `${response.items.length} items`;
const totalInfo = `${response.totalItems} total`;
const statusInfo = response.hasMore ? 'more available' : 'end reached';

logger.debug(`MDBList unified-by-slug pagination - ${pageInfo}, ${itemInfo}, ${totalInfo}, ${statusInfo}`);

if (!response.hasMore && response.items.length === 0) {
logger.debug(`MDBList unified-by-slug early exit - no more items for ${unifiedSlugIdentity.syntheticId} at page ${page}`);
return [];
}
}

let metas = await parseMDBListItems(response.items, type, language, config, includeVideos);
metas = applyAgeRatingFilter(metas, type, config);
return metas;
}

// Handle different watchlist catalog IDs
let listId: string;
Expand Down
130 changes: 130 additions & 0 deletions addon/tests/mdbListUnified.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');

process.env.HOST_NAME = process.env.HOST_NAME || 'http://localhost';
process.env.NO_CACHE = 'true';

const {
buildMDBListItemsBySlugCacheKey,
buildMDBListItemsCacheKey,
buildSyntheticMDBListUnifiedCatalogId,
normalizeMDBListItemsPayload,
parseMDBListCatalogUrl,
resolveMDBListUnifiedCatalogIdentity,
} = require('../../dist/utils/mdbList.js');

test('builds a synthetic unified catalog id from username and slug', () => {
assert.equal(
buildSyntheticMDBListUnifiedCatalogId('nobnobz', 'service-apple-tv'),
'mdblist.nobnobz.service-apple-tv.unified'
);
});

test('parses a public MDBList list URL and resolves unified slug identity from metadata', () => {
assert.deepEqual(
parseMDBListCatalogUrl('https://mdblist.com/lists/nobnobz/service-apple-tv'),
{ username: 'nobnobz', listSlug: 'service-apple-tv' }
);

assert.deepEqual(
resolveMDBListUnifiedCatalogIdentity(
{
type: 'all',
metadata: {
username: 'nobnobz',
listSlug: 'service-apple-tv',
},
},
'mdblist.nobnobz.service-apple-tv.unified'
),
{
username: 'nobnobz',
listSlug: 'service-apple-tv',
syntheticId: 'mdblist.nobnobz.service-apple-tv.unified',
}
);
});

test('falls back to metadata.url for legacy mixed MDBList configs', () => {
assert.deepEqual(
resolveMDBListUnifiedCatalogIdentity(
{
type: 'all',
metadata: {
url: 'https://mdblist.com/lists/nobnobz/service-apple-tv',
},
},
'mdblist.12345'
),
{
username: 'nobnobz',
listSlug: 'service-apple-tv',
syntheticId: 'mdblist.nobnobz.service-apple-tv.unified',
}
);
});

test('does not treat external or split catalogs as unified slug catalogs', () => {
assert.equal(
resolveMDBListUnifiedCatalogIdentity(
{
type: 'all',
sourceUrl: 'https://api.mdblist.com/external/lists/55/items',
metadata: {
username: 'nobnobz',
listSlug: 'service-apple-tv',
},
},
'mdblist.nobnobz.service-apple-tv.unified'
),
null
);
});

test('preserves mixed unified array order and derives a stable local ordinal', () => {
const items = normalizeMDBListItemsPayload([
{ id: 101, mediatype: 'show' },
{ id: 202, mediatype: 'movie' },
{ id: 303, mediatype: 'show' },
], 'all');

assert.deepEqual(
items.map(item => [item.id, item.mediatype, item._aiomOrder]),
[
[101, 'show', 0],
[202, 'movie', 1],
[303, 'show', 2],
]
);
});

test('keeps legacy movie-only and series-only MDBList payload handling intact', () => {
const payload = {
movies: [{ id: 1, mediatype: 'movie' }],
shows: [{ id: 2, mediatype: 'show' }],
};

assert.deepEqual(
normalizeMDBListItemsPayload(payload, 'movie').map(item => item.id),
[1]
);
assert.deepEqual(
normalizeMDBListItemsPayload(payload, 'series').map(item => item.id),
[2]
);
assert.deepEqual(
normalizeMDBListItemsPayload(payload, 'all').map(item => item.id),
[1, 2]
);
});

test('uses a dedicated cache identity for unified slug catalogs', () => {
const apiKeyHash = 'hash123';
const numericCacheKey = buildMDBListItemsCacheKey(apiKeyHash, '12345', 1, 'rank', 'desc', '', true, 'all', 20);
const splitMovieCacheKey = buildMDBListItemsCacheKey(apiKeyHash, '12345', 1, 'rank', 'desc', '', false, 'movie', 20);
const unifiedSlugCacheKey = buildMDBListItemsBySlugCacheKey(apiKeyHash, 'nobnobz', 'service-apple-tv', 1, 'rank', 'desc', '', 20);

assert.notEqual(unifiedSlugCacheKey, numericCacheKey);
assert.notEqual(unifiedSlugCacheKey, splitMovieCacheKey);
assert.match(unifiedSlugCacheKey, /items-by-slug/);
});
Loading