Skip to content

Replace audio from a file; downloads keep hand-replaced files - #587

Merged
henriquesebastiao merged 1 commit into
henriquesebastiao:mainfrom
BigBrownMF69:feat/replace-audio-from-file
Nov 14, 2026
Merged

henriquesebastiao merged 1 commit into
henriquesebastiao:mainfrom
BigBrownMF69:feat/replace-audio-from-file

Conversation

@BigBrownMF69

@BigBrownMF69 BigBrownMF69 commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Covers ideas 4 and 5 of #582: Replace audio from a file, and downloads leave a replaced file alone.

Why

#582's One Dance case: the automatic match picked a parody, the user put the right file in by hand, and it came back. #584 fixed the part where a deleted duplicate is downloaded again. This PR fixes the other two parts:

  • The right version is often not on YouTube at all (a CD rip, a Soulseek download), and Replace audio could only take a YouTube or SoundCloud pick.
  • With Download again if the file exists on (the default), the next download of the same song, from another monitored playlist, an artist watch or a playlist download, writes over the replacement with whatever the automatic match finds.

What it does

Pick a file Replaced, and kept from downloads Opening it again

(A local test instance in Docker; the files are generated tones.)

From a file.

  • The Replace audio dialog gets an Or use a file from your device section. Pick a file, press Replace audio.
  • POST /api/library/replace/upload?file=&name=. The body is the raw file, not multipart, like the cookies and artist-image uploads, so no python-multipart.
  • It's streamed to a staging name beside the track that the library scan skips (<name>.downtify-upgrade.upload.<ext>), up to 400 MB.
  • Then it's checked: mutagen reads a length, and ffmpeg -xerror decodes it to the end, so a renamed non-audio file or a truncated one is refused.
  • A file in the track's own format is used bit for bit. Another format (.mp3 .flac .m4a .ogg .opus .wav .aiff .aac .webm) is converted to the track's format at the chosen bitrate. Either way the path doesn't change, so there's nothing to relink.
  • After that it's the existing flow: copy_tags, os.replace, the modification time kept, _after_replacement (metadata cache, listing, track index, owners rekey).
  • Also allowed in library-only mode, since nothing is fetched. library_only now lists /api/library/replace/candidates and POST /api/library/replace instead of the whole prefix.

Kept from downloads.

  • Every replacement is remembered by library path: YouTube, SoundCloud or file. The new downtify/hand_replaced.py keeps a hand_replaced_files table in the library db.
  • The downloader checks it only when overwrite_existing_files is on. With it off, nothing existing is overwritten anyway.
    • It checks before the search when the path is known early, so most Spotify-sourced songs don't even search.
    • It checks again after enrichment, when the path is complete.
    • A download that would land on a protected file returns that file as skipped ("Kept: audio replaced by hand"), so Playlist Monitor records the song as present.
  • Library upgrade's Deezer audio replacement skips protected files. Their cover, lyrics and tags can still be upgraded.
  • The dialog shows "You replaced this audio by hand on …" and Let downloads replace it, which calls DELETE /api/library/replace/protected. GET /api/library/replace/protected gives the status.

Not in this PR

Idea 6 of #582, remembering a rejected source. Replacing a file now protects it, which covers the case where the rejected video would come back over the same file. Remembering a rejected video per song goes further, and I'd like your view on #582 before doing that.

Tests

  • tests/test_hand_replaced.py, 15 tests:
    • the store;
    • a download in overwrite mode keeping a protected file, without reaching yt-dlp;
    • other files still downloaded again;
    • Library upgrade skipping protected audio;
    • same-format upload used as it is (byte check);
    • WAV → MP3 and MP3 → FLAC conversion;
    • non-audio refused with nothing changed;
    • the routes (upload, status, lift, refusals, admin-only);
    • a YouTube replacement protecting the file too;
    • library-only routing.
  • Full backend suite in Docker (python 3.13, with ffmpeg so the audio tests run): 3436 passed. The 13 failures are the music-trash tests, which fail the same way on main on my Windows/Docker setup. ruff check passes; ruff format --check only flags tests/test_slskd_poll_backoff.py, which is already on main.
  • Frontend: 736 passed (locale parity included). The build succeeds.
  • Docs tests: all replace-audio.md translations pass. The one failure is zh-CN/features/doctor.md, which already fails on main since docs: complete Simplified Chinese feature translations and fix iOS links #585 (it predates Doctor: merge duplicate songs, keep both, listen before deciding #584's link).
  • By hand in the Docker test instance, at phone width:
    • an MP3 onto an MP3: 40 s, 320 kbps, tags and date kept, no leftovers, protected;
    • a WAV onto an MP3: converted, 320 kbps, tags and date kept;
    • Let downloads replace it removing only that file's protection;
    • the notice shown when the dialog is reopened.
  • Not tested end to end: a real download landing on a protected file. That path is covered by the downloader unit tests only.

Strings are in all 12 locales. Docs: features/replace-audio.md, with two new sections in English and the 10 translated pages, and the API reference.

🤖 Generated with Claude Code

Replace audio can now take an audio file from the admin's own device
(a CD rip, a Soulseek download): same format is used as it is, another
is converted to the track's format, so the path never changes. Tags,
date and the staging/verification of the existing flow are reused.

Every replacement (YouTube, SoundCloud or file) is remembered by path
(downtify/hand_replaced.py). With "Download again if the file exists"
on, a download that would land on such a file counts it as already
downloaded instead, and Library upgrade's audio replacement skips it.
The dialog shows it and can lift it. Refs henriquesebastiao#582 (ideas 4 and 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codspeed

codspeed Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 10 untouched benchmarks


Comparing BigBrownMF69:feat/replace-audio-from-file (404aff5) with main (ead6db5)

Open in CodSpeed

@henriquesebastiao
henriquesebastiao merged commit c8b04f6 into henriquesebastiao:main Nov 14, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this to Done in Downtify Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants