Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.0.28] - 2026-03-04

### Added
- `--description` / `-d` flag on `game init` for broadcast description
- `--thumbnail` flag on `game init` for thumbnail image path
- `GameInfo.description` and `GameInfo.thumbnail` fields
- Interactive prompts for description and thumbnail (both optional)

## [0.0.27] - 2026-03-04

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion reeln/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from __future__ import annotations

__version__ = "0.0.27"
__version__ = "0.0.28"
8 changes: 8 additions & 0 deletions reeln/commands/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ def init(
game_time: str = typer.Option("", "--game-time", "-t", help="Game time (e.g. '7:00 PM')."),
level: str | None = typer.Option(None, "--level", "-l", help="Team level for profile lookup."),
period_length: int = typer.Option(0, "--period-length", help="Period/segment length in minutes (0 = not set)."),
description: str = typer.Option("", "--description", "-d", help="Broadcast description."),
thumbnail: str = typer.Option("", "--thumbnail", help="Thumbnail image file path."),
output_dir: Path | None = typer.Option(None, "--output-dir", "-o", help="Base output directory."),
profile: str | None = typer.Option(None, "--profile", help="Named config profile."),
config_path: Path | None = typer.Option(None, "--config", help="Explicit config file path."),
Expand Down Expand Up @@ -167,6 +169,8 @@ def init(
venue=None if venue == "" else venue,
game_time=None if game_time == "" else game_time,
period_length=None if period_length == 0 else period_length,
description=None if description == "" else description,
thumbnail=None if thumbnail == "" else thumbnail,
)
except PromptAborted:
raise typer.Abort() from None
Expand All @@ -184,6 +188,8 @@ def init(
venue=info["venue"],
game_time=info["game_time"],
period_length=info["period_length"],
description=info["description"],
thumbnail=info["thumbnail"],
)
else:
# Non-interactive mode — use CLI args directly
Expand Down Expand Up @@ -211,6 +217,8 @@ def init(
venue=venue,
game_time=game_time,
period_length=period_length,
description=description,
thumbnail=thumbnail,
)

try:
Expand Down
32 changes: 32 additions & 0 deletions reeln/core/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,34 @@ def create_team_interactive(level: str, role: str) -> TeamProfile:
return profile


def prompt_description(preset: str | None = None) -> str:
"""Prompt for a broadcast description, or return *preset*.

Description is optional — an empty answer is accepted (returns ``""``).
"""
if preset is not None:
return preset
questionary = _require_questionary()
answer: str | None = questionary.text("Broadcast description (optional):").ask()
if answer is None:
return ""
return answer


def prompt_thumbnail(preset: str | None = None) -> str:
"""Prompt for a thumbnail file path, or return *preset*.

Thumbnail is optional — an empty answer is accepted (returns ``""``).
"""
if preset is not None:
return preset
questionary = _require_questionary()
answer: str | None = questionary.text("Thumbnail image path (optional):").ask()
if answer is None:
return ""
return answer


def prompt_period_length(preset: int | None = None) -> int:
"""Prompt for the period/segment length in minutes, or return *preset*.

Expand Down Expand Up @@ -237,6 +265,8 @@ def collect_game_info_interactive(
venue: str | None = None,
game_time: str | None = None,
period_length: int | None = None,
description: str | None = None,
thumbnail: str | None = None,
) -> dict[str, Any]:
"""Collect all game info fields, prompting only for missing values.

Expand Down Expand Up @@ -280,5 +310,7 @@ def collect_game_info_interactive(
result["venue"] = prompt_venue(preset=venue)
result["game_time"] = prompt_game_time(preset=game_time)
result["period_length"] = prompt_period_length(preset=period_length)
result["description"] = prompt_description(preset=description)
result["thumbnail"] = prompt_thumbnail(preset=thumbnail)

return result
6 changes: 6 additions & 0 deletions reeln/models/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class GameInfo:
venue: str = ""
game_time: str = ""
period_length: int = 0
description: str = ""
thumbnail: str = ""


@dataclass
Expand Down Expand Up @@ -77,6 +79,8 @@ def game_info_to_dict(info: GameInfo) -> dict[str, Any]:
"venue": info.venue,
"game_time": info.game_time,
"period_length": info.period_length,
"description": info.description,
"thumbnail": info.thumbnail,
}


Expand All @@ -91,6 +95,8 @@ def dict_to_game_info(data: dict[str, Any]) -> GameInfo:
venue=str(data.get("venue", data.get("rink", ""))),
game_time=str(data.get("game_time", "")),
period_length=int(data.get("period_length", 0)),
description=str(data.get("description", "")),
thumbnail=str(data.get("thumbnail", "")),
)


