From c813200003026d8485419b0666cb7b7db756bef2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:52:55 +0000 Subject: [PATCH 1/3] Initial plan From 955eb139c62da30eadd2e0de6b24523913024253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:56:56 +0000 Subject: [PATCH 2/3] feat(v1.2.1): detect core downloader integration conflict and clarify Video Normalizer guidance Co-authored-by: Geek-MD <25725990+Geek-MD@users.noreply.github.com> --- CHANGELOG.md | 11 ++++++++ README.md | 2 +- .../advanced_downloader/__init__.py | 25 ++++++++++++++++++- .../advanced_downloader/manifest.json | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2c0e2..8a613d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ 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. + +### 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. @@ -192,6 +202,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 diff --git a/README.md b/README.md index bc669d5..6ccadb7 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/custom_components/advanced_downloader/__init__.py b/custom_components/advanced_downloader/__init__.py index b8807e4..76ce7d9 100644 --- a/custom_components/advanced_downloader/__init__.py +++ b/custom_components/advanced_downloader/__init__.py @@ -68,6 +68,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. @@ -78,7 +102,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, ( diff --git a/custom_components/advanced_downloader/manifest.json b/custom_components/advanced_downloader/manifest.json index 9625856..a84f4fb 100644 --- a/custom_components/advanced_downloader/manifest.json +++ b/custom_components/advanced_downloader/manifest.json @@ -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" } From 3315c5838c967034be256bd277d18f18b8ca4666 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:09:40 +0000 Subject: [PATCH 3/3] feat(v1.2.1): add target_aspect_ratio param, last_job sensor attr, and automation migration guide Co-authored-by: Geek-MD <25725990+Geek-MD@users.noreply.github.com> --- CHANGELOG.md | 3 + README.md | 128 ++++++++++++++++++ .../advanced_downloader/__init__.py | 24 ++-- .../advanced_downloader/const.py | 1 + .../advanced_downloader/sensor.py | 9 ++ .../advanced_downloader/services.yaml | 14 ++ 6 files changed, 171 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a613d3..1a3884e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. diff --git a/README.md b/README.md index 6ccadb7..d4c0d2b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. | --- @@ -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. diff --git a/custom_components/advanced_downloader/__init__.py b/custom_components/advanced_downloader/__init__.py index 76ce7d9..2c9c9e1 100644 --- a/custom_components/advanced_downloader/__init__.py +++ b/custom_components/advanced_downloader/__init__.py @@ -40,6 +40,7 @@ ATTR_RESIZE_ENABLED, ATTR_RESIZE_WIDTH, ATTR_RESIZE_HEIGHT, + ATTR_TARGET_ASPECT_RATIO, PROCESS_DOWNLOADING, PROCESS_RESIZING, PROCESS_FILE_DELETING, @@ -143,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() @@ -191,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"): @@ -230,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) }) @@ -303,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), }), ) diff --git a/custom_components/advanced_downloader/const.py b/custom_components/advanced_downloader/const.py index 2ec4d9d..5c87ae1 100644 --- a/custom_components/advanced_downloader/const.py +++ b/custom_components/advanced_downloader/const.py @@ -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" diff --git a/custom_components/advanced_downloader/sensor.py b/custom_components/advanced_downloader/sensor.py index b10b84f..43e210a 100644 --- a/custom_components/advanced_downloader/sensor.py +++ b/custom_components/advanced_downloader/sensor.py @@ -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() @@ -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.""" diff --git a/custom_components/advanced_downloader/services.yaml b/custom_components/advanced_downloader/services.yaml index 1bb1173..a7bb170 100644 --- a/custom_components/advanced_downloader/services.yaml +++ b/custom_components/advanced_downloader/services.yaml @@ -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