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.
Files changed (160) hide show
  1. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/CHANGELOG.md +22 -0
  2. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/PKG-INFO +1 -1
  3. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/pyproject.toml +1 -1
  4. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/match.py +77 -14
  5. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/i18n.py +3 -0
  6. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/install_health.py +1 -1
  7. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/player_manager.py +84 -3
  8. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/yacine_match.py +12 -3
  9. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/yacine_live_proxy.py +37 -1
  10. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_yacine_match.py +116 -1
  11. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/uv.lock +1 -1
  12. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/binaries.yml +0 -0
  13. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/ci.yml +0 -0
  14. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/macos.yml +0 -0
  15. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.github/workflows/release.yml +0 -0
  16. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/.gitignore +0 -0
  17. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/CONTRIBUTING.md +0 -0
  18. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/LICENSE +0 -0
  19. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/README.md +0 -0
  20. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/SECURITY.md +0 -0
  21. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/assets/logo.png +0 -0
  22. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/freeflix_position.lua +0 -0
  23. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/input.conf +0 -0
  24. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/config/mpv.conf +0 -0
  25. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/data/selectors.jsonc +0 -0
  26. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/data/source_portal.jsonc +0 -0
  27. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/ARCHITECTURE.md +0 -0
  28. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/AUDIT-FREEFLIX-2.0-2026-11-04.md +0 -0
  29. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/CORRECTIONS-2.1.0.md +0 -0
  30. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/README.md +0 -0
  31. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/RELEASE.md +0 -0
  32. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/SCORECARD-2.1.0.md +0 -0
  33. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/TROUBLESHOOTING.md +0 -0
  34. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/docs/USAGE.md +0 -0
  35. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/build-binary.sh +0 -0
  36. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install-mac.sh +0 -0
  37. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.ps1 +0 -0
  38. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.py +0 -0
  39. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/install.sh +0 -0
  40. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/scripts/wrappers/mpv +0 -0
  41. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/__init__.py +0 -0
  42. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/__main__.py +0 -0
  43. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/anilist.py +0 -0
  44. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cli.py +0 -0
  45. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cli_utils.py +0 -0
  46. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/cloudflare.py +0 -0
  47. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/config_loader.py +0 -0
  48. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/data/channel_logos.json +0 -0
  49. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/data/clubs.json +0 -0
  50. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/defaults.py +0 -0
  51. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/doctor.py +0 -0
  52. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/__init__.py +0 -0
  53. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/anilist.py +0 -0
  54. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/anime_sama.py +0 -0
  55. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/coflix.py +0 -0
  56. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/french_manga.py +0 -0
  57. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/french_stream.py +0 -0
  58. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/goldenanime.py +0 -0
  59. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/goldenms.py +0 -0
  60. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/hianime.py +0 -0
  61. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/nyaa.py +0 -0
  62. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/papystreaming.py +0 -0
  63. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/playback.py +0 -0
  64. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/handlers/yts.py +0 -0
  65. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/health.py +0 -0
  66. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/history_ui.py +0 -0
  67. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/httpcache.py +0 -0
  68. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/icons.py +0 -0
  69. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/languages.py +0 -0
  70. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/logsetup.py +0 -0
  71. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/main.py +0 -0
  72. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/motion.py +0 -0
  73. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/mpv_ipc.py +0 -0
  74. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/neon.py +0 -0
  75. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/net_config.py +0 -0
  76. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/notifications.py +0 -0
  77. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/platform_android.py +0 -0
  78. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/preview_picker.py +0 -0
  79. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/progress.py +0 -0
  80. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/providers_registry.py +0 -0
  81. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/proxy.py +0 -0
  82. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/recent.py +0 -0
  83. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/__init__.py +0 -0
  84. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/anime_sama.py +0 -0
  85. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/anime_sama_planning.py +0 -0
  86. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/coflix.py +0 -0
  87. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/config.py +0 -0
  88. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/deobfuscate.py +0 -0
  89. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/eztv.py +0 -0
  90. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/french_manga.py +0 -0
  91. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/french_stream.py +0 -0
  92. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/goldenanime.py +0 -0
  93. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/goldenms.py +0 -0
  94. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/nyaa.py +0 -0
  95. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/objects.py +0 -0
  96. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/papystreaming.py +0 -0
  97. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/player.py +0 -0
  98. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/resilient.py +0 -0
  99. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/subtitles.py +0 -0
  100. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/utils.py +0 -0
  101. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/scraping/yts.py +0 -0
  102. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/setup_assistant.py +0 -0
  103. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/splash.py +0 -0
  104. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/subtitles.py +0 -0
  105. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/terminal_image.py +0 -0
  106. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/themes.py +0 -0
  107. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/tracker.py +0 -0
  108. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/ui.py +0 -0
  109. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/src/freeflix_cli/update_checker.py +0 -0
  110. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/conftest.py +0 -0
  111. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_android_setup.py +0 -0
  112. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anilist_errors.py +0 -0
  113. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anilist_token_input.py +0 -0
  114. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_anime_sama_planning.py +0 -0
  115. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_audit_fixes.py +0 -0
  116. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_batch_download.py +0 -0
  117. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_coflix_parse.py +0 -0
  118. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_core_units.py +0 -0
  119. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_cover_cache.py +0 -0
  120. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_decoy_stream.py +0 -0
  121. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_fixes.py +0 -0
  122. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_integration.py +0 -0
  123. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_download_robustness.py +0 -0
  124. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_episode_parsing.py +0 -0
  125. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_fsvid_decode.py +0 -0
  126. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_health.py +0 -0
  127. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_httpcache.py +0 -0
  128. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_i18n_complete.py +0 -0
  129. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_keyboard.py +0 -0
  130. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_managed_binaries.py +0 -0
  131. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_mouse.py +0 -0
  132. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_mpv_ipc.py +0 -0
  133. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_navigation.py +0 -0
  134. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_nerd_font.py +0 -0
  135. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_nyaa.py +0 -0
  136. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_pack_a_polish.py +0 -0
  137. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_performance.py +0 -0
  138. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_platform_android.py +0 -0
  139. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_player_dash.py +0 -0
  140. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_polish.py +0 -0
  141. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_premium_players.py +0 -0
  142. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_providers_repair.py +0 -0
  143. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_endpoint.py +0 -0
  144. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_integration.py +0 -0
  145. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_resilient.py +0 -0
  146. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_proxy_ssrf.py +0 -0
  147. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_recent_new_episodes.py +0 -0
  148. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_release_config.py +0 -0
  149. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_resilient.py +0 -0
  150. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_scraper_resilience.py +0 -0
  151. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_search_parity.py +0 -0
  152. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_sendvid.py +0 -0
  153. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_stream_analysis.py +0 -0
  154. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_stream_integrity.py +0 -0
  155. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_system_deps_cache.py +0 -0
  156. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_terminal_image.py +0 -0
  157. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_update_prompt.py +0 -0
  158. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_v1105_improvements.py +0 -0
  159. {freeflix_cli-2.4.0 → freeflix_cli-2.4.1}/tests/test_v2_aesthetic.py +0 -0
  160. {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.0
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.0"
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 = "") -> None:
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
- print_info(f"{t('Selected quality:')} {stream.get('name', '?')}")
192
- ok, why = match_stream_reachable(stream["url"], stream.get("headers") or {})
193
- if not ok:
194
- print_warning(f"{t('Cannot play this match:')} {why}")
195
- print_info(t("This channel is often blocked or taken down; try another match."))
196
- return
197
- ok = play_direct_stream(stream["url"], stream.get("headers") or {}, title,
198
- player_name=player_name or None)
199
- if not ok:
200
- print_warning(t("Could not start the match stream."))
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',
@@ -13,7 +13,7 @@ import os
13
13
  import sys
14
14
 
15
15
  # Version BAKED INTO THE CODE — bump together with pyproject.toml.
16
- CODE_VERSION = "2.4.0"
16
+ CODE_VERSION = "2.4.1"
17
17
 
18
18
 
19
19
  def metadata_version() -> str | None:
@@ -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(200)
2826
- if b"#EXTM3U" not in body and b"#EXT-X" not in body:
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 4
254
+ return 5
251
255
  if any(k in n for k in ("1440", "2k")):
252
- return 3
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
- streams.sort(key=lambda s: _quality_rank(s["name"]), reverse=True)
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
- conn, resp = outer._request(url, outer.headers)
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
- body = b"#EXTM3U\n#EXTINF:6,\nseg.ts\n"
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"]
@@ -238,7 +238,7 @@ wheels = [
238
238
 
239
239
  [[package]]
240
240
  name = "freeflix-cli"
241
- version = "2.4.0"
241
+ version = "2.4.1"
242
242
  source = { editable = "." }
243
243
  dependencies = [
244
244
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes
File without changes
File without changes