mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: add season/episode matching
This commit is contained in:
@@ -331,7 +331,15 @@ export const UserDataSchema = z.object({
|
||||
hideErrors: z.boolean().optional(),
|
||||
hideErrorsForResources: z.array(ResourceSchema).optional(),
|
||||
tmdbAccessToken: z.string().optional(),
|
||||
strictTitleMatch: z
|
||||
titleMatching: z
|
||||
.object({
|
||||
mode: z.enum(['exact', 'contains']).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
requestTypes: z.array(z.string()).optional(),
|
||||
addons: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
seasonEpisodeMatching: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
requestTypes: z.array(z.string()).optional(),
|
||||
|
||||
+96
-17
@@ -1085,7 +1085,8 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
|
||||
details: Record<string, number>;
|
||||
}
|
||||
const skipReasons: Record<string, SkipReason> = {
|
||||
strictTitleMatching: { total: 0, details: {} },
|
||||
titleMatching: { total: 0, details: {} },
|
||||
seasonEpisodeMatching: { total: 0, details: {} },
|
||||
excludedStreamType: { total: 0, details: {} },
|
||||
requiredStreamType: { total: 0, details: {} },
|
||||
excludedResolution: { total: 0, details: {} },
|
||||
@@ -1117,7 +1118,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
|
||||
const isRegexAllowed = FeatureControl.isRegexAllowed(this.userData);
|
||||
|
||||
let titles: string[] = [];
|
||||
if (this.userData.strictTitleMatch && TYPES.includes(type as any)) {
|
||||
if (this.userData.titleMatching && TYPES.includes(type as any)) {
|
||||
try {
|
||||
titles = await new TMDBMetadata().getTitles(id, type as any);
|
||||
logger.info(`Found ${titles.length} titles for ${id}`, { titles });
|
||||
@@ -1126,8 +1127,15 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
|
||||
}
|
||||
}
|
||||
|
||||
const normaliseTitle = (title: string) => {
|
||||
return title
|
||||
.replace(/[^\p{L}\p{N}+]/gu, '')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const performTitleMatch = (stream: ParsedStream) => {
|
||||
const titleMatchingOptions = this.userData.strictTitleMatch;
|
||||
const titleMatchingOptions = this.userData.titleMatching;
|
||||
if (!titleMatchingOptions || !titleMatchingOptions.enabled) {
|
||||
return true;
|
||||
}
|
||||
@@ -1155,17 +1163,76 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
|
||||
return true;
|
||||
}
|
||||
|
||||
return titles.some(
|
||||
(title) =>
|
||||
title
|
||||
.replace(/[^\p{L}\p{N}+]/gu, '')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase() ===
|
||||
streamTitle
|
||||
.replace(/[^\p{L}\p{N}+]/gu, '')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
);
|
||||
if (titleMatchingOptions.mode === 'exact') {
|
||||
// the stream title should be an exact match of a valid title
|
||||
return titles.some(
|
||||
(title) => normaliseTitle(title) === normaliseTitle(streamTitle)
|
||||
);
|
||||
} else {
|
||||
// a valid title should be present somewhere in the stream title
|
||||
const valid = titles.some((title) =>
|
||||
normaliseTitle(streamTitle).includes(normaliseTitle(title))
|
||||
);
|
||||
return valid;
|
||||
}
|
||||
};
|
||||
|
||||
const performSeasonEpisodeMatch = (stream: ParsedStream) => {
|
||||
const seasonEpisodeMatchingOptions = this.userData.seasonEpisodeMatching;
|
||||
if (
|
||||
!seasonEpisodeMatchingOptions ||
|
||||
!seasonEpisodeMatchingOptions.enabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// parse the id to get the season and episode
|
||||
const seasonEpisodeRegex = /:(\d+):(\d+)$/;
|
||||
const match = id.match(seasonEpisodeRegex);
|
||||
|
||||
if (!match || !match[1] || !match[2]) {
|
||||
// only if both season and episode are present, we can filter
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedSeason = parseInt(match[1]);
|
||||
const requestedEpisode = parseInt(match[2]);
|
||||
|
||||
if (
|
||||
seasonEpisodeMatchingOptions.requestTypes?.length &&
|
||||
!seasonEpisodeMatchingOptions.requestTypes.includes(type)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
seasonEpisodeMatchingOptions.addons?.length &&
|
||||
!seasonEpisodeMatchingOptions.addons.includes(stream.addon.id!)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// is requested season present
|
||||
if (
|
||||
(requestedSeason &&
|
||||
stream.parsedFile?.season &&
|
||||
stream.parsedFile.season !== requestedSeason) ||
|
||||
(stream.parsedFile?.seasons &&
|
||||
!stream.parsedFile.seasons.includes(requestedSeason))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is requested episode present
|
||||
if (
|
||||
requestedEpisode &&
|
||||
stream.parsedFile?.episode &&
|
||||
stream.parsedFile.episode !== requestedEpisode
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const excludedRegexPatterns =
|
||||
@@ -1731,16 +1798,28 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => `
|
||||
}
|
||||
|
||||
if (!performTitleMatch(stream)) {
|
||||
skipReasons.strictTitleMatching.total++;
|
||||
skipReasons.strictTitleMatching.details[
|
||||
skipReasons.titleMatching.total++;
|
||||
skipReasons.titleMatching.details[
|
||||
stream.parsedFile?.title || 'Unknown'
|
||||
] =
|
||||
(skipReasons.strictTitleMatching.details[
|
||||
(skipReasons.titleMatching.details[
|
||||
stream.parsedFile?.title || 'Unknown'
|
||||
] || 0) + 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!performSeasonEpisodeMatch(stream)) {
|
||||
const detail =
|
||||
stream.parsedFile?.title +
|
||||
' ' +
|
||||
(stream.parsedFile?.seasonEpisode?.join(' x ') || 'Unknown');
|
||||
|
||||
skipReasons.seasonEpisodeMatching.total++;
|
||||
skipReasons.seasonEpisodeMatching.details[detail] =
|
||||
(skipReasons.seasonEpisodeMatching.details[detail] || 0) + 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
const useMinMax = (
|
||||
minMax: [number, number] | undefined,
|
||||
defaults: { min: number; max: number }
|
||||
|
||||
@@ -39,7 +39,10 @@ export function ConfigModal({
|
||||
return;
|
||||
}
|
||||
|
||||
setUserData(result.data.config);
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
...result.data!.config, // we just checked that this is not null
|
||||
}));
|
||||
setUuid(uuid);
|
||||
setPassword(password);
|
||||
setEncryptedPassword(result.data.encryptedPassword);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FaRegTrashAlt,
|
||||
FaFileExport,
|
||||
FaFileImport,
|
||||
FaEquals,
|
||||
} from 'react-icons/fa';
|
||||
import { FaTextSlash } from 'react-icons/fa6';
|
||||
import {
|
||||
@@ -267,9 +268,9 @@ function Content() {
|
||||
<MdPerson className="text-lg mr-3" />
|
||||
Seeders
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="strict-title-matching">
|
||||
<FaTextSlash className="text-lg mr-3" />
|
||||
Strict Title Matching
|
||||
<TabsTrigger value="title-matching">
|
||||
<FaEquals className="text-lg mr-3" />
|
||||
Matching
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="keyword">
|
||||
<FaTextSlash className="text-lg mr-3" />
|
||||
@@ -523,28 +524,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedResolutions || []}
|
||||
includedOptions={userData.includedResolutions || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredResolutions: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredResolutions: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedResolutions: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedResolutions: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={RESOLUTIONS.map((resolution) => ({
|
||||
name: resolution,
|
||||
@@ -565,28 +566,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedQualities || []}
|
||||
includedOptions={userData.includedQualities || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredQualities: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredQualities: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedQualities: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedQualities: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={QUALITIES.map((quality) => ({
|
||||
name: quality,
|
||||
@@ -607,28 +608,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedEncodes || []}
|
||||
includedOptions={userData.includedEncodes || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredEncodes: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredEncodes: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedEncodes: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedEncodes: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={ENCODES.map((encode) => ({
|
||||
name: encode,
|
||||
@@ -649,28 +650,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedStreamTypes || []}
|
||||
includedOptions={userData.includedStreamTypes || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredStreamTypes: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredStreamTypes: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedStreamTypes: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedStreamTypes: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={STREAM_TYPES.map((streamType) => ({
|
||||
name: streamType,
|
||||
@@ -691,28 +692,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedVisualTags || []}
|
||||
includedOptions={userData.includedVisualTags || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredVisualTags: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredVisualTags: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedVisualTags: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedVisualTags: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={VISUAL_TAGS.map((visualTag) => ({
|
||||
name: visualTag,
|
||||
@@ -731,28 +732,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedAudioTags || []}
|
||||
includedOptions={userData.includedAudioTags || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredAudioTags: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredAudioTags: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedAudioTags: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedAudioTags: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={AUDIO_TAGS.map((audioTag) => ({
|
||||
name: audioTag,
|
||||
@@ -771,28 +772,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedAudioChannels || []}
|
||||
includedOptions={userData.includedAudioChannels || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredAudioChannels: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredAudioChannels: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedAudioChannels: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedAudioChannels: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={AUDIO_CHANNELS.map((audioChannel) => ({
|
||||
name: audioChannel,
|
||||
@@ -811,28 +812,28 @@ function Content() {
|
||||
excludedOptions={userData.excludedLanguages || []}
|
||||
includedOptions={userData.includedLanguages || []}
|
||||
onPreferredChange={(preferred) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
preferredLanguages: preferred,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onRequiredChange={(required) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
requiredLanguages: required,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onExcludedChange={(excluded) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
excludedLanguages: excluded,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
onIncludedChange={(included) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
includedLanguages: included,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
options={LANGUAGES.map((language) => ({
|
||||
name: language
|
||||
@@ -952,104 +953,203 @@ function Content() {
|
||||
</SettingsCard>
|
||||
</PageWrapper>
|
||||
</TabsContent>
|
||||
<TabsContent value="strict-title-matching" className="space-y-4">
|
||||
<TabsContent value="title-matching" className="space-y-4">
|
||||
<PageWrapper>
|
||||
<HeadingWithPageControls heading="Strict Title Matching" />
|
||||
<SettingsCard
|
||||
title="Strict Title Matching"
|
||||
description="Any streams which don't specifically match the requested title will be filtered out. You can optionally choose to only apply it to specific request types and addons"
|
||||
>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
side="right"
|
||||
value={userData.strictTitleMatch?.enabled}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
strictTitleMatch: {
|
||||
...userData.strictTitleMatch,
|
||||
enabled: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<HeadingWithPageControls heading="Matching" />
|
||||
<div className="space-y-4">
|
||||
<SettingsCard
|
||||
title="Title Matching"
|
||||
description="Any streams which don't specifically match the requested title will be filtered out. You can optionally choose to only apply it to specific request types and addons"
|
||||
>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
side="right"
|
||||
value={userData.titleMatching?.enabled ?? false}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
titleMatching: {
|
||||
...(prev.titleMatching || {}),
|
||||
enabled: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="TMDB Access Token"
|
||||
help={
|
||||
<>
|
||||
<p>
|
||||
A TMDB access token is required to fetch titles from the
|
||||
TMDB API. You can get it from your{' '}
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
className="text-[--brand] hover:underline"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
TMDB Account Settings
|
||||
</a>
|
||||
</p>
|
||||
<p></p>
|
||||
</>
|
||||
}
|
||||
required={!status?.settings.tmdbApiAvailable}
|
||||
value={userData.tmdbAccessToken}
|
||||
type="password"
|
||||
placeholder="Enter your TMDB access token"
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
tmdbAccessToken: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
disabled={!userData.titleMatching?.enabled}
|
||||
label="Matching Mode"
|
||||
options={['exact', 'contains'].map((mode) => ({
|
||||
label: mode,
|
||||
value: mode,
|
||||
}))}
|
||||
defaultValue="exact"
|
||||
value={userData.titleMatching?.mode}
|
||||
help={
|
||||
userData.titleMatching?.mode === 'contains'
|
||||
? "Streams whose detected title doesn't contain the requested title will be excluded"
|
||||
: "Streams whose detected title doesn't match the requested title exactly will be excluded"
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
titleMatching: {
|
||||
...prev.titleMatching,
|
||||
mode: value as 'exact' | 'contains' | undefined,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Combobox
|
||||
label="Request Types"
|
||||
emptyMessage="There aren't any request types to choose from..."
|
||||
help="Request types that will use strict title matching. Leave blank to apply to all request types."
|
||||
options={TYPES.map((type) => ({
|
||||
label: type,
|
||||
value: type,
|
||||
text: type,
|
||||
}))}
|
||||
value={userData.strictTitleMatch?.requestTypes}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
strictTitleMatch: {
|
||||
...userData.strictTitleMatch,
|
||||
requestTypes: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Combobox
|
||||
label="Addons"
|
||||
help="Addons that will use strict title matching. Leave blank to apply to all addons."
|
||||
emptyMessage="You haven't installed any addons yet..."
|
||||
options={userData.presets.map((preset) => ({
|
||||
label: preset.options.name,
|
||||
type: preset.options.name,
|
||||
value: JSON.stringify(preset),
|
||||
}))}
|
||||
value={userData.strictTitleMatch?.addons || []}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
strictTitleMatch: {
|
||||
...userData.strictTitleMatch,
|
||||
addons: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="TMDB Access Token"
|
||||
help={
|
||||
<>
|
||||
<p>
|
||||
A TMDB access token is required to fetch titles from
|
||||
the TMDB API. You can get it from your{' '}
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
className="text-[--brand] hover:underline"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
TMDB Account Settings
|
||||
</a>
|
||||
</p>
|
||||
<p></p>
|
||||
</>
|
||||
}
|
||||
disabled={!userData.titleMatching?.enabled}
|
||||
required={!status?.settings.tmdbApiAvailable}
|
||||
value={userData.tmdbAccessToken}
|
||||
type="password"
|
||||
placeholder="Enter your TMDB access token"
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
tmdbAccessToken: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Combobox
|
||||
disabled={!userData.titleMatching?.enabled}
|
||||
label="Request Types"
|
||||
emptyMessage="There aren't any request types to choose from..."
|
||||
help="Request types that will use strict title matching. Leave blank to apply to all request types."
|
||||
options={TYPES.map((type) => ({
|
||||
label: type,
|
||||
value: type,
|
||||
text: type,
|
||||
}))}
|
||||
value={userData.titleMatching?.requestTypes}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
titleMatching: {
|
||||
...prev.titleMatching,
|
||||
requestTypes: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Combobox
|
||||
disabled={!userData.titleMatching?.enabled}
|
||||
label="Addons"
|
||||
help="Addons that will use strict title matching. Leave blank to apply to all addons."
|
||||
emptyMessage="You haven't installed any addons yet..."
|
||||
options={userData.presets.map((preset) => ({
|
||||
label: preset.options.name,
|
||||
type: preset.options.name,
|
||||
value: JSON.stringify(preset),
|
||||
}))}
|
||||
value={userData.titleMatching?.addons || []}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
titleMatching: {
|
||||
...prev.titleMatching,
|
||||
addons: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Season/Episode Matching"
|
||||
description="Any streams which don't specifically match the requested season/episode will be filtered out. You can optionally choose to only apply it to specific request types and addons"
|
||||
>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
side="right"
|
||||
value={userData.seasonEpisodeMatching?.enabled ?? false}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
seasonEpisodeMatching: {
|
||||
...(prev.seasonEpisodeMatching || {}),
|
||||
enabled: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Combobox
|
||||
disabled={!userData.seasonEpisodeMatching?.enabled}
|
||||
label="Request Types"
|
||||
help="Request types that will use season/episode matching. Leave blank to apply to all request types."
|
||||
emptyMessage="There aren't any request types to choose from..."
|
||||
options={TYPES.map((type) => ({
|
||||
label: type,
|
||||
value: type,
|
||||
text: type,
|
||||
}))}
|
||||
value={userData.seasonEpisodeMatching?.requestTypes}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
seasonEpisodeMatching: {
|
||||
...prev.seasonEpisodeMatching,
|
||||
requestTypes: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Combobox
|
||||
disabled={!userData.seasonEpisodeMatching?.enabled}
|
||||
label="Addons"
|
||||
help="Addons that will use season/episode matching. Leave blank to apply to all addons."
|
||||
emptyMessage="You haven't installed any addons yet..."
|
||||
options={userData.presets.map((preset) => ({
|
||||
label: preset.options.name,
|
||||
type: preset.options.name,
|
||||
value: JSON.stringify(preset),
|
||||
}))}
|
||||
value={userData.seasonEpisodeMatching?.addons || []}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
seasonEpisodeMatching: {
|
||||
...prev.seasonEpisodeMatching,
|
||||
addons: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</TabsContent>
|
||||
<TabsContent value="keyword" className="space-y-4">
|
||||
|
||||
@@ -148,13 +148,13 @@ function Content() {
|
||||
// Keep userData in sync with custom formatter fields
|
||||
useEffect(() => {
|
||||
if (selectedFormatter === constants.CUSTOM_FORMATTER) {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
formatter: {
|
||||
id: constants.CUSTOM_FORMATTER,
|
||||
definition: { name: customName, description: customDescription },
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [customName, customDescription, selectedFormatter]);
|
||||
@@ -165,8 +165,8 @@ function Content() {
|
||||
setCustomName(userData.formatter?.definition?.name || '');
|
||||
setCustomDescription(userData.formatter?.definition?.description || '');
|
||||
}
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
formatter: {
|
||||
id: value as constants.FormatterType,
|
||||
definition:
|
||||
@@ -177,7 +177,7 @@ function Content() {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const formatStream = useCallback(async () => {
|
||||
|
||||
@@ -40,10 +40,10 @@ function Content() {
|
||||
side="right"
|
||||
value={userData.hideErrors}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
hideErrors: value,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Combobox
|
||||
@@ -57,10 +57,10 @@ function Content() {
|
||||
emptyMessage="No resources found"
|
||||
value={userData.hideErrorsForResources}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
hideErrorsForResources: value as (typeof RESOURCES)[number][],
|
||||
});
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</SettingsCard>
|
||||
@@ -73,10 +73,10 @@ function Content() {
|
||||
side="right"
|
||||
value={userData.precacheNextEpisode}
|
||||
onValueChange={(value) => {
|
||||
setUserData({
|
||||
...userData,
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
precacheNextEpisode: value,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -42,9 +42,7 @@ const DefaultUserData: UserData = {
|
||||
|
||||
interface UserDataContextType {
|
||||
userData: UserData;
|
||||
setUserData: (
|
||||
data: UserData | null | ((prev: UserData) => UserData | null)
|
||||
) => void;
|
||||
setUserData: (data: ((prev: UserData) => UserData | null) | null) => void;
|
||||
uuid: string | null;
|
||||
setUuid: (uuid: string | null) => void;
|
||||
password: string | null;
|
||||
@@ -66,17 +64,15 @@ export function UserDataProvider({ children }: { children: React.ReactNode }) {
|
||||
>(null);
|
||||
|
||||
const safeSetUserData = (
|
||||
data: UserData | null | ((prev: UserData) => UserData | null)
|
||||
data: ((prev: UserData) => UserData | null) | null
|
||||
) => {
|
||||
if (typeof data === 'function') {
|
||||
setUserData((prev) => {
|
||||
const result = (data as (prev: UserData) => UserData | null)(prev);
|
||||
return result === null ? DefaultUserData : result;
|
||||
});
|
||||
} else if (data === null) {
|
||||
if (data === null) {
|
||||
setUserData(DefaultUserData);
|
||||
} else {
|
||||
setUserData(data);
|
||||
setUserData((prev) => {
|
||||
const result = data(prev);
|
||||
return result === null ? DefaultUserData : result;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user