Author SHA1 Message Date
Kálmán „KAMI” Szalai 13359f9ed0 Surface recovered Invalid channels on the dashboard, wire --include-invalid into the deep check
Follow-up to the previous commit on this branch: that one added
--invalid/--include-invalid to check_channels.py but deliberately left
them unwired, because generate_dashboard.py's "most recent row per
(list, channel)" view assumed every row was a working channel -
turning --include-invalid on in a workflow would have bloated the
"needs attention" table with hundreds of already-known-dead Invalid
rows, while hiding the one thing that's actually interesting: an
Invalid channel that's alive again wouldn't show up there at all,
since that table only lists rows that are *not* alive.

- generate_dashboard.py: every existing count (cards, trend, by-list,
  "needs attention") is now built from `>` (working) rows only, via a
  `marker_of()` helper that treats history written before this field
  existed as `>` too, since only working channels were ever checked
  back then - so this is a no-op for every dashboard that hasn't
  started using --include-invalid yet.
- New "Flagged Invalid, answering again" section lists `x` rows whose
  latest check came back alive/flaky - the dashboard's equivalent of
  check_channels.py's own `RECOVERED?` line - with a note that this is
  a shortlist for a human, not something to auto-restore.
- check_channels_deep.yml now runs with --include-invalid, and its
  step-summary blurb mentions the new section.

