feat: Refine audio playback eligibility logic with URL validation and classifier fallback

- Replace simple tag-based audio detection with multi-condition validation in isAudioPlayable()
- Add AudioUrlValidator.isStreamable() check to verify URL technical playability
- Implement AudioClassifier.isAudioUrl() fallback for platform/stream recognition without explicit tags
- Prevent Play button display for audio-tagged articles without streamable URLs
- Add comprehensive French documentation explaining
This commit is contained in:
Bruno Charest 2026-02-12 16:39:10 -05:00
parent e742502bdc
commit aa32ff1c2b
3 changed files with 110 additions and 4 deletions

View File

@ -0,0 +1,85 @@
package com.shaarit.domain.audio
import com.shaarit.domain.classifier.AudioClassifier
import java.net.URI
/**
* Validateur rapide (sans appel réseau) pour déterminer si une URL
* est techniquement lisible par le lecteur audio intégré.
*
* Utilisé dans l'UI pour conditionner l'affichage du bouton Play :
* afficher le bouton SEULEMENT si (tag audio) ET (URL streamable).
*
* Cas couverts :
* - Extensions audio directes : .mp3, .aac, .ogg, .opus, .flac, .wav, .m4a, .wma
* - Extensions playlist/stream : .m3u, .m3u8, .pls, .asx
* - Extensions feed : .rss, .xml (flux podcast)
* - Protocoles radio : Icecast/Shoutcast (ports :8xxx, /stream, sous-domaines stream/live)
* - Plateformes supportées : Spotify (track/episode/show/album), SoundCloud,
* Mixcloud, Bandcamp, Deezer, Tidal, Apple Music/Podcasts, YouTube Music,
* TuneIn, Radio Garden, etc.
* - Tout ce que AudioClassifier.classify() reconnaît déjà
*
* Performance : uniquement du String/Regex matching, O(1) safe pour LazyColumn.
*/
object AudioUrlValidator {
// Extensions audio directes jouables par ExoPlayer
private val DIRECT_AUDIO_EXTENSIONS = Regex(
"""\.(mp3|aac|ogg|oga|opus|flac|wav|m4a|wma)(\?.*)?$""",
RegexOption.IGNORE_CASE
)
// Extensions playlist/stream
private val STREAM_PLAYLIST_EXTENSIONS = Regex(
"""\.(m3u|m3u8|pls|asx|mpd)(\?.*)?$""",
RegexOption.IGNORE_CASE
)
// Extensions feed (podcast RSS)
private val FEED_EXTENSIONS = Regex(
"""\.(rss|xml|atom)(\?.*)?$""",
RegexOption.IGNORE_CASE
)
// Ports typiques de flux Icecast/Shoutcast (:8000-:8999)
private val STREAMING_PORT_PATTERN = Regex(""":8\d{3}(/|$)""")
// Chemins typiques de flux radio (/stream, /listen, /live, /;)
private val STREAMING_PATH_PATTERN = Regex(
"""/(stream|listen|live|radio|;)(/|$|\?)""",
RegexOption.IGNORE_CASE
)
/**
* Détermine si l'URL est techniquement lisible par le lecteur audio.
* Vérification rapide, sans appel réseau.
*
* @return true si l'URL pointe vers un flux audio, un fichier audio,
* ou une plateforme audio supportée.
*/
fun isStreamable(url: String): Boolean {
if (url.isBlank()) return false
val lowerUrl = url.lowercase()
// 1. AudioClassifier reconnaît déjà les plateformes + flux radio/podcast/musique
if (AudioClassifier.isAudioUrl(url)) return true
// 2. Extension audio directe (.mp3, .ogg, etc.)
if (DIRECT_AUDIO_EXTENSIONS.containsMatchIn(lowerUrl)) return true
// 3. Extension playlist/stream (.m3u, .m3u8, .pls, .asx, .mpd)
if (STREAM_PLAYLIST_EXTENSIONS.containsMatchIn(lowerUrl)) return true
// 4. Extension feed (.rss, .xml) — potentiellement un podcast
if (FEED_EXTENSIONS.containsMatchIn(lowerUrl)) return true
// 5. Port de streaming typique (:8000-:8999)
if (STREAMING_PORT_PATTERN.containsMatchIn(lowerUrl)) return true
// 6. Chemin de streaming typique (/stream, /listen, /live)
if (STREAMING_PATH_PATTERN.containsMatchIn(lowerUrl)) return true
return false
}
}

View File

@ -48,12 +48,33 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.shaarit.domain.audio.AudioUrlValidator
import com.shaarit.domain.classifier.AudioClassifier
private val AUDIO_TAGS = setOf("podcast", "radio", "music", "audio") private val AUDIO_TAGS = setOf("podcast", "radio", "music", "audio")
/**
* Détermine si le bouton Play doit être affiché.
*
* Condition principale : tag audio ET URL techniquement lisible.
* Fallback : si l'URL est un flux audio évident (ex: .m3u8, plateforme connue),
* le bouton s'affiche même sans tag audio explicite.
*/
fun ShaarliLink.isAudioPlayable(): Boolean { fun ShaarliLink.isAudioPlayable(): Boolean {
return tags.any { it.lowercase() in AUDIO_TAGS } || val hasAudioTag = tags.any { it.lowercase() in AUDIO_TAGS } ||
contentType?.lowercase() in AUDIO_TAGS contentType?.lowercase() in AUDIO_TAGS
val isStreamable = AudioUrlValidator.isStreamable(url)
// Cas 1 : tag audio + URL streamable → Play
if (hasAudioTag && isStreamable) return true
// Cas 2 : URL reconnue comme audio par le classifieur (plateforme/flux)
// même sans tag → Play (ex: lien Spotify oublié sans tag "music")
if (AudioClassifier.isAudioUrl(url)) return true
// Cas 3 : tag audio seul SANS URL streamable → PAS de Play
// (ex: article de blog tagué "audio" → on ne montre pas Play)
return false
} }
/** /**

View File

@ -1,3 +1,3 @@
#Thu Feb 12 10:28:52 2026 #Thu Feb 12 12:09:07 2026
VERSION_NAME=1.2.3 VERSION_NAME=1.3.0
VERSION_CODE=11 VERSION_CODE=12