Expand Down
10 changes: 10 additions & 0 deletions registry/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
"min_reeln_version": "0.0.19",
"author": "StreamnDad",
"license": "AGPL-3.0"
},
{
"name": "google",
"package": "reeln-plugin-google",
"description": "Google platform integration — YouTube livestream creation, uploads, playlists, and comments",
"capabilities": ["hook:ON_GAME_INIT"],
"homepage": "https://github.com/StreamnDad/reeln-plugin-google",
"min_reeln_version": "0.0.19",
"author": "StreamnDad",
"license": "AGPL-3.0"
}
]
}
2 changes: 2 additions & 0 deletions tests/unit/commands/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ def _mock_collect(**overrides: object) -> dict[str, object]:
"venue": "",
"game_time": "",
"period_length": 0,
"description": "",
"thumbnail": "",
"home_profile": None,
"away_profile": None,
}
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/core/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
create_team_interactive,
prompt_away_team,
prompt_date,
prompt_description,
prompt_game_time,
prompt_home_team,
prompt_level,
prompt_period_length,
prompt_sport,
prompt_team,
prompt_thumbnail,
prompt_venue,
)
from reeln.models.team import TeamProfile
Expand Down Expand Up @@ -473,6 +475,78 @@ def test_prompt_period_length_cancelled_raises(mock_questionary: MagicMock) -> N
prompt_period_length()


# ---------------------------------------------------------------------------
# prompt_description
# ---------------------------------------------------------------------------


def test_prompt_description_preset_returns_immediately() -> None:
assert prompt_description(preset="Big game") == "Big game"


def test_prompt_description_preset_empty_string_returns_immediately() -> None:
assert prompt_description(preset="") == ""


def test_prompt_description_interactive(mock_questionary: MagicMock) -> None:
mock_questionary.text.return_value.ask.return_value = "Championship game"
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_description()
assert result == "Championship game"


def test_prompt_description_skipped_returns_empty(mock_questionary: MagicMock) -> None:
"""Description is optional — empty string is accepted."""
mock_questionary.text.return_value.ask.return_value = ""
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_description()
assert result == ""


def test_prompt_description_cancelled_returns_empty(mock_questionary: MagicMock) -> None:
"""Description is optional — cancellation returns empty, not PromptAborted."""
mock_questionary.text.return_value.ask.return_value = None
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_description()
assert result == ""


# ---------------------------------------------------------------------------
# prompt_thumbnail
# ---------------------------------------------------------------------------


def test_prompt_thumbnail_preset_returns_immediately() -> None:
assert prompt_thumbnail(preset="/tmp/thumb.jpg") == "/tmp/thumb.jpg"


def test_prompt_thumbnail_preset_empty_string_returns_immediately() -> None:
assert prompt_thumbnail(preset="") == ""


def test_prompt_thumbnail_interactive(mock_questionary: MagicMock) -> None:
mock_questionary.text.return_value.ask.return_value = "/img/banner.png"
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_thumbnail()
assert result == "/img/banner.png"


def test_prompt_thumbnail_skipped_returns_empty(mock_questionary: MagicMock) -> None:
"""Thumbnail is optional — empty string is accepted."""
mock_questionary.text.return_value.ask.return_value = ""
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_thumbnail()
assert result == ""


def test_prompt_thumbnail_cancelled_returns_empty(mock_questionary: MagicMock) -> None:
"""Thumbnail is optional — cancellation returns empty, not PromptAborted."""
mock_questionary.text.return_value.ask.return_value = None
with patch("reeln.core.prompts._require_questionary", return_value=mock_questionary):
result = prompt_thumbnail()
assert result == ""


