diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index c7b449398..4203e4fde 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -195,6 +195,8 @@ const OptionDefinition = z.object({ 'socials', 'oauth', 'custom-nntp-servers', + 'date', + 'service-tag', ]), oauth: z .object({ diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index 02908858a..83262506a 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -926,6 +926,51 @@ function validateOption( } } + if (option.type === 'date') { + if (typeof value !== 'string' || value === '') { + throw new Error( + `Option ${option.id} must be a non-empty string (date), got ${typeof value === 'string' ? 'empty string' : typeof value}` + ); + } + // Validate ISO date format using Zod's z.iso.date() + const dateResult = z.iso.date().safeParse(value); + if (!dateResult.success) { + throw new Error( + `Option ${option.id} must be a valid ISO date (YYYY-MM-DD), got ${value}` + ); + } + } + + if (option.type === 'service-tag') { + if (typeof value !== 'object' || value === null) { + throw new Error( + `Option ${option.id} must be an object, got ${typeof value}` + ); + } + if (typeof value.type !== 'string') { + throw new Error( + `Option ${option.id}.type must be a string, got ${typeof value.type}` + ); + } + if ( + value.expiryDate !== undefined && + typeof value.expiryDate !== 'string' + ) { + throw new Error( + `Option ${option.id}.expiryDate must be a string if provided, got ${typeof value.expiryDate}` + ); + } + // Validate expiry date format using Zod's z.iso.date() if present + if (value.expiryDate) { + const expiryDateResult = z.iso.date().safeParse(value.expiryDate); + if (!expiryDateResult.success) { + throw new Error( + `Option ${option.id}.expiryDate must be a valid ISO date (YYYY-MM-DD), got ${value.expiryDate}` + ); + } + } + } + return value; } diff --git a/packages/frontend/src/components/menu/addons.tsx b/packages/frontend/src/components/menu/addons.tsx index 544c2ee96..7cb41ddd5 100644 --- a/packages/frontend/src/components/menu/addons.tsx +++ b/packages/frontend/src/components/menu/addons.tsx @@ -1,5 +1,11 @@ 'use client'; -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import React, { + useState, + useMemo, + useEffect, + useRef, + useCallback, +} from 'react'; import { PageWrapper } from '../shared/page-wrapper'; import { useStatus } from '@/context/status'; import { useUserData } from '@/context/userData'; @@ -78,6 +84,15 @@ import { NumberInput } from '../ui/number-input'; import { useDisclosure } from '@/hooks/disclosure'; import { useMode } from '@/context/mode'; import { Select } from '../ui/select'; +import { ServiceExpiryBadge } from '../ui/badge'; +import { + DEFAULT_USER_TAG, + UserAddonCategory, + UserTagType, + getUserCategoryMetadata, + getUserCategoryOptions, + getUserTagOptions, +} from './user-addon-metadata'; interface CatalogModification { id: string; @@ -414,45 +429,75 @@ function Content() { ) : ( - userData.presets.map((preset) => { - const presetMetadata = status?.settings?.presets.find( - (p: any) => p.ID === preset.type - ); - return ( - { - setModalPreset(presetMetadata); - setModalInitialValues({ - options: { ...preset.options }, - }); - setModalMode('edit'); - setEditingAddonId(preset.instanceId); - setModalOpen(true); - }} - onRemove={() => { - setUserData((prev) => ({ - ...prev, - presets: prev.presets.filter( - (a) => a.instanceId !== preset.instanceId - ), - })); - }} - onToggleEnabled={(v: boolean) => { - setUserData((prev) => ({ - ...prev, - presets: prev.presets.map((p) => - p.instanceId === preset.instanceId - ? { ...p, enabled: v } - : p - ), - })); - }} - /> - ); - }) + (() => { + // Group addons by user category and render with headers + let lastCategory: UserAddonCategory | undefined = + undefined; + return userData.presets.map((preset) => { + const presetMetadata = + status?.settings?.presets.find( + (p: any) => p.ID === preset.type + ); + const userCategory = preset.options + ?.userCategory as UserAddonCategory | undefined; + const categoryMeta = + getUserCategoryMetadata(userCategory); + const showHeader = + !!userCategory && userCategory !== lastCategory; + const categoryLabel = categoryMeta?.label; + const categoryColor = categoryMeta?.color; + if (userCategory) { + lastCategory = userCategory; + } + return ( + + {showHeader && ( +
  • + {categoryLabel} +
  • + )} + { + setModalPreset(presetMetadata); + setModalInitialValues({ + options: { ...preset.options }, + }); + setModalMode('edit'); + setEditingAddonId(preset.instanceId); + setModalOpen(true); + }} + onRemove={() => { + setUserData((prev) => ({ + ...prev, + presets: prev.presets.filter( + (a) => + a.instanceId !== preset.instanceId + ), + })); + }} + onToggleEnabled={(v: boolean) => { + setUserData((prev) => ({ + ...prev, + presets: prev.presets.map((p) => + p.instanceId === preset.instanceId + ? { ...p, enabled: v } + : p + ), + })); + }} + /> +
    + ); + }); + })() )} @@ -825,9 +870,29 @@ function SortableAddonItem({ ); }; + // Get the user category color for the left border + const userCategory = preset.options?.userCategory as + | UserAddonCategory + | undefined; + const categoryColor = userCategory + ? getUserCategoryMetadata(userCategory)?.color + : undefined; + + // Get the user tag info for the badge + const userTag = preset.options?.userTag; + const tagType = (userTag?.type || 'none') as UserTagType; + const expiryDate = userTag?.expiryDate || ''; + return (
  • -
    +
    -

    - {preset.options.name} -

    +
    +

    + {preset.options.name} +

    + {tagType !== 'none' && ( + + )} +
    @@ -1146,19 +1220,124 @@ function AddonModal({ onSubmit: (values: Record) => void; }) { const { mode: configMode } = useMode(); - const [values, setValues] = useState>(initialValues); + + const normalizeValues = useCallback( + (incoming?: Record) => ({ + ...(incoming ?? {}), + options: { ...((incoming?.options as Record) ?? {}) }, + }), + [] + ); + + const [values, setValues] = useState>(() => + normalizeValues(initialValues) + ); useEffect(() => { if (open) { - setValues(initialValues); + setValues(normalizeValues(initialValues)); } else { // when closing, delay the reset to allow the animation to finish // so that the user doesn't see the values being reset setTimeout(() => { - setValues(initialValues); + setValues(normalizeValues(initialValues)); }, 150); } - }, [open, initialValues]); + }, [open, initialValues, normalizeValues]); let dynamicOptions: Option[] = presetMetadata?.OPTIONS || []; + + const currentCategoryValue = (values.options?.userCategory ?? + initialValues.options?.userCategory) as string | undefined; + const currentTagType = (values.options?.userTag?.type ?? + initialValues.options?.userTag?.type) as string | undefined; + + const categoryOptions = getUserCategoryOptions(currentCategoryValue); + const tagOptions = getUserTagOptions(currentTagType); + + // Inject userCategory and userTag options if they're not already present + const hasUserCategory = dynamicOptions.some( + (opt) => opt.id === 'userCategory' + ); + const hasUserTag = dynamicOptions.some((opt) => opt.id === 'userTag'); + + useEffect(() => { + setValues((prev) => { + const nextOptions = { ...(prev.options ?? {}) }; + let changed = false; + if (!('userTag' in nextOptions)) { + nextOptions.userTag = DEFAULT_USER_TAG; + changed = true; + } + if (!('userCategory' in nextOptions) && currentCategoryValue) { + nextOptions.userCategory = currentCategoryValue; + changed = true; + } + return changed ? { ...prev, options: nextOptions } : prev; + }); + }, [currentCategoryValue]); + + if (!hasUserCategory || !hasUserTag) { + const userOptionsToAdd: Option[] = []; + if (!hasUserCategory) { + userOptionsToAdd.push({ + id: 'userCategory', + name: 'Category', + description: + 'Assign a category to this addon for visual grouping in the addon list', + type: 'select', + required: false, + showInSimpleMode: true, + default: undefined, + options: categoryOptions.map((cat) => ({ + label: cat.label, + value: cat.value, + })), + }); + } + if (!hasUserTag) { + userOptionsToAdd.push({ + id: 'userTag', + name: 'Service Tag', + description: + 'Add a tag to this addon (e.g., subscription status, expiry date)', + type: 'service-tag', + required: false, + showInSimpleMode: true, + default: DEFAULT_USER_TAG, + options: tagOptions.map((tag) => ({ + label: tag.label, + value: tag.value, + })), + }); + } + dynamicOptions = [...dynamicOptions, ...userOptionsToAdd]; + } + + // Ensure existing options carry forward any custom values + dynamicOptions = dynamicOptions.map((opt) => { + if (opt.id === 'userCategory') { + return { + ...opt, + type: 'select', + options: categoryOptions.map((cat) => ({ + label: cat.label, + value: cat.value, + })), + }; + } + if (opt.id === 'userTag') { + return { + ...opt, + type: 'service-tag', + default: opt.default ?? DEFAULT_USER_TAG, + options: tagOptions.map((tag) => ({ + label: tag.label, + value: tag.value, + })), + }; + } + return opt; + }); + if (configMode === 'noob') { dynamicOptions = dynamicOptions.filter((opt: any) => { if (opt?.showInSimpleMode === false) return false; @@ -1166,6 +1345,20 @@ function AddonModal({ }); } + // Sort options so userCategory and userTag are always at the end + dynamicOptions = [...dynamicOptions].sort((a, b) => { + const userOptionsIds = ['userCategory', 'userTag']; + const aIsUserOption = userOptionsIds.includes(a.id); + const bIsUserOption = userOptionsIds.includes(b.id); + if (aIsUserOption && !bIsUserOption) return 1; + if (!aIsUserOption && bIsUserOption) return -1; + if (aIsUserOption && bIsUserOption) { + // Keep userCategory before userTag + return userOptionsIds.indexOf(a.id) - userOptionsIds.indexOf(b.id); + } + return 0; + }); + // Check if all required fields are filled const allRequiredFilled = dynamicOptions.every((opt: any) => { if (!opt.required) return true; @@ -1220,7 +1413,21 @@ function AddonModal({ } } if (allRequiredFilled) { - onSubmit(values); + const mergedOptions = { ...(values.options ?? {}) }; + dynamicOptions.forEach((opt) => { + if (mergedOptions[opt.id] === undefined) { + if (opt.id === 'userTag') { + mergedOptions[opt.id] = DEFAULT_USER_TAG; + } else if (opt.default !== undefined) { + mergedOptions[opt.id] = opt.default; + } + } + }); + + onSubmit({ + ...values, + options: mergedOptions, + }); } else { toast.error('Please fill in all required fields'); } diff --git a/packages/frontend/src/components/menu/service-expiry-badge.tsx b/packages/frontend/src/components/menu/service-expiry-badge.tsx new file mode 100644 index 000000000..c7ece5665 --- /dev/null +++ b/packages/frontend/src/components/menu/service-expiry-badge.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react'; + +interface ServiceExpiryBadgeProps { + text: string; + colors: { + background: string; + foreground: string; + }; + title?: string; +} + +export const ServiceExpiryBadge = memo(function ServiceExpiryBadge({ + text, + colors, + title, +}: ServiceExpiryBadgeProps) { + return ( + + {text} + + ); +}); diff --git a/packages/frontend/src/components/menu/service-expiry-date-picker.tsx b/packages/frontend/src/components/menu/service-expiry-date-picker.tsx new file mode 100644 index 000000000..063d45184 --- /dev/null +++ b/packages/frontend/src/components/menu/service-expiry-date-picker.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Button } from '../ui/button'; +import { Popover } from '../ui/popover'; +import { cn } from '../ui/core/styling'; +import { FiCalendar, FiChevronLeft, FiChevronRight } from 'react-icons/fi'; + +interface ServiceExpiryDatePickerProps { + value?: string; + onSelect: (value: string) => void; + onClear: () => void; +} + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export function ServiceExpiryDatePicker({ + value, + onSelect, + onClear, +}: ServiceExpiryDatePickerProps) { + const [open, setOpen] = useState(false); + const selectedDate = useMemo(() => parseDate(value), [value]); + const [currentMonth, setCurrentMonth] = useState( + () => selectedDate ?? today() + ); + + useEffect(() => { + if (open) { + setCurrentMonth(selectedDate ?? today()); + } + }, [open, selectedDate]); + + const monthLabel = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + month: 'long', + year: 'numeric', + }).format(currentMonth), + [currentMonth] + ); + + const weeks = useMemo(() => buildCalendar(currentMonth), [currentMonth]); + + const handleSelect = (date: Date) => { + onSelect(formatDate(date)); + setOpen(false); + }; + + const handleClear = () => { + onClear(); + setOpen(false); + }; + + const handleToday = () => { + const now = today(); + setCurrentMonth(now); + onSelect(formatDate(now)); + setOpen(false); + }; + + return ( + } + > + {selectedDate ? selectedDate.toLocaleDateString() : 'Pick a date'} + + } + className="w-[18rem] space-y-3" + > +
    +
    + +
    + {DAY_LABELS.map((label) => ( + + {label} + + ))} +
    + +
    + {weeks.map((week, weekIndex) => + week.map((date, dayIndex) => { + if (!date) { + return ; + } + + const isSelected = selectedDate + ? isSameDay(date, selectedDate) + : false; + const isToday = isSameDay(date, today()); + + return ( + + ); + }) + )} +
    + +
    + + +
    +
    + ); +} + +function today(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function parseDate(value?: string): Date | null { + if (!value) return null; + const [year, month, day] = value.split('-').map(Number); + if (!year || !month || !day) return null; + const candidate = new Date(year, month - 1, day); + if ( + candidate.getFullYear() !== year || + candidate.getMonth() !== month - 1 || + candidate.getDate() !== day + ) { + return null; + } + return candidate; +} + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function addMonths(date: Date, amount: number): Date { + const next = new Date(date); + next.setMonth(next.getMonth() + amount); + return new Date(next.getFullYear(), next.getMonth(), 1); +} + +function buildCalendar(month: Date): Array> { + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + const firstOfMonth = new Date(year, monthIndex, 1); + const firstWeekday = firstOfMonth.getDay(); + const daysInMonth = new Date(year, monthIndex + 1, 0).getDate(); + + const slots: Array = []; + for (let i = 0; i < firstWeekday; i += 1) { + slots.push(null); + } + + for (let day = 1; day <= daysInMonth; day += 1) { + slots.push(new Date(year, monthIndex, day)); + } + + while (slots.length % 7 !== 0) { + slots.push(null); + } + + const weeks: Array> = []; + for (let i = 0; i < slots.length; i += 7) { + weeks.push(slots.slice(i, i + 7)); + } + return weeks; +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} diff --git a/packages/frontend/src/components/menu/services.tsx b/packages/frontend/src/components/menu/services.tsx index 2c3dfc88d..b2bf70884 100644 --- a/packages/frontend/src/components/menu/services.tsx +++ b/packages/frontend/src/components/menu/services.tsx @@ -1,602 +1,728 @@ -'use client'; -import { useStatus } from '@/context/status'; -import { PageWrapper } from '../shared/page-wrapper'; -import { - // SERVICE_DETAILS, - ServiceId, -} from '../../../../core/src/utils/constants'; -import { useUserData } from '@/context/userData'; -import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; -import { - arrayMove, - SortableContext, - verticalListSortingStrategy, - useSortable, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { IconButton } from '../ui/button'; -import { FiArrowLeft, FiArrowRight, FiSettings } from 'react-icons/fi'; -import { Switch } from '../ui/switch'; -import { Modal } from '../ui/modal'; -import { useState, useEffect } from 'react'; -import { - DndContext, - useSensors, - PointerSensor, - TouchSensor, - useSensor, -} from '@dnd-kit/core'; -import TemplateOption from '../shared/template-option'; -import { Button } from '../ui/button'; -import MarkdownLite from '../shared/markdown-lite'; -import { Alert } from '../ui/alert'; -import { useMenu } from '@/context/menu'; -import { PageControls } from '../shared/page-controls'; -import { SettingsCard } from '../shared/settings-card'; -import { TextInput } from '../ui/text-input'; -import { PasswordInput } from '../ui/password-input'; -import { StatusResponse, UserData } from '@aiostreams/core'; -export function ServicesMenu() { - return ( - <> - - - - - ); -} - -// we show all services, along with its signUpText and a setting icon button, and switch to enable/disable the service. -// this will be in a sortable lis twith dnd, similar to the addons menu. -// when the setting icon button is clicked, it will open a modal with all the credentials (option definitions) for the service - -// - -function Content() { - const { status } = useStatus(); - if (!status) return null; - const { setUserData, userData } = useUserData(); - const { setSelectedMenu, nextMenu, previousMenu } = useMenu(); - const [modalOpen, setModalOpen] = useState(false); - const [modalService, setModalService] = useState(null); - const [modalValues, setModalValues] = useState>({}); - const [isDragging, setIsDragging] = useState(false); - - // DND logic - function handleDragEnd(event: any) { - const { active, over } = event; - if (!over) return; - if (active.id !== over.id) { - setUserData((prev) => { - const services = prev.services ?? []; - const oldIndex = services.findIndex((s) => s.id === active.id); - const newIndex = services.findIndex((s) => s.id === over.id); - const newServices = arrayMove(services, oldIndex, newIndex); - return { ...prev, services: newServices }; - }); - } - setIsDragging(false); - } - - function handleDragStart(event: any) { - setIsDragging(true); - } - - // Modal handlers - const handleServiceClick = (service: ServiceId) => { - setModalService(service); - const svc = userData.services?.find((s) => s.id === service); - setModalValues(svc?.credentials || {}); - setModalOpen(true); - }; - - const handleModalClose = () => { - setModalOpen(false); - setModalService(null); - setModalValues({}); - }; - - const handleModalSubmit = (values: Record) => { - setUserData((prev) => { - const newUserData = { ...prev }; - newUserData.services = (newUserData.services ?? []).map((service) => { - if (service.id === modalService) { - return { - ...service, - enabled: true, - credentials: values, - }; - } - return service; - }); - return newUserData; - }); - handleModalClose(); - }; - - const handleModalValuesChange = (newValues: Record) => { - setModalValues((prevValues) => ({ - ...prevValues, - ...newValues, - })); - }; - - useEffect(() => { - const allServiceIds: ServiceId[] = Object.keys( - status.settings.services - ) as ServiceId[]; - const currentServices = userData.services ?? []; - - // Remove any services not in SERVICE_DETAILS and apply forced/default credentials - let filtered = currentServices.filter((s) => allServiceIds.includes(s.id)); - - // Add any missing services from SERVICE_DETAILS - const missing = allServiceIds.filter( - (id) => !filtered.some((s) => s.id === id) - ); - - if (missing.length > 0 || filtered.length !== currentServices.length) { - const toAdd = missing.map((id) => { - const svcMeta = status.settings.services[id]!; - const credentials: Record = {}; - let enabled = false; - - return { - id, - enabled, - credentials, - }; - }); - - setUserData((prev: any) => ({ - ...prev, - services: [...filtered, ...toAdd], - })); - } - }, [status.settings.services, userData.services]); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(TouchSensor, { - activationConstraint: { - delay: 150, - tolerance: 8, - }, - }) - ); - - useEffect(() => { - function preventTouchMove(e: TouchEvent) { - if (isDragging) { - e.preventDefault(); - } - } - - function handleDragEnd() { - setIsDragging(false); - } - - if (isDragging) { - document.body.addEventListener('touchmove', preventTouchMove, { - passive: false, - }); - // Add listeners for when drag ends outside context - document.addEventListener('pointerup', handleDragEnd); - document.addEventListener('touchend', handleDragEnd); - } else { - document.body.removeEventListener('touchmove', preventTouchMove); - } - - // Cleanup - return () => { - document.body.removeEventListener('touchmove', preventTouchMove); - document.removeEventListener('pointerup', handleDragEnd); - document.removeEventListener('touchend', handleDragEnd); - }; - }, [isDragging]); - - const invalidServices = - userData.services - ?.filter((service) => { - const svcMeta = status.settings.services[service.id]; - if (!svcMeta) return false; - // Check if any required credential is missing - return ( - service.enabled && - svcMeta.credentials.some( - (cred) => !service.credentials?.[cred.id] && cred.required - ) - ); - }) - .map((service) => status.settings.services[service.id]?.name) ?? []; - - // Render - return ( - <> -
    -
    -

    Services

    -

    - Provide credentials for any services you want to use. -

    -
    -
    - -
    -
    - {invalidServices && invalidServices.length > 0 && ( -
    - - The following services are missing credentials: -
    - {invalidServices.map((service) => ( -
    -
    - {service} -
    - ))} -
    - - } - /> -
    - )} -
    - - s.id) || []} - strategy={verticalListSortingStrategy} - > -
    -
      - {(userData.services?.length ?? 0) === 0 ? ( -
    • -
      - - Looks like you don't have any services configured. -
      - Add and configure services above. -
      -
      -
    • - ) : ( - userData.services?.map((service, idx) => { - const svcMeta = status.settings.services[service.id] as - | StatusResponse['settings']['services'][ServiceId] - | undefined; - if (!svcMeta) return null; - return ( - handleServiceClick(service.id)} - onToggleEnabled={(v: boolean) => { - setUserData((prev) => { - return { - ...prev, - services: (prev.services ?? []).map((s) => - s.id === service.id ? { ...s, enabled: v } : s - ), - }; - }); - }} - /> - ); - }) - )} -
    -
    -
    -
    -
    - - - - Get your API Key from{' '} - - here - - - } - value={userData.rpdbApiKey} - onValueChange={(v) => { - setUserData((prev) => ({ - ...prev, - rpdbApiKey: v, - })); - }} - /> - - - If enabled, poster URLs will first contact AIOStreams and then be - redirected to RPDB. This allows fallback posters to be used if the - RPDB API is down or does not have a poster for that item. It can - however cause a minimal slowdown due to having to contact - AIOStreams first. This setting requires the BASE_URL{' '} - environment variable to be set. - - } - value={ - userData.rpdbUseRedirectApi !== undefined - ? userData.rpdbUseRedirectApi - : !!status.settings.baseUrl - } - onValueChange={(v) => { - setUserData((prev) => ({ - ...prev, - rpdbUseRedirectApi: v, - })); - }} - /> - - - - -

    - You can get it from your{' '} - - TMDB Account Settings.{' '} - - Make sure to copy the Read Access Token and not the 32 character - API Key. -

    -

    - - } - required={!status?.settings.tmdbApiAvailable} - value={userData.tmdbAccessToken} - placeholder="Enter your TMDB access token" - onValueChange={(value) => { - setUserData((prev) => ({ - ...prev, - tmdbAccessToken: value, - })); - }} - /> - - - You can get it from your{' '} - - TMDB Account Settings.{' '} - - Make sure to copy the 32 character API Key and not the Read Access - Token. - - } - placeholder="Enter your TMDB API Key" - value={userData.tmdbApiKey} - onValueChange={(value) => { - setUserData((prev) => ({ - ...prev, - tmdbApiKey: value, - })); - }} - /> -
    - - - - Sign up for a free API Key at{' '} - - TVDB.{' '} - - - } - onValueChange={(value) => { - setUserData((prev) => ({ - ...prev, - tvdbApiKey: value, - })); - }} - /> - - - - - ); -} - -function SortableServiceItem({ - service, - meta, - onEdit, - onToggleEnabled, -}: { - service: Exclude[number]; - meta: Exclude; - onEdit: () => void; - onToggleEnabled: (v: boolean) => void; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: service.id }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - const disableEdit = meta.credentials.every((cred) => { - return cred.forced; - }); - return ( -
  • -
    -
    -
    - - {meta?.name || service.id} - - - {meta?.signUpText} - -
    -
    - - } - intent="primary-outline" - onClick={onEdit} - disabled={disableEdit} - /> -
    -
    -
  • - ); -} -function ServiceModal({ - open, - onOpenChange, - serviceId, - values, - onSubmit, - onClose, -}: { - open: boolean; - onOpenChange: (v: boolean) => void; - serviceId: ServiceId | null; - values: Record; - onSubmit: (v: Record) => void; - onClose: () => void; -}) { - const { status } = useStatus(); - const [localValues, setLocalValues] = useState>({}); - - useEffect(() => { - if (open) { - setLocalValues(values); - } - }, [open, values]); - - if (!status) return null; - if (!serviceId) return null; - const meta = status.settings.services[serviceId]!; - const credentials = meta.credentials || []; - - const handleCredentialChange = (optId: string, newValue: any) => { - setLocalValues((prev) => ({ - ...prev, - [optId]: newValue, - })); - }; - - return ( - -
    { - e.preventDefault(); - onSubmit(localValues); - }} - > - {credentials.map((opt) => ( - handleCredentialChange(opt.id, v || undefined)} - /> - ))} -
    - - -
    - -
    - ); -} +'use client'; +import { useStatus } from '@/context/status'; +import { useServiceExpiry } from '@/hooks/useServiceExpiry'; +import { + canServiceAutoFetch, + getExpiryPreference, + isTrackedService, + setExpiryPreference, + type ExpiryMode, +} from '@/utils/service-expiry'; +import { PageWrapper } from '../shared/page-wrapper'; +import { + // SERVICE_DETAILS, + ServiceId, +} from '../../../../core/src/utils/constants'; +import { useUserData } from '@/context/userData'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { IconButton } from '../ui/button'; +import { FiArrowLeft, FiArrowRight, FiSettings } from 'react-icons/fi'; +import { Switch } from '../ui/switch'; +import { Modal } from '../ui/modal'; +import { useState, useEffect } from 'react'; +import { + DndContext, + useSensors, + PointerSensor, + TouchSensor, + useSensor, +} from '@dnd-kit/core'; +import TemplateOption from '../shared/template-option'; +import { Button } from '../ui/button'; +import MarkdownLite from '../shared/markdown-lite'; +import { Alert } from '../ui/alert'; +import { useMenu } from '@/context/menu'; +import { PageControls } from '../shared/page-controls'; +import { SettingsCard } from '../shared/settings-card'; +import { TextInput } from '../ui/text-input'; +import { PasswordInput } from '../ui/password-input'; +import { Select } from '../ui/select'; +import { StatusResponse, UserData } from '@aiostreams/core'; +import { ServiceExpiryBadge } from './service-expiry-badge'; +import { ServiceExpiryDatePicker } from './service-expiry-date-picker'; +export function ServicesMenu() { + return ( + <> + + + + + ); +} + +// we show all services, along with its signUpText and a setting icon button, and switch to enable/disable the service. +// this will be in a sortable lis twith dnd, similar to the addons menu. +// when the setting icon button is clicked, it will open a modal with all the credentials (option definitions) for the service + +// + +function Content() { + const { status } = useStatus(); + const { setUserData, userData } = useUserData(); + const { setSelectedMenu, nextMenu, previousMenu } = useMenu(); + const [modalOpen, setModalOpen] = useState(false); + const [modalService, setModalService] = useState(null); + const [modalValues, setModalValues] = useState>({}); + const [isDragging, setIsDragging] = useState(false); + + if (!status) return null; + + // DND logic + function handleDragEnd(event: any) { + const { active, over } = event; + if (!over) return; + if (active.id !== over.id) { + setUserData((prev) => { + const services = prev.services ?? []; + const oldIndex = services.findIndex((s) => s.id === active.id); + const newIndex = services.findIndex((s) => s.id === over.id); + const newServices = arrayMove(services, oldIndex, newIndex); + return { ...prev, services: newServices }; + }); + } + setIsDragging(false); + } + + function handleDragStart(event: any) { + setIsDragging(true); + } + + // Modal handlers + const handleServiceClick = (service: ServiceId) => { + setModalService(service); + const svc = userData.services?.find((s) => s.id === service); + setModalValues(svc?.credentials || {}); + setModalOpen(true); + }; + + const handleModalClose = () => { + setModalOpen(false); + setModalService(null); + setModalValues({}); + }; + + const handleModalSubmit = (values: Record) => { + setUserData((prev) => { + const newUserData = { ...prev }; + newUserData.services = (newUserData.services ?? []).map((service) => { + if (service.id === modalService) { + return { + ...service, + enabled: true, + credentials: values, + }; + } + return service; + }); + return newUserData; + }); + handleModalClose(); + }; + + const handleModalValuesChange = (newValues: Record) => { + setModalValues((prevValues) => ({ + ...prevValues, + ...newValues, + })); + }; + + useEffect(() => { + const allServiceIds: ServiceId[] = Object.keys( + status.settings.services + ) as ServiceId[]; + const currentServices = userData.services ?? []; + + // Remove any services not in SERVICE_DETAILS and apply forced/default credentials + let filtered = currentServices.filter((s) => allServiceIds.includes(s.id)); + + // Add any missing services from SERVICE_DETAILS + const missing = allServiceIds.filter( + (id) => !filtered.some((s) => s.id === id) + ); + + if (missing.length > 0 || filtered.length !== currentServices.length) { + const toAdd = missing.map((id) => { + const svcMeta = status.settings.services[id]!; + const credentials: Record = {}; + let enabled = false; + + return { + id, + enabled, + credentials, + }; + }); + + setUserData((prev: any) => ({ + ...prev, + services: [...filtered, ...toAdd], + })); + } + }, [status.settings.services, userData.services]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(TouchSensor, { + activationConstraint: { + delay: 150, + tolerance: 8, + }, + }) + ); + + useEffect(() => { + function preventTouchMove(e: TouchEvent) { + if (isDragging) { + e.preventDefault(); + } + } + + function handleDragEnd() { + setIsDragging(false); + } + + if (isDragging) { + document.body.addEventListener('touchmove', preventTouchMove, { + passive: false, + }); + // Add listeners for when drag ends outside context + document.addEventListener('pointerup', handleDragEnd); + document.addEventListener('touchend', handleDragEnd); + } else { + document.body.removeEventListener('touchmove', preventTouchMove); + } + + // Cleanup + return () => { + document.body.removeEventListener('touchmove', preventTouchMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('touchend', handleDragEnd); + }; + }, [isDragging]); + + const invalidServices = + userData.services + ?.filter((service) => { + const svcMeta = status.settings.services[service.id]; + if (!svcMeta) return false; + // Check if any required credential is missing + return ( + service.enabled && + svcMeta.credentials.some( + (cred) => !service.credentials?.[cred.id] && cred.required + ) + ); + }) + .map((service) => status.settings.services[service.id]?.name) ?? []; + + // Render + return ( + <> +
    +
    +

    Services

    +

    + Provide credentials for any services you want to use. +

    +
    +
    + +
    +
    + {invalidServices && invalidServices.length > 0 && ( +
    + + The following services are missing credentials: +
    + {invalidServices.map((service) => ( +
    +
    + {service} +
    + ))} +
    + + } + /> +
    + )} +
    + + s.id) || []} + strategy={verticalListSortingStrategy} + > +
    +
      + {(userData.services?.length ?? 0) === 0 ? ( +
    • +
      + + Looks like you don't have any services configured. +
      + Add and configure services above. +
      +
      +
    • + ) : ( + userData.services?.map((service, idx) => { + const svcMeta = status.settings.services[service.id] as + | StatusResponse['settings']['services'][ServiceId] + | undefined; + if (!svcMeta) return null; + return ( + handleServiceClick(service.id)} + onToggleEnabled={(v: boolean) => { + setUserData((prev) => { + return { + ...prev, + services: (prev.services ?? []).map((s) => + s.id === service.id ? { ...s, enabled: v } : s + ), + }; + }); + }} + /> + ); + }) + )} +
    +
    +
    +
    +
    + + + + Get your API Key from{' '} + + here + + + } + value={userData.rpdbApiKey} + onValueChange={(v) => { + setUserData((prev) => ({ + ...prev, + rpdbApiKey: v, + })); + }} + /> + + + If enabled, poster URLs will first contact AIOStreams and then be + redirected to RPDB. This allows fallback posters to be used if the + RPDB API is down or does not have a poster for that item. It can + however cause a minimal slowdown due to having to contact + AIOStreams first. This setting requires the BASE_URL{' '} + environment variable to be set. + + } + value={ + userData.rpdbUseRedirectApi !== undefined + ? userData.rpdbUseRedirectApi + : !!status.settings.baseUrl + } + onValueChange={(v) => { + setUserData((prev) => ({ + ...prev, + rpdbUseRedirectApi: v, + })); + }} + /> + + + + +

    + You can get it from your{' '} + + TMDB Account Settings.{' '} + + Make sure to copy the Read Access Token and not the 32 character + API Key. +

    +

    + + } + required={!status?.settings.tmdbApiAvailable} + value={userData.tmdbAccessToken} + placeholder="Enter your TMDB access token" + onValueChange={(value) => { + setUserData((prev) => ({ + ...prev, + tmdbAccessToken: value, + })); + }} + /> + + + You can get it from your{' '} + + TMDB Account Settings.{' '} + + Make sure to copy the 32 character API Key and not the Read Access + Token. + + } + placeholder="Enter your TMDB API Key" + value={userData.tmdbApiKey} + onValueChange={(value) => { + setUserData((prev) => ({ + ...prev, + tmdbApiKey: value, + })); + }} + /> +
    + + + + Sign up for a free API Key at{' '} + + TVDB.{' '} + + + } + onValueChange={(value) => { + setUserData((prev) => ({ + ...prev, + tvdbApiKey: value, + })); + }} + /> + + + + + ); +} + +function SortableServiceItem({ + service, + meta, + onEdit, + onToggleEnabled, +}: { + service: Exclude[number]; + meta: Exclude; + onEdit: () => void; + onToggleEnabled: (v: boolean) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: service.id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + const disableEdit = meta.credentials.every((cred) => { + return cred.forced; + }); + const expiry = useServiceExpiry({ + serviceId: service.id, + serviceName: meta?.name || service.id, + credentials: service.credentials, + }); + const serviceTitle = + expiry.status === 'error' + ? `${meta?.name || service.id}: ${expiry.error}` + : expiry.status === 'disabled' + ? `${meta?.name || service.id}: Expiry badge hidden` + : undefined; + return ( +
  • +
    +
    +
    +
    + + {meta?.name || service.id} + + {expiry.status === 'success' && + expiry.badgeText && + expiry.badgeColors && ( + + )} +
    + + {meta?.signUpText} + +
    +
    + + } + intent="primary-outline" + onClick={onEdit} + disabled={disableEdit} + /> +
    +
    +
  • + ); +} +function ServiceModal({ + open, + onOpenChange, + serviceId, + values, + onSubmit, + onClose, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + serviceId: ServiceId | null; + values: Record; + onSubmit: (v: Record) => void; + onClose: () => void; +}) { + const { status } = useStatus(); + const [localValues, setLocalValues] = useState>({}); + const [expiryMode, setExpiryMode] = useState('auto'); + const [manualExpiryInput, setManualExpiryInput] = useState(''); + const [manualExpiryUpdatedAt, setManualExpiryUpdatedAt] = useState< + number | null + >(null); + + useEffect(() => { + if (!open) return; + setLocalValues(values); + if (!serviceId) { + setExpiryMode('auto'); + setManualExpiryInput(''); + setManualExpiryUpdatedAt(null); + return; + } + const preference = getExpiryPreference(serviceId); + const supportsAuto = canServiceAutoFetch(serviceId); + const resolvedMode = + preference.mode === 'auto' && !supportsAuto ? 'manual' : preference.mode; + setExpiryMode(resolvedMode); + setManualExpiryInput(preference.date ?? ''); + setManualExpiryUpdatedAt(preference.updatedAt ?? null); + }, [open, serviceId, values]); + + if (!status) return null; + if (!serviceId) return null; + const meta = status.settings.services[serviceId]!; + const credentials = meta.credentials || []; + const allowExpiryControls = isTrackedService(serviceId); + const autoSupported = canServiceAutoFetch(serviceId); + + const handleCredentialChange = (optId: string, newValue: any) => { + setLocalValues((prev) => ({ + ...prev, + [optId]: newValue, + })); + }; + + return ( + +
    { + e.preventDefault(); + if (allowExpiryControls) { + const trimmed = manualExpiryInput.trim(); + if (expiryMode === 'auto') { + setExpiryPreference(serviceId, null); + } else if (expiryMode === 'hidden') { + setExpiryPreference(serviceId, { mode: 'hidden' }); + } else { + setExpiryPreference(serviceId, { + mode: 'manual', + date: trimmed ? trimmed : undefined, + }); + setManualExpiryUpdatedAt(trimmed ? Date.now() : null); + } + } + onSubmit(localValues); + }} + > + {credentials.map((opt) => ( + handleCredentialChange(opt.id, v || undefined)} + /> + ))} + {allowExpiryControls && ( +
    + + {tagType === 'expires' && ( +
    + Expiry Date + handleExpiryDateChange('')} + /> +
    + )} + {tagType !== 'none' && ( +
    + Preview: + +
    + )} + {description && ( +
    + {description} +
    + )} +
    + ); + } default: return null; } diff --git a/packages/frontend/src/components/ui/badge/badge.tsx b/packages/frontend/src/components/ui/badge/badge.tsx new file mode 100644 index 000000000..660fcf6ce --- /dev/null +++ b/packages/frontend/src/components/ui/badge/badge.tsx @@ -0,0 +1,178 @@ +'use client'; + +import * as React from 'react'; +import { cva, VariantProps } from 'class-variance-authority'; +import { cn } from '../core/styling'; + +/* ------------------------------------------------------------------------------------------------- + * Badge Anatomy + * -----------------------------------------------------------------------------------------------*/ + +const badgeVariants = cva( + [ + 'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold', + 'transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + ], + { + variants: { + intent: { + default: 'bg-primary text-primary-foreground', + secondary: 'bg-secondary text-secondary-foreground', + success: + 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30', + warning: 'bg-orange-500/20 text-orange-400 border border-orange-500/30', + danger: 'bg-red-500/20 text-red-400 border border-red-500/30', + info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30', + purple: 'bg-purple-500/20 text-purple-400 border border-purple-500/30', + custom: '', // Allow custom styling via style prop + }, + size: { + sm: 'text-[0.65rem] px-1.5 py-0.5', + md: 'text-xs px-2 py-0.5', + lg: 'text-sm px-2.5 py-1', + }, + }, + defaultVariants: { + intent: 'default', + size: 'md', + }, + } +); + +/* ------------------------------------------------------------------------------------------------- + * Badge Component + * -----------------------------------------------------------------------------------------------*/ + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + /** + * Custom background color (overrides intent) + */ + backgroundColor?: string; + /** + * Custom text color (overrides intent) + */ + textColor?: string; +} + +export const Badge = React.forwardRef( + ( + { + className, + intent, + size, + backgroundColor, + textColor, + style, + children, + ...props + }, + ref + ) => { + const customStyles: React.CSSProperties = { + ...style, + ...(backgroundColor && { backgroundColor }), + ...(textColor && { color: textColor }), + }; + + return ( + + {children} + + ); + } +); + +Badge.displayName = 'Badge'; + +/* ------------------------------------------------------------------------------------------------- + * ServiceExpiryBadge - Specialized badge for addon service expiry + * -----------------------------------------------------------------------------------------------*/ + +export interface ServiceExpiryBadgeProps { + tagType?: string; + expiryDate?: string; // ISO date string (YYYY-MM-DD) + className?: string; +} + +export const ServiceExpiryBadge: React.FC = ({ + tagType, + expiryDate, + className, +}) => { + const resolvedType = (tagType ?? 'none').toString(); + const normalizedType = resolvedType.toLowerCase(); + + if (!resolvedType || normalizedType === 'none') { + return null; + } + + // Parse date string as local date (not UTC) to avoid timezone issues + const parseLocalDate = (dateStr: string) => { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day); + }; + + const isExpired = () => { + if (normalizedType !== 'expires' || !expiryDate) return false; + const expiry = parseLocalDate(expiryDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + expiry.setHours(0, 0, 0, 0); + return expiry < today; + }; + + const formatCustomLabel = (value: string) => + value.replace(/[_-]+/g, ' ').trim().toUpperCase(); + + const getLabel = () => { + switch (normalizedType) { + case 'lifetime': + return 'LIFETIME'; + case 'free': + return 'FREE'; + case 'expires': + if (expiryDate) { + const date = parseLocalDate(expiryDate); + const formatted = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + return isExpired() ? `Expired ${formatted}` : `Expires ${formatted}`; + } + return 'EXPIRES'; + default: + return formatCustomLabel(resolvedType); + } + }; + + const getIntent = (): BadgeProps['intent'] => { + if (normalizedType === 'lifetime') return 'purple'; + if (normalizedType === 'free') return 'success'; + if (normalizedType === 'expires') return isExpired() ? 'danger' : 'warning'; + return 'info'; + }; + + return ( + + {getLabel()} + + ); +}; + +ServiceExpiryBadge.displayName = 'ServiceExpiryBadge'; diff --git a/packages/frontend/src/components/ui/badge/index.tsx b/packages/frontend/src/components/ui/badge/index.tsx new file mode 100644 index 000000000..6e76b255b --- /dev/null +++ b/packages/frontend/src/components/ui/badge/index.tsx @@ -0,0 +1,2 @@ +export { Badge, ServiceExpiryBadge } from './badge'; +export type { BadgeProps, ServiceExpiryBadgeProps } from './badge'; diff --git a/packages/frontend/src/hooks/useServiceExpiry.ts b/packages/frontend/src/hooks/useServiceExpiry.ts new file mode 100644 index 000000000..3463b7b7c --- /dev/null +++ b/packages/frontend/src/hooks/useServiceExpiry.ts @@ -0,0 +1,408 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import type { ServiceId } from '@aiostreams/core'; +import { + calculateDaysRemaining, + canServiceAutoFetch, + EXPIRY_OVERRIDE_EVENT, + formatExpiryTitle, + getBadgeColors, + getCachedExpiry, + getExpiryPreference, + isTrackedService, + ServiceExpirySource, + setCachedExpiry, +} from '@/utils/service-expiry'; + +type Credentials = Record | undefined; + +type ExpiryStatus = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'disabled' } + | { + status: 'success'; + daysRemaining: number; + expiresAt: string; + source: ServiceExpirySource; + updatedAt: number | null; + } + | { status: 'error'; error: string }; + +interface UseServiceExpiryOptions { + serviceId: ServiceId; + serviceName: string; + credentials: Credentials; +} + +interface UseServiceExpiryResult { + status: ExpiryStatus['status']; + daysRemaining?: number; + expiresAt?: string; + badgeText?: string; + badgeColors?: { background: string; foreground: string }; + tooltip?: string; + error?: string; +} + +export function useServiceExpiry( + options: UseServiceExpiryOptions +): UseServiceExpiryResult { + const { serviceId, serviceName, credentials } = options; + const isTracked = isTrackedService(serviceId); + const credentialSignature = useMemo( + () => JSON.stringify(credentials ?? {}), + [credentials] + ); + + const [overrideVersion, setOverrideVersion] = useState(0); + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + if (!isTracked) { + setState({ status: 'idle' }); + return; + } + function handleOverride(event: Event) { + const customEvent = event as CustomEvent<{ serviceId: ServiceId }>; + if (!customEvent.detail || customEvent.detail.serviceId === serviceId) { + setOverrideVersion((v: number) => v + 1); + } + } + window.addEventListener(EXPIRY_OVERRIDE_EVENT, handleOverride); + return () => { + window.removeEventListener(EXPIRY_OVERRIDE_EVENT, handleOverride); + }; + }, [isTracked, serviceId]); + + useEffect(() => { + if (!isTracked) { + setState({ status: 'idle' }); + return; + } + + const preference = getExpiryPreference(serviceId); + + if (preference.mode === 'hidden') { + setState({ status: 'disabled' }); + return; + } + + if (preference.mode === 'manual') { + if (!preference.date) { + setState({ + status: 'error', + error: 'Set a manual expiry date to show the badge.', + }); + return; + } + const days = calculateDaysRemaining(preference.date); + if (days === null) { + setState({ status: 'error', error: 'Invalid manual expiry date.' }); + return; + } + setState({ + status: 'success', + daysRemaining: days, + expiresAt: preference.date, + source: 'manual', + updatedAt: preference.updatedAt ?? null, + }); + return; + } + + if (!canServiceAutoFetch(serviceId)) { + setState({ + status: 'error', + error: `${serviceName} does not support automatic expiry detection. Switch to Manual or hide the badge.`, + }); + return; + } + + const cached = getCachedExpiry(serviceId); + if (cached && cached.daysRemaining !== null) { + setState({ + status: 'success', + daysRemaining: cached.daysRemaining, + expiresAt: cached.expiresAt, + source: 'cache', + updatedAt: cached.timestamp, + }); + return; + } + + let cancelled = false; + setState({ status: 'loading' }); + + fetchExpiryFromProvider(serviceId, credentials) + .then((expiresAt) => { + if (cancelled) return; + if (!expiresAt) { + setState({ + status: 'error', + error: 'No expiry information available', + }); + return; + } + const days = calculateDaysRemaining(expiresAt); + if (days === null) { + setState({ + status: 'error', + error: 'Unable to parse expiry date', + }); + return; + } + setCachedExpiry(serviceId, expiresAt); + setState({ + status: 'success', + daysRemaining: days, + expiresAt, + source: 'api', + updatedAt: Date.now(), + }); + }) + .catch((error) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : 'Failed to fetch expiry'; + setState({ status: 'error', error: message }); + }); + + return () => { + cancelled = true; + }; + }, [credentialSignature, isTracked, overrideVersion, serviceId, serviceName]); + + if (state.status !== 'success') { + if (state.status === 'disabled') { + return { status: 'disabled' }; + } + if (state.status === 'error') { + return { status: 'error', error: state.error }; + } + return { status: state.status }; + } + + const badgeColors = getBadgeColors(state.daysRemaining); + const badgeText = + state.daysRemaining > 0 ? `${state.daysRemaining} DAYS` : 'EXPIRED'; + const tooltip = formatExpiryTitle({ + serviceName, + expiresAt: state.expiresAt, + source: state.source, + updatedAt: state.updatedAt, + }); + + return { + status: 'success', + daysRemaining: state.daysRemaining, + expiresAt: state.expiresAt, + badgeColors, + badgeText, + tooltip, + }; +} + +async function fetchExpiryFromProvider( + serviceId: ServiceId, + credentials: Credentials +): Promise { + switch (serviceId) { + case 'realdebrid': + return fetchRealDebridExpiry(credentials); + case 'alldebrid': + return fetchAllDebridExpiry(credentials); + case 'premiumize': + return fetchPremiumizeExpiry(credentials); + case 'debridlink': + return fetchDebridLinkExpiry(credentials); + case 'torbox': + return fetchTorBoxExpiry(credentials); + default: + return null; + } +} + +async function fetchRealDebridExpiry( + credentials: Credentials +): Promise { + const apiKey = credentials?.apiKey?.trim(); + if (!apiKey) { + throw new Error('Enter your Real-Debrid API key to view expiry.'); + } + const response = await fetch('https://api.real-debrid.com/rest/1.0/user', { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`Real-Debrid API error (${response.status})`); + } + const data: { expiration?: string } = await response.json(); + return data.expiration ?? null; +} + +async function fetchAllDebridExpiry( + credentials: Credentials +): Promise { + const apiKey = credentials?.apiKey?.trim(); + if (!apiKey) { + throw new Error('Enter your AllDebrid API key to view expiry.'); + } + const response = await fetch('https://api.alldebrid.com/v4/user', { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`AllDebrid API error (${response.status})`); + } + const data = await response.json(); + if (data?.status !== 'success' || !data?.data?.user) { + throw new Error('AllDebrid API returned an unexpected response.'); + } + const user = data.data.user as { + premiumUntil?: number | string; + premium_until?: number | string; + premiumUntilIso?: string; + premium_until_iso?: string; + }; + if (typeof user?.premiumUntilIso === 'string' && user.premiumUntilIso) { + return user.premiumUntilIso; + } + if (typeof user?.premium_until_iso === 'string' && user.premium_until_iso) { + return user.premium_until_iso; + } + const secondsRaw = Number(user?.premiumUntil ?? user?.premium_until ?? 0); + if (Number.isFinite(secondsRaw) && secondsRaw > 0) { + const millis = + secondsRaw > 1_000_000_000_000 ? secondsRaw : secondsRaw * 1000; + return new Date(millis).toISOString(); + } + const fallbackDate = (user?.premiumUntil ?? user?.premium_until) as + | string + | undefined; + if (typeof fallbackDate === 'string' && fallbackDate) { + const parsed = new Date(fallbackDate); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return null; +} + +async function fetchPremiumizeExpiry( + credentials: Credentials +): Promise { + const apiKey = credentials?.apiKey?.trim(); + if (!apiKey) { + throw new Error('Enter your Premiumize API key to view expiry.'); + } + const url = new URL('https://www.premiumize.me/api/account/info'); + url.searchParams.set('apikey', apiKey); + const response = await fetch(url.toString(), { + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`Premiumize API error (${response.status})`); + } + const data = await response.json(); + if (String(data?.status).toLowerCase() !== 'success') { + throw new Error('Premiumize API returned an unexpected response.'); + } + const expires = Number(data?.premium_until ?? data?.premiumUntil ?? 0); + if (Number.isFinite(expires) && expires > 0) { + const millis = expires > 1_000_000_000_000 ? expires : expires * 1000; + return new Date(millis).toISOString(); + } + const rawExpiry = (data?.premium_until ?? data?.premiumUntil) as + | string + | undefined; + if (typeof rawExpiry === 'string' && rawExpiry) { + const parsed = new Date(rawExpiry); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return null; +} + +async function fetchDebridLinkExpiry( + credentials: Credentials +): Promise { + const apiKey = credentials?.apiKey?.trim(); + if (!apiKey) { + throw new Error('Enter your Debrid-Link API key to view expiry.'); + } + const response = await fetch('https://debrid-link.com/api/account/infos', { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`Debrid-Link API error (${response.status})`); + } + const data = await response.json(); + if (!data?.success || !data?.value) { + throw new Error('Debrid-Link API returned an unexpected response.'); + } + const value = data.value as { + premiumLeft?: number | string; + premium_left?: number | string; + premiumUntilIso?: string; + premium_until_iso?: string; + premiumUntil?: string; + }; + if (typeof value?.premiumUntilIso === 'string' && value.premiumUntilIso) { + return value.premiumUntilIso; + } + if (typeof value?.premium_until_iso === 'string' && value.premium_until_iso) { + return value.premium_until_iso; + } + if (typeof value?.premiumUntil === 'string' && value.premiumUntil) { + const parsed = new Date(value.premiumUntil); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + const secondsRaw = Number(value?.premiumLeft ?? value?.premium_left ?? 0); + if (Number.isFinite(secondsRaw) && secondsRaw > 0) { + return new Date(Date.now() + secondsRaw * 1000).toISOString(); + } + return null; +} + +async function fetchTorBoxExpiry( + credentials: Credentials +): Promise { + const apiKey = credentials?.apiKey?.trim(); + if (!apiKey) { + throw new Error('Enter your TorBox API key to view expiry.'); + } + const response = await fetch( + 'https://api.torbox.app/v1/api/user/me?settings=true', + { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + cache: 'no-store', + } + ); + if (!response.ok) { + throw new Error(`TorBox API error (${response.status})`); + } + const json = await response.json(); + const data = json?.data ?? json; + const expiresAt = + data?.premium_expires_at || + data?.premium_until_iso || + data?.premiumUntilIso || + data?.premiumExpiresAt; + return typeof expiresAt === 'string' && expiresAt.length > 0 + ? expiresAt + : null; +} diff --git a/packages/frontend/src/utils/service-expiry.ts b/packages/frontend/src/utils/service-expiry.ts new file mode 100644 index 000000000..01917bce9 --- /dev/null +++ b/packages/frontend/src/utils/service-expiry.ts @@ -0,0 +1,287 @@ +import type { ServiceId } from '@aiostreams/core'; + +const CACHE_KEY = 'aiostreams-service-expiry-cache'; +const OVERRIDES_KEY = 'aiostreams-service-expiry-overrides'; +export const EXPIRY_OVERRIDE_EVENT = 'aiostreams:expiry-override-changed'; +export const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours + +export type ServiceExpirySource = 'api' | 'cache' | 'manual'; + +export type ExpiryMode = 'auto' | 'manual' | 'hidden'; + +export interface ExpiryPreference { + mode: ExpiryMode; + date?: string; + updatedAt?: number; +} + +export interface CachedExpiryEntry { + expiresAt: string; + timestamp: number; +} + +type LegacyManualExpiryEntry = { + date: string; + updatedAt?: number; +}; + +type StoredPreferenceEntry = ExpiryPreference | LegacyManualExpiryEntry; + +type PreferenceStore = Partial>; + +export type CachedExpiryMap = Partial>; + +export const TRACKED_SERVICE_IDS: ServiceId[] = [ + 'realdebrid', + 'debridlink', + 'premiumize', + 'alldebrid', + 'torbox', + 'easydebrid', + 'debrider', + 'putio', + 'pikpak', + 'offcloud', + 'seedr', + 'easynews', + 'nzbdav', + 'altmount', + 'stremio_nntp', +]; + +export function isTrackedService(serviceId: ServiceId): boolean { + return TRACKED_SERVICE_IDS.includes(serviceId); +} + +export const AUTO_FETCH_SERVICE_IDS: ServiceId[] = [ + 'realdebrid', + 'alldebrid', + 'premiumize', + 'debridlink', + 'torbox', +]; + +export function canServiceAutoFetch(serviceId: ServiceId): boolean { + return AUTO_FETCH_SERVICE_IDS.includes(serviceId); +} + +function safeParseJSON(value: string | null): T | null { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +function readCache(): CachedExpiryMap { + if (typeof window === 'undefined') return {}; + const stored = safeParseJSON( + window.localStorage.getItem(CACHE_KEY) + ); + return stored ?? {}; +} + +function writeCache(map: CachedExpiryMap): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(CACHE_KEY, JSON.stringify(map)); +} + +export function getCachedExpiry( + serviceId: ServiceId +): (CachedExpiryEntry & { daysRemaining: number | null }) | null { + const cache = readCache(); + const entry = cache[serviceId]; + if (!entry) return null; + const isFresh = Date.now() - entry.timestamp < CACHE_DURATION_MS; + if (!isFresh) { + delete cache[serviceId]; + writeCache(cache); + return null; + } + const daysRemaining = calculateDaysRemaining(entry.expiresAt); + return { + ...entry, + daysRemaining, + }; +} + +export function setCachedExpiry(serviceId: ServiceId, expiresAt: string): void { + if (typeof window === 'undefined') return; + const cache = readCache(); + cache[serviceId] = { + expiresAt, + timestamp: Date.now(), + }; + writeCache(cache); +} + +export function clearCachedExpiry(serviceId: ServiceId): void { + if (typeof window === 'undefined') return; + const cache = readCache(); + if (cache[serviceId]) { + delete cache[serviceId]; + writeCache(cache); + } +} + +function readOverrides(): PreferenceStore { + if (typeof window === 'undefined') return {}; + const stored = safeParseJSON( + window.localStorage.getItem(OVERRIDES_KEY) + ); + return stored ?? {}; +} + +function writeOverrides(map: PreferenceStore): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(OVERRIDES_KEY, JSON.stringify(map)); +} + +function normalisePreference( + entry: StoredPreferenceEntry | undefined +): ExpiryPreference | null { + if (!entry || typeof entry !== 'object') return null; + const maybePreference = entry as ExpiryPreference; + if (maybePreference.mode === 'auto') { + return { mode: 'auto' }; + } + if (maybePreference.mode === 'manual' || maybePreference.mode === 'hidden') { + return { + mode: maybePreference.mode, + date: + typeof maybePreference.date === 'string' + ? maybePreference.date + : undefined, + updatedAt: + typeof maybePreference.updatedAt === 'number' + ? maybePreference.updatedAt + : undefined, + }; + } + + const legacy = entry as LegacyManualExpiryEntry; + if (typeof legacy.date === 'string') { + return { + mode: 'manual', + date: legacy.date, + updatedAt: + typeof legacy.updatedAt === 'number' ? legacy.updatedAt : undefined, + }; + } + return null; +} + +export function getExpiryPreference(serviceId: ServiceId): ExpiryPreference { + const overrides = readOverrides(); + const normalised = normalisePreference(overrides[serviceId]); + return normalised ?? { mode: 'auto' }; +} + +export function setExpiryPreference( + serviceId: ServiceId, + preference: ExpiryPreference | null +): void { + if (typeof window === 'undefined') return; + const overrides = readOverrides(); + const existing = normalisePreference(overrides[serviceId]); + + if (!preference || preference.mode === 'auto') { + if (overrides[serviceId]) { + delete overrides[serviceId]; + writeOverrides(overrides); + } else { + writeOverrides(overrides); + } + } else { + const next: ExpiryPreference = { + mode: preference.mode, + date: + preference.mode === 'manual' + ? (preference.date ?? existing?.date) + : undefined, + updatedAt: + preference.mode === 'manual' + ? preference.date + ? Date.now() + : existing?.updatedAt + : undefined, + }; + overrides[serviceId] = next; + writeOverrides(overrides); + } + clearCachedExpiry(serviceId); + window.dispatchEvent( + new CustomEvent(EXPIRY_OVERRIDE_EVENT, { detail: { serviceId } }) + ); +} + +function normaliseExpiryDate(dateString: string): Date | null { + if (!dateString) return null; + let candidate = dateString.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { + candidate = `${candidate}T23:59:59`; + } + let parsed = new Date(candidate); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + parsed = new Date(candidate.replace(' ', 'T')); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + return null; +} + +export function calculateDaysRemaining(dateString: string): number | null { + const expiryDate = normaliseExpiryDate(dateString); + if (!expiryDate) return null; + const diff = expiryDate.getTime() - Date.now(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +export function getBadgeColors(daysRemaining: number): { + background: string; + foreground: string; +} { + if (daysRemaining <= 0) { + return { background: '#ef4444', foreground: '#ffffff' }; + } + if (daysRemaining <= 7) { + return { background: '#f97316', foreground: '#ffffff' }; + } + if (daysRemaining <= 15) { + return { background: '#eab308', foreground: '#000000' }; + } + return { background: '#22c55e', foreground: '#ffffff' }; +} + +export function formatExpiryTitle(params: { + serviceName: string; + expiresAt?: string; + source: ServiceExpirySource; + updatedAt?: number | null; +}): string { + const { serviceName, expiresAt, source, updatedAt } = params; + const parts: string[] = []; + if (expiresAt) { + const parsed = normaliseExpiryDate(expiresAt); + const formatted = parsed ? parsed.toLocaleString() : expiresAt; + parts.push(`Expires on ${formatted}`); + } + switch (source) { + case 'manual': + parts.push('Manual override'); + break; + case 'cache': + parts.push('Cached result'); + break; + case 'api': + parts.push('Fetched from provider API'); + break; + } + if (updatedAt) { + parts.push(`Last updated ${new Date(updatedAt).toLocaleString()}`); + } + return `${serviceName}: ${parts.join(' • ')}`; +}