Root cause of Sokhi's endless 0-findings: the SCAN resolved each track's path through the path-mapping layer with NO fallback, while the APPLY (_fix_missing_cover_art) uses `_resolve_file_path(...) or p` — i.e. it falls back to the raw DB path when mapping returns nothing. On his Docker setup the mapping returns None, so the scan set has_local=False and skipped every album (never looking at the folder), even though the apply WOULD have written the cover.jpg from the raw path. Fix: make the scan match the apply — if mapping returns nothing but the raw DB path is a real file (container path == stored path), use it as-is. Now the scan actually inspects the folder, sees the missing cover.jpg, and flags it; the apply then writes it from the embedded art. Tests: unresolved-path-but-real-file → flagged (sidecar_from_embedded); the fallback does NOT fire for a non-existent path (media-server-only stays skipped). Kept the [cover-diag] logging from the prior commit to confirm on Sokhi's run.
522 lines
25 KiB
Python
522 lines
25 KiB
Python
"""Missing Cover Art Filler Job — finds albums without artwork and locates art from APIs."""
|
|
|
|
import os
|
|
import re
|
|
|
|
from core.metadata.art_apply import file_has_embedded_art, folder_has_cover_sidecar
|
|
from core.library.path_resolver import resolve_library_file_path
|
|
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
|
|
from core.repair_jobs import register_job
|
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("repair_job.cover_art")
|
|
|
|
# Stopwords dropped before comparing album/artist names so trivial words
|
|
# ("the", "and") don't make two different names look like a match.
|
|
_NAME_STOPWORDS = {'the', 'a', 'an', 'and', 'of', 'feat', 'ft', 'featuring'}
|
|
|
|
|
|
def _norm_name(value) -> str:
|
|
"""Lowercase, strip bracketed qualifiers (Deluxe/Remaster/feat.) and
|
|
punctuation so names can be compared on their significant words."""
|
|
s = (value or '').lower()
|
|
s = re.sub(r'[\(\[\{].*?[\)\]\}]', ' ', s) # drop (...) [...] qualifiers
|
|
s = re.sub(r'\b(?:feat|ft|featuring)\b.*', ' ', s) # drop trailing "feat. X"
|
|
s = re.sub(r'[^a-z0-9]+', ' ', s)
|
|
return ' '.join(s.split())
|
|
|
|
|
|
def _name_tokens(value) -> set:
|
|
return set(_norm_name(value).split()) - _NAME_STOPWORDS
|
|
|
|
|
|
def _names_match(a, b) -> bool:
|
|
"""True when two names share all the significant words of the shorter one
|
|
(so "Album" matches "Album (Deluxe)", but unrelated titles don't)."""
|
|
ta, tb = _name_tokens(a), _name_tokens(b)
|
|
if not ta or not tb:
|
|
return False
|
|
return ta <= tb or tb <= ta
|
|
|
|
|
|
@register_job
|
|
class MissingCoverArtJob(RepairJob):
|
|
job_id = 'missing_cover_art'
|
|
display_name = 'Cover Art Filler'
|
|
description = 'Finds albums missing artwork and locates art from metadata sources'
|
|
help_text = (
|
|
'Scans your library for albums that have no cover art stored in the database. '
|
|
'For each missing cover, it searches for matching artwork using the album name '
|
|
'and artist. If you have configured cover-art sources (Settings > metadata '
|
|
'enhancement art order), those are used first; otherwise it falls back to '
|
|
'Prefer Source (if set) or the primary metadata source.\n\n'
|
|
'When artwork is found, a finding is created with the image URL so you can review '
|
|
'and apply it. The job does not download or embed artwork automatically.\n\n'
|
|
'Settings:\n'
|
|
'- Prefer Source: Optional source to try first; otherwise the primary metadata source is used'
|
|
)
|
|
icon = 'repair-icon-coverart'
|
|
default_enabled = True
|
|
default_interval_hours = 48
|
|
default_settings = {}
|
|
auto_fix = False
|
|
|
|
def scan(self, context: JobContext) -> JobResult:
|
|
result = JobResult()
|
|
|
|
settings = self._get_settings(context)
|
|
primary_source = get_primary_source()
|
|
source_priority = get_source_priority(primary_source)
|
|
# User's configured cover-art source order (caa/deezer/itunes/spotify/
|
|
# audiodb). Empty by default → falls back to the source-priority loop.
|
|
art_order = (context.config_manager.get('metadata_enhancement.album_art_order')
|
|
if context.config_manager else None)
|
|
prefer_source = settings.get('prefer_source')
|
|
if prefer_source and prefer_source != primary_source and prefer_source in source_priority:
|
|
source_priority.remove(prefer_source)
|
|
source_priority.insert(0, prefer_source)
|
|
if primary_source in source_priority:
|
|
source_priority.remove(primary_source)
|
|
source_priority.insert(1, primary_source)
|
|
|
|
# Fetch albums with missing artwork
|
|
albums = []
|
|
conn = None
|
|
try:
|
|
conn = context.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("PRAGMA table_info(albums)")
|
|
album_columns = {column[1] for column in cursor.fetchall()}
|
|
|
|
select_cols = [
|
|
"al.id",
|
|
"al.title",
|
|
"ar.name",
|
|
"al.spotify_album_id",
|
|
"al.thumb_url",
|
|
"ar.thumb_url",
|
|
# A representative local track path, so we can check whether the
|
|
# album actually has art ON DISK (embedded / cover.jpg) — not just
|
|
# whether the DB row has a thumb_url.
|
|
("(SELECT t.file_path FROM tracks t WHERE t.album_id = al.id "
|
|
"AND t.file_path IS NOT NULL AND t.file_path != '' "
|
|
"ORDER BY t.disc_number, t.track_number LIMIT 1) AS rep_path"),
|
|
]
|
|
column_map = [
|
|
("itunes_album_id", "al.itunes_album_id"),
|
|
("deezer_album_id", "al.deezer_id"),
|
|
("discogs_album_id", "al.discogs_id"),
|
|
("hydrabase_album_id", "al.soul_id"),
|
|
]
|
|
column_index = {}
|
|
for alias, column in column_map:
|
|
if column.split('.', 1)[1] in album_columns:
|
|
column_index[alias] = len(select_cols)
|
|
select_cols.append(f"{column} AS {alias}")
|
|
|
|
# Scan every titled album — we decide per-album whether art is
|
|
# missing in the DB OR on disk (the file/cover.jpg). The on-disk
|
|
# check is cheap-first (a sidecar stat before opening any audio).
|
|
cursor.execute(f"""
|
|
SELECT {', '.join(select_cols)}
|
|
FROM albums al
|
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
|
WHERE al.title IS NOT NULL AND al.title != ''
|
|
""")
|
|
albums = cursor.fetchall()
|
|
except Exception as e:
|
|
logger.error("Error fetching albums without artwork: %s", e, exc_info=True)
|
|
result.errors += 1
|
|
return result
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
total = len(albums)
|
|
if context.update_progress:
|
|
context.update_progress(0, total)
|
|
|
|
logger.info("Found %d albums missing cover art", total)
|
|
download_folder = (context.config_manager.get('soulseek.download_path', '')
|
|
if context.config_manager else None)
|
|
# Skip-reason breakdown so a "0 findings" scan tells us WHY each album
|
|
# was skipped instead of guessing (Sokhi cover.jpg saga). Logged in the
|
|
# summary line below.
|
|
skip_reasons = {
|
|
'have_disk_art': 0, # local file has embedded art + sidecar (or sidecar not wanted)
|
|
'no_local_db_has_art': 0, # file path didn't resolve; DB already has a thumb
|
|
'no_art_source': 0, # needs fix, but no API art found and nothing embedded to extract
|
|
}
|
|
_diag_logged = 0
|
|
|
|
if context.report_progress:
|
|
context.report_progress(phase=f'Searching artwork for {total} albums...', total=total)
|
|
|
|
for i, row in enumerate(albums):
|
|
if context.check_stop():
|
|
return result
|
|
if i % 10 == 0 and context.wait_if_paused():
|
|
return result
|
|
|
|
album_id, title, artist_name, spotify_album_id, album_thumb, artist_thumb, rep_path = row[:7]
|
|
source_album_ids = {
|
|
'spotify': spotify_album_id,
|
|
'itunes': row[column_index['itunes_album_id']] if 'itunes_album_id' in column_index else None,
|
|
'deezer': row[column_index['deezer_album_id']] if 'deezer_album_id' in column_index else None,
|
|
'discogs': row[column_index['discogs_album_id']] if 'discogs_album_id' in column_index else None,
|
|
'hydrabase': row[column_index['hydrabase_album_id']] if 'hydrabase_album_id' in column_index else None,
|
|
}
|
|
result.scanned += 1
|
|
|
|
# Art can be missing in the DB (no thumb_url) and/or on disk (no
|
|
# embedded art and no cover.jpg). Skip albums that already have both.
|
|
db_missing = not (str(album_thumb).strip() if album_thumb else '')
|
|
# Resolve the representative path the SAME way the apply does
|
|
# (_fix_missing_cover_art) before checking disk art. Checking the raw
|
|
# DB path would fail on any path-mapped setup (docker mounts, a
|
|
# Plex/SoulSync path mismatch) — the file isn't found, album art
|
|
# reads as "missing", and EVERY album gets flagged while the apply
|
|
# (which resolves) then finds the art already present. Unresolvable →
|
|
# treat as no-local-file (don't claim disk-missing).
|
|
resolved_rep = resolve_library_file_path(
|
|
rep_path,
|
|
transfer_folder=getattr(context, 'transfer_folder', None),
|
|
download_folder=download_folder,
|
|
config_manager=context.config_manager,
|
|
) if rep_path else None
|
|
# Match the apply (_fix_missing_cover_art line ~1376: `... or p`): if
|
|
# the path-mapping layer returns nothing but the raw DB path is
|
|
# already a real file (the common Docker case where the container
|
|
# path == the stored path), use it as-is. Without this the scan never
|
|
# sees the folder and skips the album, while the apply WOULD have
|
|
# written the cover.jpg — the exact gap behind Sokhi's 0-findings.
|
|
if not resolved_rep and rep_path and os.path.isfile(rep_path):
|
|
resolved_rep = rep_path
|
|
has_local = bool(resolved_rep)
|
|
# Check embedded art and the cover.jpg sidecar SEPARATELY (not the
|
|
# combined album_has_art_on_disk, which returns True if EITHER is
|
|
# present). An album can have embedded art but no cover.jpg — and if
|
|
# the user wants cover.jpg files, that's still a fixable "missing"
|
|
# (Sokhi: scans returned 0 because embedded-art albums were treated
|
|
# as fully arted and skipped, so their cover.jpg never got written).
|
|
has_embedded = file_has_embedded_art(resolved_rep) if has_local else False
|
|
has_sidecar = folder_has_cover_sidecar(os.path.dirname(resolved_rep)) if has_local else False
|
|
# cover.jpg sidecars are only a "missing" thing when the user has
|
|
# cover.jpg writing enabled (Boulder: "only scan for cover.jpgs when
|
|
# they're enabled"). Default on.
|
|
cover_sidecar_enabled = bool(
|
|
context.config_manager.get('metadata_enhancement.cover_art_download', True)
|
|
if context.config_manager else True)
|
|
|
|
if has_local:
|
|
embed_missing = not has_embedded
|
|
sidecar_missing = cover_sidecar_enabled and not has_sidecar
|
|
# has_embedded + no sidecar → the apply writes cover.jpg from the
|
|
# existing embedded art, so it's fixable even if the API finds no
|
|
# art. embed_missing still requires API art (nothing to embed).
|
|
sidecar_from_embedded = sidecar_missing and has_embedded
|
|
needs_fix = embed_missing or sidecar_missing
|
|
else:
|
|
# Media-server-only album: the DB thumb is the only art.
|
|
embed_missing = db_missing
|
|
sidecar_from_embedded = False
|
|
needs_fix = db_missing
|
|
|
|
# Diagnostic: dump the decision inputs for the first few albums so a
|
|
# confusing "0 findings" scan reveals path-resolution + on-disk state
|
|
# (Sokhi: scans kept returning 0 with no way to see why).
|
|
if _diag_logged < 5:
|
|
logger.info(
|
|
"[cover-diag] album=%r db_path=%r resolved=%r local=%s embedded=%s "
|
|
"sidecar=%s db_missing=%s cover_enabled=%s needs_fix=%s",
|
|
title, rep_path, resolved_rep, has_local, has_embedded, has_sidecar,
|
|
db_missing, cover_sidecar_enabled, needs_fix)
|
|
_diag_logged += 1
|
|
|
|
if not needs_fix:
|
|
if not has_local:
|
|
skip_reasons['no_local_db_has_art'] += 1
|
|
else:
|
|
skip_reasons['have_disk_art'] += 1
|
|
result.skipped += 1
|
|
continue
|
|
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
scanned=i + 1, total=total,
|
|
phase=f'Searching {i + 1} / {total}',
|
|
log_line=f'Searching: {title or "Unknown"} — {artist_name or "Unknown"}',
|
|
log_type='info'
|
|
)
|
|
|
|
artwork_url = None
|
|
|
|
# Honor the user's configured cover-art sources first (the same
|
|
# metadata_enhancement.album_art_order the post-process embed and the
|
|
# Library Re-tag job use) so "cover art sources" means one thing
|
|
# app-wide. Non-breaking: returns None when no order is configured
|
|
# (the default), so we fall back to the prefer_source / metadata
|
|
# source-priority loop below — unchanged behavior for existing users.
|
|
try:
|
|
from core.metadata.art_lookup import select_preferred_art_url
|
|
artwork_url = select_preferred_art_url(
|
|
artist_name, title,
|
|
{'musicbrainz_release_id': None}, art_order,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("preferred cover-art lookup failed for album %s: %s", album_id, e)
|
|
|
|
# Try source-specific IDs first, then title/artist search, in priority order.
|
|
if not artwork_url:
|
|
for source in source_priority:
|
|
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
|
if artwork_url:
|
|
break
|
|
|
|
# Fixable if we found API art (to embed/write), OR it's just a
|
|
# missing cover.jpg on an album that already has embedded art — the
|
|
# apply writes the sidecar from that embedded art, no API art needed.
|
|
if artwork_url or sidecar_from_embedded:
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
log_line=(f'Found art: {title or "Unknown"}' if artwork_url
|
|
else f'Will write cover.jpg from embedded art: {title or "Unknown"}'),
|
|
log_type='success'
|
|
)
|
|
# Also search for an artist image so the finding can offer it as
|
|
# an independently-applyable target (Pache711). Searched even
|
|
# when the artist already has art, so a wrong existing image can
|
|
# be swapped; the UI only surfaces it when it differs from the
|
|
# current one.
|
|
found_artist_url = self._find_artist_art(artist_name, source_priority)
|
|
# Create finding for user to approve
|
|
if context.create_finding:
|
|
try:
|
|
_desc = (f'Album "{title}" by {artist_name or "Unknown"} has no cover art. '
|
|
+ ('Found artwork from API.' if artwork_url
|
|
else 'Will write cover.jpg from the existing embedded art.'))
|
|
inserted = context.create_finding(
|
|
job_id=self.job_id,
|
|
finding_type='missing_cover_art',
|
|
severity='info',
|
|
entity_type='album',
|
|
entity_id=str(album_id),
|
|
file_path=None,
|
|
title=f'Missing artwork: {title or "Unknown"}',
|
|
description=_desc,
|
|
details={
|
|
'album_id': album_id,
|
|
'album_title': title,
|
|
'artist': artist_name,
|
|
'found_artwork_url': artwork_url,
|
|
'spotify_album_id': spotify_album_id,
|
|
'artist_thumb_url': artist_thumb or None,
|
|
# Found artist image (None if none matched, or it
|
|
# equals the current one — nothing to offer then).
|
|
'found_artist_url': (
|
|
found_artist_url
|
|
if found_artist_url and found_artist_url != artist_thumb
|
|
else None
|
|
),
|
|
# Where the files live + what was missing, so the
|
|
# apply can embed into the audio + write cover.jpg.
|
|
'album_folder': os.path.dirname(rep_path) if rep_path else None,
|
|
'db_missing': db_missing,
|
|
'embed_missing': embed_missing,
|
|
'sidecar_from_embedded': sidecar_from_embedded,
|
|
'musicbrainz_release_id': None,
|
|
}
|
|
)
|
|
if inserted:
|
|
result.findings_created += 1
|
|
else:
|
|
result.findings_skipped_dedup += 1
|
|
except Exception as e:
|
|
logger.debug("Error creating cover art finding for album %s: %s", album_id, e)
|
|
result.errors += 1
|
|
else:
|
|
skip_reasons['no_art_source'] += 1
|
|
result.skipped += 1
|
|
|
|
if context.update_progress and (i + 1) % 5 == 0:
|
|
context.update_progress(i + 1, total)
|
|
|
|
if context.update_progress:
|
|
context.update_progress(total, total)
|
|
|
|
logger.info("Cover art scan: %d albums checked, %d found art, %d skipped",
|
|
result.scanned, result.findings_created, result.skipped)
|
|
if result.skipped:
|
|
logger.info(
|
|
"[cover-diag] skip breakdown — have_disk_art=%d (already have embedded+sidecar), "
|
|
"no_local_db_has_art=%d (file path didn't resolve, DB has thumb), "
|
|
"no_art_source=%d (needed art but none found/embedded)",
|
|
skip_reasons['have_disk_art'], skip_reasons['no_local_db_has_art'],
|
|
skip_reasons['no_art_source'])
|
|
return result
|
|
|
|
def _try_source(self, source, source_album_id, title, artist_name):
|
|
"""Try to get album art from a specific metadata source."""
|
|
client = get_client_for_source(source)
|
|
if not client:
|
|
return None
|
|
|
|
query = f"{artist_name} {title}" if artist_name else title
|
|
|
|
try:
|
|
if source_album_id:
|
|
album_data = self._get_album_for_source(source, client, source_album_id)
|
|
artwork_url = self._extract_artwork_url(album_data)
|
|
if artwork_url:
|
|
return artwork_url
|
|
|
|
if query and hasattr(client, 'search_albums'):
|
|
# Pull a few results and only accept one whose title AND artist
|
|
# actually match this album. The old code grabbed results[0]'s
|
|
# artwork unconditionally, so a loose full-text search returning
|
|
# the wrong album gave the wrong cover.
|
|
results = client.search_albums(query, limit=5) or []
|
|
for res in results:
|
|
if not self._result_matches(res, title, artist_name):
|
|
continue
|
|
artwork_url = self._extract_artwork_url(res)
|
|
if artwork_url:
|
|
return artwork_url
|
|
candidate_id = self._extract_album_id(res)
|
|
if candidate_id:
|
|
album_data = self._get_album_for_source(source, client, candidate_id)
|
|
artwork_url = self._extract_artwork_url(album_data)
|
|
if artwork_url:
|
|
return artwork_url
|
|
except Exception as e:
|
|
logger.debug("%s art lookup failed for '%s': %s", source.capitalize(), title, e)
|
|
return None
|
|
|
|
def _find_artist_art(self, artist_name, source_priority):
|
|
"""Search the configured sources for an artist image, in priority
|
|
order. Returns the first confidently name-matched artist image URL,
|
|
or None. Mirrors _try_source but for artists (Pache711: let the
|
|
Cover Art Filler offer artist art as its own fixable target)."""
|
|
if not artist_name:
|
|
return None
|
|
for source in source_priority:
|
|
client = get_client_for_source(source)
|
|
if not client or not hasattr(client, 'search_artists'):
|
|
continue
|
|
try:
|
|
for res in (client.search_artists(artist_name, limit=5) or []):
|
|
r_name = getattr(res, 'name', None)
|
|
if isinstance(res, dict):
|
|
r_name = res.get('name')
|
|
# Exact significant-word match — never hang a wrong artist
|
|
# photo on someone just because the search was fuzzy.
|
|
if not r_name or _name_tokens(r_name) != _name_tokens(artist_name):
|
|
continue
|
|
url = self._extract_artwork_url(res)
|
|
if url:
|
|
return url
|
|
except Exception as e:
|
|
logger.debug("%s artist-art lookup failed for '%s': %s",
|
|
source.capitalize(), artist_name, e)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _result_title_artist(item):
|
|
"""Pull (title, artist) from a search result that may be a dict or an
|
|
Album-like object, across the various source clients."""
|
|
if item is None:
|
|
return '', ''
|
|
if isinstance(item, dict):
|
|
title = item.get('title') or item.get('name') or item.get('album') or ''
|
|
artist = item.get('artist') or item.get('artist_name') or ''
|
|
if not artist:
|
|
artists = item.get('artists') or []
|
|
if isinstance(artists, list) and artists:
|
|
a0 = artists[0]
|
|
artist = a0.get('name', '') if isinstance(a0, dict) else str(a0)
|
|
else:
|
|
title = getattr(item, 'title', None) or getattr(item, 'name', None) or getattr(item, 'album', None) or ''
|
|
artist = getattr(item, 'artist', None) or getattr(item, 'artist_name', None) or ''
|
|
if not artist:
|
|
arts = getattr(item, 'artists', None) or []
|
|
if isinstance(arts, list) and arts:
|
|
a0 = arts[0]
|
|
artist = a0.get('name', '') if isinstance(a0, dict) else str(a0)
|
|
return str(title or ''), str(artist or '')
|
|
|
|
@classmethod
|
|
def _result_matches(cls, result, album_title, album_artist) -> bool:
|
|
"""Reject a search result unless it confidently matches the album.
|
|
|
|
Title must match; if both the result and the album carry an artist, the
|
|
artist must match too (the strongest guard against wrong covers). When
|
|
the result has no artist to compare, require an exact title match.
|
|
"""
|
|
r_title, r_artist = cls._result_title_artist(result)
|
|
# Title may carry extra qualifiers (Deluxe/Remaster) → allow subset.
|
|
if not _names_match(r_title, album_title):
|
|
return False
|
|
# Artist is the strong guard, so require its significant words to match
|
|
# EXACTLY (not subset) — "Different Artist" must NOT match "Artist".
|
|
if r_artist and album_artist:
|
|
return _name_tokens(r_artist) == _name_tokens(album_artist)
|
|
# No artist on the result → require an exact title match instead.
|
|
return _norm_name(r_title) == _norm_name(album_title)
|
|
|
|
@staticmethod
|
|
def _get_album_for_source(source, client, album_id):
|
|
if source == 'spotify':
|
|
return client.get_album(album_id)
|
|
return client.get_album(album_id, include_tracks=False)
|
|
|
|
@staticmethod
|
|
def _extract_album_id(item):
|
|
if hasattr(item, 'id'):
|
|
return getattr(item, 'id', None)
|
|
if isinstance(item, dict):
|
|
return item.get('id')
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_artwork_url(item):
|
|
if not item:
|
|
return None
|
|
if hasattr(item, 'image_url') and getattr(item, 'image_url', None):
|
|
return item.image_url
|
|
if isinstance(item, dict):
|
|
if item.get('image_url'):
|
|
return item['image_url']
|
|
images = item.get('images') or []
|
|
if images and isinstance(images, list):
|
|
first = images[0]
|
|
if isinstance(first, dict):
|
|
return first.get('url')
|
|
return None
|
|
|
|
def _get_settings(self, context: JobContext) -> dict:
|
|
if not context.config_manager:
|
|
return self.default_settings.copy()
|
|
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
|
|
merged = self.default_settings.copy()
|
|
merged.update(cfg)
|
|
return merged
|
|
|
|
def estimate_scope(self, context: JobContext) -> int:
|
|
conn = None
|
|
try:
|
|
conn = context.db._get_connection()
|
|
cursor = conn.cursor()
|
|
# Upper bound: every titled album is examined (the per-album DB/disk
|
|
# art check decides which actually need filling).
|
|
cursor.execute("""
|
|
SELECT COUNT(*) FROM albums
|
|
WHERE title IS NOT NULL AND title != ''
|
|
""")
|
|
row = cursor.fetchone()
|
|
return row[0] if row else 0
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
if conn:
|
|
conn.close()
|