Tested: wrote a small synthetic history.jsonl mixing a marker-less
legacy row, `>` rows, and `x` rows (one alive, one dead) and confirmed
the channel count/cards only reflect the `>` rows, the alive `x` row
shows up under "Flagged Invalid, answering again", the dead `x` row
shows up nowhere (expected - it's just confirming the status quo), and
an all-`>` history renders the new section's "none right now" empty
state. Also ran it against this repo's real 77MB
.github/checker-history/history.jsonl (all pre-dates this change, so
no `x` rows in it) - renders in ~1.5s, unchanged output. pylint:
10.00/10 on both files.
2026-10-23 09:20:28 +02:00
Kálmán „KAMI” Szalai f444da3b2a Add --invalid/--include-invalid to check_channels.py (closes #1226)
Per #1226: once a channel is marked Invalid ([x]), nothing ever probes
it again, so a source that comes back online stays listed as dead
forever, and the scheduled fast/deep runs plus the PR check only ever
look at [>] rows.

- ROW now captures which marker (`>` or `x`) a row carries.
- --invalid probes only the Invalid-flagged rows; anything that now
  answers alive/flaky is printed as "RECOVERED?" instead of being
  judged dead/unreachable (the expected, uninteresting outcome for a
  row that's supposed to be broken).
- --include-invalid runs both passes together in one worker pool.
- The exit code still only reflects dead [>] channels - a dead [x]
  channel is the status quo, not a regression - so --invalid alone
  always exits 0 and is safe to add to any workflow without risking a
  spurious failure.
- --json records now carry the row's marker too.

Deliberately not wired into check_channels_deep.yml yet: the history
file and generate_dashboard.py's "most recent row per (list, channel)"
view assume every row is a working channel. Mixing in hundreds of
Invalid rows per run would bloat the dashboard's "not currently alive"
table with already-known-dead noise, and - worse - an Invalid channel
that comes back alive wouldn't show there either, since that table
only lists rows that are *not* alive. That's a second, separate change
to generate_dashboard.py; flagging it here rather than shipping it
half-done.

Tried --invalid on bulgaria/latvia (channels this account's own PR
moved to Invalid days ago, confirmed dead at the time) - correctly
reported dead/blocked, not recovered. Tried --include-invalid on
hungary and it immediately surfaced 4 real recovery candidates
(AlföldTV, Lóverseny közvetítés, Bajai TV, Tisza TV); re-checked those
4 by hand afterwards with a longer timeout and they went straight back
to unreachable, which is itself a useful data point - exactly the kind
of flaky intermittent case the tool is supposed to hand to a human
rather than silently act on, so none were restored. pylint: 10.00/10.
2026-10-23 09:13:24 +02:00
KAMI 1db3cc0553 Merge pull request #1244 from Free-TV/fix/restore-working-channels
Restore working channels flagged Invalid, fix dead Hungarian streams
2026-10-23 09:06:27 +02:00
6 changed files with 166 additions and 42 deletions
+2 -2
View File
@@ -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 --json run.jsonl | tee check_summary.txt python3 check_channels.py --attempts 3 --timeout 12 --pause 5 --workers 40 --confirm-dead --include-invalid --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." 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 "" echo ""
echo '```' echo '```'
cat check_summary.txt cat check_summary.txt
+86 -25
View File
@@ -43,11 +43,24 @@ 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 ./check_channels.py # every list, working channels only
./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
@@ -73,8 +86,9 @@ ProbeSettings = namedtuple(
"ProbeSettings", ["attempts", "timeout", "pause", "workers", "confirm_dead"], "ProbeSettings", ["attempts", "timeout", "pause", "workers", "confirm_dead"],
) )
# `| 1 | Channel name | [>](url) | ...` is the row format used by every list # `| 1 | Channel name | [>](url) | ...` (working) or `| 1 | Channel name | [x](url) | ...`
ROW = re.compile(r"^\|[^|]*\|([^|]+)\|[^|]*\[>\]\((https?://[^)\s]+)\)") # (flagged Invalid) is the row format used by every list
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 "
@@ -103,15 +117,19 @@ GONE_CODES = (404, 410)
FFPROBE_TIMEOUT = 25 FFPROBE_TIMEOUT = 25
def read_channels(name): def read_channels(name, markers):
"""Return the `(channel, url)` pairs of the list called `name`.""" """Return the `(channel, url, marker)` triples 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: if match and match.group(2) in markers:
channels.append((match.group(1).strip(), match.group(2))) channels.append((match.group(1).strip(), match.group(3), match.group(2)))
return channels return channels
@@ -231,7 +249,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 = channel name, url, marker = 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))
@@ -241,19 +259,24 @@ 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, state, outcomes return name, url, marker, state, outcomes
def check_all(names, settings): def check_all(names, markers, settings):
"""Probe every channel of every named list and return `{list_name: [results]}`. """Probe every selected channel of every named list and return `{list_name: [results]}`.
All channels of all lists share one pool of `settings.workers`, so a run across `markers` is passed straight through to `read_channels` - `{">"}` for the default
many lists is not slower per list than a run of one - a list with two channels working-channels-only run, `{"x"}` for an Invalid-only recovery scan, or both at
does not pay the same wall-clock floor as a list with two hundred just because once for --include-invalid. All channels of all lists share one pool of
it was handed its own pool that then sits mostly idle. `settings.workers`, so a run across many lists is not slower per list than a run
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: [c for c in read_channels(name) if not c[1].startswith("https://www.youtube.com")] name: [
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
} }
@@ -270,18 +293,32 @@ def check_all(names, settings):
def report(name, results, attempts): def report(name, results, attempts):
"""Print the results of one list and return how many channels are dead.""" """Print the results of one list; return `(dead, recovered)` for this list.
`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}")
for channel, url, state, outcomes in results: dead = 0
if state == ALIVE: recovered = 0
continue for channel, url, marker, state, outcomes in results:
detail = "/".join(outcomes) if state != FLAKY else f"{outcomes.count(OK)}/{attempts} ok" 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
if state == DEAD:
dead += 1
if state != ALIVE:
print(f" {state:12} {channel} [{detail}] -> {url}") print(f" {state:12} {channel} [{detail}] -> {url}")
return states.get(DEAD, 0) return dead, recovered
def as_records(list_name, results, checked_at, confirm_dead): def as_records(list_name, results, checked_at, confirm_dead):
@@ -297,11 +334,12 @@ 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, state, outcomes in results for channel, url, marker, state, outcomes in results
] ]
@@ -319,6 +357,16 @@ 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 "
@@ -351,10 +399,23 @@ 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, settings) results_by_list = check_all(names, markers, 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)
+75 -4
View File
@@ -12,10 +12,20 @@ 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
@@ -32,6 +42,15 @@ 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}
@@ -236,13 +255,43 @@ def build_problem_rows(current):
return "".join(rows) return "".join(rows)
def render(history_path, generated_at): def build_recovered_rows(recovered):
"""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))
current = latest_snapshot(rows) # every top-of-page count, the trend, and "needs attention" are built from working
# (`>`) 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
@@ -252,18 +301,29 @@ def render(history_path, generated_at):
for row in current: for row in current:
by_list[row["list"]].append(row) by_list[row["list"]].append(row)
trend = run_trend(rows) trend = run_trend(working_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" &middot; {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>
@@ -418,7 +478,7 @@ footer a {{ color: var(--accent); }}
<div class="masthead"> <div class="masthead">
<h1>Channel Signal <span class="on-air">&mdash; {alive_pct_overall:.0f}% on air</span></h1> <h1>Channel Signal <span class="on-air">&mdash; {alive_pct_overall:.0f}% on air</span></h1>
</div> </div>
<p class="subline">{total_channels} channels &middot; {len(by_list)} lists &middot; last swept {escape(last_checked)} &middot; page built {escape(generated_at)}</p> <p class="subline">{total_channels} channels &middot; {len(by_list)} lists{recovered_subline} &middot; last swept {escape(last_checked)} &middot; page built {escape(generated_at)}</p>
<div class="cards">{cards}</div> <div class="cards">{cards}</div>
@@ -457,6 +517,17 @@ 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
View File
@@ -29,7 +29,6 @@ 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>
@@ -52,7 +51,6 @@ 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>
@@ -111,6 +109,8 @@ 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 |
-4
View File
@@ -1033,8 +1033,6 @@ 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
@@ -1049,8 +1047,6 @@ 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
-4
View File
@@ -23,8 +23,6 @@ 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
@@ -39,8 +37,6 @@ 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