AI EPG Matching

Added AI EPG matching
This commit is contained in:
Dispatcharr 2025-03-02 12:27:21 -06:00
parent d6477cef55
commit c63ddcfe7b
5 changed files with 445 additions and 143 deletions

View file

@ -9,6 +9,8 @@ from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
from .tasks import match_epg_channels
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
@ -30,6 +32,7 @@ class StreamViewSet(viewsets.ModelViewSet):
qs = qs.filter(channels__isnull=True)
return qs
# ─────────────────────────────────────────────────────────
# 2) Channel Group Management (CRUD)
# ─────────────────────────────────────────────────────────
@ -38,6 +41,7 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
serializer_class = ChannelGroupSerializer
permission_classes = [IsAuthenticated]
# ─────────────────────────────────────────────────────────
# 3) Channel Management (CRUD)
# ─────────────────────────────────────────────────────────
@ -178,6 +182,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Gather current used numbers once.
used_numbers = set(Channel.objects.all().values_list('channel_number', flat=True))
next_number = 1
def get_auto_number():
nonlocal next_number
while next_number in used_numbers:
@ -236,6 +241,20 @@ class ChannelViewSet(viewsets.ModelViewSet):
return Response(response_data, status=status.HTTP_201_CREATED)
# ─────────────────────────────────────────────────────────
# 6) EPG Fuzzy Matching
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method='post',
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data.",
responses={202: "EPG matching task initiated"}
)
@action(detail=False, methods=['post'], url_path='match-epg')
def match_epg(self, request):
match_epg_channels.delay()
return Response({"message": "EPG matching task initiated."}, status=status.HTTP_202_ACCEPTED)
# ─────────────────────────────────────────────────────────
# 4) Bulk Delete Streams
# ─────────────────────────────────────────────────────────
@ -262,6 +281,7 @@ class BulkDeleteStreamsAPIView(APIView):
Stream.objects.filter(id__in=stream_ids).delete()
return Response({"message": "Streams deleted successfully!"}, status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────────────────────────────────────
# 5) Bulk Delete Channels
# ─────────────────────────────────────────────────────────

207
apps/channels/tasks.py Normal file
View file

@ -0,0 +1,207 @@
# apps/channels/tasks.py
import logging
import re
from celery import shared_task
from rapidfuzz import fuzz
from sentence_transformers import SentenceTransformer, util
from django.db import transaction
from apps.channels.models import Channel
from apps.epg.models import EPGData
from core.models import CoreSettings # to retrieve "preferred-region" setting
logger = logging.getLogger(__name__)
# Load the model once at module level
SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
st_model = SentenceTransformer(SENTENCE_MODEL_NAME)
# Threshold constants
BEST_FUZZY_THRESHOLD = 70
LOWER_FUZZY_THRESHOLD = 40
EMBED_SIM_THRESHOLD = 0.65
# Common extraneous words
COMMON_EXTRANEOUS_WORDS = [
"tv", "channel", "network", "television",
"east", "west", "hd", "uhd", "us", "usa", "not", "24/7",
"1080p", "720p", "540p", "480p",
"arabic", "latino", "film", "movie", "movies"
]
def normalize_channel_name(name: str) -> str:
"""
A more aggressive normalization that:
- Lowercases
- Removes bracketed/parenthesized text
- Removes punctuation
- Strips extraneous words
- Collapses extra spaces
"""
if not name:
return ""
# Lowercase
norm = name.lower()
# Remove bracketed text
norm = re.sub(r"\[.*?\]", "", norm)
norm = re.sub(r"\(.*?\)", "", norm)
# Remove punctuation except word chars/spaces
norm = re.sub(r"[^\w\s]", "", norm)
# Remove extraneous tokens
tokens = norm.split()
tokens = [t for t in tokens if t not in COMMON_EXTRANEOUS_WORDS]
# Rejoin
norm = " ".join(tokens).strip()
return norm
@shared_task
def match_epg_channels():
"""
Goes through all Channels and tries to find a matching EPGData row by:
1) If channel.tvg_id is valid in EPGData, skip
2) If channel has a tvg_id but not found in EPGData, attempt direct EPGData lookup
3) Otherwise do name-based fuzzy ratio pass:
- add region-based bonus if region code is found in the EPG row
- if fuzzy >= BEST_FUZZY_THRESHOLD => accept
- if fuzzy in [LOWER_FUZZY_THRESHOLD..BEST_FUZZY_THRESHOLD) => do embedding check
- else skip
4) Log summary
"""
logger.info("Starting EPG matching logic...")
# Try to get user's preferred region from CoreSettings
try:
region_obj = CoreSettings.objects.get(key="preferred-region")
region_code = region_obj.value.strip().lower() # e.g. "us"
except CoreSettings.DoesNotExist:
region_code = None
# 1) Gather EPG rows
all_epg = list(EPGData.objects.all())
epg_rows = []
for e in all_epg:
epg_rows.append({
"epg_id": e.id,
"tvg_id": e.tvg_id or "", # e.g. "Fox News.us"
"raw_name": e.channel_name,
"norm_name": normalize_channel_name(e.channel_name),
})
# 2) Pre-encode embeddings if possible
epg_embeddings = None
if any(row["norm_name"] for row in epg_rows):
epg_embeddings = st_model.encode(
[row["norm_name"] for row in epg_rows],
convert_to_tensor=True
)
matched_channels = []
with transaction.atomic():
for chan in Channel.objects.all():
# A) Skip if channel.tvg_id is valid
if chan.tvg_id and EPGData.objects.filter(tvg_id=chan.tvg_id).exists():
continue
# B) If channel has a tvg_id but not in EPG, do direct lookup
if chan.tvg_id:
epg_match = EPGData.objects.filter(tvg_id=chan.tvg_id).first()
if epg_match:
logger.info(
f"Channel {chan.id} '{chan.channel_name}' => found EPG by tvg_id={chan.tvg_id}"
)
continue
# C) No valid tvg_id => name-based matching
fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.channel_name
norm_chan = normalize_channel_name(fallback_name)
if not norm_chan:
logger.info(
f"Channel {chan.id} '{chan.channel_name}' => empty after normalization, skipping"
)
continue
best_score = 0
best_epg = None
for row in epg_rows:
if not row["norm_name"]:
continue
# Base fuzzy ratio
base_score = fuzz.ratio(norm_chan, row["norm_name"])
# If we have a region_code, add a small bonus if the epg row has that region
# e.g. tvg_id or raw_name might contain ".us" or "us"
bonus = 0
if region_code:
# example: if region_code is "us" and row["tvg_id"] ends with ".us"
# or row["raw_name"] has "us" in it, etc.
# We'll do a naive check:
combined_text = row["tvg_id"].lower() + " " + row["raw_name"].lower()
if region_code in combined_text:
bonus = 15 # pick a small bonus
score = base_score + bonus
if score > best_score:
best_score = score
best_epg = row
if not best_epg:
logger.info(f"Channel {chan.id} '{fallback_name}' => no EPG match at all.")
continue
# E) Decide acceptance
if best_score >= BEST_FUZZY_THRESHOLD:
# Accept
chan.tvg_id = best_epg["tvg_id"]
chan.save()
matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"]))
logger.info(
f"Channel {chan.id} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} (score={best_score})"
)
elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None:
# borderline => do embedding
chan_embedding = st_model.encode(norm_chan, convert_to_tensor=True)
sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0]
top_index = int(sim_scores.argmax())
top_value = float(sim_scores[top_index])
if top_value >= EMBED_SIM_THRESHOLD:
matched_epg = epg_rows[top_index]
chan.tvg_id = matched_epg["tvg_id"]
chan.save()
matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"]))
logger.info(
f"Channel {chan.id} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} "
f"(fuzzy={best_score}, cos-sim={top_value:.2f})"
)
else:
logger.info(
f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score}, "
f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping"
)
else:
# no match
logger.info(
f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score} < {LOWER_FUZZY_THRESHOLD}, skipping"
)
# Final summary
total_matched = len(matched_channels)
if total_matched:
logger.info(f"Match Summary: {total_matched} channel(s) matched.")
for (cid, cname, tvg) in matched_channels:
logger.info(f" - Channel ID={cid}, Name='{cname}' => tvg_id='{tvg}'")
else:
logger.info("No new channels were matched.")
logger.info("Finished EPG matching logic.")
return f"Done. Matched {total_matched} channel(s)."

