Choix de la langue VF/VOSTFR sur la fiche titre
- Episode : nouveau champ version (vf/vostfr) - French-Manga : épisodes listés pour chaque langue disponible (VOSTFR puis VF) au lieu d'un choix automatique, fragment #ep=<version>-<num> déjà résolu par extract_embed_links - Vostfree : langue déduite du titre h1 ou du slug de la fiche - UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode, téléchargement de saison limité à la langue filtrée, compteur d'épisodes unique, version incluse dans le nom de fichier
This commit is contained in:
@@ -38,7 +38,7 @@ class Episode:
|
|||||||
title: str | None
|
title: str | None
|
||||||
url: str # page de l'épisode chez la source
|
url: str # page de l'épisode chez la source
|
||||||
season: int = 1
|
season: int = 1
|
||||||
|
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TitleDetails:
|
class TitleDetails:
|
||||||
|
|||||||
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
|
|||||||
if season_match:
|
if season_match:
|
||||||
season = int(season_match.group(1))
|
season = int(season_match.group(1))
|
||||||
|
|
||||||
numbers: set[float] = set()
|
info = data.get("info") or {}
|
||||||
|
episodes: list[Episode] = []
|
||||||
for version in versions:
|
for version in versions:
|
||||||
|
numbers: set[float] = set()
|
||||||
for number_text in data.get(version) or {}:
|
for number_text in data.get(version) or {}:
|
||||||
try:
|
try:
|
||||||
numbers.add(float(number_text))
|
numbers.add(float(number_text))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
|
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
|
||||||
|
|
||||||
info = data.get("info") or {}
|
|
||||||
episodes: list[Episode] = []
|
|
||||||
for number in sorted(numbers):
|
for number in sorted(numbers):
|
||||||
number_text = str(int(number)) if number.is_integer() else str(number)
|
number_text = str(int(number)) if number.is_integer() else str(number)
|
||||||
episode_info = info.get(number_text) or info.get(str(int(number)))
|
episode_info = info.get(number_text) or info.get(str(int(number)))
|
||||||
label = episode_info.get("title") if isinstance(episode_info, dict) else None
|
label = episode_info.get("title") if isinstance(episode_info, dict) else None
|
||||||
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
|
|
||||||
episodes.append(
|
episodes.append(
|
||||||
Episode(
|
Episode(
|
||||||
number=number,
|
number=number,
|
||||||
title=label,
|
title=label,
|
||||||
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
||||||
season=season,
|
season=season,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not episodes:
|
if not episodes:
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
|
|||||||
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
|
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
|
||||||
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
|
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||||
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _merged_config() -> dict:
|
def _merged_config() -> dict:
|
||||||
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
|
|||||||
return int(season_match.group(1))
|
return int(season_match.group(1))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
def _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
|
||||||
|
config = _merged_config()
|
||||||
|
title_el = soup.select_one(config["details"]["title"])
|
||||||
|
for text in (self._text(title_el), page_url):
|
||||||
|
match = _VERSION_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1).lower()
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
||||||
config = _merged_config()
|
config = _merged_config()
|
||||||
|
version = self._detect_version(soup, page_url)
|
||||||
options = soup.select(config["episodes"]["option"])
|
options = soup.select(config["episodes"]["option"])
|
||||||
entries: list[tuple[str, str]] = []
|
entries: list[tuple[str, str]] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
|
|||||||
title=label or f"Episode {index}",
|
title=label or f"Episode {index}",
|
||||||
url=f"{page_url}#{button_id}" if button_id else page_url,
|
url=f"{page_url}#{button_id}" if button_id else page_url,
|
||||||
season=season,
|
season=season,
|
||||||
|
version=version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return episodes
|
return episodes
|
||||||
|
|||||||
@@ -676,3 +676,30 @@ button { font-family: inherit; }
|
|||||||
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
||||||
@keyframes update-spin { to { transform: rotate(360deg); } }
|
@keyframes update-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ filtre VF/VOSTFR */
|
||||||
|
|
||||||
|
.version-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
margin-left: 0.8rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.version-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.28rem 0.75rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.version-tab:hover { color: var(--text); }
|
||||||
|
.version-tab.active { background: var(--accent); color: #fff; }
|
||||||
|
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="title-stats">
|
<div class="title-stats">
|
||||||
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
|
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
|
||||||
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
|
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
|
||||||
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
|
<span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="title-actions">
|
<div class="title-actions">
|
||||||
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
|
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
|
||||||
@@ -43,11 +43,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">Épisodes</h2>
|
<h2 class="section-title">Épisodes
|
||||||
|
<span class="version-filter" x-show="epVersions.length" x-cloak>
|
||||||
|
<template x-for="v in epVersions" :key="v">
|
||||||
|
<button class="version-tab" :class="{ active: versionFilter === v }"
|
||||||
|
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
|
||||||
|
</template>
|
||||||
|
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
|
||||||
|
@click="versionFilter = 'all'">Tout</button>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
<div class="episode-list">
|
<div class="episode-list">
|
||||||
<template x-for="ep in details.episodes" :key="ep.number">
|
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
|
||||||
<div class="episode-row">
|
<div class="episode-row">
|
||||||
<span class="episode-num" x-text="'E' + ep.number"></span>
|
<span class="episode-num" x-text="'E' + ep.number"></span>
|
||||||
|
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
|
||||||
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
|
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
|
||||||
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
|
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
|
||||||
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
|
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
|
||||||
@@ -76,6 +86,23 @@ function titlePage() {
|
|||||||
details: null, loading: true, error: null, favorite: false,
|
details: null, loading: true, error: null, favorite: false,
|
||||||
player: { active: false, hls: null },
|
player: { active: false, hls: null },
|
||||||
seasonJob: { running: false, done: 0, total: 0 },
|
seasonJob: { running: false, done: 0, total: 0 },
|
||||||
|
versionFilter: 'all',
|
||||||
|
|
||||||
|
get epVersions() {
|
||||||
|
if (!this.details) return [];
|
||||||
|
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredEpisodes() {
|
||||||
|
if (!this.details) return [];
|
||||||
|
if (this.versionFilter === 'all') return this.details.episodes;
|
||||||
|
return this.details.episodes.filter(e => e.version === this.versionFilter);
|
||||||
|
},
|
||||||
|
|
||||||
|
get uniqueEpisodeCount() {
|
||||||
|
if (!this.details) return 0;
|
||||||
|
return new Set(this.details.episodes.map(e => e.number)).size;
|
||||||
|
},
|
||||||
|
|
||||||
async load(source, sourceId) {
|
async load(source, sourceId) {
|
||||||
this.source = source; this.sourceId = sourceId;
|
this.source = source; this.sourceId = sourceId;
|
||||||
@@ -84,6 +111,7 @@ function titlePage() {
|
|||||||
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
|
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
|
||||||
this.details = await res.json();
|
this.details = await res.json();
|
||||||
this.details.episodes.forEach(e => e._busy = false);
|
this.details.episodes.forEach(e => e._busy = false);
|
||||||
|
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
|
||||||
document.title = this.details.title + ' — Ohm Stream';
|
document.title = this.details.title + ' — Ohm Stream';
|
||||||
} catch (e) { this.error = e.message; }
|
} catch (e) { this.error = e.message; }
|
||||||
finally { this.loading = false; }
|
finally { this.loading = false; }
|
||||||
@@ -98,7 +126,10 @@ function titlePage() {
|
|||||||
return data.links[0];
|
return data.links[0];
|
||||||
},
|
},
|
||||||
|
|
||||||
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
|
epLabel(ep) {
|
||||||
|
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
|
||||||
|
return `${this.details.title} - E${ep.number}${version}`;
|
||||||
|
},
|
||||||
|
|
||||||
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
|
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
|
||||||
openPlayer(link) {
|
openPlayer(link) {
|
||||||
@@ -148,8 +179,8 @@ function titlePage() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async downloadSeason() {
|
async downloadSeason() {
|
||||||
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
|
this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
|
||||||
for (const ep of this.details.episodes) {
|
for (const ep of this.filteredEpisodes) {
|
||||||
await this.downloadEpisode(ep);
|
await this.downloadEpisode(ep);
|
||||||
this.seasonJob.done++;
|
this.seasonJob.done++;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user