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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.2.1] - 2026-03-10

### Added
- **Core `downloader` integration conflict detection:** if the built-in `downloader` integration is loaded (i.e. `downloader:` is present in `configuration.yaml`), a persistent notification now appears at startup. The notification explains that Advanced Downloader is a full superset and guides the user to remove `downloader:` from `configuration.yaml` and restart Home Assistant.
- **`target_aspect_ratio` parameter for `download_file`:** the service now accepts an optional `target_aspect_ratio` (float, e.g. `1.777` for 16:9) that is forwarded to Video Normalizer's `VideoProcessor.process_video` during aspect normalisation. This replaces the equivalent parameter that was previously only available via the standalone `video_normalizer.normalize_video` service.
- **`last_job` attribute on `sensor.advanced_downloader_status`:** the sensor now exposes a `last_job` attribute (`null`, `success`, or `failed`) that reflects the outcome of the most recent download job, providing parity with the `last_job` attribute previously available on `sensor.video_normalizer_status`.
- **Automation migration guide** added to README, showing step-by-step how to replace automations that combined the core `downloader` integration with the standalone Video Normalizer integration.

### Fixed
- The Video Normalizer conflict notification import (`persistent_notification`) is now hoisted and shared between both conflict checks, removing a redundant in-block import.

---

## [1.2.0] - 2026-03-10

> ⚠️ **Breaking change:** The integration domain has changed from `media_downloader` to `advanced_downloader`. Update any automations, scripts, or templates that reference `media_downloader.*` services or `media_downloader_*` events.
Expand Down Expand Up @@ -192,6 +205,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Service `media_downloader.download_file` with optional subdirectories, custom filenames, overwrite control, and per-download timeout.
- Events: `media_downloader_download_started`, `media_downloader_download_completed` (with `success` and `error` fields).

