diff --git a/app/api/channels/route.ts b/app/api/channels/route.ts new file mode 100644 index 0000000..1324f55 --- /dev/null +++ b/app/api/channels/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' + +const IPTV_ORG_URL = 'https://iptv-org.github.io/api/channels.json' + +interface IptvChannel { + id: string + name: string + country: string + categories: string[] + is_nsfw: boolean + is_closed: boolean +} + +interface SlimChannel { + id: string + name: string + country: string + categories: string[] +} + +let cache: { data: SlimChannel[]; fetchedAt: number } | null = null +const CACHE_TTL = 60 * 60 * 1000 // 1 hour + +export async function GET() { + try { + if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) { + return NextResponse.json(cache.data) + } + + const res = await fetch(IPTV_ORG_URL, { next: { revalidate: 3600 } }) + if (!res.ok) { + throw new Error(`iptv-org API returned ${res.status}`) + } + + const channels: IptvChannel[] = await res.json() + + const filtered: SlimChannel[] = channels + .filter((ch) => !ch.is_nsfw && !ch.is_closed) + .map(({ id, name, country, categories }) => ({ id, name, country, categories })) + + cache = { data: filtered, fetchedAt: Date.now() } + + return NextResponse.json(filtered) + } catch (error) { + console.error('Failed to fetch channels:', error) + // Return cached data even if stale on error + if (cache) { + return NextResponse.json(cache.data) + } + return NextResponse.json( + { error: 'Failed to load channel list' }, + { status: 502 } + ) + } +} diff --git a/app/api/request-channel/route.ts b/app/api/request-channel/route.ts index 774f11f..32c38e1 100644 --- a/app/api/request-channel/route.ts +++ b/app/api/request-channel/route.ts @@ -1,28 +1,44 @@ import { NextRequest, NextResponse } from 'next/server' import nodemailer from 'nodemailer' +interface ChannelSelection { + id: string + name: string + country: string + categories: string[] +} + export async function POST(request: NextRequest) { try { const body = await request.json() - const { channelName, category, url, notes, email } = body + const { email, channels } = body as { email: string; channels: ChannelSelection[] } - // Validate required fields - if (!channelName || !email) { + // Validate email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email || !emailRegex.test(email)) { return NextResponse.json( - { error: 'Channel name and email are required' }, + { error: 'A valid email is required' }, { status: 400 } ) } - // Email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(email)) { + // Validate channels + if (!Array.isArray(channels) || channels.length === 0 || channels.length > 20) { return NextResponse.json( - { error: 'Invalid email format' }, + { error: 'Select between 1 and 20 channels' }, { status: 400 } ) } + for (const ch of channels) { + if (!ch.id || !ch.name) { + return NextResponse.json( + { error: 'Invalid channel data' }, + { status: 400 } + ) + } + } + const smtpHost = process.env.SMTP_HOST const smtpUser = process.env.SMTP_USER const smtpPass = process.env.SMTP_PASS @@ -44,48 +60,39 @@ export async function POST(request: NextRequest) { tls: { rejectUnauthorized: false }, }) + const channelListHtml = channels + .map( + (ch) => + `
  • ${escapeHtml(ch.name)}${escapeHtml(ch.id)}` + + (ch.country ? ` (${escapeHtml(ch.country)})` : '') + + (ch.categories.length > 0 ? ` [${ch.categories.map(escapeHtml).join(', ')}]` : '') + + `
  • ` + ) + .join('\n') + + const subject = + channels.length === 1 + ? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}` + : `[Jefflix] Channel Request: ${channels.length} channels` + await transporter.sendMail({ from: `Jefflix <${smtpUser}>`, to: adminEmail, - subject: `[Jefflix] Channel Request: ${escapeHtml(channelName)}`, + subject, html: `

    New Channel Request

    -

    Someone has requested a new Live TV channel:

    - - - - - - - - - - - - - - - - - - - - - - - - - -
    Channel:${escapeHtml(channelName)}
    Category:${escapeHtml(category || 'Not specified')}
    URL/Link:${url ? `${escapeHtml(url)}` : 'Not provided'}
    Notes:${escapeHtml(notes || 'None')}
    Email:${escapeHtml(email)}
    Requested:${new Date().toLocaleString()}
    -

    To add this channel:

    +

    ${escapeHtml(email)} requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:

    + +

    To add these channels:

      -
    1. Check iptv-org for an existing stream
    2. -
    3. If found, add to Threadfin channel list and map the stream
    4. -
    5. If not in iptv-org, add as a custom M3U source in Threadfin
    6. -
    7. Reply to ${escapeHtml(email)} to let them know it's been added
    8. +
    9. Search each channel ID in Threadfin / iptv-org
    10. +
    11. Map the streams in Threadfin
    12. +
    13. Reply to ${escapeHtml(email)} to confirm

    -

    This is an automated message from Jefflix.

    +

    Automated message from Jefflix · ${new Date().toLocaleString()}

    `, }) diff --git a/app/request-channel/page.tsx b/app/request-channel/page.tsx index 37833e6..3b43ddc 100644 --- a/app/request-channel/page.tsx +++ b/app/request-channel/page.tsx @@ -1,49 +1,105 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { JefflixLogo } from "@/components/jefflix-logo" -import { Radio, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react" +import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2 } from "lucide-react" import Link from 'next/link' -const categories = ['Sports', 'News', 'Entertainment', 'Music', 'Movies', 'Kids', 'Other'] +interface Channel { + id: string + name: string + country: string + categories: string[] +} export default function RequestChannelPage() { - const [formData, setFormData] = useState({ - channelName: '', - category: '', - url: '', - notes: '', - email: '', - }) - const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState('') + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [selected, setSelected] = useState([]) + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle') const [errorMessage, setErrorMessage] = useState('') + const debounceRef = useRef(null) + + // Fetch channel list on mount + useEffect(() => { + fetch('/api/channels') + .then((res) => { + if (!res.ok) throw new Error('Failed to load channels') + return res.json() + }) + .then((data: Channel[]) => { + setChannels(data) + setLoading(false) + }) + .catch((err) => { + setLoadError(err.message) + setLoading(false) + }) + }, []) + + // Debounce search input + useEffect(() => { + debounceRef.current = setTimeout(() => setDebouncedQuery(query), 200) + return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } + }, [query]) + + const results = debouncedQuery.length < 2 + ? [] + : channels.filter((ch) => { + const q = debouncedQuery.toLowerCase() + return ( + ch.name.toLowerCase().includes(q) || + ch.country.toLowerCase().includes(q) || + ch.categories.some((c) => c.toLowerCase().includes(q)) + ) + }) + + const totalMatches = results.length + const displayResults = results.slice(0, 50) + + const toggleChannel = useCallback((ch: Channel) => { + setSelected((prev) => + prev.some((s) => s.id === ch.id) + ? prev.filter((s) => s.id !== ch.id) + : prev.length < 20 + ? [...prev, ch] + : prev + ) + }, []) + + const removeChannel = useCallback((id: string) => { + setSelected((prev) => prev.filter((s) => s.id !== id)) + }, []) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setStatus('loading') + if (selected.length === 0) return + setStatus('submitting') setErrorMessage('') try { - const response = await fetch('/api/request-channel', { + const res = await fetch('/api/request-channel', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(formData), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + channels: selected.map(({ id, name, country, categories }) => ({ + id, name, country, categories, + })), + }), }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to submit request') - } - + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to submit request') setStatus('success') - } catch (error) { + } catch (err) { setStatus('error') - setErrorMessage(error instanceof Error ? error.message : 'Something went wrong') + setErrorMessage(err instanceof Error ? err.message : 'Something went wrong') } } @@ -56,7 +112,8 @@ export default function RequestChannelPage() {

    Request Submitted!

    - We'll look into adding the channel and let you know once it's available. + We'll look into adding {selected.length === 1 ? 'this channel' : `these ${selected.length} channels`} and + let you know once {selected.length === 1 ? "it's" : "they're"} available.

    Most channels are added within a few days. @@ -85,128 +142,169 @@ export default function RequestChannelPage() { {/* Main Content */}

    -
    +
    - +

    Request a Channel

    - Can't find a channel you're looking for? Let us know and we'll try to add it - to the Live TV lineup. + Search the iptv-org catalog and select channels you'd like us to add to the Live TV lineup.

    - - 3,468 Channels & Growing -
    -
    -
    - - setFormData({ ...formData, channelName: e.target.value })} - className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500" - placeholder="e.g. BBC World News, ESPN2" - /> + {loading ? ( +
    + +

    Loading channel catalog...

    - -
    - - + ) : loadError ? ( +
    + +

    {loadError}

    +
    - -
    - - setFormData({ ...formData, url: e.target.value })} - className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500" - placeholder="M3U URL or channel website" - /> -

    - If you know where to find a stream URL or M3U link, paste it here -

    -
    - -
    - -