# ---------------------------------------------------------------------------
# collect_game_info_interactive
# ---------------------------------------------------------------------------
Expand All @@ -488,6 +562,8 @@ def test_collect_all_presets_no_import_needed() -> None:
venue="OVAL",
game_time="7:00 PM",
period_length=15,
description="Big game",
thumbnail="/tmp/thumb.jpg",
)
assert result["home"] == "eagles"
assert result["away"] == "bears"
Expand All @@ -496,6 +572,8 @@ def test_collect_all_presets_no_import_needed() -> None:
assert result["venue"] == "OVAL"
assert result["game_time"] == "7:00 PM"
assert result["period_length"] == 15
assert result["description"] == "Big game"
assert result["thumbnail"] == "/tmp/thumb.jpg"
assert result["home_profile"] is None
assert result["away_profile"] is None

Expand All @@ -510,6 +588,8 @@ def test_collect_no_profiles_when_both_preset() -> None:
venue="OVAL",
game_time="7:00 PM",
period_length=15,
description="",
thumbnail="",
)
assert result["home_profile"] is None
assert result["away_profile"] is None
Expand All @@ -528,6 +608,8 @@ def test_collect_with_profiles(mock_questionary: MagicMock) -> None:
patch("reeln.core.prompts.prompt_venue", return_value="OVAL"),
patch("reeln.core.prompts.prompt_game_time", return_value="7:00 PM"),
patch("reeln.core.prompts.prompt_period_length", return_value=15),
patch("reeln.core.prompts.prompt_description", return_value="Test desc"),
patch("reeln.core.prompts.prompt_thumbnail", return_value="/tmp/t.jpg"),
):
result = collect_game_info_interactive()

Expand All @@ -540,6 +622,8 @@ def test_collect_with_profiles(mock_questionary: MagicMock) -> None:
assert result["venue"] == "OVAL"
assert result["game_time"] == "7:00 PM"
assert result["period_length"] == 15
assert result["description"] == "Test desc"
assert result["thumbnail"] == "/tmp/t.jpg"


def test_collect_with_profiles_and_presets() -> None:
Expand All @@ -554,6 +638,8 @@ def test_collect_with_profiles_and_presets() -> None:
patch("reeln.core.prompts.prompt_venue", return_value=""),
patch("reeln.core.prompts.prompt_game_time", return_value=""),
patch("reeln.core.prompts.prompt_period_length", return_value=15),
patch("reeln.core.prompts.prompt_description", return_value=""),
patch("reeln.core.prompts.prompt_thumbnail", return_value=""),
):
result = collect_game_info_interactive(
home="roseville",
Expand Down Expand Up @@ -584,6 +670,8 @@ def test_collect_with_home_prompted_away_preset() -> None:
patch("reeln.core.prompts.prompt_venue", return_value=""),
patch("reeln.core.prompts.prompt_game_time", return_value=""),
patch("reeln.core.prompts.prompt_period_length", return_value=15),
patch("reeln.core.prompts.prompt_description", return_value=""),
patch("reeln.core.prompts.prompt_thumbnail", return_value=""),
):
result = collect_game_info_interactive(
home=None,
Expand Down Expand Up @@ -624,6 +712,8 @@ def test_collect_all_interactive() -> None:
patch("reeln.core.prompts.prompt_venue", return_value="OVAL"),
patch("reeln.core.prompts.prompt_game_time", return_value="7:00 PM"),
patch("reeln.core.prompts.prompt_period_length", return_value=15),
patch("reeln.core.prompts.prompt_description", return_value="Game day"),
patch("reeln.core.prompts.prompt_thumbnail", return_value=""),
):
result = collect_game_info_interactive()

Expand All @@ -634,5 +724,7 @@ def test_collect_all_interactive() -> None:
assert result["venue"] == "OVAL"
assert result["game_time"] == "7:00 PM"
assert result["period_length"] == 15
assert result["description"] == "Game day"
assert result["thumbnail"] == ""
assert result["home_profile"] is home_prof
assert result["away_profile"] is away_prof
Loading