feat: add age rating filter system

- Add age rating selection component with visual badges
- Implement US certification filtering for movies and TV shows
- Add certification parameters to TMDB API calls
- Create age rating data structure with G, PG, PG-13, R, NC-17 ratings
- Add info message about trending catalogs limitation
- Handle certification filtering in discover and search endpoints

This feature allows users to filter content based on US age ratings,
helping them find appropriate content for their desired audience.
This commit is contained in:
mrcanelas
2025-02-14 18:39:36 -03:00
parent 17a8533c38
commit c529cbca7d
18 changed files with 1538 additions and 47 deletions
+2 -3
View File
@@ -82,7 +82,6 @@ addon.get("/:catalogChoices?/catalog/:type/:id/:extra?.json", async function (re
const { catalogChoices, type, id } = req.params;
const config = parseConfig(catalogChoices)
const language = config.language || DEFAULT_LANGUAGE;
const includeAdult = config.includeAdult || false
const rpdbkey = config.rpdbkey
const sessionId = config.sessionId
const { genre, skip, search } = req.params.extra
@@ -96,7 +95,7 @@ addon.get("/:catalogChoices?/catalog/:type/:id/:extra?.json", async function (re
const args = [type, language, page];
if (search) {
metas = await getSearch(type, language, search, includeAdult);
metas = await getSearch(type, language, search, config);
} else {
switch (id) {
case "tmdb.trending":
@@ -109,7 +108,7 @@ addon.get("/:catalogChoices?/catalog/:type/:id/:extra?.json", async function (re
metas = await getWatchList(...args, sessionId);
break;
default:
metas = await getCatalog(...args, id, genre);
metas = await getCatalog(...args, id, genre, config);
break;
}
}
+27 -6
View File
@@ -7,11 +7,9 @@ const { getTrending } = require("./getTrending");
const { parseMedia } = require("../utils/parseProps");
const CATALOG_TYPES = require("../static/catalog-types.json");
async function getCatalog(type, language, page, id, genre) {
if (id === "tmdb.top" && !genre) return await getTrending(type, language, page, "week");
async function getCatalog(type, language, page, id, genre, config) {
const genreList = await getGenreList(language, type);
const parameters = await buildParameters(type, language, page, id, genre, genreList);
const parameters = await buildParameters(type, language, page, id, genre, genreList, config);
const fetchFunction = type === "movie" ? moviedb.discoverMovie.bind(moviedb) : moviedb.discoverTv.bind(moviedb);
@@ -22,9 +20,32 @@ async function getCatalog(type, language, page, id, genre) {
.catch(console.error);
}
async function buildParameters(type, language, page, id, genre, genreList) {
async function buildParameters(type, language, page, id, genre, genreList, config) {
const languages = await getLanguages();
const parameters = { language, page, 'vote_count.gte': 10 };
const parameters = { language, page };
if (config.ageRating) {
switch(config.ageRating) {
case "G":
parameters.certification_country = "US";
parameters.certification = type === "movie" ? "G" : "TV-G";
break;
case "PG":
parameters.certification_country = "US";
parameters.certification = type === "movie" ? ["G", "PG"].join("|") : ["TV-G", "TV-PG"].join("|");
break;
case "PG-13":
parameters.certification_country = "US";
parameters.certification = type === "movie" ? ["G", "PG", "PG-13"].join("|") : ["TV-G", "TV-PG", "TV-14"].join("|");
break;
case "R":
parameters.certification_country = "US";
parameters.certification = type === "movie" ? ["G", "PG", "PG-13", "R"].join("|") : ["TV-G", "TV-PG", "TV-14", "TV-MA"].join("|");
break;
case "NC-17":
break;
}
}
if (id.includes("streaming")) {
const provider = findProvider(id.split(".")[1]);
+29 -3
View File
@@ -8,18 +8,44 @@ function isNonLatin(text) {
return /[^\u0000-\u007F]/.test(text);
}
async function getSearch(type, language, query, include_adult) {
async function getSearch(type, language, query, config) {
let searchQuery = query;
if (isNonLatin(query)) {
searchQuery = transliterate(query);
}
const parameters = {
query: searchQuery,
language,
include_adult: config.includeAdult
};
// Adicionar filtro de classificação etária
if (config.ageRating) {
parameters.certification_country = "US";
switch(config.ageRating) {
case "G":
parameters.certification = type === "movie" ? "G" : "TV-G";
break;
case "PG":
parameters.certification = type === "movie" ? ["G", "PG"].join("|") : ["TV-G", "TV-PG"].join("|");
break;
case "PG-13":
parameters.certification = type === "movie" ? ["G", "PG", "PG-13"].join("|") : ["TV-G", "TV-PG", "TV-14"].join("|");
break;
case "R":
parameters.certification = type === "movie" ? ["G", "PG", "PG-13", "R"].join("|") : ["TV-G", "TV-PG", "TV-14", "TV-MA"].join("|");
break;
// NC-17 não tem filtro (mostra tudo)
}
}
if (type === "movie") {
const searchMovie = [];
await moviedb
.searchMovie({ query, language, include_adult })
.searchMovie(parameters)
.then((res) => {
res.results.map((el) => {searchMovie.push(parseMedia(el, 'movie'));});
})
@@ -61,7 +87,7 @@ async function getSearch(type, language, query, include_adult) {
const searchTv = [];
await moviedb
.searchTv({ query, language, include_adult })
.searchTv(parameters)
.then((res) => {
res.results.map((el) => {searchTv.push(parseMedia(el, 'tv'))});
})
@@ -0,0 +1,61 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ageRatings } from "@/data/ageRatings";
import { useConfig } from "@/contexts/ConfigContext";
import { Info } from "lucide-react";
export function AgeRatingSelect() {
const { ageRating, setAgeRating } = useConfig();
const selectedRating = ageRatings.find(rating => rating.id === ageRating);
const handleChange = (value: string) => {
setAgeRating(value === "NONE" ? undefined : value);
};
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Age Rating</label>
<div className="flex items-center text-xs text-muted-foreground">
<Info className="h-3 w-3 mr-1" />
Not available for trending catalogs
</div>
</div>
<Select value={ageRating || "NONE"} onValueChange={handleChange}>
<SelectTrigger>
<SelectValue>
{selectedRating && (
<div className="flex items-center gap-2">
<Badge className={`${selectedRating.badge.color} text-white w-16 justify-center`}>
{selectedRating.badge.text}
</Badge>
<span>{selectedRating.name}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-background border shadow-md">
{ageRatings.map((rating) => (
<SelectItem key={rating.id} value={rating.id}>
<div className="flex items-center gap-2">
<Badge className={`${rating.badge.color} text-white w-16 justify-center`}>
{rating.badge.text}
</Badge>
<div className="flex flex-col">
<span>{rating.name}</span>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -21,6 +21,7 @@ export function MultiActionButton() {
tmdbPrefix,
language,
sessionId,
ageRating,
catalogs
} = useConfig();
const [currentAction, setCurrentAction] = useState<number>(0);
@@ -33,6 +34,7 @@ export function MultiActionButton() {
tmdbPrefix,
language,
sessionId,
ageRating,
catalogs: catalogs.map(catalog => ({
...catalog,
enabled: true
+151
View File
@@ -0,0 +1,151 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+3
View File
@@ -25,6 +25,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) {
const [sessionId, setSessionId] = useState("");
const [streaming, setStreaming] = useState<string[]>([]);
const [catalogs, setCatalogs] = useState<CatalogConfig[]>([]);
const [ageRating, setAgeRating] = useState<string | undefined>(undefined);
const loadConfigFromUrl = () => {
try {
@@ -80,6 +81,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) {
sessionId,
streaming,
catalogs,
ageRating,
setRpdbkey,
setMdblistkey,
setIncludeAdult,
@@ -89,6 +91,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) {
setSessionId,
setStreaming,
setCatalogs,
setAgeRating,
loadConfigFromUrl
};
+2
View File
@@ -17,6 +17,7 @@ export interface ConfigContextType {
sessionId: string;
streaming: string[];
catalogs: CatalogConfig[];
ageRating: string;
setRpdbkey: (value: string) => void;
setMdblistkey: (value: string) => void;
setIncludeAdult: (value: boolean) => void;
@@ -26,6 +27,7 @@ export interface ConfigContextType {
setSessionId: (value: string) => void;
setStreaming: (value: string[]) => void;
setCatalogs: (value: CatalogConfig[] | ((prev: CatalogConfig[]) => CatalogConfig[])) => void;
setAgeRating: (value: string) => void;
}
export const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
+76
View File
@@ -0,0 +1,76 @@
export interface AgeRating {
id: string;
name: string;
description: string;
order: number;
badge: {
text: string;
color: string;
};
}
export const ageRatings: AgeRating[] = [
{
id: "NONE",
name: "No Restriction",
description: "Show all content without age restrictions",
order: 0,
badge: {
text: "ALL",
color: "bg-gray-500"
}
},
{
id: "G",
name: "General Audience",
description: "All ages admitted. There is no content that would be objectionable to most parents.",
order: 1,
badge: {
text: "G",
color: "bg-green-500"
}
},
{
id: "PG",
name: "Parental Guidance",
description: "Some material may not be suitable for children under 10. May contain mild language, crude/suggestive humor, scary moments and/or violence.",
order: 2,
badge: {
text: "PG",
color: "bg-blue-500"
}
},
{
id: "PG-13",
name: "Parental Guidance 13",
description: "Some material may be inappropriate for children under 13. May contain sexual content, brief nudity, strong language, mature themes and intense action violence.",
order: 3,
badge: {
text: "PG-13",
color: "bg-yellow-500"
}
},
{
id: "R",
name: "Restricted",
description: "Under 17 requires accompanying parent or adult guardian. May contain strong profanity, graphic sexuality, nudity, strong violence, horror, gore, and drug use.",
order: 4,
badge: {
text: "R",
color: "bg-red-500"
}
},
{
id: "NC-17",
name: "Adults Only",
description: "Adults only. Contains excessive graphic violence, intense sex, depraved behavior, explicit drug abuse or any other elements beyond R rating.",
order: 5,
badge: {
text: "NC-17",
color: "bg-purple-500"
}
}
];
// Ordenar por ordem
ageRatings.sort((a, b) => a.order - b.order);
+1 -4
View File
@@ -6,6 +6,7 @@ interface AddonConfig {
tmdbPrefix?: boolean;
language?: string;
sessionId?: string;
ageRating?: string;
catalogs?: Array<{
id: string;
type: string;
@@ -15,20 +16,16 @@ interface AddonConfig {
}
export function generateAddonUrl(config: AddonConfig): string {
// Criar um novo objeto apenas com os valores necessários
const configToEncode = {
...config,
// Remove os itens se forem nulos/vazios
rpdbkey: config.rpdbkey || undefined,
mdblistkey: config.mdblistkey || undefined,
sessionId: config.sessionId || undefined,
// Filtra apenas catálogos habilitados
catalogs: config.catalogs?.filter(c => c.enabled).map(({ id, type, showInHome }) => ({
id,
type,
showInHome
})) || undefined,
// Converte booleanos para strings
includeAdult: config.includeAdult === true ? "true" : undefined,
provideImdbId: config.provideImdbId === true ? "true" : undefined,
tmdbPrefix: config.tmdbPrefix === true ? "true" : undefined
+2 -2
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
}
+9 -5
View File
@@ -1,6 +1,7 @@
import { Card } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { useConfig } from "@/contexts/ConfigContext";
import { AgeRatingSelect } from "@/components/AgeRatingSelect";
const Others = () => {
const { includeAdult, setIncludeAdult } = useConfig();
@@ -16,16 +17,19 @@ const Others = () => {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="flex flex-row items-center justify-between p-4 sm:p-6 hover:shadow-lg transition-shadow cursor-pointer">
<Card className="p-6">
<AgeRatingSelect />
</Card>
<Card className="flex flex-row items-center justify-between p-6">
<div className="space-y-0.5">
<h1 className="text-sm font-semibold mb-1">Enable adult content</h1>
<p className="text-gray-500 text-sm">
Include adult content in search results.
<h2 className="text-sm font-medium">Enable adult content</h2>
<p className="text-sm text-muted-foreground">
Include adult content in search results
</p>
</div>
<Switch
checked={includeAdult}
onCheckedChange={() => setIncludeAdult(!includeAdult)}
onCheckedChange={setIncludeAdult}
/>
</Card>
<Card className="flex flex-row items-center justify-between p-4 sm:p-6 hover:shadow-lg transition-shadow cursor-pointer">
+1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="react" />
+1134 -19
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -29,9 +29,10 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
@@ -44,6 +45,7 @@
"cache-manager-mongodb": "^0.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"fanart.tv-api": "^1.0.3",
+4 -4
View File
@@ -9,8 +9,8 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
@@ -18,13 +18,13 @@
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./configure/src/*"]
}
},
"types": ["vite/client", "react", "react-dom"]
},
"include": ["src"]
"include": ["configure/src"]
}