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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ python scheduler.py collect-setlist # 단건 setlist 수집
| 단계 | 수집 주기 | 진입점 |
|------|---------|--------|
| ① MusicBrainz 아티스트 | 초기 1회 | `run_initial_collect()` |
| ② 공연 상태 갱신 | 매일 04:00 | `run_concert_status_update()` |
| ③ 신규 공연 수집·매칭 | 매일 04:30 | `run_new_concert_collect()` |
| ② 공연 상태 갱신 | 매일 00:00 | `run_concert_status_update()` |
| ③ 신규 공연 수집·매칭 | 매일 00:30 | `run_new_concert_collect()` |
| ④ 릴리즈 (앨범·트랙·커버) | 초기 + 매일 05:00 | `run_release_update()` |
| ⑤ 아티스트 이미지 | 매주 목 02:00 | `run_artist_image_update()` |
| ⑥ 로마자→한글 alias 변환 | 매주 목 03:00 | `run_ja_romanize_collect()` |
Expand Down
54 changes: 51 additions & 3 deletions collectors/musicbrainz.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ def _get(path: str, params: dict) -> dict:
return response.json()


def _with_retry(fn, retries: int = 3, backoff: float = 1.0):
"""5xx·네트워크 오류만 재시도한다. 4xx는 즉시 재발생시킨다."""
last_exc = None
for attempt in range(1, retries + 1):
try:
return fn()
except requests.HTTPError as e:
status = e.response.status_code if e.response is not None else None
if status is not None and 400 <= status < 500:
raise
last_exc = e
except requests.RequestException as e:
last_exc = e
if attempt < retries:
logger.debug("MusicBrainz 요청 재시도 %d/%d: %s", attempt, retries, last_exc)
time.sleep(backoff * attempt)
raise last_exc


def _search_artists(offset: int) -> dict:
return _get(
"/artist/",
Expand Down Expand Up @@ -127,13 +146,42 @@ def _parse_artist(detail: dict) -> dict:
}


_LUCENE_SPECIAL_CHARS = re.compile(r'([+\-&|!(){}\[\]^"~*?:\\/])')


def _escape_lucene(name: str) -> str:
return _LUCENE_SPECIAL_CHARS.sub(r"\\\1", name)


def search_artists(name: str) -> list[dict]:
"""아티스트명으로 MusicBrainz 검색. 최대 10건 반환."""
"""아티스트명으로 MusicBrainz 검색. 최대 10건 반환.

1차로 구문(phrase) 검색을 시도하고, 결과가 없으면 unquoted 쿼리로 폴백한다.
"""
escaped = _escape_lucene(name)
try:
data = _get("/artist/", {"query": f"artist:{name}", "fmt": "json", "limit": 10})
data = _with_retry(
lambda: _get(
"/artist/",
{"query": f'artist:"{escaped}"', "fmt": "json", "limit": 10},
)
)
except requests.RequestException as e:
logger.error("아티스트 검색 실패 name=%s: %s", name, e)
return []

if not data.get("artists"):
try:
data = _with_retry(
lambda: _get(
"/artist/",
{"query": f"artist:{escaped}", "fmt": "json", "limit": 10},
)
)
except requests.RequestException as e:
logger.error("아티스트 검색 실패(폴백) name=%s: %s", name, e)
return []

return [
{
"mbid": item.get("id"),
Expand All @@ -153,7 +201,7 @@ def collect_single_artist(mbid: str) -> Optional[dict]:
수집 실패 시 None을 반환한다.
"""
try:
detail = _fetch_artist_detail(mbid)
detail = _with_retry(lambda: _fetch_artist_detail(mbid))
except requests.RequestException as e:
logger.error("단건 아티스트 수집 실패 mbid=%s: %s", mbid, e)
return None
Expand Down
64 changes: 58 additions & 6 deletions db/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set:
"""수집된 공연 목록을 concert 테이블에 저장한다. kopis_id 중복 시 무시.

title·start_date·end_date가 모두 일치하는 공연이 이미 존재하면(예: 어드민이
kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛴다.
kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛰고, 기존 행의
kopis_id·kopis_update_date만 채워 KOPIS 연동 상태로 전환한다(다른 필드는
어드민 입력값을 유지하기 위해 건드리지 않음).

use_prfstate=True이면 prfstate → _KOPIS_STATUS_MAP으로 status를 결정한다.
기본값(False)은 배치 수집용 PENDING을 사용한다.
Expand All @@ -179,18 +181,50 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set:
text("""
SELECT id FROM concert
WHERE title = :title AND start_date = :start_date
AND end_date = :end_date
AND end_date = :end_date AND kopis_id IS NULL
"""),
{
"title": concert["prfnm"],
"start_date": concert["prfpdfrom"],
"end_date": concert["prfpdto"],
},
).fetchone()
if title_match is not None:
artist_match = None
if title_match is None and concert.get("_matched_artist_ids"):
artist_match = session.execute(
text("""
SELECT c.id FROM concert c
JOIN concert_artist ca ON ca.concert_id = c.id
WHERE ca.artist_id = ANY(:artist_ids)
AND c.start_date = :start_date AND c.end_date = :end_date
AND c.kopis_id IS NULL
LIMIT 1
"""),
{
"artist_ids": concert["_matched_artist_ids"],
"start_date": concert["prfpdfrom"],
"end_date": concert["prfpdto"],
},
).fetchone()

if title_match is not None or artist_match is not None:
matched_id = title_match[0] if title_match is not None else artist_match[0]
session.execute(
text("""
UPDATE concert
SET kopis_id = :kopis_id, kopis_update_date = :kopis_update_date
WHERE id = :id AND kopis_id IS NULL
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""),
{
"kopis_id": concert["kopis_id"],
"kopis_update_date": concert["updatedate"],
"id": matched_id,
},
)
logger.info(
"동일 공연명·기간 이미 존재 — 저장 건너뜀: title=%s, kopis_id=%s",
concert["prfnm"], concert["kopis_id"],
"동일 공연명·기간 또는 동일 아티스트·기간 이미 존재 — 저장 대신 "
"kopis_id 연동: title=%s, kopis_id=%s, concert_id=%s",
concert["prfnm"], concert["kopis_id"], matched_id,
)
duplicate_kopis_ids.add(concert["kopis_id"])
continue
Expand Down Expand Up @@ -721,14 +755,32 @@ def update_artist_is_coming() -> int:
return updated


def end_expired_concerts() -> int:
"""KOPIS 연동 여부와 무관하게 종료일이 지난 UPCOMING/ONGOING 공연을 ENDED로 전환한다."""
with get_session() as session:
result = session.execute(
text("""
UPDATE concert
SET status = 'ENDED'
WHERE status IN ('UPCOMING', 'ONGOING')
AND end_date < CURRENT_DATE
""")
)
updated = result.rowcount
logger.info("종료일 경과 공연 강제 종료 처리: %d건", updated)
return updated


def get_active_concerts() -> list[dict]:
"""status가 UPCOMING 또는 ONGOING인 공연의 kopis_id·kopis_update_date를 반환한다."""
"""status가 UPCOMING 또는 ONGOING이고 kopis_id가 있는 공연의 kopis_id·kopis_update_date를
반환한다."""
with get_session() as session:
rows = session.execute(
text("""
SELECT kopis_id, kopis_update_date
FROM concert
WHERE status IN ('UPCOMING', 'ONGOING')
AND kopis_id IS NOT NULL
""")
).fetchall()
return [
Expand Down
5 changes: 5 additions & 0 deletions matchers/artist_matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def has_match(concert_raw: dict, aliases: list[dict]) -> bool:
return bool(_phrase_match_title(title, aliases))


def matched_artist_ids(title: str, aliases: list[dict]) -> list[int]:
"""제목에서 매칭되는 artist_id 목록을 반환한다."""
return [a["artist_id"] for a in _phrase_match_title(title, aliases)]


def match_concert(concert: dict, aliases: list[dict]) -> tuple[list[dict], list[dict]]:
"""공연-아티스트 매칭 실행.

Expand Down
11 changes: 8 additions & 3 deletions scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from collectors import artist_image, ja_romanize, kopis, musicbrainz, release, setlist
from collectors.spotify_client import SpotifyRateLimitError
from db.repository import (
end_expired_concerts,
get_active_concerts,
get_all_aliases,
get_all_artist_mbids,
Expand Down Expand Up @@ -45,7 +46,7 @@
update_spotify_album_total,
upsert_artist_url,
)
from matchers.artist_matcher import has_match, match_concert
from matchers.artist_matcher import has_match, match_concert, matched_artist_ids
from notifier.discord import notify_new_concert

logging.basicConfig(
Expand Down Expand Up @@ -386,6 +387,8 @@ def run_concert_status_update() -> None:
with _job_timer("공연 상태 갱신 잡"):
logger.info("=== 공연 상태 갱신 잡 시작 ===")

end_expired_concerts()

active = get_active_concerts()
if active:
logger.info("활성 공연 %d건 상태 갱신 시작", len(active))
Expand Down Expand Up @@ -434,6 +437,8 @@ def run_new_concert_collect(stdate: Optional[str] = None, use_prfstate: bool = F
c for c in concerts
if c["kopis_id"] not in existing_ids and has_match(c, aliases)
]
for c in new_concerts:
c["_matched_artist_ids"] = matched_artist_ids(c["prfnm"], aliases)
if new_concerts:
logger.info("신규 공연 %d건 저장 시작", len(new_concerts))
save_concerts(new_concerts, use_prfstate=use_prfstate)
Expand Down Expand Up @@ -778,8 +783,8 @@ def collect_and_save_setlist(concert_id: int) -> dict:

def _build_scheduler() -> BackgroundScheduler:
scheduler = BackgroundScheduler(timezone="Asia/Seoul")
scheduler.add_job(run_concert_status_update, "cron", hour=4, minute=0)
scheduler.add_job(run_new_concert_collect, "cron", hour=4, minute=30)
scheduler.add_job(run_concert_status_update, "cron", hour=0, minute=0)
scheduler.add_job(run_new_concert_collect, "cron", hour=0, minute=30)
scheduler.add_job(run_release_update, "cron", hour=5)
scheduler.add_job(run_ja_romanize_collect, "cron", day_of_week="thu", hour=3)
scheduler.add_job(run_artist_image_update, "cron", day_of_week="thu", hour=2)
Expand Down
27 changes: 26 additions & 1 deletion tests/test_artist_matcher.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from matchers.artist_matcher import has_match, match_concert
from matchers.artist_matcher import has_match, match_concert, matched_artist_ids

_ALIASES = [
{"artist_id": 1, "name": "아이유"},
Expand Down Expand Up @@ -187,3 +187,28 @@ def test_empty_aliases_always_fails(self):

assert matches == []
assert failures == [{"concert_id": 41}]


class TestMatchedArtistIds:
def test_returns_matched_artist_ids(self):
"""매칭되는 alias가 있으면 해당 artist_id 리스트를 반환해야 한다."""
result = matched_artist_ids("BTS World Tour 콘서트", _ALIASES)

assert result == [2]

def test_returns_empty_list_when_no_match(self):
"""매칭 없으면 빈 리스트를 반환해야 한다."""
result = matched_artist_ids("전혀 관계없는 공연 제목 xyzxyz", _ALIASES)

assert result == []

def test_returns_all_matched_ids_for_joint_concert(self):
"""합동 공연처럼 복수 아티스트가 매칭되는 경우 전부 반환해야 한다."""
aliases = [
{"artist_id": 30, "name": "Perfume"},
{"artist_id": 31, "name": "BABYMETAL"},
]
result = matched_artist_ids("Perfume × BABYMETAL LIVE IN SEOUL", aliases)

assert set(result) == {30, 31}
assert len(result) == 2
Loading
Loading