View file

@ -1,3 +1,4 @@
// src/api.js (updated)
import useAuthStore from './store/auth';
import useChannelsStore from './store/channels';
import useUserAgentsStore from './store/userAgents';
@ -7,18 +8,17 @@ import useStreamsStore from './store/streams';
import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
// const axios = Axios.create({
// withCredentials: true,
// });
// If needed, you can set a base host or keep it empty if relative requests
const host = '';
export const getAuthToken = async () => {
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
return token;
};
export default class API {
/**
* A static method so we can do: await API.getAuthToken()
*/
static async getAuthToken() {
return await useAuthStore.getState().getToken();
}
static async login(username, password) {
const response = await fetch(`${host}/api/accounts/token/`, {
method: 'POST',
@ -31,11 +31,11 @@ export default class API {
return await response.json();
}
static async refreshToken(refreshToken) {
static async refreshToken(refresh) {
const response = await fetch(`${host}/api/accounts/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken }),
body: JSON.stringify({ refresh }),
});
const retval = await response.json();
@ -54,7 +54,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
@ -66,7 +66,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/groups/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
@ -78,7 +78,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/groups/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -97,7 +97,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/groups/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -114,6 +114,7 @@ export default class API {
static async addChannel(channel) {
let body = null;
if (channel.logo_file) {
// Must send FormData for file upload
body = new FormData();
for (const prop in channel) {
body.append(prop, channel[prop]);
@ -127,7 +128,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
...(channel.logo_file
? {}
: {
@ -149,7 +150,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -162,7 +163,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_ids }),
@ -176,7 +177,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -195,26 +196,22 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/assign/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_order: channelIds }),
});
// The backend returns something like { "message": "Channels have been auto-assigned!" }
if (!response.ok) {
// If you want to handle errors gracefully:
const text = await response.text();
throw new Error(`Assign channels failed: ${response.status} => ${text}`);
}
// Usually it has a { message: "..."} or similar
const retval = await response.json();
// If you want to automatically refresh the channel list in Zustand:
// Optionally refresh the channel list in Zustand
await useChannelsStore.getState().fetchChannels();
// Return the entire JSON result (so the caller can see the "message")
return retval;
}
@ -222,7 +219,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/channels/from-stream/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -242,7 +239,7 @@ export default class API {
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -261,7 +258,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/streams/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
@ -273,7 +270,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/streams/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -292,7 +289,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -310,7 +307,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -322,7 +319,7 @@ export default class API {
const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stream_ids: ids }),
@ -335,7 +332,7 @@ export default class API {
const response = await fetch(`${host}/api/core/useragents/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
@ -347,7 +344,7 @@ export default class API {
const response = await fetch(`${host}/api/core/useragents/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -366,7 +363,7 @@ export default class API {
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -384,7 +381,7 @@ export default class API {
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -395,7 +392,7 @@ export default class API {
static async getPlaylist(id) {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -407,7 +404,7 @@ export default class API {
static async getPlaylists() {
const response = await fetch(`${host}/api/m3u/accounts/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -420,7 +417,7 @@ export default class API {
const response = await fetch(`${host}/api/m3u/accounts/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -438,7 +435,7 @@ export default class API {
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -451,7 +448,7 @@ export default class API {
const response = await fetch(`${host}/api/m3u/refresh/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -464,7 +461,7 @@ export default class API {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -477,7 +474,7 @@ export default class API {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -494,7 +491,7 @@ export default class API {
static async getEPGs() {
const response = await fetch(`${host}/api/epg/sources/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -503,18 +500,8 @@ export default class API {
return retval;
}
static async refreshPlaylist(id) {
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
// Notice there's a duplicated "refreshPlaylist" method above;
// you might want to rename or remove one if it's not needed.
static async addEPG(values) {
let body = null;
@ -532,7 +519,7 @@ export default class API {
const response = await fetch(`${host}/api/epg/sources/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
...(values.epg_file
? {}
: {
@ -554,7 +541,7 @@ export default class API {
const response = await fetch(`${host}/api/epg/sources/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -566,7 +553,7 @@ export default class API {
const response = await fetch(`${host}/api/epg/import/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ id }),
@ -579,7 +566,7 @@ export default class API {
static async getStreamProfiles() {
const response = await fetch(`${host}/api/core/streamprofiles/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -592,7 +579,7 @@ export default class API {
const response = await fetch(`${host}/api/core/streamprofiles/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -610,7 +597,7 @@ export default class API {
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -628,7 +615,7 @@ export default class API {
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -639,7 +626,7 @@ export default class API {
static async getGrid() {
const response = await fetch(`${host}/api/epg/grid/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
});
@ -654,7 +641,7 @@ export default class API {
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
@ -663,7 +650,7 @@ export default class API {
const retval = await response.json();
if (retval.id) {
// Fetch m3u account to update it with its new playlists
// Refresh the playlist
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore
.getState()
@ -679,7 +666,7 @@ export default class API {
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
}
@ -696,7 +683,7 @@ export default class API {
{
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@ -711,7 +698,7 @@ export default class API {
const response = await fetch(`${host}/api/core/settings/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
@ -724,7 +711,7 @@ export default class API {
const response = await fetch(`${host}/api/core/settings/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),

View file

@ -24,13 +24,14 @@ import {
SwapVert as SwapVertIcon,
LiveTv as LiveTvIcon,
ContentCopy,
Tv as TvIcon, // <-- ADD THIS IMPORT
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore'; // NEW import
import useVideoStore from '../../store/useVideoStore';
const ChannelsTable = () => {
const [channel, setChannel] = useState(null);
@ -116,6 +117,7 @@ const ChannelsTable = () => {
4,
selected.map((chan) => () => deleteChannel(chan.original.id))
);
// If you have a real bulk-delete endpoint, call it here:
// await API.deleteChannels(selected.map((sel) => sel.id));
setIsLoading(false);
};
@ -144,6 +146,32 @@ const ChannelsTable = () => {
}
};
// ─────────────────────────────────────────────────────────
// The new "Match EPG" button logic
// ─────────────────────────────────────────────────────────
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
const resp = await fetch('/api/channels/channels/match-epg/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
if (resp.ok) {
setSnackbarMessage('EPG matching task started!');
} else {
const text = await resp.text();
setSnackbarMessage(`Failed to start EPG matching: ${text}`);
}
} catch (err) {
setSnackbarMessage(`Error: ${err.message}`);
}
setSnackbarOpen(true);
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
@ -294,6 +322,18 @@ const ChannelsTable = () => {
</IconButton>
</Tooltip>
{/* Our brand-new button for EPG matching */}
<Tooltip title="Auto-match EPG with fuzzy logic">
<IconButton
size="small"
color="success"
variant="contained"
onClick={matchEpg}
>
<TvIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL

View file

@ -1,66 +1,89 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import {
Grid2,
Grid as Grid2,
Box,
Container,
Typography,
TextField,
Button,
FormControl,
Select,
MenuItem,
CircularProgress,
InputLabel,
Button,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
const SettingsPage = () => {
const { settings } = useSettingsStore();
const { userAgents } = useUserAgentsStore();
const { profiles: streamProfiles } = useStreamProfilesStore();
// Add your region choices here:
const regionChoices = [
{ value: 'us', label: 'US' },
{ value: 'uk', label: 'UK' },
{ value: 'nl', label: 'NL' },
{ value: 'de', label: 'DE' },
// Add more if needed
];
const formik = useFormik({
initialValues: {
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
// The region is optional or required as you prefer
// 'preferred-region': Yup.string().required('Region is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
const changedSettings = {};
for (const setting in values) {
if (values[setting] != settings[setting].value) {
changedSettings[setting] = values[setting];
for (const settingKey in values) {
// If the user changed the settings value from whats in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = values[settingKey];
}
}
console.log(changedSettings);
for (const updated in changedSettings) {
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updated],
value: values[updated],
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
setSubmitting(false);
// Dont necessarily resetForm, in case the user wants to see new values
},
});
// Initialize form values once settings / userAgents / profiles are loaded
useEffect(() => {
formik.setValues(
Object.values(settings).reduce((acc, setting) => {
acc[setting.key] = parseInt(setting.value) || setting.value;
// If the settings value is numeric, parse it
// Otherwise, just store as string
const possibleNumber = parseInt(setting.value, 10);
acc[setting.key] = isNaN(possibleNumber)
? setting.value
: possibleNumber;
return acc;
}, {})
);
}, [settings, streamProfiles, userAgents]);
// eslint-disable-next-line
}, [settings, userAgents, streamProfiles]);
return (
<Container maxWidth="md">
@ -68,65 +91,90 @@ const SettingsPage = () => {
<Typography variant="h4" gutterBottom>
Settings
</Typography>
<form onSubmit={formik.handleSubmit}>
<Grid2 container spacing={3}>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id={settings['default-user-agent'].id}
name={settings['default-user-agent'].key}
label={settings['default-user-agent'].name}
value={formik.values['default-user-agent']}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-user-agent'] &&
Boolean(formik.errors['default-user-agent'])
}
helperText={
formik.touched['default-user-agent'] &&
formik.errors['default-user-agent']
}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
{/* Default User-Agent */}
<Grid2 xs={12}>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
value={formik.values['default-user-agent'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-user-agent'] &&
Boolean(formik.errors['default-user-agent'])
}
variant="standard"
>
{userAgents.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Default Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id={settings['default-stream-profile'].id}
name={settings['default-stream-profile'].key}
label={settings['default-stream-profile'].name}
value={formik.values['default-stream-profile']}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-stream-profile'] &&
Boolean(formik.errors['default-stream-profile'])
}
helperText={
formik.touched['default-stream-profile'] &&
formik.errors['default-stream-profile']
}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
{/* Default Stream Profile */}
<Grid2 xs={12}>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Default Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
value={formik.values['default-stream-profile'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-stream-profile'] &&
Boolean(formik.errors['default-stream-profile'])
}
variant="standard"
>
{streamProfiles.map((profile) => (
<MenuItem key={profile.id} value={profile.id}>
{profile.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
{/* Preferred Region */}
<Grid2 xs={12}>
{/* Only render if you do indeed have "preferred-region" in the DB */}
{settings['preferred-region'] && (
<FormControl variant="standard" fullWidth>
<InputLabel id="region-label">Preferred Region</InputLabel>
<Select
labelId="region-label"
id={settings['preferred-region'].id}
name={settings['preferred-region'].key}
label={settings['preferred-region'].name}
value={formik.values['preferred-region'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
variant="standard"
>
{regionChoices.map((r) => (
<MenuItem key={r.value} value={r.value}>
{r.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Grid2>
</Grid2>
<Box mt={4} display="flex" justifyContent="flex-end">