From c63ddcfe7b75fec5cbfec18d785f5f320b725614 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Sun, 2 Mar 2025 12:27:21 -0600 Subject: [PATCH] AI EPG Matching Added AI EPG matching --- apps/channels/api_views.py | 20 ++ apps/channels/tasks.py | 207 ++++++++++++++++++ frontend/src/api.js | 127 +++++------ .../src/components/tables/ChannelsTable.js | 42 +++- frontend/src/pages/Settings.js | 192 ++++++++++------ 5 files changed, 445 insertions(+), 143 deletions(-) create mode 100644 apps/channels/tasks.py diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ea55e3e6..75772509 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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 # ───────────────────────────────────────────────────────── diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py new file mode 100644 index 00000000..c4bf8177 --- /dev/null +++ b/apps/channels/tasks.py @@ -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)." diff --git a/frontend/src/api.js b/frontend/src/api.js index f840d1ab..0ed976f0 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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), diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index e271511c..b09d9566 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -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 = () => { + {/* Our brand-new button for EPG matching */} + + + + + +