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: 8 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ env:
jobs:
test:
runs-on: ubuntu-latest
# The Docker image's base, so the real-file tests run against the FFmpeg
# the image ships. Ubuntu's FFmpeg 6.1 misses corruption that 7.1 catches.
container: python:3.14-slim
steps:
- uses: actions/checkout@v7
- name: Install FFmpeg and the tools checkout and Codecov need
run: |
apt-get update
apt-get install -y --no-install-recommends ffmpeg git curl gnupg ca-certificates

- name: Set up Python 3.14
uses: actions/setup-python@v7
with:
python-version: '3.14'
cache: 'pip'
- uses: actions/checkout@v7

- name: Install dependencies
run: |
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ on:
jobs:
test:
runs-on: ubuntu-latest
# The Docker image's base, so the real-file tests run against the FFmpeg
# the image ships. Ubuntu's FFmpeg 6.1 misses corruption that 7.1 catches.
container: python:3.14-slim
steps:
- uses: actions/checkout@v7
- name: Install FFmpeg and the tools checkout and Codecov need
run: |
apt-get update
apt-get install -y --no-install-recommends ffmpeg git curl gnupg ca-certificates

- name: Set up Python 3.14
uses: actions/setup-python@v7
with:
python-version: '3.14'
cache: 'pip'
- uses: actions/checkout@v7

- name: Install dependencies
run: |
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Changelog

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.0] - 2026-10-04

### Changed

- Lossy sources are copied, not encoded. An MP3, M4A, AAC, Ogg, Opus or WMA file in the source folder lands in a lossless output (ALAC, FLAC, WAV) as an unchanged copy with its own extension. Before, everything except MP3 was encoded to ALAC, which turned a lossy file into a lossless-sized one that only looked lossless.
- Into a lossy output, a lossy source is copied unchanged when it already has that output's codec (an MP3 into an MP3 output, an M4A into an AAC output). It is transcoded only when the codecs differ. That is the one lossy-to-lossy encode left.
- Ogg and Opus are now treated as lossy. AIF and TAK are now recognised as lossless sources, and WMA as a lossy one. Files with these extensions used to be ignored.
- When a lossless and a lossy file share a name in the source folder, the lossless file wins in every output. If the lossy file arrived first, its copy is removed and the lossless file is encoded when it is processed, not left behind until an orphan pass.
- The periodic full sync logs as "Periodic sync" instead of "Initial sync", so the logs no longer read like restarts. Its interval is the new setting `settings.sync_interval_seconds` (default 300).
- A lyrics file written next to a source now gets the source file's owner and group and mode 0664, instead of root and 0644.

### Added

- ALAC and AAC outputs keep the tags the standard MP4 atoms have no room for: ReplayGain track and album gain and peak, the MusicBrainz track, album, artist, album artist and release group ids, ISRC, label, catalog number, and the artist, album artist and album sort names. They use the atom names MusicBrainz Picard writes (`----:com.apple.iTunes:replaygain_track_gain`, `----:com.apple.iTunes:MusicBrainz Track Id`, `ISRC`, `LABEL`, `CATALOGNUMBER`), so Picard, TagLib, foobar2000 and iTunes all read them, and the sort names go to the standard `soar`, `soaa` and `soal` atoms. FLAC, Ogg, APE, WavPack, TAK, WAV and AIFF sources are read.
- `CHANGELOG.md`.

### Removed

- The Whisper lyrics fallback, and with it the `openai-whisper` dependency (and PyTorch), which makes the image much smaller. Old configs that still set `whisper_fallback` or `whisper_model` load fine; the first load logs one warning that the keys are ignored.

### Fixed

- A source that does not decode cleanly now fails instead of producing a short copy. FFmpeg runs with `-xerror`, and a decode error on its stderr counts as a failure even when it exits 0. A corrupt FLAC used to come out two seconds short with nothing in the logs. Now the error is logged with the file name, no output is written, and the file is not tried again until its modification time changes.
- FFmpeg also checks every frame checksum of the input (`-err_detect crccheck+explode`). Without it, the FFmpeg 7.1 in the image decoded a FLAC with a single flipped bit without a word.
- An ffmpeg run that takes longer than 30 minutes is killed and counts as a failure, so one hung encode can no longer stall every later sync.
- The retry without cover art only runs when the error is about the picture stream. It used to run on any error that mentioned "decode".
- The periodic sync could delete an output the watcher had just written, because it judged orphans from a source list built before the file arrived. The orphan pass now reads the source folder again, and it leaves alone any file younger than 120 seconds or whose source is being processed.
- Startup and periodic cleanup no longer delete `.tmp__ff` files younger than 10 minutes, which could belong to an encode still running.
- Lyrics: when the providers return nothing, nothing is written. Results made of one repeated token, with fewer than 4 timed lines, or with only an advertisement line are rejected, and the reason is logged.
- Deleting one of two sources that share a name (for example the MP3 next to a FLAC) no longer deletes the `.lrc` copies the other one still needs.
- 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.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ Keep one library in several formats: lossless for the archive, lossy for phones
- Watches the source folder and reacts to new, changed, renamed and deleted files.
- Writes any number of outputs in one pass, in ALAC, AAC, MP3, Opus, FLAC or WAV at the bitrate you pick.
- Mirrors the Artist/Album folder tree in every output.
- Fetches synced `.lrc` lyrics, with Whisper speech-to-text when none are found online.
- Copies lossy sources (MP3, M4A, Ogg, Opus, WMA) unchanged instead of inflating them into a lossless output.
- Fails loudly on a source that does not decode cleanly, and writes no output for it.
- Keeps ReplayGain, MusicBrainz ids, ISRC, label and sort tags in ALAC and AAC copies.
- Fetches synced `.lrc` lyrics, and writes nothing when no real lyrics are found.
- Embeds cover art if you want it.
- Writes atomically and removes orphaned outputs, but never deletes anything when a folder looks empty.
- Handles Unicode file names.
Expand All @@ -32,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.5.2
drumsergio/audio-transcoder:0.6.0
```

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 All @@ -45,6 +48,7 @@ Edit `config.yaml` first so its outputs match the folders you mount; the example
- [How it works](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/how-it-works.md): sync and watch mode, folder mirroring, safety guards, performance
- [Development](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/development.md): tests, building the image, contributing
- [Roadmap](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/ROADMAP.md)
- [Changelog](https://github.com/GeiserX/audio-transcode-watcher/blob/main/CHANGELOG.md)

## Related projects

Expand Down
13 changes: 12 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Audio Transcode Watcher Configuration
# Audio Transcode Watcher Configuration (0.6.0)
# ======================================
#
# This configuration file defines the source folder and output destinations
Expand All @@ -7,6 +7,9 @@
# Mount this file into the container and set CONFIG_FILE=/app/config.yaml

# Source folder containing original audio files (FLAC, WAV, etc.)
# Lossy sources (mp3, m4a, aac, ogg, opus, wma) are copied unchanged into
# lossless outputs, and into lossy outputs of the same codec; they are only
# transcoded into a lossy output of another codec.
source:
path: /music/flac

Expand Down Expand Up @@ -47,6 +50,14 @@ settings:
# Minimum time a file must be unchanged before processing (seconds)
min_stable_seconds: 1.0

# Fetch synced .lrc lyrics online; nothing is written when none are found
fetch_lyrics: true

# Seconds between periodic full syncs (default: 300)
sync_interval_seconds: 300

# whisper_fallback and whisper_model were removed in 0.6.0 and are ignored


# ============================================================================
# SUPPORTED CODECS
Expand Down
11 changes: 6 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Current version: **0.5.0**
Current version: **0.6.0**

## Completed

Expand All @@ -9,10 +9,11 @@ Current version: **0.5.0**
- **Stale temp file cleanup** on startup (v0.1.2)
- **Synced lyrics sidecar** (.lrc) copying to outputs (v0.2.0)
- **Auto-fetch synced lyrics** via syncedlyrics providers (v0.3.0)
- **Whisper local transcription fallback** for lyrics (v0.4.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)

## v0.6.0 -- Quality of Life
## v0.7.0 -- Quality of Life

- [ ] **Progress reporting during initial sync**
- Show file count progress (e.g. 42/500)
Expand All @@ -26,7 +27,7 @@ Current version: **0.5.0**
- Preview what would be encoded without doing it
- Useful for testing configuration changes

## v0.7.0 -- Observability
## v0.8.0 -- Observability

- [ ] **Health check endpoint**
- HTTP endpoint for container health monitoring
Expand All @@ -36,7 +37,7 @@ Current version: **0.5.0**
- Webhook/Shoutrrr support for encoding events
- Error alerts for failed encodes

## v0.8.0 -- Smart Encoding
## v0.9.0 -- Smart Encoding

- [ ] **FFmpeg multi-output (tee muxer)**
- Single decode pass, multiple encodes
Expand Down
37 changes: 32 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,40 @@ settings:
# Auto-fetch synced .lrc lyrics (default: true)
fetch_lyrics: true

# Use Whisper local transcription as fallback when no lyrics found online (default: true)
whisper_fallback: true

# Whisper model size: tiny, base, small, medium, large (default: base)
whisper_model: base
# Seconds between periodic full syncs (default: 300)
sync_interval_seconds: 300
```

`whisper_fallback` and `whisper_model` were removed in 0.6.0 along with the Whisper lyrics fallback. A config that still sets them loads, and the first load logs one warning that they are ignored.

## Source formats

| Kind | Extensions |
|----------|--------------------------------------------------------------------|
| Lossless | `.flac` `.alac` `.wav` `.aiff` `.aif` `.ape` `.wv` `.tta` `.tak` |
| Lossy | `.mp3` `.aac` `.m4a` `.ogg` `.opus` `.wma` |

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.

## Tags in ALAC and AAC outputs

FFmpeg writes the standard MP4 atoms (title, artist, album and so on). After an ALAC or AAC encode, the tags FFmpeg drops are copied from the source under the names MusicBrainz Picard uses, so Picard, TagLib (Navidrome, Jellyfin), foobar2000 and iTunes all read them:

| Source tag | MP4 atom |
|----------------------------------------------|-------------------------------------------------------|
| `REPLAYGAIN_TRACK_GAIN`, `_TRACK_PEAK`, `_ALBUM_GAIN`, `_ALBUM_PEAK` | `----:com.apple.iTunes:replaygain_track_gain` and so on, lowercase |
| `MUSICBRAINZ_TRACKID` | `----:com.apple.iTunes:MusicBrainz Track Id` |
| `MUSICBRAINZ_ALBUMID` | `----:com.apple.iTunes:MusicBrainz Album Id` |
| `MUSICBRAINZ_ARTISTID` | `----:com.apple.iTunes:MusicBrainz Artist Id` |
| `MUSICBRAINZ_ALBUMARTISTID` | `----:com.apple.iTunes:MusicBrainz Album Artist Id` |
| `MUSICBRAINZ_RELEASEGROUPID` | `----:com.apple.iTunes:MusicBrainz Release Group Id` |
| `ISRC`, `LABEL`, `CATALOGNUMBER` | `----:com.apple.iTunes:ISRC`, `LABEL`, `CATALOGNUMBER` |
| `ARTISTSORT`, `ALBUMARTISTSORT`, `ALBUMSORT` | the standard sort atoms `soar`, `soaa`, `soal` |

## Supported Codecs

| Codec | Extension | Bitrate | Artwork | Description |
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.5.2
image: drumsergio/audio-transcoder:0.6.0
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.5.2
drumsergio/audio-transcoder:0.6.0
```

15 changes: 11 additions & 4 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# How it works

1. **Initial sync** -- On startup, scans the source folder and encodes any missing files to all configured outputs.
1. **Initial sync** -- On startup, scans the source folder and encodes any missing files to all configured outputs. The same pass runs again every `sync_interval_seconds` (default 300) and logs as "Periodic sync".
2. **Watch mode** -- Continuously monitors the source folder for changes:
- **New files** are encoded to all configured outputs
- **Modified files** are re-encoded to all outputs
- **Renamed files** trigger deletion of old outputs and creation of new ones
- **Deleted files** have their corresponding outputs removed
3. **Orphan cleanup** -- Removes output files that no longer have a matching source.
4. **Lyrics sync** -- Fetches synced `.lrc` lyrics from online databases; falls back to Whisper transcription when no lyrics are found.
3. **Orphan cleanup** -- Removes output files that no longer have a matching source. It reads the source folder again just before, and skips any file younger than 120 seconds or whose source is being processed, so a file that arrives during a long sync keeps its output.
4. **Lyrics sync** -- Fetches synced `.lrc` lyrics from online databases (LRCLIB and the other syncedlyrics providers). When nothing usable comes back, no file is written. A result made of one repeated token, with fewer than 4 timed lines, or with only an advertisement line is rejected and the reason is logged. The `.lrc` gets the source file's owner and mode 0664.

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

## 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.

## Recursive Directory Support

Expand Down Expand Up @@ -35,7 +41,8 @@ The service includes multiple guards to prevent data loss:

- If the source folder appears empty, no deletions are performed
- If any output folder appears empty, no deletions are performed
- All writes are atomic -- encoding happens to a temporary file that is moved into place only on success
- All writes are atomic -- encoding and copying happen to a `.tmp__ff` file that is moved into place only on success
- Cleanup leaves `.tmp__ff` files younger than 10 minutes alone, since they may belong to an encode still running

## Performance

Expand Down
3 changes: 1 addition & 2 deletions 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.5.2"
version = "0.6.0"

description = "Watch a source folder and automatically transcode audio files to multiple formats"
readme = "README.md"
Expand All @@ -30,7 +30,6 @@ dependencies = [
"pyyaml>=6.0",
"mutagen>=1.47.0",
"syncedlyrics>=1.0.0",
"openai-whisper>=20240930",
]

[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.1.2"
__version__ = "0.6.0"
Loading
Loading