mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
AI EPG Matching
Added AI EPG matching
This commit is contained in:
parent
d6477cef55
commit
c63ddcfe7b
5 changed files with 445 additions and 143 deletions
|
|
@ -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
207
apps/channels/tasks.py
Normal 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)."
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 setting’s value from what’s 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);
|
||||
// Don’t 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 setting’s 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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue