Compare commits

...

15 Commits

Author SHA1 Message Date
deven 9fb0342d03 Channel Name adjustment 2025-08-26 10:57:22 -04:00
deven 5e50d6af3a tvg-group => group-title 2025-08-26 00:17:38 -04:00
deven 943361f9e9 Merge pull request 'Manual channel curation and fuzzy channel searching' (#1) from channel-fuzzy-search into master
Reviewed-on: #1
2025-08-26 00:12:48 -04:00
deven a1eb7c4d04 Manual channel curation 2025-08-26 00:08:58 -04:00
deven 99c1b861c7 Memoized fuses 2025-08-25 22:36:28 -04:00
deven 3cb503d927 Find channel by fuzzy search 2025-08-25 20:58:26 -04:00
deven e776c8247d ESLint fixes 2025-08-22 01:04:09 -04:00
deven a57431198c SDLive multi-instance 2025-08-22 00:51:52 -04:00
deven 665727562d Add tvg-id to sdlive-playlist 2025-08-22 00:16:38 -04:00
deven 89a829dac0 debugLog 2025-08-22 00:16:10 -04:00
deven a733118d35 Internal sdlive URL 2025-08-19 22:27:15 -04:00
deven 99a8f3c8ac Health endpoint 2025-08-19 22:09:52 -04:00
deven 802992f551 Basic m3u passthrough 2025-08-16 21:41:54 -04:00
deven 0341d5958f Docker setup 2025-08-16 21:31:30 -04:00
deven 03f8adf33d Create next app 2025-08-16 19:54:07 -04:00
24 changed files with 4488 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "api-internal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"fuse.js": "^7.1.0",
"next": "15.4.6",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+3782
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+5
View File
@@ -0,0 +1,5 @@
export async function GET() {
return new Response('OK', {
status: 200,
})
}
+76
View File
@@ -0,0 +1,76 @@
import { manualNameToIdMappings } from "@/utils/constants"
import { debugLog } from "@/utils/debugLog"
export async function GET() {
const response = await fetch(`https://sdlive-1-internal.d-ho.me/playlist.m3u8`)
if (!response.ok) {
return new Response('Failed to fetch playlist', { status: 500 })
}
const playlist = await response.text()
const lines = playlist.split("\n")
debugLog(lines)
const channels: string[] = []
for (let i = 1; i + 1 < lines.length; i += 2) {
const info = lines[i]
const streamURL = lines[i + 1]
debugLog(`Attempting to parse: info: ${info}. streamURL: ${streamURL}`)
// Basic validation to hope that assumptions are valid
if (!info.startsWith("#EXTINF:-1")) {
console.error(`iptv/sdlive-playlist.m3u8: One of the info lines did not start with #EXTINF:. Instead got: ${info}`)
return new Response("Internal Server Error", { status: 500 })
} else if (!streamURL.match(/\/stream\/[0-9]+\.m3u8$/)) {
console.error(`iptv/sdlive-playlist.m3u8: One of the streamURLs did not match expected ending. Instead got: ${streamURL}`)
return new Response("Internal Server Error", { status: 500 })
}
const channelName = info.split(",").at(-1)
if (channelName) channels.push(channelName)
}
const oneoffs = ["BBC America (BBCA)", "CBC CA", "CBS Sports Network (CBSSN)", "CTV 2 Canada", "CTV Canada", "Citytv ", "Discovery Channel", "Discovery Family", "Discovery Life Channel", "Disney", "E! Entertainment Television", "FX Movie Channel", "FYI", "Fox News", "Freeform", "Game Show Network", "Global CA", "HGTV", "MSNBC ", "NBC Sports", "NICK", "National Geographic", "Oprah Winfrey Network (OWN)", "Oxygen True Crime", "Paramount Network", "RDS CA", "RDS 2 CA", "TLC", "The Food Network", "The Hallmark Channel", "The Weather Channel", "VICE TV", "WWE Network"]
const filteredChannels = channels.filter(name => {
if (name.includes("USA")) return true
else if (name.includes("UK") && name.match(/ITV|BBC/)) return true
else if (name.includes("TSN") && name.match(/[1-5]/g)) return true
else if (name.includes("Sportsnet") && name.match(/360|East|One|Ontario|West|World/g)) return true
else if (oneoffs.includes(name)) return true
else return false
})
// const url = new URL(request.url)
// const baseUrl = `${url.protocol}//${url.host}`
// const nameToIDMap: Record<string, string> = {}
// await Promise.all(filteredChannels.map(async channelName => {
// const country = channelName.endsWith("USA")
// ? "US" :
// channelName.endsWith("CA") || channelName.endsWith("Canada")
// ? "CA" :
// channelName.endsWith("UK")
// ? "UK" :
// null
// const response = await fetch(`${baseUrl}/iptv/channel?name=${encodeURIComponent(channelName)}${country ? `&country=${country}` : ""}`)
// const channelInfo = await response.json()
// const foundId: string = channelInfo.id
// nameToIDMap[channelName] = foundId
// }))
const nameToIDMap = filteredChannels.map(channelName => `${channelName} => ${manualNameToIdMappings[channelName]}`)
return new Response(JSON.stringify(nameToIDMap), {
headers: {
'Content-Type': 'application/json',
}
})
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest } from "next/server"
import Fuse from "fuse.js"
import { debugLog } from "@/utils/debugLog";
import { manualNameToIdMappings } from "@/utils/constants";
type ChannelInfo = {
id: string,
name: string,
alt_names: string[],
network: string | null,
owners: string[],
country: string,
subdivision: string | null,
city: string | null,
categories: string[],
is_nsfw: boolean,
launched: string | null,
closed: string | null,
replaced_by: string | null,
website: string | null
}
let memoChannelData: ChannelInfo[] | null = null;
const memoFuses: Record<string, Fuse<ChannelInfo>> = {}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const name = searchParams.get('name')
const country = searchParams.get('country')
if (!name) {
return new Response("Must be supplied with a name to search", { status: 400 })
}
let fuse: Fuse<ChannelInfo> | null = null;
const channelData = memoChannelData || (await (await fetch("https://iptv-org.github.io/api/channels.json")).json() as ChannelInfo[])
memoChannelData = channelData
debugLog(name)
// Skip logic for manual mappings
if (manualNameToIdMappings[name]) {
const manualId = manualNameToIdMappings[name]
const channel: ChannelInfo | undefined = channelData.find(data => data.id === manualId)
const channelInfo = channel ?? { id: manualId, name }
return new Response(JSON.stringify(channelInfo), {
headers: {
"Content-Type": "application/json"
}
})
}
// Memoize fuses
if (!memoFuses[country ?? ""]) {
debugLog(`Creating memoFuse for ${country ?? "null"}`)
const limitedChannelData = country
? channelData.filter(c => c.country === country.toUpperCase())
: channelData.filter(c => ["US", "CA", "UK"].includes(c.country))
memoFuses[country ?? ""] = new Fuse(limitedChannelData, {
keys: [
"name",
"alt_names",
"id"
],
includeScore: true,
distance: 20
})
fuse = memoFuses[country ?? ""]
} else {
fuse = memoFuses[country ?? ""]
}
const found: { item: ChannelInfo }[] = fuse.search(name, { limit: 1 })
const foundChannel: ChannelInfo | null = found[0].item ?? null
return new Response(foundChannel ? JSON.stringify(foundChannel) : null, {
headers: {
"Content-Type": "application/json"
}
})
}
@@ -0,0 +1,8 @@
import { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const baseUrl = `${url.protocol}//${url.host}`
const response = await fetch(`${baseUrl}/iptv/sdlive-playlist.m3u8?id=1`)
return response
}
@@ -0,0 +1,8 @@
import { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const baseUrl = `${url.protocol}//${url.host}`
const response = await fetch(`${baseUrl}/iptv/sdlive-playlist.m3u8?id=2`)
return response
}
@@ -0,0 +1,72 @@
import { manualNameToIdMappings } from "@/utils/constants"
import { debugLog } from "@/utils/debugLog"
import { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
if (!id || !["1", "2"].includes(id)) {
return new Response("Must be supplied with an id 1 or 2", { status: 400 })
}
const response = await fetch(`https://sdlive-${id}-internal.d-ho.me/playlist.m3u8`)
if (!response.ok) {
return new Response('Failed to fetch playlist', { status: 500 })
}
const playlist = await response.text()
const lines = playlist.split("\n")
debugLog(lines)
const newLines = [lines[0]]
for (let i = 1; i + 1 < lines.length; i += 2) {
const info = lines[i]
const streamURL = lines[i + 1]
debugLog(`Attempting to parse: info: ${info}. streamURL: ${streamURL}`)
// Basic validation to hope that assumptions are valid
if (!info.startsWith("#EXTINF:-1")) {
console.error(`iptv/sdlive-playlist.m3u8: One of the info lines did not start with #EXTINF:. Instead got: ${info}`)
return new Response("Internal Server Error", { status: 500 })
} else if (info.includes("tvg-id")) {
console.warn("iptv/sdlive-playlist.m3u8: One of the channels already has a tvg-id. Channel skipped.")
newLines.push(info, streamURL)
continue
} else if (!streamURL.match(/\/stream\/[0-9]+\.m3u8$/)) {
console.error(`iptv/sdlive-playlist.m3u8: One of the streamURLs did not match expected ending. Instead got: ${streamURL}`)
return new Response("Internal Server Error", { status: 500 })
}
// Add tvg-id. This will need to be better parsed
const afterExtInf = info.slice(11)
const sdliveChannelNum = streamURL.split("/").at(-1)?.slice(0, -5)
if (sdliveChannelNum == null) {
console.error("iptv/sdlive-playlist.m3u8: One of the streamURLs was not a number")
return new Response("Internal Server Error", { status: 500 })
}
const channelName = info.split(",").at(-1) ?? ""
const tvgid = manualNameToIdMappings[channelName]
const newInfo = `#EXTINF:-1 ${tvgid ? `tvg-id="${tvgid}" group-title="dhome-curated"` : `tvg-id="${sdliveChannelNum}.sdlive"`} sdlive-id="${sdliveChannelNum}.sdlive" ${afterExtInf}`
newLines.push(newInfo, streamURL)
}
const parsedPlaylist = newLines.join("\n")
return new Response(parsedPlaylist, {
headers: {
'Content-Disposition': 'attachment; filename="sdlive-playlist.m3u8"',
'Content-Type': 'application/vnd.apple.mpegurl',
}
})
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+103
View File
@@ -0,0 +1,103 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
export const manualNameToIdMappings: Record<string, string> = {
"5 USA": "5USA.uk",
"A&E USA": "AE.us",
"ABC USA": "ABC.us",
"ABCNY USA": "ABC.us",
"ACC Network USA": "ACCNetwork.us",
"AMC USA": "AMC.us",
"AXS TV USA": "AXSTV.us",
"BBC America (BBCA)": "BBCAmerica.us",
"BBC Four UK": "BBCFour.uk",
"BBC One UK": "BBCOne.uk",
"BBC Three UK": "BBCThree.uk",
"BBC Two UK": "BBCTwo.uk",
"BET USA": "BET.us",
"BIG TEN Network (BTN USA)": "BigTenNetwork.us",
"BeIN SPORTS USA": "beINSportsUSA.us",
"Bravo USA": "Bravo.us",
"CBC CA": "CBLTDT.ca",
"CBS Sports Network (CBSSN)": "CBSSportsNetworkCanada.ca",
"CBS USA": "CBS.us",
"CBSNY USA": "CBS.us",
"CMT USA": "CMT.us",
"CNBC USA": "CNBC.us",
"CNN USA": "CNN.us",
"COZI TV USA": "CoziTV.us",
"CTV 2 Canada": "CHCJDT.ca",
"CTV Canada": "CFTODT.ca",
"CW USA": "CW.us",
"Cinemax USA": "Cinemax.us",
"Comet USA": "Comet.us",
"Cooking Channel USA": "CookingChannel.us",
"Crime+ Investigation USA": "CrimePlusInvestigation.us",
"Discovery Channel": "DiscoveryChannel.ca",
"Discovery Family": "DiscoveryFamily.us",
"Discovery Life Channel": "DiscoveryLife.us",
"E! Entertainment Television": "E.us",
"ESPN USA": "ESPN.us",
"ESPN2 USA": "ESPN2.us",
"ESPNU USA": "ESPNU.us",
"FOX Deportes USA": "FoxDeportes.us",
"FOX USA": "Fox.us",
"FOXNY USA": "Fox.us",
"FX Movie Channel": "FXMovieChannel.us",
"FX USA": "FX.us",
"FXX USA": "FXX.us",
"FYI": "FYI.us",
"Fox News": "FoxNewsChannel.us",
"Fox Sports 1 USA": "FoxSports1.us",
"Fox Sports 2 USA": "FoxSports2.us",
"Freeform": "Freeform.us",
"GOLF Channel USA": "GolfChannel.us",
"Galavisi贸n USA": "Galavision.us",
"Game Show Network": "GameShowNetwork.us",
"Global CA": "CIIIDT.ca",
"HBO Comedy USA": "HBOComedy.us",
"HBO Family USA": "HBOFamily.us",
"HBO Latino USA": "HBOLatino.us",
"HBO Signature USA": "HBOSignature.us",
"HBO USA": "HBO.us",
"HBO Zone USA": "HBOZone.us",
"HBO2 USA": "HBO2.us",
"HGTV": "HGTV.ca",
"History USA": "History.us",
"IFC TV USA": "IFC.us",
"ION USA": "IONTV.us",
"ITV 1 UK": "ITV1.uk",
"ITV 2 UK": "ITV2.uk",
"ITV 3 UK": "ITV3.uk",
"ITV 4 UK": "ITV4.uk",
"Investigation Discovery (ID USA)": "InvestigationDiscovery.us",
"Longhorn Network USA": "LonghornNetwork.us",
"MASN USA": "MASN.us",
"MAVTV USA": "MAVTV.us",
"METV USA": "MercedEducationalTV.us",
"MLB Network USA": "MLBNetwork.us",
"MSG USA": "MSG.us",
"MTV USA": "MTV.us",
"MY9TV USA": "WWORDT1.us",
"NBA TV USA": "NBATV.us",
"NBC USA": "NBC.us",
"NBCNY USA": "NBC.us",
"NESN USA": "NESN.us",
"NHL Network USA": "NHLNetwork.us",
"NICK": "Nickelodeon.us",
"Nat Geo Wild USA": "NationalGeographicWild.us",
"NewsNation USA": "NewsNation.us",
"Newsmax USA": "NewsmaxTV.us",
"Oprah Winfrey Network (OWN)": "OWN.ca",
"Oxygen True Crime": "Oxygen.us",
"POP TV USA": "PopTV.us",
"Paramount Network": "ParamountNetwork.us",
"RDS 2 CA": "RDS2.ca",
"RDS CA": "RDS.ca",
"SEC Network USA": "SECNetwork.us",
"SYFY USA": "Syfy.us",
"Showtime SHOxBET USA": "ShoxBet.us",
"Showtime USA": "Showtime.us",
"Sportsnet 360": "Sportsnet360.ca",
"Sportsnet East": "SportsnetEast.ca",
"Sportsnet One": "SportsnetOne.ca",
"Sportsnet Ontario": "SportsnetOntario.ca",
"Sportsnet West": "SportsnetWest.ca",
"Sportsnet World": "SportsnetWorld.ca",
"TBS USA": "TBS.us",
"TCM USA": "TCM.us",
"TLC": "TLC.us",
"TMC Channel USA": "TheMovieChannel.us",
"TNT USA": "TNT.us",
"TSN1": "TSN1.ca",
"TSN2": "TSN2.ca",
"TSN3": "TSN3.ca",
"TSN4": "TSN4.ca",
"TSN5": "TSN5.ca",
"TUDN USA": "TUDN.us",
"TV ONE USA": "TVOne.us",
"The Food Network": "FoodNetwork.us",
"The Hallmark Channel": "HallmarkChannel.us",
"The Weather Channel": "TheWeatherChannel.us",
"TruTV USA": "truTV.us",
"USA Network": "USANetwork.us",
"Universal Kids USA": "UniversalKids.us",
"VH1 USA": "VH1.us",
"VICE TV": "VICETV.us",
"WETV USA": "WeTV.us",
"WWE Network": "WWENetwork.ca",
"YES Network USA": "YesNetwork.us"
}
+6
View File
@@ -0,0 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const debugLog = (...data: any[]): void => {
if (process.env.LOG_LEVEL === "DEBUG") {
console.log(...data)
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}