freeflix-cli 2.4.0__tar.gz → 2.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/CHANGELOG.md +22 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/PKG-INFO +1 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/pyproject.toml +1 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/match.py +77 -14
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/i18n.py +3 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/install_health.py +1 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/player_manager.py +84 -3
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/yacine_match.py +12 -3
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/yacine_live_proxy.py +37 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_yacine_match.py +116 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/uv.lock +1 -1
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/binaries.yml +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/ci.yml +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/macos.yml +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/release.yml +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.gitignore +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/CONTRIBUTING.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/LICENSE +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/README.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/SECURITY.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/assets/logo.png +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/freeflix_position.lua +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/input.conf +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/mpv.conf +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/data/selectors.jsonc +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/data/source_portal.jsonc +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/ARCHITECTURE.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/AUDIT-FREEFLIX-2.0-2026-11-04.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/CORRECTIONS-2.1.0.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/README.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/RELEASE.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/SCORECARD-2.1.0.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/TROUBLESHOOTING.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/USAGE.md +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/build-binary.sh +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install-mac.sh +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.ps1 +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.sh +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/wrappers/mpv +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/__init__.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/__main__.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/anilist.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cli.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cli_utils.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cloudflare.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/config_loader.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/data/channel_logos.json +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/data/clubs.json +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/defaults.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/doctor.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/__init__.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/anilist.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/anime_sama.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/coflix.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/french_manga.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/french_stream.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/goldenanime.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/goldenms.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/hianime.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/nyaa.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/papystreaming.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/playback.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/yts.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/health.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/history_ui.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/httpcache.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/icons.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/languages.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/logsetup.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/main.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/motion.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/mpv_ipc.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/neon.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/net_config.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/notifications.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/platform_android.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/preview_picker.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/progress.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/providers_registry.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/proxy.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/recent.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/__init__.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/anime_sama.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/anime_sama_planning.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/coflix.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/config.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/deobfuscate.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/eztv.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/french_manga.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/french_stream.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/goldenanime.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/goldenms.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/nyaa.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/objects.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/papystreaming.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/player.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/resilient.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/subtitles.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/utils.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/yts.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/setup_assistant.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/splash.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/subtitles.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/terminal_image.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/themes.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/tracker.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/ui.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/update_checker.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/conftest.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_android_setup.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anilist_errors.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anilist_token_input.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anime_sama_planning.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_audit_fixes.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_batch_download.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_coflix_parse.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_core_units.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_cover_cache.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_decoy_stream.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_fixes.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_integration.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_robustness.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_episode_parsing.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_fsvid_decode.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_health.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_httpcache.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_i18n_complete.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_keyboard.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_managed_binaries.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_mouse.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_mpv_ipc.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_navigation.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_nerd_font.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_nyaa.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_pack_a_polish.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_performance.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_platform_android.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_player_dash.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_polish.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_premium_players.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_providers_repair.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_endpoint.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_integration.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_resilient.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_ssrf.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_recent_new_episodes.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_release_config.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_resilient.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_scraper_resilience.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_search_parity.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_sendvid.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_stream_analysis.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_stream_integrity.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_system_deps_cache.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_terminal_image.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_update_prompt.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_v1105_improvements.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_v2_aesthetic.py +0 -0
- {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tools/audit_providers.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.1
|
|
4
|
+
|
|
5
|
+
- **Match : une seule ligne par affiche**. Le catalogue répétait un même
|
|
6
|
+
match sur plusieurs créneaux (un « Twente - Ajax » en direct alors qu'un
|
|
7
|
+
autre du même jour était déjà terminé). Une entrée live/à venir/terminée
|
|
8
|
+
par match, la plus utile gagne.
|
|
9
|
+
- **Bascule automatique de source** : un match en direct expose désormais
|
|
10
|
+
HD, SD, Low et des flux **Multi**. Si la qualité choisie est refusée par
|
|
11
|
+
le CDN, FreeFlix essaie les autres sources au lieu de s'arrêter.
|
|
12
|
+
- **Contrôle avant lecture fiable** : le préflight passe par **ffmpeg en
|
|
13
|
+
direct** (comme la lecture) et lit sa sortie en continu. Le proxy local
|
|
14
|
+
déclenchait des 404 anti-hotlink et un live qui joue « sans fin » était
|
|
15
|
+
pris pour une panne — les deux sont corrigés. Les erreurs sont
|
|
16
|
+
distinguées : `403` (serveur qui refuse) vs `404/invalid data` (flux sans
|
|
17
|
+
données).
|
|
18
|
+
- **Multi en tête** : les flux « Multi » passent avant HD/SD/Low (source la
|
|
19
|
+
plus fiable en direct) ; tri stable, ordre du catalogue conservé à
|
|
20
|
+
qualité égale.
|
|
21
|
+
- Proxy HLS : rafraîchissement automatique de la playlist quand un segment
|
|
22
|
+
devient introuvable.
|
|
23
|
+
- 618 tests au vert, ruff OK.
|
|
24
|
+
|
|
3
25
|
## 2.4.0
|
|
4
26
|
|
|
5
27
|
- **Match regroupé par statut** : sections `En direct (n)`, `À venir (n)`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: freeflix-cli
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Watch movies, series, and anime from your terminal — multi-language, multi-source, no browser required. A maintained fork of autoflix-cli.
|
|
5
5
|
Project-URL: Homepage, https://github.com/freedy237/freeflix-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/freedy237/freeflix-cli.git
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "freeflix-cli"
|
|
7
|
-
version = "2.4.
|
|
7
|
+
version = "2.4.1"
|
|
8
8
|
description = "Watch movies, series, and anime from your terminal — multi-language, multi-source, no browser required. A maintained fork of autoflix-cli."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9.2"
|
|
@@ -81,6 +81,52 @@ def _sort_events(events: list[ym.MatchEvent], now_ts: int) -> list[ym.MatchEvent
|
|
|
81
81
|
return sorted(events, key=lambda e: (order.get(e.status(now_ts), 3), e.start))
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _dedupe(events: list[ym.MatchEvent], now_ts: int) -> list[ym.MatchEvent]:
|
|
85
|
+
"""One row per fixture: the catalogue repeats some matches across
|
|
86
|
+
channels and time slots (a live "Twente - Ajax" while an older
|
|
87
|
+
"Twente - Ajax" of the same channel is already finished).
|
|
88
|
+
|
|
89
|
+
The most useful entry wins: live > upcoming > finished, then the nearest
|
|
90
|
+
kick-off to now. Every channel showing the fixture is kept in a side map
|
|
91
|
+
so the row can still show where else to watch it.
|
|
92
|
+
"""
|
|
93
|
+
rank = {"live": 0, "upcoming": 1, "ended": 2}
|
|
94
|
+
best: dict[tuple, ym.MatchEvent] = {}
|
|
95
|
+
for ev in events:
|
|
96
|
+
key = (
|
|
97
|
+
re.sub(r"\s+", " ", (ev.home or "").strip().lower()),
|
|
98
|
+
re.sub(r"\s+", " ", (ev.away or "").strip().lower()),
|
|
99
|
+
)
|
|
100
|
+
if not key[0] or not key[1]:
|
|
101
|
+
continue
|
|
102
|
+
cur = best.get(key)
|
|
103
|
+
if cur is None:
|
|
104
|
+
best[key] = ev
|
|
105
|
+
continue
|
|
106
|
+
new_rank = (rank.get(ev.status(now_ts), 3), abs(ev.start - now_ts))
|
|
107
|
+
cur_rank = (rank.get(cur.status(now_ts), 3), abs(cur.start - now_ts))
|
|
108
|
+
if new_rank < cur_rank:
|
|
109
|
+
best[key] = ev
|
|
110
|
+
return list(best.values())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _channels_for(events: list[ym.MatchEvent]) -> dict[tuple, list[str]]:
|
|
114
|
+
"""All channels broadcasting a given fixture pair (deduplicated)."""
|
|
115
|
+
out: dict[tuple, list[str]] = {}
|
|
116
|
+
for ev in events:
|
|
117
|
+
key = (
|
|
118
|
+
re.sub(r"\s+", " ", (ev.home or "").strip().lower()),
|
|
119
|
+
re.sub(r"\s+", " ", (ev.away or "").strip().lower()),
|
|
120
|
+
)
|
|
121
|
+
if not ev.channel:
|
|
122
|
+
continue
|
|
123
|
+
bucket = out.setdefault(key, [])
|
|
124
|
+
label = _channel_label(ev.channel)
|
|
125
|
+
if label and label not in bucket:
|
|
126
|
+
bucket.append(label)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
84
130
|
_GROUP_ICONS = {"live": "fire", "upcoming": "clock", "ended": "check"}
|
|
85
131
|
|
|
86
132
|
|
|
@@ -184,20 +230,37 @@ def _quality_label(stream: dict) -> str:
|
|
|
184
230
|
f"~{gb:.2f} {t('GB/h')}")
|
|
185
231
|
|
|
186
232
|
|
|
187
|
-
def _play(event: ym.MatchEvent, stream: dict, player_name: str = ""
|
|
233
|
+
def _play(event: ym.MatchEvent, stream: dict, player_name: str = "",
|
|
234
|
+
alternatives: list = None) -> None:
|
|
235
|
+
"""Play `stream`, falling back to another source if it is dead.
|
|
236
|
+
|
|
237
|
+
A live fixture now ships several sources (HD, SD, Low, Multi…). The main
|
|
238
|
+
quality can be refused by the CDN while "Multi" works, so never give up
|
|
239
|
+
on the first failure: walk the remaining sources and pick the first one
|
|
240
|
+
that actually answers.
|
|
241
|
+
"""
|
|
188
242
|
from ..player_manager import match_stream_reachable, play_direct_stream
|
|
189
243
|
|
|
190
244
|
title = f"YacineTV · {event.home} VS {event.away}"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
245
|
+
queue = [stream] + [s for s in (alternatives or [])
|
|
246
|
+
if s is not stream and s.get("url") != stream.get("url")]
|
|
247
|
+
for i, cand in enumerate(queue):
|
|
248
|
+
name = cand.get("name", "?")
|
|
249
|
+
if i == 0:
|
|
250
|
+
print_info(f"{t('Selected quality:')} {name}")
|
|
251
|
+
else:
|
|
252
|
+
print_info(f"{t('Trying another source:')} {name}")
|
|
253
|
+
ok, why = match_stream_reachable(cand["url"], cand.get("headers") or {})
|
|
254
|
+
if not ok:
|
|
255
|
+
if i == 0:
|
|
256
|
+
print_warning(f"{t('Cannot play this match:')} {why}")
|
|
257
|
+
continue
|
|
258
|
+
ok = play_direct_stream(cand["url"], cand.get("headers") or {}, title,
|
|
259
|
+
player_name=player_name or None)
|
|
260
|
+
if ok:
|
|
261
|
+
return
|
|
262
|
+
print_warning(t("Could not start the match stream."))
|
|
263
|
+
print_info(t("This channel is often blocked or taken down; try another match."))
|
|
201
264
|
|
|
202
265
|
|
|
203
266
|
def _available_players() -> list[str]:
|
|
@@ -266,7 +329,7 @@ def handle_match() -> None:
|
|
|
266
329
|
|
|
267
330
|
now = datetime.now(tz=_CAM)
|
|
268
331
|
now_ts = int(now.timestamp())
|
|
269
|
-
ordered = _sort_events(events, now_ts)
|
|
332
|
+
ordered = _sort_events(_dedupe(events, now_ts), now_ts)
|
|
270
333
|
player_name = _current_player()
|
|
271
334
|
# Group by status with section headers so the rows line up in
|
|
272
335
|
# columns; "finished" stays collapsed at the bottom.
|
|
@@ -326,11 +389,11 @@ def handle_match() -> None:
|
|
|
326
389
|
f"{t('GB/h')}")
|
|
327
390
|
chosen = _choose_player(player_name, allow_cancel=True)
|
|
328
391
|
if chosen:
|
|
329
|
-
_play(event, stream, chosen)
|
|
392
|
+
_play(event, stream, chosen, streams)
|
|
330
393
|
else:
|
|
331
394
|
return
|
|
332
395
|
continue
|
|
333
396
|
|
|
334
397
|
chosen = _choose_player(player_name, allow_cancel=True)
|
|
335
398
|
if chosen:
|
|
336
|
-
_play(event, streams[choice], chosen)
|
|
399
|
+
_play(event, streams[choice], chosen, streams)
|
|
@@ -1026,9 +1026,12 @@ _FR = {
|
|
|
1026
1026
|
'LIVE now': 'En direct',
|
|
1027
1027
|
'Channel server unreachable (DNS).':
|
|
1028
1028
|
'Serveur de la chaîne injoignable (DNS).',
|
|
1029
|
+
'The stream has no data (offline).':
|
|
1030
|
+
'Le flux ne diffuse aucune donnée (hors ligne).',
|
|
1029
1031
|
'Channel server refused the stream.':
|
|
1030
1032
|
'Le serveur de la chaîne refuse le flux.',
|
|
1031
1033
|
'Cannot play this match:': 'Impossible de lire ce match :',
|
|
1034
|
+
'Trying another source:': 'Essai d’une autre source :',
|
|
1032
1035
|
'This channel is often blocked or taken down; try another match.':
|
|
1033
1036
|
'Cette chaîne est souvent bloquée ou retirée ; essaie un autre match.',
|
|
1034
1037
|
'Mbps': 'Mbps',
|
|
@@ -2816,15 +2816,95 @@ def match_stream_reachable(stream_url: str, headers: dict,
|
|
|
2816
2816
|
socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP)
|
|
2817
2817
|
except Exception:
|
|
2818
2818
|
return False, t("Channel server unreachable (DNS).")
|
|
2819
|
+
# Probe with ffmpeg DIRECTLY (same path as playback): the CDN pins some
|
|
2820
|
+
# feeds to the client that opened the playlist, so going through our
|
|
2821
|
+
# local proxy reported "offline" for streams that actually play.
|
|
2822
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
2823
|
+
if ffmpeg:
|
|
2824
|
+
hdr = "".join(
|
|
2825
|
+
f"{k}: {str(v).replace(chr(13), ' ').replace(chr(10), ' ')}\r\n"
|
|
2826
|
+
for k, v in (headers or {}).items() if v)
|
|
2827
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-headers", hdr,
|
|
2828
|
+
"-allowed_segment_extensions", "ALL", "-extension_picky", "0",
|
|
2829
|
+
"-rw_timeout", str(max(4, timeout) * 1000000),
|
|
2830
|
+
"-analyzeduration", "3000000", "-probesize", "1000000",
|
|
2831
|
+
# Live playlists never end: bound the read so the probe cannot
|
|
2832
|
+
# hang on a continuous stream (-t BEFORE -i is an input cap).
|
|
2833
|
+
"-t", "4", "-i", stream_url,
|
|
2834
|
+
"-t", "1", "-f", "null", "-"]
|
|
2835
|
+
# IMPORTANT: a live playlist may play forever, so "no exit" is a
|
|
2836
|
+
# best-case result, not a failure. Read stderr while it runs and stop
|
|
2837
|
+
# as soon as the verdict is known.
|
|
2838
|
+
try:
|
|
2839
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
|
|
2840
|
+
stderr=subprocess.PIPE, text=True,
|
|
2841
|
+
errors="replace")
|
|
2842
|
+
except Exception:
|
|
2843
|
+
proc = None
|
|
2844
|
+
if proc is not None:
|
|
2845
|
+
deadline = time.time() + max(8, timeout + 4)
|
|
2846
|
+
lines: list[str] = []
|
|
2847
|
+
verdict = None
|
|
2848
|
+
try:
|
|
2849
|
+
import select as _select
|
|
2850
|
+
while time.time() < deadline:
|
|
2851
|
+
ready, _, _ = _select.select([proc.stderr], [], [], 0.2)
|
|
2852
|
+
if not ready:
|
|
2853
|
+
continue
|
|
2854
|
+
line = proc.stderr.readline()
|
|
2855
|
+
if not line:
|
|
2856
|
+
break
|
|
2857
|
+
lines.append(line.strip())
|
|
2858
|
+
blob = " ".join(lines).lower()
|
|
2859
|
+
if "403 forbidden" in blob:
|
|
2860
|
+
verdict = ("refused", t("Channel server refused the stream."))
|
|
2861
|
+
break
|
|
2862
|
+
if any(k in blob for k in ("detected", "video:", "audio:",
|
|
2863
|
+
"frame=", "stream #")):
|
|
2864
|
+
verdict = ("ok", "")
|
|
2865
|
+
break
|
|
2866
|
+
if "invalid data" in blob or "404" in blob:
|
|
2867
|
+
verdict = ("empty", t("The stream has no data (offline)."))
|
|
2868
|
+
break
|
|
2869
|
+
finally:
|
|
2870
|
+
try:
|
|
2871
|
+
proc.terminate()
|
|
2872
|
+
proc.wait(timeout=3)
|
|
2873
|
+
except Exception:
|
|
2874
|
+
try:
|
|
2875
|
+
proc.kill()
|
|
2876
|
+
except Exception:
|
|
2877
|
+
pass
|
|
2878
|
+
if verdict:
|
|
2879
|
+
if verdict[0] == "ok":
|
|
2880
|
+
return True, ""
|
|
2881
|
+
return False, verdict[1]
|
|
2882
|
+
# No verdict in time: the stream keeps flowing (live) → playable.
|
|
2883
|
+
if proc.returncode in (0, None) or proc.returncode == -15:
|
|
2884
|
+
return True, ""
|
|
2885
|
+
return False, t("The stream has no data (offline).")
|
|
2886
|
+
|
|
2819
2887
|
try:
|
|
2820
2888
|
from .yacine_live_proxy import start_live_proxy
|
|
2821
2889
|
proxy, local = start_live_proxy(stream_url, headers or {})
|
|
2822
2890
|
try:
|
|
2823
2891
|
import urllib.request
|
|
2824
2892
|
with urllib.request.urlopen(local, timeout=timeout) as r:
|
|
2825
|
-
body = r.read(
|
|
2826
|
-
|
|
2893
|
+
body = r.read()
|
|
2894
|
+
text = body.decode("utf-8", "replace")
|
|
2895
|
+
if "#EXTM3U" not in text and "#EXT-X" not in text:
|
|
2827
2896
|
return False, t("Channel server refused the stream.")
|
|
2897
|
+
segs = [ln.strip() for ln in text.splitlines()
|
|
2898
|
+
if ln.strip() and not ln.startswith("#")]
|
|
2899
|
+
if segs and segs[0].startswith("/p/"):
|
|
2900
|
+
base = f"http://127.0.0.1:{proxy.httpd.server_address[1]}"
|
|
2901
|
+
try:
|
|
2902
|
+
with urllib.request.urlopen(base + segs[-1],
|
|
2903
|
+
timeout=timeout) as seg:
|
|
2904
|
+
if not seg.read(64):
|
|
2905
|
+
return False, t("The stream has no data (offline).")
|
|
2906
|
+
except Exception:
|
|
2907
|
+
return False, t("The stream has no data (offline).")
|
|
2828
2908
|
return True, ""
|
|
2829
2909
|
finally:
|
|
2830
2910
|
proxy.stop()
|
|
@@ -2860,10 +2940,11 @@ def play_direct_stream(stream_url: str, headers: dict, title: str,
|
|
|
2860
2940
|
if not exe:
|
|
2861
2941
|
print_error(t("mpv is not installed or not in PATH."))
|
|
2862
2942
|
return False
|
|
2943
|
+
# Direct ffmpeg pipe first: the CDN answers 404 when the playlist is
|
|
2944
|
+
# relayed through the local proxy, but serves ffmpeg directly.
|
|
2863
2945
|
if shutil.which("ffmpeg") and _play_ffmpeg_pipe(
|
|
2864
2946
|
exe, stream_url, headers, title):
|
|
2865
2947
|
return True
|
|
2866
|
-
# No ffmpeg (or pipe failed): fall back to the local HLS proxy.
|
|
2867
2948
|
proxy, local_url = start_live_proxy(stream_url, headers)
|
|
2868
2949
|
try:
|
|
2869
2950
|
return _play_dash_mpv(exe, local_url, {}, title, live=True)
|
|
@@ -246,10 +246,14 @@ def fetch_events(timeout: int = 10) -> tuple[list[MatchEvent], bool]:
|
|
|
246
246
|
|
|
247
247
|
def _quality_rank(name: str) -> int:
|
|
248
248
|
n = (name or "").lower()
|
|
249
|
+
# "Multi" sources are official multi-camera feeds: usually the most
|
|
250
|
+
# reliable live source when the per-quality CDN is throttled.
|
|
251
|
+
if "multi" in n:
|
|
252
|
+
return 3
|
|
249
253
|
if any(k in n for k in ("4k", "2160", "uhd")):
|
|
250
|
-
return
|
|
254
|
+
return 5
|
|
251
255
|
if any(k in n for k in ("1440", "2k")):
|
|
252
|
-
return
|
|
256
|
+
return 4
|
|
253
257
|
if any(k in n for k in ("hd", "fhd", "1080", "full")):
|
|
254
258
|
return 3
|
|
255
259
|
if any(k in n for k in ("sd", "720", "medium")):
|
|
@@ -293,7 +297,12 @@ def fetch_streams(event_id: str, timeout: int = 10) -> list[dict]:
|
|
|
293
297
|
streams = [s for s in (_stream_from_dict(r) for r in (raw or []))
|
|
294
298
|
if s and not s.get("drm")]
|
|
295
299
|
if streams:
|
|
296
|
-
|
|
300
|
+
# Stable sort: rank first, then keep the catalogue order.
|
|
301
|
+
# "Multi" feeds come first (most reliable live source),
|
|
302
|
+
# HD/SD/Low after them.
|
|
303
|
+
order = {id(s): i for i, s in enumerate(streams)}
|
|
304
|
+
streams.sort(
|
|
305
|
+
key=lambda s: (-_quality_rank(s["name"]), order[id(s)]))
|
|
297
306
|
return streams
|
|
298
307
|
except Exception as exc: # noqa: BLE001
|
|
299
308
|
last_err = exc
|
|
@@ -66,6 +66,33 @@ class _LiveProxy:
|
|
|
66
66
|
raise RuntimeError(f"upstream HTTP {code}")
|
|
67
67
|
return conn, resp
|
|
68
68
|
|
|
69
|
+
def _refresh_playlist_items(self) -> None:
|
|
70
|
+
"""Re-fetch the upstream playlist and re-map the MOST RECENT segments.
|
|
71
|
+
|
|
72
|
+
Some CDNs answer 404 for a segment that was valid when the playlist
|
|
73
|
+
was fetched. The live playlist keeps moving, so the newest segments
|
|
74
|
+
almost always work: drop the old mappings and rebuild them from a
|
|
75
|
+
fresh fetch before giving up on a 404.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
conn, resp = self._request(self.playlist_url, self.headers)
|
|
79
|
+
try:
|
|
80
|
+
text = resp.read().decode("utf-8", "replace")
|
|
81
|
+
finally:
|
|
82
|
+
conn.close()
|
|
83
|
+
except Exception:
|
|
84
|
+
return
|
|
85
|
+
segs = [ln.strip() for ln in text.splitlines()
|
|
86
|
+
if ln.strip() and not ln.startswith("#")]
|
|
87
|
+
if not segs:
|
|
88
|
+
return
|
|
89
|
+
base = self.playlist_url
|
|
90
|
+
for old, (_url, kind) in list(self.items.items()):
|
|
91
|
+
if kind == "segment":
|
|
92
|
+
self.items.pop(old, None)
|
|
93
|
+
for ln in segs[-6:]:
|
|
94
|
+
self._register(urllib.parse.urljoin(base, ln), "segment")
|
|
95
|
+
|
|
69
96
|
def _register(self, url: str, kind: str) -> str:
|
|
70
97
|
self._counter += 1
|
|
71
98
|
key = f"{self._counter}.{'m3u8' if kind == 'playlist' else 'ts'}"
|
|
@@ -148,7 +175,16 @@ class _LiveProxy:
|
|
|
148
175
|
self.end_headers()
|
|
149
176
|
self.wfile.write(data)
|
|
150
177
|
return
|
|
151
|
-
|
|
178
|
+
try:
|
|
179
|
+
conn, resp = outer._request(url, outer.headers)
|
|
180
|
+
except Exception:
|
|
181
|
+
# Stale segment id: rebuild from the fresh playlist.
|
|
182
|
+
outer._refresh_playlist_items()
|
|
183
|
+
newest = [u for u, k in reversed(list(outer.items.values()))
|
|
184
|
+
if k == "segment"]
|
|
185
|
+
if not newest or newest[0] == url:
|
|
186
|
+
raise
|
|
187
|
+
conn, resp = outer._request(newest[0], outer.headers)
|
|
152
188
|
try:
|
|
153
189
|
self.send_response(200)
|
|
154
190
|
# Upstream labels segments as application/pdf; VLC's
|
|
@@ -398,7 +398,28 @@ def test_match_stream_reachable_reports_dns_failure(monkeypatch):
|
|
|
398
398
|
assert ok is False and why
|
|
399
399
|
|
|
400
400
|
|
|
401
|
+
_TS_FIXTURE = ""
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _make_ts_fixture(path):
|
|
405
|
+
"""Tiny real MPEG-TS so ffmpeg can decode at least one frame."""
|
|
406
|
+
import shutil as _sh
|
|
407
|
+
import subprocess
|
|
408
|
+
|
|
409
|
+
if not _sh.which("ffmpeg"):
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
subprocess.run(
|
|
413
|
+
["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i",
|
|
414
|
+
"testsrc=size=64x64:rate=5", "-f", "lavfi", "-i",
|
|
415
|
+
"sine=frequency=440", "-t", "2", "-c:v", "libx264",
|
|
416
|
+
"-preset", "ultrafast", "-c:a", "aac", "-f", "mpegts", path],
|
|
417
|
+
check=True, timeout=60)
|
|
418
|
+
return True
|
|
419
|
+
|
|
420
|
+
|
|
401
421
|
def test_match_stream_reachable_true_for_local_manifest(monkeypatch, tmp_path):
|
|
422
|
+
|
|
402
423
|
import http.server
|
|
403
424
|
import threading
|
|
404
425
|
from freeflix_cli import player_manager as pm
|
|
@@ -408,13 +429,19 @@ def test_match_stream_reachable_true_for_local_manifest(monkeypatch, tmp_path):
|
|
|
408
429
|
pass
|
|
409
430
|
|
|
410
431
|
def do_GET(self):
|
|
411
|
-
|
|
432
|
+
if "seg" in self.path:
|
|
433
|
+
body = b"\x47" * 188
|
|
434
|
+
else:
|
|
435
|
+
body = b"#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6,\nseg.ts\n"
|
|
412
436
|
self.send_response(200)
|
|
413
437
|
self.send_header("Content-Type", "application/vnd.apple.mpegurl")
|
|
414
438
|
self.send_header("Content-Length", str(len(body)))
|
|
415
439
|
self.end_headers()
|
|
416
440
|
self.wfile.write(body)
|
|
417
441
|
|
|
442
|
+
# CI runners have no ffmpeg: force the proxy fallback path.
|
|
443
|
+
monkeypatch.setattr(pm.shutil, "which",
|
|
444
|
+
lambda name: None if name == "ffmpeg" else "x")
|
|
418
445
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
419
446
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
420
447
|
port = srv.server_address[1]
|
|
@@ -425,3 +452,91 @@ def test_match_stream_reachable_true_for_local_manifest(monkeypatch, tmp_path):
|
|
|
425
452
|
finally:
|
|
426
453
|
srv.shutdown()
|
|
427
454
|
srv.server_close()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_dedupe_prefers_live_fixture_over_finished_copy():
|
|
458
|
+
"""The catalogue repeats fixtures; one row must be shown, the live one."""
|
|
459
|
+
import time
|
|
460
|
+
from freeflix_cli.handlers import match as mh
|
|
461
|
+
|
|
462
|
+
now = int(time.time())
|
|
463
|
+
finished = ym.MatchEvent("old", "NED", "Dubai Sports 1",
|
|
464
|
+
now - 7200, now - 5400, "Twente", "Ajax")
|
|
465
|
+
live = ym.MatchEvent("new", "NED", "Dubai Sports 1",
|
|
466
|
+
now - 600, now + 6600, "Twente", "Ajax")
|
|
467
|
+
other = ym.MatchEvent("o", "FRA", "beIN SPORTS 1",
|
|
468
|
+
now - 600, now + 6600, "Lens", "Marseille")
|
|
469
|
+
kept = mh._dedupe([finished, live, other], now)
|
|
470
|
+
assert len(kept) == 2
|
|
471
|
+
twente = [e for e in kept if e.home == "Twente"][0]
|
|
472
|
+
assert twente.id == "new" and twente.status(now) == "live"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_dedupe_never_lists_one_fixture_in_two_statuses():
|
|
476
|
+
import time
|
|
477
|
+
from freeflix_cli.handlers import match as mh
|
|
478
|
+
|
|
479
|
+
now = int(time.time())
|
|
480
|
+
events = [
|
|
481
|
+
ym.MatchEvent("a", "L", "C1", now - 8000, now - 7000, "A", "B"),
|
|
482
|
+
ym.MatchEvent("b", "L", "C2", now - 60, now + 7000, "A", "B"),
|
|
483
|
+
ym.MatchEvent("c", "L", "C1", now + 9000, now + 16000, "A", "B"),
|
|
484
|
+
]
|
|
485
|
+
kept = mh._dedupe(events, now)
|
|
486
|
+
assert len(kept) == 1
|
|
487
|
+
assert kept[0].status(now) == "live"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def test_preflight_rejects_manifest_with_dead_segments(monkeypatch):
|
|
491
|
+
"""A playlist whose segments 404 must not open a player."""
|
|
492
|
+
import http.server
|
|
493
|
+
import threading
|
|
494
|
+
from freeflix_cli import player_manager as pm
|
|
495
|
+
|
|
496
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
497
|
+
def log_message(self, *a):
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
def do_GET(self):
|
|
501
|
+
if "seg" in self.path:
|
|
502
|
+
self.send_response(404)
|
|
503
|
+
self.end_headers()
|
|
504
|
+
return
|
|
505
|
+
body = b"#EXTM3U\n#EXTINF:6,\nseg.ts\n"
|
|
506
|
+
self.send_response(200)
|
|
507
|
+
self.send_header("Content-Length", str(len(body)))
|
|
508
|
+
self.end_headers()
|
|
509
|
+
self.wfile.write(body)
|
|
510
|
+
|
|
511
|
+
monkeypatch.setattr(pm.shutil, "which",
|
|
512
|
+
lambda name: None if name == "ffmpeg" else "x")
|
|
513
|
+
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
514
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
515
|
+
try:
|
|
516
|
+
ok, why = pm.match_stream_reachable(
|
|
517
|
+
f"http://127.0.0.1:{srv.server_address[1]}/live/index.m3u8", {})
|
|
518
|
+
assert ok is False
|
|
519
|
+
assert why
|
|
520
|
+
finally:
|
|
521
|
+
srv.shutdown()
|
|
522
|
+
srv.server_close()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_play_falls_back_to_another_source(monkeypatch):
|
|
526
|
+
"""When the chosen quality is dead, the next source is tried."""
|
|
527
|
+
from freeflix_cli.handlers import match as mh
|
|
528
|
+
from freeflix_cli import player_manager
|
|
529
|
+
|
|
530
|
+
ev = ym.MatchEvent("1", "L", "beIN SPORTS 1", 1, 2, "A", "B")
|
|
531
|
+
hd = {"name": "HD", "url": "https://dead.example/hd.m3u8", "headers": {}}
|
|
532
|
+
multi = {"name": "Multi", "url": "https://ok.example/m.m3u8", "headers": {}}
|
|
533
|
+
results = {"https://dead.example/hd.m3u8": (False, "dead"),
|
|
534
|
+
"https://ok.example/m.m3u8": (True, "")}
|
|
535
|
+
monkeypatch.setattr(player_manager, "match_stream_reachable",
|
|
536
|
+
lambda url, h: results[url])
|
|
537
|
+
played = []
|
|
538
|
+
monkeypatch.setattr(player_manager, "play_direct_stream",
|
|
539
|
+
lambda url, h, title, player_name=None:
|
|
540
|
+
played.append(url) or True)
|
|
541
|
+
mh._play(ev, hd, "mpv", [hd, multi])
|
|
542
|
+
assert played == ["https://ok.example/m.m3u8"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|