[1.2.1]: https://github.com/Geek-MD/Advanced_Downloader/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/Geek-MD/Advanced_Downloader/compare/v1.1.6...v1.2.0
[1.1.6]: https://github.com/Geek-MD/Advanced_Downloader/compare/v1.1.5...v1.1.6
[1.1.5]: https://github.com/Geek-MD/Advanced_Downloader/compare/v1.1.4...v1.1.5
Expand Down
130 changes: 129 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
**Advanced Downloader** is a custom Home Assistant integration that greatly extends file downloading capabilities beyond the built-in `downloader` integration. It downloads, normalizes, and manages media files directly from Home Assistant through simple services — and leverages [Video Normalizer](https://github.com/Geek-MD/Video_Normalizer) as a dependency for all video processing.

> **Domain:** `advanced_downloader`
> This integration coexists with the core `downloader` integration. If you no longer need the core `downloader`, remove `downloader:` from your `configuration.yaml`.
> Advanced Downloader is a full superset of the core `downloader` integration. If you have `downloader:` in your `configuration.yaml`, a persistent notification will appear at startup reminding you to remove it. After removing it, restart Home Assistant.

---

Expand Down Expand Up @@ -107,6 +107,7 @@ For video files, the following steps are always performed via Video Normalizer:
| `resize_enabled` | no | If true, resize the video when dimensions mismatch. |
| `resize_width` | no | Target width for resize (default 640). |
| `resize_height` | no | Target height for resize (default 360). |
| `target_aspect_ratio` | no | Target display aspect ratio as decimal (e.g. `1.777` for 16:9). Passed to Video Normalizer during normalization. Omit to let Video Normalizer infer from the video's own dimensions. |

#### Example:
```yaml
Expand Down Expand Up @@ -157,6 +158,7 @@ The integration provides the persistent entity:
| `last_changed` | Datetime when state last changed. |
| `subprocess` | Current subprocess name (`downloading`, `resizing`, `file_deleting`, `dir_deleting`). |
| `active_processes` | List of all currently active subprocesses. |
| `last_job` | Outcome of the last download job: `null` (no job yet), `success`, or `failed`. |

---

Expand Down Expand Up @@ -201,6 +203,132 @@ The integration provides the persistent entity:

---

## 🔄 Migration Guide

### Migrating from `downloader` + `video_normalizer` automations

If you previously had automations that combined the core **Downloader** integration
(`downloader.download_file`) with the standalone **Video Normalizer** integration
(`video_normalizer.normalize_video`), follow this guide to migrate them to
**Advanced Downloader**, which performs both operations in a single service call.

#### Before (two integrations)

```yaml
# 1. Download the file using the core Downloader integration
- action: downloader.download_file
data:
url: "{{ url }}"
subdir: ring
filename: ring.mp4
overwrite: true

# 2. Wait for the download event fired by the core Downloader
- wait_for_trigger:
- trigger: event
event_type: downloader_download_completed
continue_on_timeout: false

# 3. Normalize the video using the standalone Video Normalizer integration
- action: video_normalizer.normalize_video
data:
overwrite: true
normalize_aspect: true
generate_thumbnail: true
input_file_path: /media/ring/ring.mp4
target_aspect_ratio: 1.777

# 4. Wait for Video Normalizer to finish (poll the sensor)
- if:
- condition: state
entity_id: sensor.video_normalizer_status
state: idle
then: []
else:
- wait_for_trigger:
- trigger: state
entity_id: sensor.video_normalizer_status
to: idle

# 5. Act on outcome via last_job attribute
- if:
- condition: template
value_template: >-
{{ state_attr('sensor.video_normalizer_status', 'last_job') in
['success', 'skipped'] }}
then:
- action: telegram_bot.send_video
data:
file: /media/ring/ring.mp4
caption: Se detectó movimiento en Entrada.
else:
- action: telegram_bot.send_message
data:
message: Se detectó movimiento en Entrada.
```

#### After (Advanced Downloader only)

```yaml
# 1. Download + normalize in one call
- action: advanced_downloader.download_file
data:
url: "{{ url }}"
subdir: ring
filename: ring.mp4
overwrite: true
target_aspect_ratio: 1.777 # forwarded to Video Normalizer

# 2. Wait for job completion (success) or failure
- wait_for_trigger:
- trigger: event
event_type: advanced_downloader_job_completed
- trigger: event
event_type: advanced_downloader_download_failed
continue_on_timeout: false

# 3. Act on outcome via the trigger event type
- if:
- condition: template
value_template: >-
{{ wait.trigger.event.event_type ==
'advanced_downloader_job_completed' }}
then:
- action: telegram_bot.send_video
data:
file: /media/ring/ring.mp4
caption: Se detectó movimiento en Entrada.
else:
- action: telegram_bot.send_message
data:
message: Se detectó movimiento en Entrada.
```

Alternatively, you can check the `last_job` attribute on
`sensor.advanced_downloader_status` (values: `success` or `failed`) the same
way you previously checked `sensor.video_normalizer_status`:

```yaml
- if:
- condition: template
value_template: >-
{{ state_attr('sensor.advanced_downloader_status', 'last_job') ==
'success' }}
then: ...
```

#### Key mapping

| Old | New |
|-----|-----|
| `downloader.download_file` | `advanced_downloader.download_file` |
| `video_normalizer.normalize_video` | *(built-in — no separate call needed)* |
| `downloader_download_completed` event + poll `sensor.video_normalizer_status` → idle | `advanced_downloader_job_completed` event (success) or `advanced_downloader_download_failed` event (failure) |
| `sensor.video_normalizer_status` state | `sensor.advanced_downloader_status` state |
| `sensor.video_normalizer_status` `last_job` | `sensor.advanced_downloader_status` `last_job` |

---

## 🧾 License
MIT License. See [LICENSE](LICENSE) for details.

Expand Down
49 changes: 40 additions & 9 deletions custom_components/advanced_downloader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
ATTR_RESIZE_ENABLED,
ATTR_RESIZE_WIDTH,
ATTR_RESIZE_HEIGHT,
ATTR_TARGET_ASPECT_RATIO,
PROCESS_DOWNLOADING,
PROCESS_RESIZING,
PROCESS_FILE_DELETING,
Expand Down Expand Up @@ -68,6 +69,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
)

from homeassistant.components import persistent_notification as _pn # noqa: PLC0415

# Warn if the core "downloader" integration is loaded (configured via
# configuration.yaml). Advanced Downloader is a full superset of its
# functionality; having both active serves no purpose and may cause confusion.
if "downloader" in hass.config.components:
_LOGGER.warning(
"The built-in 'downloader' integration is active alongside Advanced "
"Downloader. Remove 'downloader:' from your configuration.yaml and "
"restart Home Assistant to avoid redundancy."
)
_pn.async_create(
hass,
(
"The built-in **Downloader** integration is loaded in your "
"configuration. **Advanced Downloader** provides a full superset of "
"its functionality.\n\n"
"To avoid redundancy, remove `downloader:` from your "
"`configuration.yaml` and restart Home Assistant."
),
title="Advanced Downloader: Remove core Downloader integration",
notification_id="advanced_downloader_core_downloader_conflict",
)

# Warn if Video Normalizer is also configured as a standalone integration.
# Its code must remain installed (Advanced Downloader imports from it), but
# the standalone config entry should be removed to avoid duplicate processing.
Expand All @@ -78,7 +103,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"Services to avoid duplicate video processing. Keep the HACS package "
"installed — Advanced Downloader still requires its code."
)
from homeassistant.components import persistent_notification as _pn # noqa: PLC0415
_pn.async_create(
hass,
(
Expand Down Expand Up @@ -120,6 +144,7 @@ async def _async_download(call: ServiceCall) -> None:
resize_enabled: bool = call.data.get(ATTR_RESIZE_ENABLED, False)
resize_width: int = int(call.data.get(ATTR_RESIZE_WIDTH, 640))
resize_height: int = int(call.data.get(ATTR_RESIZE_HEIGHT, 360))
target_aspect_ratio: Optional[float] = call.data.get(ATTR_TARGET_ASPECT_RATIO)

base_dir, default_overwrite = _get_config()
base_dir = base_dir.resolve()
Expand Down Expand Up @@ -168,14 +193,17 @@ async def _async_download(call: ServiceCall) -> None:
if resize_enabled:
sensor.start_process(PROCESS_RESIZING)
try:
process_result = await video_processor.process_video(
video_path=str(dest_path),
overwrite=True,
normalize_aspect=True,
generate_thumbnail=True,
resize_width=resize_width if resize_enabled else None,
resize_height=resize_height if resize_enabled else None,
)
process_kwargs: dict = {
"video_path": str(dest_path),
"overwrite": True,
"normalize_aspect": True,
"generate_thumbnail": True,
"resize_width": resize_width if resize_enabled else None,
"resize_height": resize_height if resize_enabled else None,
}
if target_aspect_ratio is not None:
process_kwargs["target_aspect_ratio"] = target_aspect_ratio
process_result = await video_processor.process_video(**process_kwargs)
operations = process_result.get("operations", {})

if operations.get("normalize_aspect"):
Expand Down Expand Up @@ -207,12 +235,14 @@ async def _async_download(call: ServiceCall) -> None:
if resize_enabled:
sensor.end_process(PROCESS_RESIZING)

sensor.set_last_job("success")
hass.bus.async_fire("advanced_downloader_job_completed", {
"url": url, "path": str(dest_path)
})

except Exception as err:
_LOGGER.error("Download failed: %s", err)
sensor.set_last_job("failed")
hass.bus.async_fire("advanced_downloader_download_failed", {
"url": url, "error": str(err)
})
Expand Down Expand Up @@ -280,6 +310,7 @@ async def _async_delete_directory(call: ServiceCall) -> None:
vol.Optional(ATTR_RESIZE_ENABLED): cv.boolean,
vol.Optional(ATTR_RESIZE_WIDTH): vol.Coerce(int),
vol.Optional(ATTR_RESIZE_HEIGHT): vol.Coerce(int),
vol.Optional(ATTR_TARGET_ASPECT_RATIO): vol.Coerce(float),
}),
)

Expand Down
1 change: 1 addition & 0 deletions custom_components/advanced_downloader/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ATTR_RESIZE_WIDTH = "resize_width"
ATTR_RESIZE_HEIGHT = "resize_height"
ATTR_RESIZED = "resized"
ATTR_TARGET_ASPECT_RATIO = "target_aspect_ratio"

# Subprocess names
PROCESS_DOWNLOADING = "downloading"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/advanced_downloader/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"issue_tracker": "https://github.com/Geek-MD/Advanced_Downloader/issues",
"quality_scale": "legacy",
"requirements": [],
"version": "1.2.0"
"version": "1.2.1"
}
9 changes: 9 additions & 0 deletions custom_components/advanced_downloader/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def __init__(self, hass: HomeAssistant) -> None:
"last_changed": None,
"subprocess": None,
"active_processes": [],
"last_job": None,
}
self._hass = hass
self._active_processes: set[str] = set()
Expand Down Expand Up @@ -53,6 +54,14 @@ def end_process(self, name: str) -> None:
self._attr_extra_state_attributes["last_changed"] = datetime.now().isoformat()
self.async_write_ha_state()

def set_last_job(self, value: str | None) -> None:
"""Record the outcome of the last completed download job.

Accepted values: ``'success'``, ``'failed'``, or ``None`` to reset.
"""
self._attr_extra_state_attributes["last_job"] = value
self.async_write_ha_state()

@property
def device_info(self) -> DeviceInfo:
"""Return device info for grouping in HA UI."""
Expand Down
14 changes: 14 additions & 0 deletions custom_components/advanced_downloader/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ download_file:
min: 1
max: 2160
mode: box
target_aspect_ratio:
name: Target aspect ratio
description: >
Target display aspect ratio for video normalization expressed as a decimal
(e.g. 1.777 for 16:9). When omitted, the aspect ratio is inferred from the
video's own pixel dimensions.
required: false
example: 1.777
selector:
number:
min: 0.1
max: 10
step: 0.001
mode: box

delete_file:
name: Delete file
Expand Down