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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).

## [0.6.1] - 2026-10-04

### Added

- Per-output `channels` and `max_sample_rate`. An `aac` output now defaults to `channels: 2` and `max_sample_rate: 48000`, so the AAC copies play everywhere a phone, AirPods or CarPlay can take them: anything with more than two channels is downmixed to stereo, 88.2 and 176.4 kHz sources become 44.1 kHz, and 96 and 192 kHz sources become 48 kHz. Nothing is ever upsampled or upmixed. A lossy AAC source that exceeds the limits is transcoded rather than copied. Other codecs have no limit unless they set one, and `0` turns a limit off. ALAC is unchanged.

### Fixed

- A lossless file that arrives after a lossy one of the same name now replaces the lossy file's transcode too, not only its copy. An Ogg transcoded into an MP3 output as `X.mp3` used to block the later `X.flac` from ever being encoded there. Each output folder now keeps `.atw-manifest.json`, recording which source made each file and how (encode, copy or transcode). A row is trusted only while the output still has the size and modification time it was written with. A missing or unreadable manifest means "unknown", which behaves exactly as before, and the orphan pass drops rows whose file is gone.

### Upgrading

- Existing AAC files are not rebuilt by the upgrade. They are re-encoded only when their source changes, or with `force_reencode: true` on startup. To rebuild just the hi-res and multichannel ones, delete the AAC files above 48 kHz or 2 channels and the next periodic sync encodes them again: `docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;`

### Security

- `urllib3` is now required at 2.8.0 or newer, for GHSA-vxq7-64xx-v4gw, GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g. It comes in through syncedlyrics and requests; the image installs from `pyproject.toml`, so the floor is declared there and `uv.lock` resolves 2.8.0.

## [0.6.0] - 2026-10-04

### Changed
Expand Down Expand Up @@ -35,4 +53,5 @@ All notable changes to this project are documented here. The format follows [Kee
- Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
- `force_reencode: true` purges the outputs once at startup. It used to purge them again on every periodic sync, which re-encoded the whole library every five minutes.

[0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
[0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
-v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \
drumsergio/audio-transcoder:0.6.0
drumsergio/audio-transcoder:0.6.1
```

Edit `config.yaml` first so its outputs match the folders you mount; the example writes ALAC, MP3 and AAC. [Getting started](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/getting-started.md) has Docker Compose and the full `docker run`.
Expand Down
10 changes: 8 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Audio Transcode Watcher Configuration (0.6.0)
# Audio Transcode Watcher Configuration (0.6.1)
# ======================================
#
# This configuration file defines the source folder and output destinations
Expand Down Expand Up @@ -28,12 +28,18 @@ outputs:
path: /music/mp3
include_artwork: true

# AAC 256kbps for modern devices
# AAC 256kbps for phones, AirPods and CarPlay
- name: aac-256
codec: aac
bitrate: 256k
path: /music/aac
include_artwork: true
# Portable limits. These are the AAC defaults, shown for reference:
# more than 2 channels are downmixed to stereo, and rates above 48 kHz
# drop to 44.1 kHz (88.2/176.4 kHz sources) or 48 kHz (96/192 kHz).
# Lower rates are never upsampled. Any codec can opt in; 0 = no limit.
channels: 2
max_sample_rate: 48000

# Optional settings
settings:
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Current version: **0.6.0**
Current version: **0.6.1**

## Completed

Expand All @@ -12,6 +12,7 @@ Current version: **0.6.0**
- **Whisper local transcription fallback** for lyrics (v0.4.0, removed in v0.6.0)
- **Recursive directory support** -- mirror source folder hierarchy in outputs (v0.5.0)
- **Lossy sources copied, not inflated**, corrupt sources fail loudly, ReplayGain and MusicBrainz tags kept in M4A outputs, configurable periodic sync (v0.6.0)
- **Portable AAC**: stereo downmix and a 48 kHz cap for AAC outputs, per-output `channels` and `max_sample_rate` (v0.6.1)

## v0.7.0 -- Quality of Life

Expand Down
25 changes: 24 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,30 @@ A lossless source is encoded to every output.

A lossy source is never encoded into a lossless output, because that only makes a big file that looks lossless. It is copied there unchanged, with its own extension, so an ALAC folder can hold `.m4a` encodes next to `.mp3` or `.ogg` copies. Into a lossy output it is copied unchanged when it already has that output's codec (`.mp3` into `mp3`, `.m4a` or `.aac` into `aac`, `.opus` into `opus`) and transcoded otherwise. Copies keep their tags and cover as they are.

When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied, the copy is removed and the lossless file is encoded as soon as it is processed.
When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied or transcoded, that file is replaced by an encode of the lossless one as soon as it is processed. To tell those files apart, each output folder keeps a hidden `.atw-manifest.json` recording which source made each file and whether it was an encode, a copy or a transcode. Deleting it is safe: files it does not know about are treated as they were before 0.6.1.

## Portable limits (channels and sample rate)

Each output can cap the channel count and the sample rate:

```yaml
- name: aac-256
codec: aac
bitrate: 256k
path: /music/aac
channels: 2 # downmix anything with more channels to stereo
max_sample_rate: 48000 # resample anything above 48 kHz
```

`aac` outputs get `channels: 2` and `max_sample_rate: 48000` when they don't set them, because they are for phones, AirPods and CarPlay. Every other codec has no limit unless you set one, and `0` turns a limit off (also for `aac`).

The sample rate never goes up, and a rate above the cap drops within its own family: 88.2 and 176.4 kHz become 44.1 kHz, 96 and 192 kHz become 48 kHz. A rate from neither family goes to the cap. A mono or stereo source is never upmixed. A lossy source that would normally be copied into the output (an `.m4a` into `aac`) is transcoded instead when it exceeds a limit. ALAC and the other lossless outputs keep the source's rate and channels.

Changing these settings does not rebuild files that already exist. They are re-encoded when their source changes, or on startup with `force_reencode: true`. To rebuild only the files above the limits, delete them and let the next periodic sync encode them again:

```bash
docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;
```

## Tags in ALAC and AAC outputs

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ outputs:
```yaml
services:
audio-transcoder:
image: drumsergio/audio-transcoder:0.6.0
image: drumsergio/audio-transcoder:0.6.1
container_name: audio_transcoder
environment:
- TZ=Europe/Madrid
Expand Down Expand Up @@ -60,6 +60,6 @@ docker run -d \
-v /path/to/flac:/music/flac:ro \
-v /path/to/mp3:/music/mp3 \
--restart unless-stopped \
drumsergio/audio-transcoder:0.6.0
drumsergio/audio-transcoder:0.6.1
```

4 changes: 4 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats).

## Provenance manifest

Each output folder holds `.atw-manifest.json`, a map from each output file to the source that made it (path, size, modification time) and how: `encode`, `copy` or `transcode`. It is written in batches at most every 5 seconds and at the end of each sync. It is only used to replace a file made from a lossy source once a lossless source of the same name appears. A missing or unreadable manifest just means "unknown".

## Corrupt sources

FFmpeg runs with `-xerror` and `-err_detect crccheck+explode`, so a frame whose checksum does not match stops the encode. If a source does not decode cleanly, the encode fails even when FFmpeg exits 0 but printed a decode error. The error is logged with the file name and no output is written. The file is not tried again until its modification time changes, or the service restarts.
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "audio-transcode-watcher"

version = "0.6.0"
version = "0.6.1"

description = "Watch a source folder and automatically transcode audio files to multiple formats"
readme = "README.md"
Expand All @@ -30,6 +30,9 @@ dependencies = [
"pyyaml>=6.0",
"mutagen>=1.47.0",
"syncedlyrics>=1.0.0",
# Transitive via syncedlyrics/requests; pinned up for GHSA-vxq7-64xx-v4gw,
# GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g.
"urllib3>=2.8.0",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion src/audio_transcode_watcher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Audio Transcode Watcher - Automatic audio file transcoding."""

__version__ = "0.6.0"
__version__ = "0.6.1"
73 changes: 49 additions & 24 deletions src/audio_transcode_watcher/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,45 +42,64 @@
"opus": "128k",
}

# Portable-playback limits applied when an output does not set them.
# AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
# Any codec can opt in by setting channels / max_sample_rate; 0 = no limit.
DEFAULT_CHANNELS = {"aac": 2}
DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}


@dataclass
class OutputConfig:
"""Configuration for a single output destination."""

name: str
codec: str
path: str
bitrate: str = ""
include_artwork: bool = True

channels: int | None = None # Downmix above this many channels; 0 = keep
max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep

def __post_init__(self) -> None:
"""Validate and set defaults after initialization."""
self.codec = self.codec.lower()

if self.codec not in CODEC_EXTENSIONS:
raise ValueError(
f"Unknown codec '{self.codec}'. "
f"Supported: {', '.join(CODEC_EXTENSIONS.keys())}"
)

# Set default bitrate for lossy codecs
if not self.bitrate and self.codec in DEFAULT_BITRATES:
self.bitrate = DEFAULT_BITRATES[self.codec]

# Artwork not supported for some codecs
if self.include_artwork and self.codec not in ARTWORK_SUPPORTED_CODECS:
self.include_artwork = False


if self.channels is None:
self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
if self.max_sample_rate is None:
self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0)
for key in ("channels", "max_sample_rate"):
value = getattr(self, key)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(
f"Output '{self.name}': {key} must be a whole number, 0 for no limit"
)

@property
def extension(self) -> str:
"""Get the file extension for this codec."""
return CODEC_EXTENSIONS[self.codec]

@property
def is_lossless(self) -> bool:
"""Check if this codec is lossless."""
return self.codec in {"alac", "flac", "wav"}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
"""Create OutputConfig from a dictionary."""
Expand All @@ -90,13 +109,15 @@ def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
path=data["path"],
bitrate=data.get("bitrate", ""),
include_artwork=data.get("include_artwork", True),
channels=data.get("channels"),
max_sample_rate=data.get("max_sample_rate"),
)


@dataclass
class Config:
"""Main configuration for audio-transcode-watcher."""

source_path: str
outputs: list[OutputConfig] = field(default_factory=list)
force_reencode: bool = False
Expand All @@ -106,48 +127,52 @@ class Config:
min_stable_seconds: float = 1.0
fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
sync_interval_seconds: int = 300 # Seconds between periodic full syncs

def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if not self.source_path:
raise ValueError("source_path is required")

interval = self.sync_interval_seconds
if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0:
if (
isinstance(interval, bool)
or not isinstance(interval, (int, float))
or interval <= 0
):
raise ValueError("sync_interval_seconds must be a number greater than 0")

if not self.outputs:
raise ValueError("At least one output is required")

# Check for duplicate output names
names = [o.name for o in self.outputs]
if len(names) != len(set(names)):
raise ValueError("Duplicate output names detected")

# Check for duplicate output paths
paths = [o.path for o in self.outputs]
if len(paths) != len(set(paths)):
raise ValueError("Duplicate output paths detected")

@property
def output_paths(self) -> list[str]:
"""Get list of all output directory paths."""
return [o.path for o in self.outputs]

def get_output_by_name(self, name: str) -> OutputConfig | None:
"""Get an output configuration by name."""
for output in self.outputs:
if output.name == name:
return output
return None

@classmethod
def from_dict(cls, data: dict[str, Any]) -> Config:
"""Create Config from a dictionary."""
outputs = [OutputConfig.from_dict(o) for o in data.get("outputs", [])]
settings = data.get("settings", {})
_warn_deprecated_settings(settings)

return cls(
source_path=data.get("source", {}).get("path", ""),
outputs=outputs,
Expand All @@ -159,14 +184,14 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
fetch_lyrics=settings.get("fetch_lyrics", True),
sync_interval_seconds=settings.get("sync_interval_seconds", 300),
)

@classmethod
def from_yaml_file(cls, path: str) -> Config:
"""Load configuration from a YAML file."""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return cls.from_dict(data)

@classmethod
def from_json_string(cls, json_str: str) -> Config:
"""Load configuration from a JSON string."""
Expand All @@ -191,11 +216,11 @@ def _warn_deprecated_settings(settings: dict[str, Any]) -> None:
def load_config() -> Config:
"""
Load configuration from environment variables.

Configuration is loaded from one of these sources (in priority order):
1. CONFIG_FILE env var - path to a YAML config file
2. CONFIG_JSON env var - JSON string with full configuration

Raises:
ValueError: If no valid configuration is found
"""
Expand All @@ -205,12 +230,12 @@ def load_config() -> Config:
if not Path(config_file).exists():
raise ValueError(f"CONFIG_FILE not found: {config_file}")
return Config.from_yaml_file(config_file)

# Try CONFIG_JSON
config_json = os.getenv("CONFIG_JSON")
if config_json:
return Config.from_json_string(config_json)

# No configuration provided
raise ValueError(
"No configuration found. Set CONFIG_FILE (path to YAML config) "
Expand Down
Loading
Loading