Compare commits

...

3 Commits

Author SHA1 Message Date
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
7 changed files with 311 additions and 8 deletions
+7 -6
View File
@@ -9,19 +9,20 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"fuse.js": "^7.1.0",
"next": "15.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0"
"next": "15.4.6"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"@eslint/eslintrc": "^3" "tailwindcss": "^4",
"typescript": "^5"
} }
} }
+9
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
fuse.js:
specifier: ^7.1.0
version: 7.1.0
next: next:
specifier: 15.4.6 specifier: 15.4.6
version: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -1026,6 +1029,10 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuse.js@7.1.0:
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
engines: {node: '>=10'}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2890,6 +2897,8 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
fuse.js@7.1.0: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
+1 -1
View File
@@ -1,4 +1,4 @@
export async function GET(request: Request) { export async function GET() {
return new Response('OK', { return new Response('OK', {
status: 200, 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"
}
})
}
+5 -1
View File
@@ -1,3 +1,4 @@
import { manualNameToIdMappings } from "@/utils/constants"
import { debugLog } from "@/utils/debugLog" import { debugLog } from "@/utils/debugLog"
import { NextRequest } from "next/server" import { NextRequest } from "next/server"
@@ -50,7 +51,10 @@ export async function GET(request: NextRequest) {
return new Response("Internal Server Error", { status: 500 }) return new Response("Internal Server Error", { status: 500 })
} }
const newInfo = `#EXTINF:-1 tvg-id="${sdliveChannelNum}.sdlive" ${afterExtInf}` const channelName = info.split(",").at(-1) ?? ""
const tvgid = manualNameToIdMappings[channelName]
const newInfo = `#EXTINF:-1 ${tvgid ? `tvg-id="${tvgid}" tvg-group="dhome-curated"` : `tvg-id="${sdliveChannelNum}.sdlive"`} sdlive-id="${sdliveChannelNum}.sdlive" ${afterExtInf}`
newLines.push(newInfo, streamURL) newLines.push(newInfo, streamURL)
} }
+126
View File
@@ -0,0 +1,126 @@
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": "RacerNetwork.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": "Sportsnet.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",
"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"
}