Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04c36af6b3 | |||
| fdcba992fd |
@@ -70,15 +70,15 @@ Optional, add these later if you want them:
|
|||||||
|
|
||||||
### 1. Get the image
|
### 1. Get the image
|
||||||
|
|
||||||
Clone this repository onto your Docker host and build the image locally:
|
Pull the prebuilt image onto your Docker host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <this-repo-url> alembic
|
docker pull git.kretzer.club/andrew/alembic:0.5
|
||||||
cd alembic
|
|
||||||
docker build -t alembic:latest .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This takes a few minutes the first time. You don't need to touch anything inside the `app/` or `pipeline/` folders, the Dockerfile handles all of it.
|
That is the whole install. You do not need to download the source or build anything. The `0.5` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
|
||||||
|
|
||||||
|
(If you would rather build it yourself from source, you can, but you do not need to.)
|
||||||
|
|
||||||
### 2. Create your folders
|
### 2. Create your folders
|
||||||
|
|
||||||
@@ -131,12 +131,12 @@ In return, it will give you three values you'll need in the next step:
|
|||||||
|
|
||||||
### 5. Write your docker-compose file
|
### 5. Write your docker-compose file
|
||||||
|
|
||||||
Create a `docker-compose.yml` next to your alembic folder:
|
Create a file called `docker-compose.yml` on your server (put it wherever you keep your other compose files):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
alembic:
|
alembic:
|
||||||
image: alembic:latest
|
image: git.kretzer.club/andrew/alembic:0.5
|
||||||
container_name: alembic
|
container_name: alembic
|
||||||
ports:
|
ports:
|
||||||
- "8420:8420"
|
- "8420:8420"
|
||||||
@@ -220,15 +220,15 @@ That's it. On its next scheduled run (or immediately, using the **Run now** butt
|
|||||||
|
|
||||||
## Updating alembic
|
## Updating alembic
|
||||||
|
|
||||||
Pull the latest code, rebuild, and restart:
|
When a new version is released, change the version in your `docker-compose.yml` (for example `:0.5` to the new number), then pull and restart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd alembic
|
docker compose pull
|
||||||
git pull
|
docker compose up -d
|
||||||
docker build -t alembic:latest .
|
|
||||||
docker compose up -d --force-recreate alembic
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you used the `latest` tag instead of a version number, you can skip the edit and just run those two commands.
|
||||||
|
|
||||||
Your library, credentials, and settings all live in your mounted folders, so updating the image never touches your data.
|
Your library, credentials, and settings all live in your mounted folders, so updating the image never touches your data.
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|||||||
@@ -208,15 +208,33 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
|||||||
try:
|
try:
|
||||||
candidates = [db.get(DedupCandidate, cid) for cid in candidate_ids]
|
candidates = [db.get(DedupCandidate, cid) for cid in candidate_ids]
|
||||||
candidates = [c for c in candidates if c is not None and not c.applied]
|
candidates = [c for c in candidates if c is not None and not c.applied]
|
||||||
candidates = [c for c in candidates if Path(c.delete_path).exists() and Path(c.keep_path).exists()]
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# A candidate whose delete target no longer exists (already removed by an
|
||||||
|
# earlier dedup, the mp3->flac upgrade, or by hand) is effectively already
|
||||||
|
# resolved. Mark it applied so it clears from the pending queue, instead
|
||||||
|
# of the old behavior of filtering it out and silently no-oping -- which
|
||||||
|
# left such a stale candidate stuck in the list forever with no way to
|
||||||
|
# delete or clear it.
|
||||||
|
already_gone = [c for c in candidates if not Path(c.delete_path).exists()]
|
||||||
|
for c in already_gone:
|
||||||
|
c.confirmed = True
|
||||||
|
c.confirmed_by = confirmed_by
|
||||||
|
c.confirmed_at = now
|
||||||
|
c.applied = True
|
||||||
|
if already_gone:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# The rest need both files present for a safe delete.
|
||||||
|
actionable = [c for c in candidates if Path(c.delete_path).exists() and Path(c.keep_path).exists()]
|
||||||
|
|
||||||
by_script: dict[str, list[DedupCandidate]] = {}
|
by_script: dict[str, list[DedupCandidate]] = {}
|
||||||
for c in candidates:
|
for c in actionable:
|
||||||
by_script.setdefault(_script_for_pass(c.pass_name), []).append(c)
|
by_script.setdefault(_script_for_pass(c.pass_name), []).append(c)
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
finished_at = now
|
finished_at = now
|
||||||
log_paths = []
|
log_paths = []
|
||||||
ran_candidates: list[DedupCandidate] = []
|
ran_candidates: list[DedupCandidate] = []
|
||||||
@@ -246,7 +264,8 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
|||||||
ran_candidates.extend(group)
|
ran_candidates.extend(group)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if not ran_candidates:
|
# Nothing to report: no stale ones cleared and nothing ran.
|
||||||
|
if not already_gone and not ran_candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
still_there = {c.delete_path for c in ran_candidates if Path(c.delete_path).exists()}
|
still_there = {c.delete_path for c in ran_candidates if Path(c.delete_path).exists()}
|
||||||
@@ -261,7 +280,7 @@ async def confirm_and_apply(candidate_ids: list[int], confirmed_by: str) -> Dedu
|
|||||||
started_at=now,
|
started_at=now,
|
||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
mode="apply",
|
mode="apply",
|
||||||
deleted=actually_deleted,
|
deleted=actually_deleted + len(already_gone),
|
||||||
kept=len(ran_candidates) - actually_deleted,
|
kept=len(ran_candidates) - actually_deleted,
|
||||||
log_path=";".join(log_paths) or None,
|
log_path=";".join(log_paths) or None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,17 +130,19 @@ MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = {
|
|||||||
dict(minute=5, hour=9),
|
dict(minute=5, hour=9),
|
||||||
_lib("maintenance:export_laptop_playlists", "export-laptop-playlists.py"),
|
_lib("maintenance:export_laptop_playlists", "export-laptop-playlists.py"),
|
||||||
),
|
),
|
||||||
"maintenance:enrich_buy_url": (
|
|
||||||
dict(minute=10, hour=9),
|
|
||||||
# 30-min cap: this hits external buy-link APIs per track and has hung
|
|
||||||
# holding the pipeline lock (2026-07-09/10), starving every job after
|
|
||||||
# it. A timeout releases the lock so the rest of the 9am chain runs.
|
|
||||||
_lib("maintenance:enrich_buy_url", "enrich-buy-url.py", ["--apply"], timeout=1800),
|
|
||||||
),
|
|
||||||
"maintenance:build_fingerprint_index": (
|
"maintenance:build_fingerprint_index": (
|
||||||
dict(minute=25, hour=9),
|
dict(minute=25, hour=9),
|
||||||
_lib("maintenance:build_fingerprint_index", "build-fingerprint-index.py", ["--workers", "8"], timeout=3600),
|
_lib("maintenance:build_fingerprint_index", "build-fingerprint-index.py", ["--workers", "8"], timeout=3600),
|
||||||
),
|
),
|
||||||
|
"maintenance:enrich_buy_url": (
|
||||||
|
# Runs LAST in the 9am block: it hits external buy-link APIs per track
|
||||||
|
# and can run long, so scheduling it after the fingerprint index (09:25)
|
||||||
|
# and the status report (09:30) keeps it from starving them via the
|
||||||
|
# shared lock. The 30-min cap is a backstop; enrich-buy-url.py also now
|
||||||
|
# caches "tried, no match" so it stops re-querying the whole backlog.
|
||||||
|
dict(minute=45, hour=9),
|
||||||
|
_lib("maintenance:enrich_buy_url", "enrich-buy-url.py", ["--apply"], timeout=1800),
|
||||||
|
),
|
||||||
"maintenance:pipeline_status_report": (
|
"maintenance:pipeline_status_report": (
|
||||||
dict(minute=30, hour=9),
|
dict(minute=30, hour=9),
|
||||||
# Read-only (reads logs, pings Navidrome, sends Telegram). Runs WITHOUT
|
# Read-only (reads logs, pings Navidrome, sends Telegram). Runs WITHOUT
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ from pathlib import Path
|
|||||||
|
|
||||||
UA = "Mozilla/5.0 (X11; Linux x86_64) enrich-buy-url/1.0"
|
UA = "Mozilla/5.0 (X11; Linux x86_64) enrich-buy-url/1.0"
|
||||||
BUY_URL_TAG = "COMMERCIAL_INFORMATION"
|
BUY_URL_TAG = "COMMERCIAL_INFORMATION"
|
||||||
|
# Marker (unix timestamp) stamped on a FLAC when a buy-link lookup found
|
||||||
|
# nothing, so daily runs don't re-query the whole no-match backlog every time.
|
||||||
|
# Retried after the window below; --force ignores it.
|
||||||
|
BUY_URL_TRIED_TAG = "BUY_URL_TRIED"
|
||||||
|
_TRIED_RETRY_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _tried_recently(ts_str):
|
||||||
|
try:
|
||||||
|
return (time.time() - float(ts_str)) < _TRIED_RETRY_DAYS * 86400
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
ITUNES_SEARCH = "https://itunes.apple.com/search"
|
ITUNES_SEARCH = "https://itunes.apple.com/search"
|
||||||
BC_AUTOCOMPLETE = "https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic"
|
BC_AUTOCOMPLETE = "https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic"
|
||||||
QOBUZ_SEARCH = "https://www.qobuz.com/api.json/0.2/track/search"
|
QOBUZ_SEARCH = "https://www.qobuz.com/api.json/0.2/track/search"
|
||||||
@@ -451,6 +463,14 @@ def main():
|
|||||||
else:
|
else:
|
||||||
continue # already tagged, not up for upgrade
|
continue # already tagged, not up for upgrade
|
||||||
else:
|
else:
|
||||||
|
# Skip tracks we recently tried and found nothing for, so a daily
|
||||||
|
# run doesn't re-query the whole no-match backlog (external, rate-
|
||||||
|
# limited lookups) every time -- that churn is what pushed this job
|
||||||
|
# past its timeout. --force re-checks everything.
|
||||||
|
if not args.force:
|
||||||
|
tried = flac_tag(sp, BUY_URL_TRIED_TAG)
|
||||||
|
if tried and _tried_recently(tried):
|
||||||
|
continue
|
||||||
run_sources = cascade # untagged, or --force
|
run_sources = cascade # untagged, or --force
|
||||||
artist = flac_tag(sp, "ARTIST").split(";")[0].strip() or flac_tag(sp, "ALBUMARTIST")
|
artist = flac_tag(sp, "ARTIST").split(";")[0].strip() or flac_tag(sp, "ALBUMARTIST")
|
||||||
title = flac_tag(sp, "TITLE")
|
title = flac_tag(sp, "TITLE")
|
||||||
@@ -470,6 +490,10 @@ def main():
|
|||||||
|
|
||||||
rel = sp[len(args.library):].lstrip("/")
|
rel = sp[len(args.library):].lstrip("/")
|
||||||
if not url:
|
if not url:
|
||||||
|
# Record that we checked and found nothing, so the next daily run
|
||||||
|
# skips it (until the retry window). Apply mode only.
|
||||||
|
if args.apply:
|
||||||
|
set_flac_tag(sp, BUY_URL_TRIED_TAG, str(int(time.time())))
|
||||||
continue
|
continue
|
||||||
found += 1
|
found += 1
|
||||||
by_source[src] += 1
|
by_source[src] += 1
|
||||||
|
|||||||
@@ -315,8 +315,13 @@ dedup_today_status() {
|
|||||||
# silently drops Qobuz and falls through to iTunes.
|
# silently drops Qobuz and falls through to iTunes.
|
||||||
if [[ -f ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token ]]; then
|
if [[ -f ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token ]]; then
|
||||||
q_token=$(tr -d '\r\n' < ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)
|
q_token=$(tr -d '\r\n' < ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/token)
|
||||||
q_appid=$(tr -d '\r\n' < ${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/app_id 2>/dev/null)
|
# Guard the app_id read with a file-exists check: `< missing 2>/dev/null`
|
||||||
q_appid=${q_appid:-798273057}
|
# still leaks the shell's own "No such file" redirection error (the redirect
|
||||||
|
# is opened before 2>/dev/null takes effect). Most setups have a token but
|
||||||
|
# no separate app_id, so this fired on every run.
|
||||||
|
q_appid=798273057
|
||||||
|
q_appid_file="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/qobuz/app_id"
|
||||||
|
[[ -f "$q_appid_file" ]] && q_appid=$(tr -d '\r\n' < "$q_appid_file")
|
||||||
q_code=$(curl -s -o /dev/null --connect-timeout 5 --max-time 12 -A "Mozilla/5.0" \
|
q_code=$(curl -s -o /dev/null --connect-timeout 5 --max-time 12 -A "Mozilla/5.0" \
|
||||||
-H "X-App-Id: $q_appid" -H "X-User-Auth-Token: $q_token" -w '%{http_code}' \
|
-H "X-App-Id: $q_appid" -H "X-User-Auth-Token: $q_token" -w '%{http_code}' \
|
||||||
"https://www.qobuz.com/api.json/0.2/track/search?query=test&limit=1&app_id=$q_appid" 2>/dev/null)
|
"https://www.qobuz.com/api.json/0.2/track/search?query=test&limit=1&app_id=$q_appid" 2>/dev/null)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""confirm_and_apply: a candidate whose delete target is already gone must be
|
||||||
|
marked applied (cleared from the queue), not silently no-op'd and left stuck.
|
||||||
|
This is the 'can't delete this one' case that stranded a stale fuzzy candidate."""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import DedupCandidate, DedupRun
|
||||||
|
from app.services import dedup_review_service as d
|
||||||
|
|
||||||
|
|
||||||
|
def _make_candidate(keep_path: str, delete_path: str) -> int:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
run = DedupRun(started_at=time.time(), mode="dry_run")
|
||||||
|
db.add(run)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(run)
|
||||||
|
c = DedupCandidate(
|
||||||
|
dedup_run_id=run.id, pass_name="fuzzy_audio",
|
||||||
|
keep_path=keep_path, delete_path=delete_path, delete_id=None,
|
||||||
|
)
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return c.id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_gone_delete_path_is_resolved(tmp_path):
|
||||||
|
keep = tmp_path / "keep.flac"
|
||||||
|
keep.write_bytes(b"x") # keep still exists
|
||||||
|
gone = tmp_path / "gone.mp3" # delete target already removed
|
||||||
|
cid = _make_candidate(str(keep), str(gone))
|
||||||
|
|
||||||
|
# No actionable file to delete -> no subprocess/lock; the async call resolves
|
||||||
|
# the stale candidate directly.
|
||||||
|
result = asyncio.run(d.confirm_and_apply([cid], "tester"))
|
||||||
|
|
||||||
|
assert result is not None # a run row was produced (not a silent no-op)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
c = db.get(DedupCandidate, cid)
|
||||||
|
assert c.applied is True # cleared from the pending queue
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# and it no longer appears in the pending list
|
||||||
|
assert cid not in [c.id for c in d.list_pending_candidates()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignore_then_unignore(tmp_path):
|
||||||
|
cid = _make_candidate(str(tmp_path / "a.flac"), str(tmp_path / "b.mp3"))
|
||||||
|
assert d.ignore_candidate(cid, "tester").ignored is True
|
||||||
|
assert cid not in [c.id for c in d.list_pending_candidates()]
|
||||||
|
assert d.unignore_candidate(cid).ignored is False
|
||||||
|
assert cid in [c.id for c in d.list_pending_candidates()]
|
||||||
Reference in New Issue
Block a user