refactor: extract OutputLayoutService

Extract output path resolution from conversion_planner.py into
application/output_layout_service.py as a standalone service.

Functions:
- resolve_output_layout(request) -> OutputLayout
- resolve_merged_path(layout, request) -> Path
- resolve_chapter_path(layout, request, title, index) -> Path
- should_merge_output(request) -> bool

Planner now imports from output_layout_service instead of inline logic.
This commit is contained in:
Artem Akymenko
2026-07-21 11:17:28 +03:00
parent 75a3ad517a
commit cd3cc9bce7
2 changed files with 152 additions and 51 deletions
+3 -51
View File
@@ -20,17 +20,12 @@ from abogen.application.conversion_models import (
SegmentPlan,
)
from abogen.application.conversion_request import ConversionRequest
from abogen.application.output_layout_service import resolve_output_layout
from abogen.domain.chapter_overrides import apply_chapter_overrides
from abogen.domain.file_type import auto_select_relevant_chapters
from abogen.domain.intro_outro import resolve_intro, resolve_outro
from abogen.domain.metadata_extraction import extract_metadata_for_file
from abogen.domain.metadata_merge import merge_metadata
from abogen.domain.output_paths import (
resolve_output_directory,
resolve_project_layout,
resolve_unique_path,
sanitize_output_stem,
)
from abogen.subtitle_utils import split_text_by_voice_markers
@@ -71,7 +66,7 @@ def build_conversion_plan(request: ConversionRequest) -> ConversionPlan:
intro, outro = _build_intro_outro(metadata, request)
# 7. Resolve output layout
output_layout = _resolve_output_layout(request)
output_layout = resolve_output_layout(request)
return ConversionPlan(
request=request,
@@ -354,47 +349,4 @@ def _build_intro_outro(
return intro_spec, outro_spec
def _resolve_output_layout(request: ConversionRequest) -> OutputLayout:
"""Resolve output paths for the conversion."""
# Determine base output directory
if request.save_mode == "custom_folder" and request.output_folder:
parent_dir = Path(request.output_folder)
elif request.source_path:
parent_dir = request.source_path.parent
else:
parent_dir = Path.cwd()
# Determine base name
if request.original_filename:
base_name = sanitize_output_stem(request.original_filename)
elif request.source_path:
base_name = sanitize_output_stem(request.source_path.stem)
else:
base_name = "output"
# Find unique output path
allowed_exts = {request.output_format, "srt", "ass", "vtt", "mp4", "m4b"}
unique_base = resolve_unique_path(
parent_dir, base_name, "", allowed_extensions=allowed_exts
)
# Resolve project layout
project_root = None
audio_dir = parent_dir
subtitle_dir = None
metadata_dir = None
if request.save_as_project:
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
original_filename=request.original_filename,
save_as_project=True,
base_dir=parent_dir,
)
return OutputLayout(
parent_dir=parent_dir,
project_root=project_root,
audio_dir=audio_dir,
subtitle_dir=subtitle_dir,
metadata_dir=metadata_dir,
)
# Output layout resolution is now in application/output_layout_service.py
+149
View File
@@ -0,0 +1,149 @@
"""Output layout resolution service.
Determines where conversion outputs (audio, subtitles, metadata) should be written.
Extracted from conversion_planner.py as a standalone service per plan Stage 5.
Responsibilities:
- Resolve base output directory from save_mode and source_path
- Determine base filename from original_filename
- Find unique output path to avoid overwrites
- Resolve project layout (audio_dir, subtitle_dir, metadata_dir)
- Force merged output for m4b format
- Return OutputLayout dataclass
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from abogen.application.conversion_models import OutputLayout
from abogen.application.conversion_request import ConversionRequest
from abogen.domain.output_paths import (
resolve_project_layout,
resolve_unique_path,
sanitize_output_stem,
)
def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
"""Resolve output paths for a conversion request.
This is the single entry point for output path resolution,
used by both UIs and the conversion service.
Args:
request: Normalized conversion request
Returns:
OutputLayout with resolved paths
"""
# Determine base output directory
if request.save_mode == "custom_folder" and request.output_folder:
parent_dir = Path(request.output_folder)
elif request.source_path:
parent_dir = request.source_path.parent
else:
parent_dir = Path.cwd()
# Determine base name
if request.original_filename:
base_name = sanitize_output_stem(request.original_filename)
elif request.source_path:
base_name = sanitize_output_stem(request.source_path.stem)
else:
base_name = "output"
# Find unique output path
allowed_exts = {request.output_format, "srt", "ass", "vtt", "mp4", "m4b"}
unique_base = resolve_unique_path(
parent_dir, base_name, "", allowed_extensions=allowed_exts
)
# Resolve project layout
project_root = None
audio_dir = parent_dir
subtitle_dir = None
metadata_dir = None
if request.save_as_project:
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
original_filename=request.original_filename,
save_as_project=True,
base_dir=parent_dir,
)
return OutputLayout(
parent_dir=parent_dir,
project_root=project_root,
audio_dir=audio_dir,
subtitle_dir=subtitle_dir,
metadata_dir=metadata_dir,
)
def resolve_merged_path(
layout: OutputLayout,
request: ConversionRequest,
) -> Path:
"""Resolve the merged output audio file path.
Args:
layout: Resolved output layout
request: Conversion request
Returns:
Path to the merged output file
"""
base_name = sanitize_output_stem(
request.original_filename or "output"
)
return layout.audio_dir / f"{base_name}.{request.output_format}"
def resolve_chapter_path(
layout: OutputLayout,
request: ConversionRequest,
chapter_title: str,
chapter_index: int,
) -> Path:
"""Resolve the output path for a separate chapter file.
Args:
layout: Resolved output layout
request: Conversion request
chapter_title: Chapter title for filename
chapter_index: Chapter number (1-based)
Returns:
Path to the chapter output file
"""
import re
slug = re.sub(r'[^\w\s-]', '', chapter_title.lower())
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
if not slug:
slug = f"chapter_{chapter_index}"
filename = f"{chapter_index:02d}_{slug}.{request.separate_chapters_format}"
return layout.audio_dir / "chapters" / filename
def should_merge_output(request: ConversionRequest) -> bool:
"""Determine if merged output is required.
Rules:
- m4b format always forces merged output
- If save_chapters_separately is False, merged is required
- Otherwise, use merge_chapters_at_end setting
Args:
request: Conversion request
Returns:
True if merged output should be created
"""
if request.output_format.lower() == "m4b":
return True
if not request.save_chapters_separately:
return True
return request.merge_chapters_at_end