mirror of
https://github.com/Free-TV/IPTV.git
synced 2026-11-13 16:55:02 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e22bc489a |
@@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run the deep check (HTTP probes + ffprobe confirmation on dead channels)
|
- name: Run the deep check (HTTP probes + ffprobe confirmation on dead channels)
|
||||||
run: |
|
run: |
|
||||||
python3 check_channels.py --attempts 3 --timeout 12 --pause 5 --workers 40 --confirm-dead --include-invalid --json run.jsonl | tee check_summary.txt
|
python3 check_channels.py --attempts 3 --timeout 12 --pause 5 --workers 40 --confirm-dead --json run.jsonl | tee check_summary.txt
|
||||||
|
|
||||||
- name: Append to history and prune entries older than 90 days
|
- name: Append to history and prune entries older than 90 days
|
||||||
run: |
|
run: |
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "## Deep channel check - $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
echo "## Deep channel check - $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Channels that looked \`dead\` over HTTP were given a second opinion by \`ffprobe\`. A channel still reported \`dead\` here survived both checks and is safe to act on; one reported \`disputed\` looked dead over HTTP but ffprobe found a real stream behind it - needs a human look, not an automatic edit. This run also re-checks every channel already flagged Invalid (\`--include-invalid\`) - one reported \`RECOVERED?\` answered like a live stream again and shows up on the dashboard's own 'Flagged Invalid, answering again' section, also pending a human look before anyone restores it."
|
echo "Channels that looked \`dead\` over HTTP were given a second opinion by \`ffprobe\`. A channel still reported \`dead\` here survived both checks and is safe to act on; one reported \`disputed\` looked dead over HTTP but ffprobe found a real stream behind it - needs a human look, not an automatic edit."
|
||||||
echo ""
|
echo ""
|
||||||
echo '```'
|
echo '```'
|
||||||
cat check_summary.txt
|
cat check_summary.txt
|
||||||
|
|||||||
+25
-86
@@ -43,24 +43,11 @@ does real network I/O per candidate, on top of the HTTP probes already spent fin
|
|||||||
that candidate, and only makes sense where that time is available (a weekly run),
|
that candidate, and only makes sense where that time is available (a weekly run),
|
||||||
not where it is not (a PR check blocking on a handful of changed links).
|
not where it is not (a PR check blocking on a handful of changed links).
|
||||||
|
|
||||||
By default this only probes the `[>]` rows - the channels currently listed as working.
|
|
||||||
A row already marked `[x]` (Invalid) is never looked at again once it is marked, so a
|
|
||||||
source that comes back online stays listed as dead forever. --invalid flips that
|
|
||||||
around: it probes only the `[x]` rows, and anything that now answers `alive` or
|
|
||||||
`flaky` is printed as a recovery candidate instead of being judged dead/unreachable -
|
|
||||||
which is the expected, uninteresting outcome for a row that is supposed to be broken.
|
|
||||||
--include-invalid runs both passes together, in one pool of workers, so a dashboard or
|
|
||||||
history file sees the whole list rather than just the working half of it. The exit
|
|
||||||
code only ever reflects dead `[>]` channels - a dead `[x]` channel is not a
|
|
||||||
regression, it is the status quo - so --invalid on its own always exits 0.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
./check_channels.py # every list, working channels only
|
./check_channels.py # every list
|
||||||
./check_channels.py greece italy # only those lists
|
./check_channels.py greece italy # only those lists
|
||||||
./check_channels.py --attempts 5 greece # more attempts per channel
|
./check_channels.py --attempts 5 greece # more attempts per channel
|
||||||
./check_channels.py --confirm-dead greece # + ffprobe second opinion on dead ones
|
./check_channels.py --confirm-dead greece # + ffprobe second opinion on dead ones
|
||||||
./check_channels.py --invalid greece # only the channels already flagged Invalid
|
|
||||||
./check_channels.py --include-invalid # working channels + Invalid ones, one run
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -86,9 +73,8 @@ ProbeSettings = namedtuple(
|
|||||||
"ProbeSettings", ["attempts", "timeout", "pause", "workers", "confirm_dead"],
|
"ProbeSettings", ["attempts", "timeout", "pause", "workers", "confirm_dead"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# `| 1 | Channel name | [>](url) | ...` (working) or `| 1 | Channel name | [x](url) | ...`
|
# `| 1 | Channel name | [>](url) | ...` is the row format used by every list
|
||||||
# (flagged Invalid) is the row format used by every list
|
ROW = re.compile(r"^\|[^|]*\|([^|]+)\|[^|]*\[>\]\((https?://[^)\s]+)\)")
|
||||||
ROW = re.compile(r"^\|[^|]*\|([^|]+)\|[^|]*\[([>x])\]\((https?://[^)\s]+)\)")
|
|
||||||
|
|
||||||
USER_AGENT = (
|
USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
@@ -117,19 +103,15 @@ GONE_CODES = (404, 410)
|
|||||||
FFPROBE_TIMEOUT = 25
|
FFPROBE_TIMEOUT = 25
|
||||||
|
|
||||||
|
|
||||||
def read_channels(name, markers):
|
def read_channels(name):
|
||||||
"""Return the `(channel, url, marker)` triples of the list called `name`.
|
"""Return the `(channel, url)` pairs of the list called `name`."""
|
||||||
|
|
||||||
`markers` selects which rows to pull: `>` for the ones currently listed as
|
|
||||||
working, `x` for the ones already flagged Invalid.
|
|
||||||
"""
|
|
||||||
path = os.path.join(LISTS_DIR, name + ".md")
|
path = os.path.join(LISTS_DIR, name + ".md")
|
||||||
channels = []
|
channels = []
|
||||||
with open(path, encoding="utf-8") as handle:
|
with open(path, encoding="utf-8") as handle:
|
||||||
for line in handle:
|
for line in handle:
|
||||||
match = ROW.match(line.strip())
|
match = ROW.match(line.strip())
|
||||||
if match and match.group(2) in markers:
|
if match:
|
||||||
channels.append((match.group(1).strip(), match.group(3), match.group(2)))
|
channels.append((match.group(1).strip(), match.group(2)))
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
|
|
||||||
@@ -249,7 +231,7 @@ def check(channel, settings):
|
|||||||
chance through ffprobe before reporting it as dead - see the module docstring
|
chance through ffprobe before reporting it as dead - see the module docstring
|
||||||
for why this can only pull a verdict out of `dead`, never push one into it.
|
for why this can only pull a verdict out of `dead`, never push one into it.
|
||||||
"""
|
"""
|
||||||
name, url, marker = channel
|
name, url = channel
|
||||||
outcomes = []
|
outcomes = []
|
||||||
for attempt in range(settings.attempts):
|
for attempt in range(settings.attempts):
|
||||||
outcomes.append(probe(url, settings.timeout))
|
outcomes.append(probe(url, settings.timeout))
|
||||||
@@ -259,24 +241,19 @@ def check(channel, settings):
|
|||||||
if state == DEAD and settings.confirm_dead:
|
if state == DEAD and settings.confirm_dead:
|
||||||
if ffprobe_finds_a_stream(url):
|
if ffprobe_finds_a_stream(url):
|
||||||
state = DISPUTED
|
state = DISPUTED
|
||||||
return name, url, marker, state, outcomes
|
return name, url, state, outcomes
|
||||||
|
|
||||||
|
|
||||||
def check_all(names, markers, settings):
|
def check_all(names, settings):
|
||||||
"""Probe every selected channel of every named list and return `{list_name: [results]}`.
|
"""Probe every channel of every named list and return `{list_name: [results]}`.
|
||||||
|
|
||||||
`markers` is passed straight through to `read_channels` - `{">"}` for the default
|
All channels of all lists share one pool of `settings.workers`, so a run across
|
||||||
working-channels-only run, `{"x"}` for an Invalid-only recovery scan, or both at
|
many lists is not slower per list than a run of one - a list with two channels
|
||||||
once for --include-invalid. All channels of all lists share one pool of
|
does not pay the same wall-clock floor as a list with two hundred just because
|
||||||
`settings.workers`, so a run across many lists is not slower per list than a run
|
it was handed its own pool that then sits mostly idle.
|
||||||
of one - a list with two channels does not pay the same wall-clock floor as a list
|
|
||||||
with two hundred just because it was handed its own pool that then sits mostly idle.
|
|
||||||
"""
|
"""
|
||||||
channels_by_list = {
|
channels_by_list = {
|
||||||
name: [
|
name: [c for c in read_channels(name) if not c[1].startswith("https://www.youtube.com")]
|
||||||
c for c in read_channels(name, markers)
|
|
||||||
if not c[1].startswith("https://www.youtube.com")
|
|
||||||
]
|
|
||||||
for name in names
|
for name in names
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,32 +270,18 @@ def check_all(names, markers, settings):
|
|||||||
|
|
||||||
|
|
||||||
def report(name, results, attempts):
|
def report(name, results, attempts):
|
||||||
"""Print the results of one list; return `(dead, recovered)` for this list.
|
"""Print the results of one list and return how many channels are dead."""
|
||||||
|
|
||||||
`dead` only counts `[>]` (working) channels - a dead `[x]` channel is the status
|
|
||||||
quo, not a regression, so it never counts here. `recovered` counts `[x]` channels
|
|
||||||
that answered `alive` or `flaky` this run: the opposite of the usual case, so it
|
|
||||||
gets printed even though `report` otherwise skips `alive` as uninteresting.
|
|
||||||
"""
|
|
||||||
states = {}
|
states = {}
|
||||||
for _, _, _, state, _ in results:
|
for _, _, state, _ in results:
|
||||||
states[state] = states.get(state, 0) + 1
|
states[state] = states.get(state, 0) + 1
|
||||||
summary = ", ".join(f"{states[s]} {s}" for s in sorted(states))
|
summary = ", ".join(f"{states[s]} {s}" for s in sorted(states))
|
||||||
print(f"{name}: {len(results)} checked, {summary}")
|
print(f"{name}: {len(results)} checked, {summary}")
|
||||||
dead = 0
|
for channel, url, state, outcomes in results:
|
||||||
recovered = 0
|
if state == ALIVE:
|
||||||
for channel, url, marker, state, outcomes in results:
|
|
||||||
detail = "/".join(outcomes) if state != FLAKY else f"{outcomes.count(OK)}/{attempts} ok"
|
|
||||||
if marker == "x":
|
|
||||||
if state in (ALIVE, FLAKY):
|
|
||||||
recovered += 1
|
|
||||||
print(f" RECOVERED? {channel} now looks {state} [{detail}] -> {url}")
|
|
||||||
continue
|
continue
|
||||||
if state == DEAD:
|
detail = "/".join(outcomes) if state != FLAKY else f"{outcomes.count(OK)}/{attempts} ok"
|
||||||
dead += 1
|
|
||||||
if state != ALIVE:
|
|
||||||
print(f" {state:12} {channel} [{detail}] -> {url}")
|
print(f" {state:12} {channel} [{detail}] -> {url}")
|
||||||
return dead, recovered
|
return states.get(DEAD, 0)
|
||||||
|
|
||||||
|
|
||||||
def as_records(list_name, results, checked_at, confirm_dead):
|
def as_records(list_name, results, checked_at, confirm_dead):
|
||||||
@@ -334,12 +297,11 @@ def as_records(list_name, results, checked_at, confirm_dead):
|
|||||||
"list": list_name,
|
"list": list_name,
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
"url": url,
|
"url": url,
|
||||||
"marker": marker,
|
|
||||||
"state": state,
|
"state": state,
|
||||||
"outcomes": outcomes,
|
"outcomes": outcomes,
|
||||||
"confirm_dead": confirm_dead,
|
"confirm_dead": confirm_dead,
|
||||||
}
|
}
|
||||||
for channel, url, marker, state, outcomes in results
|
for channel, url, state, outcomes in results
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -357,16 +319,6 @@ def parse_args():
|
|||||||
"--confirm-dead", action="store_true",
|
"--confirm-dead", action="store_true",
|
||||||
help="give ffprobe a second opinion on channels that look dead (slower; needs ffmpeg)",
|
help="give ffprobe a second opinion on channels that look dead (slower; needs ffmpeg)",
|
||||||
)
|
)
|
||||||
mode = parser.add_mutually_exclusive_group()
|
|
||||||
mode.add_argument(
|
|
||||||
"--invalid", action="store_true",
|
|
||||||
help="check only the channels already flagged Invalid ([x]), to find ones that "
|
|
||||||
"work again - never affects the exit code",
|
|
||||||
)
|
|
||||||
mode.add_argument(
|
|
||||||
"--include-invalid", action="store_true",
|
|
||||||
help="check the working channels and the ones flagged Invalid together, in one run",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--json", metavar="PATH",
|
"--json", metavar="PATH",
|
||||||
help="also write every channel's result as one JSON record per line to PATH, for "
|
help="also write every channel's result as one JSON record per line to PATH, for "
|
||||||
@@ -399,23 +351,10 @@ def main():
|
|||||||
settings = ProbeSettings(
|
settings = ProbeSettings(
|
||||||
args.attempts, args.timeout, args.pause, args.workers, args.confirm_dead,
|
args.attempts, args.timeout, args.pause, args.workers, args.confirm_dead,
|
||||||
)
|
)
|
||||||
markers = {"x"} if args.invalid else ({">", "x"} if args.include_invalid else {">"})
|
|
||||||
|
|
||||||
results_by_list = check_all(names, markers, settings)
|
results_by_list = check_all(names, settings)
|
||||||
|
|
||||||
dead_total = 0
|
|
||||||
recovered_total = 0
|
|
||||||
for name in names:
|
|
||||||
dead, recovered = report(name, results_by_list[name], args.attempts)
|
|
||||||
dead_total += dead
|
|
||||||
recovered_total += recovered
|
|
||||||
|
|
||||||
if recovered_total:
|
|
||||||
print(
|
|
||||||
f"\n{recovered_total} channel(s) flagged Invalid answered like a live stream "
|
|
||||||
"this run - worth a human look before restoring them."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
dead_total = sum(report(name, results_by_list[name], args.attempts) for name in names)
|
||||||
if args.json:
|
if args.json:
|
||||||
with open(args.json, "w", encoding="utf-8") as handle:
|
with open(args.json, "w", encoding="utf-8") as handle:
|
||||||
write_json_records(handle, names, results_by_list, checked_at, args.confirm_dead)
|
write_json_records(handle, names, results_by_list, checked_at, args.confirm_dead)
|
||||||
|
|||||||
+4
-75
@@ -12,20 +12,10 @@ The page shows:
|
|||||||
- a trend of the alive share across every run kept in the history
|
- a trend of the alive share across every run kept in the history
|
||||||
- one row per list (country) with its current breakdown
|
- one row per list (country) with its current breakdown
|
||||||
- every channel that is not currently `alive`, searchable and filterable by state
|
- every channel that is not currently `alive`, searchable and filterable by state
|
||||||
- any channel flagged Invalid in the lists that answered like a live stream again,
|
|
||||||
if the history has rows for those (see check_channels.py's --invalid/--include-invalid)
|
|
||||||
|
|
||||||
"Currently" means: for each (list, channel), the most recent row in the history -
|
"Currently" means: for each (list, channel), the most recent row in the history -
|
||||||
older rows for the same channel are trend data, not part of today's snapshot.
|
older rows for the same channel are trend data, not part of today's snapshot.
|
||||||
|
|
||||||
A row's `marker` says whether it came from a working (`>`) or an Invalid (`x`) list
|
|
||||||
entry; older history written before that field existed has no `marker` and is always
|
|
||||||
treated as `>`, since only working channels were ever checked back then. Every count
|
|
||||||
above - the state breakdown, the trend, the by-list table, "needs attention" - is
|
|
||||||
built from `>` rows only, so turning on --include-invalid in a workflow never changes
|
|
||||||
what those numbers mean; the `x` rows only ever feed the separate "flagged Invalid"
|
|
||||||
section below them.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
./generate_dashboard.py # reads the default history path
|
./generate_dashboard.py # reads the default history path
|
||||||
./generate_dashboard.py --history path/to.jsonl --out path/to.html
|
./generate_dashboard.py --history path/to.jsonl --out path/to.html
|
||||||
@@ -42,15 +32,6 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|||||||
DEFAULT_HISTORY = os.path.join(BASE_DIR, ".github", "checker-history", "history.jsonl")
|
DEFAULT_HISTORY = os.path.join(BASE_DIR, ".github", "checker-history", "history.jsonl")
|
||||||
DEFAULT_OUT = os.path.join(BASE_DIR, "docs", "index.html")
|
DEFAULT_OUT = os.path.join(BASE_DIR, "docs", "index.html")
|
||||||
|
|
||||||
# history written before check_channels.py recorded `marker` has none - it only ever
|
|
||||||
# checked working channels back then, so the absence of the field means `>` too
|
|
||||||
WORKING = ">"
|
|
||||||
|
|
||||||
|
|
||||||
def marker_of(row):
|
|
||||||
"""Return `row`'s marker, treating pre-`marker` history as a working (`>`) row."""
|
|
||||||
return row.get("marker", WORKING)
|
|
||||||
|
|
||||||
# rendering order and the CSS custom property carrying each state's color
|
# rendering order and the CSS custom property carrying each state's color
|
||||||
STATE_ORDER = ["alive", "flaky", "blocked", "unreachable", "disputed", "dead"]
|
STATE_ORDER = ["alive", "flaky", "blocked", "unreachable", "disputed", "dead"]
|
||||||
STATE_VAR = {s: f"var(--s-{s})" for s in STATE_ORDER}
|
STATE_VAR = {s: f"var(--s-{s})" for s in STATE_ORDER}
|
||||||
@@ -255,43 +236,13 @@ def build_problem_rows(current):
|
|||||||
return "".join(rows)
|
return "".join(rows)
|
||||||
|
|
||||||
|
|
||||||
def build_recovered_rows(recovered):
|
def render(history_path, generated_at):
|
||||||
"""Return one `<tr>` per Invalid-flagged channel that answered like a live stream.
|
|
||||||
|
|
||||||
Unlike `build_problem_rows`, every row here is worth printing - there is no
|
|
||||||
`alive` case to skip, since being `alive`/`flaky` again is exactly what makes a
|
|
||||||
row belong in this table in the first place.
|
|
||||||
"""
|
|
||||||
ordered = sorted(recovered, key=lambda r: (r["list"], r["channel"]))
|
|
||||||
rows = []
|
|
||||||
for row in ordered:
|
|
||||||
rows.append(
|
|
||||||
f"<tr>"
|
|
||||||
f'<td>{escape(row["list"])}</td>'
|
|
||||||
f'<td>{escape(row["channel"])}</td>'
|
|
||||||
f'<td><span class="pill" style="background:{STATE_VAR[row["state"]]}">'
|
|
||||||
f'{escape(row["state"])}</span></td>'
|
|
||||||
f'<td class="url"><a href="{escape(row["url"])}">{escape(row["url"])}</a></td>'
|
|
||||||
f'<td class="dim">{escape(row["checked_at"][:10])}</td>'
|
|
||||||
"</tr>"
|
|
||||||
)
|
|
||||||
return "".join(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def render(history_path, generated_at): # pylint: disable=too-many-locals
|
|
||||||
"""Build the full dashboard page for the history found at `history_path`."""
|
"""Build the full dashboard page for the history found at `history_path`."""
|
||||||
rows = read_history(history_path)
|
rows = read_history(history_path)
|
||||||
if not rows:
|
if not rows:
|
||||||
return EMPTY_PAGE.format(path=escape(history_path))
|
return EMPTY_PAGE.format(path=escape(history_path))
|
||||||
|
|
||||||
# every top-of-page count, the trend, and "needs attention" are built from working
|
current = latest_snapshot(rows)
|
||||||
# (`>`) rows only, so turning --include-invalid on in a workflow can only ever add
|
|
||||||
# the separate "flagged Invalid" section below - it never changes what these numbers
|
|
||||||
# mean, and never hides a dead working channel behind hundreds of expected-dead
|
|
||||||
# Invalid ones.
|
|
||||||
working_rows = [row for row in rows if marker_of(row) == WORKING]
|
|
||||||
|
|
||||||
current = latest_snapshot(working_rows)
|
|
||||||
overall_counts = defaultdict(int)
|
overall_counts = defaultdict(int)
|
||||||
for row in current:
|
for row in current:
|
||||||
overall_counts[row["state"]] += 1
|
overall_counts[row["state"]] += 1
|
||||||
@@ -301,29 +252,18 @@ def render(history_path, generated_at): # pylint: disable=too-many-locals
|
|||||||
for row in current:
|
for row in current:
|
||||||
by_list[row["list"]].append(row)
|
by_list[row["list"]].append(row)
|
||||||
|
|
||||||
trend = run_trend(working_rows)
|
trend = run_trend(rows)
|
||||||
last_checked = rows[-1]["checked_at"]
|
last_checked = rows[-1]["checked_at"]
|
||||||
alive_pct_overall = (
|
alive_pct_overall = (
|
||||||
overall_counts.get("alive", 0) / total_channels * 100 if total_channels else 0
|
overall_counts.get("alive", 0) / total_channels * 100 if total_channels else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# the opposite case from "needs attention": a channel that is *supposed* to be
|
|
||||||
# dead (it is sitting on an `[x]` row) but just answered like a live stream -
|
|
||||||
# recorded only when a workflow actually ran check_channels.py with --invalid or
|
|
||||||
# --include-invalid, so this is empty rather than wrong on a history that never did
|
|
||||||
invalid_current = latest_snapshot([row for row in rows if marker_of(row) != WORKING])
|
|
||||||
recovered = [row for row in invalid_current if row["state"] in ("alive", "flaky")]
|
|
||||||
|
|
||||||
cards = build_cards(overall_counts)
|
cards = build_cards(overall_counts)
|
||||||
list_rows = build_list_rows(by_list)
|
list_rows = build_list_rows(by_list)
|
||||||
problem_rows = build_problem_rows(current)
|
problem_rows = build_problem_rows(current)
|
||||||
recovered_rows = build_recovered_rows(recovered)
|
|
||||||
state_options = "".join(
|
state_options = "".join(
|
||||||
f'<option value="{s}">{s}</option>' for s in STATE_ORDER if s != "alive"
|
f'<option value="{s}">{s}</option>' for s in STATE_ORDER if s != "alive"
|
||||||
)
|
)
|
||||||
recovered_subline = (
|
|
||||||
f" · {len(recovered)} flagged Invalid now answering again" if recovered else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
@@ -478,7 +418,7 @@ footer a {{ color: var(--accent); }}
|
|||||||
<div class="masthead">
|
<div class="masthead">
|
||||||
<h1>Channel Signal <span class="on-air">— {alive_pct_overall:.0f}% on air</span></h1>
|
<h1>Channel Signal <span class="on-air">— {alive_pct_overall:.0f}% on air</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="subline">{total_channels} channels · {len(by_list)} lists{recovered_subline} · last swept {escape(last_checked)} · page built {escape(generated_at)}</p>
|
<p class="subline">{total_channels} channels · {len(by_list)} lists · last swept {escape(last_checked)} · page built {escape(generated_at)}</p>
|
||||||
|
|
||||||
<div class="cards">{cards}</div>
|
<div class="cards">{cards}</div>
|
||||||
|
|
||||||
@@ -517,17 +457,6 @@ footer a {{ color: var(--accent); }}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Flagged Invalid, answering again</h2>
|
|
||||||
<p class="section-note">Channels sitting on an <code>[x]</code> row that answered like a live stream when a workflow last ran <code>check_channels.py</code> with <code>--invalid</code> or <code>--include-invalid</code>. Not proof the source is back for good - a single good answer can be a flaky CDN having a moment, not a restored channel - so this is a shortlist to look at by hand, not something to auto-restore.</p>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>List</th><th>Channel</th><th>State</th><th>URL</th><th>Last checked</th></tr></thead>
|
|
||||||
<tbody>{"".join(recovered_rows) or '<tr><td colspan="5" class="dim">none right now</td></tr>'}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>generated by <a href="https://github.com/Free-TV/IPTV/blob/master/check_channels.py">check_channels.py</a></footer>
|
<footer>generated by <a href="https://github.com/Free-TV/IPTV/blob/master/check_channels.py">check_channels.py</a></footer>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+2
-2
@@ -29,6 +29,7 @@ https://en.wikipedia.org/wiki/List_of_television_stations_in_Hungary
|
|||||||
| # | Channel | Link | Logo | EPG id|
|
| # | Channel | Link | Logo | EPG id|
|
||||||
|:---:|:--------------:|:-----:|:----:|:-----:|
|
|:---:|:--------------:|:-----:|:----:|:-----:|
|
||||||
| 1 | Cool | [>](http://141.94.193.53:80/Cool_HD/index.m3u8) | | Cool.hu |
|
| 1 | Cool | [>](http://141.94.193.53:80/Cool_HD/index.m3u8) | | Cool.hu |
|
||||||
|
| 2 | Spektrum Home | [>](http://141.94.193.53:80/Spektrum_Home_HD/index.m3u8) | <img height="20" src="https://upload.wikimedia.org/wikipedia/commons/2/2d/Spektrum-home.png" /> | SpektrumHome.hu |
|
||||||
|
|
||||||
<h2>Religion</h2>
|
<h2>Religion</h2>
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ https://en.wikipedia.org/wiki/List_of_television_stations_in_Hungary
|
|||||||
| # | Channel | Link | Logo | EPG id|
|
| # | Channel | Link | Logo | EPG id|
|
||||||
|:---:|:--------------:|:-----:|:----:|:-----:|
|
|:---:|:--------------:|:-----:|:----:|:-----:|
|
||||||
| 1 | Film+ | [>](http://141.94.193.53:80/Film+_HD/index.m3u8) | | Film+.hu |
|
| 1 | Film+ | [>](http://141.94.193.53:80/Film+_HD/index.m3u8) | | Film+.hu |
|
||||||
|
| 2 | Izaura TV | [>](http://141.94.193.53:80/Izaura_TV_HD/index.m3u8) | <img height="20" src="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Izaura_TV_logo.svg/250px-Izaura_TV_logo.svg.png" /> | IzauraTV.hu |
|
||||||
|
|
||||||
<h2>News</h2>
|
<h2>News</h2>
|
||||||
|
|
||||||
@@ -109,8 +111,6 @@ https://en.wikipedia.org/wiki/List_of_television_stations_in_Hungary
|
|||||||
| 8 | Hatoscsatorna | [x](https://hatoscsatorna.hu:8082/Hatoscsatorna/livestream/playlist.m3u8) | <img height="20" src="https://i.imgur.com/vraAfd7.png" /> | Hatoscsatorna.hu |
|
| 8 | Hatoscsatorna | [x](https://hatoscsatorna.hu:8082/Hatoscsatorna/livestream/playlist.m3u8) | <img height="20" src="https://i.imgur.com/vraAfd7.png" /> | Hatoscsatorna.hu |
|
||||||
| 9 | Putnok Városi TV | [>](http://91.147.248.75:5080/LiveApp/streams/nvUds5LHykQyOVXZ1706907141950.m3u8) | <img height="20" src="https://i.imgur.com/eKXPBFb.png" /> | PVTV.hu |
|
| 9 | Putnok Városi TV | [>](http://91.147.248.75:5080/LiveApp/streams/nvUds5LHykQyOVXZ1706907141950.m3u8) | <img height="20" src="https://i.imgur.com/eKXPBFb.png" /> | PVTV.hu |
|
||||||
|
|
||||||
| 16 | Spektrum Home | [x](http://88.212.15.19/live/spektrum_home_hun/index.m3u8) | <img height="20" src="https://upload.wikimedia.org/wikipedia/commons/2/2d/Spektrum-home.png" /> | SpektrumHome.hu |
|
|
||||||
| 17 | FEM3 (TV2 Klub) | [x](http://88.212.15.19/live/test_fem3_atktv/playlist.m3u8) | <img height="20" src="https://i.imgur.com/AF4Sz8Z.png" /> | FEM3.hu |
|
| 17 | FEM3 (TV2 Klub) | [x](http://88.212.15.19/live/test_fem3_atktv/playlist.m3u8) | <img height="20" src="https://i.imgur.com/AF4Sz8Z.png" /> | FEM3.hu |
|
||||||
| 18 | Viasat 6 | [x](http://88.212.15.19/live/viasat6/index.m3u8) | <img height="20" src="https://i.imgur.com/kypSbFX.png" /> | Viasat6.hu |
|
| 18 | Viasat 6 | [x](http://88.212.15.19/live/viasat6/index.m3u8) | <img height="20" src="https://i.imgur.com/kypSbFX.png" /> | Viasat6.hu |
|
||||||
| 19 | Izaura TV | [x](http://88.212.15.19/live/izaura/index.m3u8) | <img height="20" src="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Izaura_TV_logo.svg/250px-Izaura_TV_logo.svg.png" /> | IzauraTV.hu |
|
|
||||||
| 20 | DTV | [x](http://cloudfront44.lexanetwork.com:1732/hlsrelay003/hls/livestream.sdp.m3u8) | <img height="20" src="https://i.imgur.com/YSpqmSO.png" /> | DTV.hu |
|
| 20 | DTV | [x](http://cloudfront44.lexanetwork.com:1732/hlsrelay003/hls/livestream.sdp.m3u8) | <img height="20" src="https://i.imgur.com/YSpqmSO.png" /> | DTV.hu |
|
||||||
|
|||||||
@@ -1033,6 +1033,8 @@ http://141.94.193.53:80/ATV_HD/index.m3u8
|
|||||||
http://141.94.193.53:80/TV4_HD/index.m3u8
|
http://141.94.193.53:80/TV4_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="Cool" tvg-logo="" tvg-id="Cool.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Cool
|
#EXTINF:-1 tvg-name="Cool" tvg-logo="" tvg-id="Cool.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Cool
|
||||||
http://141.94.193.53:80/Cool_HD/index.m3u8
|
http://141.94.193.53:80/Cool_HD/index.m3u8
|
||||||
|
#EXTINF:-1 tvg-name="Spektrum Home" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/2/2d/Spektrum-home.png" tvg-id="SpektrumHome.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Spektrum Home
|
||||||
|
http://141.94.193.53:80/Spektrum_Home_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="EWTN TV" tvg-logo="https://katolikus.tv/wp-content/themes/bonum/img/ewtn-badge.jpg" tvg-id="BonumTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",EWTN TV
|
#EXTINF:-1 tvg-name="EWTN TV" tvg-logo="https://katolikus.tv/wp-content/themes/bonum/img/ewtn-badge.jpg" tvg-id="BonumTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",EWTN TV
|
||||||
https://hls.iptvservice.eu/hls/ewtn-hd.m3u8
|
https://hls.iptvservice.eu/hls/ewtn-hd.m3u8
|
||||||
#EXTINF:-1 tvg-name="Apostol TV" tvg-logo="https://www.apostoltv.hu/images/header-logo.png" tvg-id="ApostolTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Apostol TV
|
#EXTINF:-1 tvg-name="Apostol TV" tvg-logo="https://www.apostoltv.hu/images/header-logo.png" tvg-id="ApostolTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Apostol TV
|
||||||
@@ -1047,6 +1049,8 @@ https://www.youtube.com/@radio1hungary/live
|
|||||||
https://www.youtube.com/c/DikhTvGipsyTv/live
|
https://www.youtube.com/c/DikhTvGipsyTv/live
|
||||||
#EXTINF:-1 tvg-name="Film+" tvg-logo="" tvg-id="Film+.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Film+
|
#EXTINF:-1 tvg-name="Film+" tvg-logo="" tvg-id="Film+.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Film+
|
||||||
http://141.94.193.53:80/Film+_HD/index.m3u8
|
http://141.94.193.53:80/Film+_HD/index.m3u8
|
||||||
|
#EXTINF:-1 tvg-name="Izaura TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Izaura_TV_logo.svg/250px-Izaura_TV_logo.svg.png" tvg-id="IzauraTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Izaura TV
|
||||||
|
http://141.94.193.53:80/Izaura_TV_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="Euronews Hungarian" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/7/7e/Euronews._2016_logo.png" tvg-id="EuronewsHungarian.fr" tvg-chno="1" tvg-country="HU" group-title="Hungary",Euronews Hungarian Ⓨ
|
#EXTINF:-1 tvg-name="Euronews Hungarian" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/7/7e/Euronews._2016_logo.png" tvg-id="EuronewsHungarian.fr" tvg-chno="1" tvg-country="HU" group-title="Hungary",Euronews Hungarian Ⓨ
|
||||||
https://www.youtube.com/channel/UC4Ct8gIf9f0n4mdyGsFiZRA/live
|
https://www.youtube.com/channel/UC4Ct8gIf9f0n4mdyGsFiZRA/live
|
||||||
#EXTINF:-1 tvg-name="Parlamenti közvetítés" tvg-logo="" tvg-chno="2" tvg-country="HU" group-title="Hungary",Parlamenti közvetítés
|
#EXTINF:-1 tvg-name="Parlamenti közvetítés" tvg-logo="" tvg-chno="2" tvg-country="HU" group-title="Hungary",Parlamenti közvetítés
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ http://141.94.193.53:80/ATV_HD/index.m3u8
|
|||||||
http://141.94.193.53:80/TV4_HD/index.m3u8
|
http://141.94.193.53:80/TV4_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="Cool" tvg-logo="" tvg-id="Cool.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Cool
|
#EXTINF:-1 tvg-name="Cool" tvg-logo="" tvg-id="Cool.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Cool
|
||||||
http://141.94.193.53:80/Cool_HD/index.m3u8
|
http://141.94.193.53:80/Cool_HD/index.m3u8
|
||||||
|
#EXTINF:-1 tvg-name="Spektrum Home" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/2/2d/Spektrum-home.png" tvg-id="SpektrumHome.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Spektrum Home
|
||||||
|
http://141.94.193.53:80/Spektrum_Home_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="EWTN TV" tvg-logo="https://katolikus.tv/wp-content/themes/bonum/img/ewtn-badge.jpg" tvg-id="BonumTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",EWTN TV
|
#EXTINF:-1 tvg-name="EWTN TV" tvg-logo="https://katolikus.tv/wp-content/themes/bonum/img/ewtn-badge.jpg" tvg-id="BonumTV.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",EWTN TV
|
||||||
https://hls.iptvservice.eu/hls/ewtn-hd.m3u8
|
https://hls.iptvservice.eu/hls/ewtn-hd.m3u8
|
||||||
#EXTINF:-1 tvg-name="Apostol TV" tvg-logo="https://www.apostoltv.hu/images/header-logo.png" tvg-id="ApostolTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Apostol TV
|
#EXTINF:-1 tvg-name="Apostol TV" tvg-logo="https://www.apostoltv.hu/images/header-logo.png" tvg-id="ApostolTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Apostol TV
|
||||||
@@ -37,6 +39,8 @@ https://www.youtube.com/@radio1hungary/live
|
|||||||
https://www.youtube.com/c/DikhTvGipsyTv/live
|
https://www.youtube.com/c/DikhTvGipsyTv/live
|
||||||
#EXTINF:-1 tvg-name="Film+" tvg-logo="" tvg-id="Film+.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Film+
|
#EXTINF:-1 tvg-name="Film+" tvg-logo="" tvg-id="Film+.hu" tvg-chno="1" tvg-country="HU" group-title="Hungary",Film+
|
||||||
http://141.94.193.53:80/Film+_HD/index.m3u8
|
http://141.94.193.53:80/Film+_HD/index.m3u8
|
||||||
|
#EXTINF:-1 tvg-name="Izaura TV" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Izaura_TV_logo.svg/250px-Izaura_TV_logo.svg.png" tvg-id="IzauraTV.hu" tvg-chno="2" tvg-country="HU" group-title="Hungary",Izaura TV
|
||||||
|
http://141.94.193.53:80/Izaura_TV_HD/index.m3u8
|
||||||
#EXTINF:-1 tvg-name="Euronews Hungarian" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/7/7e/Euronews._2016_logo.png" tvg-id="EuronewsHungarian.fr" tvg-chno="1" tvg-country="HU" group-title="Hungary",Euronews Hungarian Ⓨ
|
#EXTINF:-1 tvg-name="Euronews Hungarian" tvg-logo="https://upload.wikimedia.org/wikipedia/commons/7/7e/Euronews._2016_logo.png" tvg-id="EuronewsHungarian.fr" tvg-chno="1" tvg-country="HU" group-title="Hungary",Euronews Hungarian Ⓨ
|
||||||
https://www.youtube.com/channel/UC4Ct8gIf9f0n4mdyGsFiZRA/live
|
https://www.youtube.com/channel/UC4Ct8gIf9f0n4mdyGsFiZRA/live
|
||||||
#EXTINF:-1 tvg-name="Parlamenti közvetítés" tvg-logo="" tvg-chno="2" tvg-country="HU" group-title="Hungary",Parlamenti közvetítés
|
#EXTINF:-1 tvg-name="Parlamenti közvetítés" tvg-logo="" tvg-chno="2" tvg-country="HU" group-title="Hungary",Parlamenti közvetítés
|
||||||
|
|||||||
Reference in New Issue
Block a user