e5d9c7f043
- Add "Connect Spotify" OAuth flow (/connect/spotify) so newly created Spotify apps can read playlists: Spotify now requires a user token for playlist reads, which client-credentials alone can no longer provide. The stored refresh token is rendered as spotify-refresh into each playlist's sldl .conf -- sldl's own docs confirm supplying it skips its interactive login flow, which is what was causing multi-hour hangs on a stuck sync. Grandfathered apps keep working unchanged if no account is connected. - Fix the connect flow's redirect_uri: request.url_for() reflected the raw connection scheme (http) rather than what the reverse proxy actually served over, which Spotify's exact-match redirect_uri check rejected. Force https rather than trust the unproxied scheme. - Add an AzuraCast base URL credential field alongside the API key, with a keyfile fallback so the scheduled enrich-buy-url.py job can actually reach AzuraCast (previously it had no way to receive a base URL at all when run from the scheduler, so the reprocess call was silently always skipped). - Fix build-fingerprint-index.py exiting non-zero on any single fingerprint failure instead of only when nothing was written. - Guard enrich-buy-url.py's AzuraCast reprocess against an empty --azuracast-base (raised ValueError since PD2 removed the hardcoded base). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
511 lines
18 KiB
Python
Executable File
511 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
fix-track-metadata.py — interactively rewrite a single track's metadata
|
|
from a Spotify or iTunes URL.
|
|
|
|
Prompts for an audio file path and a Spotify/iTunes URL, then:
|
|
1. Fetches canonical metadata (artist, album, title, track#, date, cover).
|
|
2. Writes tags to the file (artist semicolon-joined, albumartist primary
|
|
only — matches the rest of the booksandtunes pipeline).
|
|
3. Strips SoundCloud / upload-junk frames (WWWAUDIOFILE, comment,
|
|
compatible_brands, major_brand, minor_version).
|
|
4. Replaces embedded cover with the URL's artwork and drops a cover.jpg
|
|
sidecar in the album folder.
|
|
5. Runs `beet update` so beets's DB matches.
|
|
6. Runs `beet move` to relocate the file under
|
|
/music/<albumartist>/<album>/<title>.<ext>.
|
|
7. Strips GENRE and runs spotify-genre.py to refill from the artist's
|
|
Spotify per-artist genres (use --keep-genre to skip this).
|
|
8. Triggers a Navidrome rescan via the Subsonic API.
|
|
|
|
Usage:
|
|
./fix-track-metadata.py # interactive
|
|
./fix-track-metadata.py <file> <url> # non-interactive
|
|
./fix-track-metadata.py --keep-genre <f> <u> # leave existing GENRE alone
|
|
|
|
The <file> arg accepts any of:
|
|
- absolute host path: $MUSIC_DATA_DIR/Library/Foo/Bar.flac
|
|
- beets container path: /music/Foo/Bar.flac
|
|
- library-relative path: Foo/Bar.flac
|
|
(resolved against $MUSIC_DATA_DIR/Library/)
|
|
|
|
Supported URLs:
|
|
https://open.spotify.com/track/<id>...
|
|
https://open.spotify.com/album/<id>... (single-track albums only)
|
|
https://music.apple.com/.../id<col>?i=<track> (iTunes track URL)
|
|
"""
|
|
import sys, os, re, json, base64, argparse, subprocess, unicodedata, urllib.parse, urllib.request
|
|
|
|
from _spotify_auth import get_token
|
|
|
|
from mutagen import File as MFile
|
|
from mutagen.id3 import ID3, ID3NoHeaderError, TPE1, TPE2, TALB, TIT2, TRCK, TDRC, TCON, APIC
|
|
from mutagen.flac import FLAC, Picture
|
|
from mutagen.mp4 import MP4, MP4Cover
|
|
from mutagen.oggopus import OggOpus
|
|
from mutagen.oggvorbis import OggVorbis
|
|
|
|
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
|
|
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
|
|
|
|
LIBRARY = f"{_MUSIC_DATA_DIR}/Library"
|
|
# Still "/music" through the migration's Stage 0-3 transitional beets mount;
|
|
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
|
|
CONTAINER_PREFIX = "/music"
|
|
SPOTIFY_ENV = f"{_ALEMBIC_CONFIG_DIR}/pipeline/_spotify.env"
|
|
SPOTIFY_GENRE = f"{os.environ.get('PIPELINE_DIR', '/app/pipeline')}/lib/spotify-genre.py"
|
|
|
|
|
|
def _load_navidrome_env():
|
|
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
|
|
env = {}
|
|
if os.path.exists(path):
|
|
for line in open(path):
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
env[k] = v.strip().strip("'").strip('"')
|
|
return env
|
|
|
|
_nd_env = _load_navidrome_env()
|
|
NAVIDROME_URL = f"{_nd_env.get('ND_BASE', 'http://navidrome:4533')}/rest/startScan.view"
|
|
NAVIDROME_USER = _nd_env.get("ND_USER", "")
|
|
NAVIDROME_PASS = _nd_env.get("ND_PASS", "")
|
|
|
|
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
|
|
JUNK_TXXX = {"WWWAUDIOFILE", "comment", "compatible_brands",
|
|
"major_brand", "minor_version"}
|
|
|
|
|
|
# ---------- Spotify ----------
|
|
|
|
def _spotify_token():
|
|
env = {}
|
|
with open(SPOTIFY_ENV) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
env[k] = v.strip().strip("'").strip('"')
|
|
return get_token(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"],
|
|
env.get("SPOTIFY_REFRESH_TOKEN") or None)
|
|
|
|
|
|
def _spotify_get(path, token):
|
|
req = urllib.request.Request(f"https://api.spotify.com/v1{path}",
|
|
headers={"Authorization": f"Bearer {token}"})
|
|
return json.loads(urllib.request.urlopen(req, timeout=15).read())
|
|
|
|
|
|
def fetch_spotify_track(track_id):
|
|
tok = _spotify_token()
|
|
t = _spotify_get(f"/tracks/{track_id}", tok)
|
|
a = t["album"]
|
|
return {
|
|
"title": t["name"],
|
|
"artists": [ar["name"] for ar in t["artists"]],
|
|
"album": a["name"],
|
|
"albumartists": [ar["name"] for ar in a["artists"]],
|
|
"date": a["release_date"],
|
|
"track": t["track_number"],
|
|
"tracktotal": a["total_tracks"],
|
|
"cover_url": a["images"][0]["url"] if a["images"] else None,
|
|
}
|
|
|
|
|
|
def fetch_spotify_album(album_id):
|
|
tok = _spotify_token()
|
|
a = _spotify_get(f"/albums/{album_id}", tok)
|
|
if a["total_tracks"] != 1:
|
|
raise SystemExit(f"album has {a['total_tracks']} tracks — pass a track URL instead")
|
|
t = a["tracks"]["items"][0]
|
|
return {
|
|
"title": t["name"],
|
|
"artists": [ar["name"] for ar in t["artists"]],
|
|
"album": a["name"],
|
|
"albumartists": [ar["name"] for ar in a["artists"]],
|
|
"date": a["release_date"],
|
|
"track": 1,
|
|
"tracktotal": 1,
|
|
"cover_url": a["images"][0]["url"] if a["images"] else None,
|
|
}
|
|
|
|
|
|
# ---------- iTunes ----------
|
|
|
|
def fetch_itunes_track(track_id):
|
|
url = f"https://itunes.apple.com/lookup?id={track_id}&entity=song"
|
|
data = json.loads(urllib.request.urlopen(url, timeout=15).read())
|
|
items = [r for r in data.get("results", []) if r.get("wrapperType") == "track"]
|
|
if not items:
|
|
raise SystemExit(f"iTunes lookup returned no track for id={track_id}")
|
|
r = items[0]
|
|
# iTunes only gives one artist string — the project convention is
|
|
# semicolon-joined for ARTIST. The user can split manually after if needed.
|
|
artists = [r["artistName"]]
|
|
cover = r.get("artworkUrl100", "")
|
|
if cover:
|
|
# 100x100 → 600x600 by URL substitution (well-known iTunes hack)
|
|
cover = re.sub(r"/\d+x\d+bb\.jpg$", "/600x600bb.jpg", cover)
|
|
return {
|
|
"title": r["trackName"],
|
|
"artists": artists,
|
|
"album": r["collectionName"],
|
|
"albumartists": artists,
|
|
"date": (r.get("releaseDate") or "")[:10],
|
|
"track": r.get("trackNumber", 1),
|
|
"tracktotal": r.get("trackCount", 1),
|
|
"cover_url": cover or None,
|
|
}
|
|
|
|
|
|
# ---------- URL dispatch ----------
|
|
|
|
def fetch_metadata(url):
|
|
m = re.search(r"open\.spotify\.com/track/([A-Za-z0-9]+)", url)
|
|
if m:
|
|
return fetch_spotify_track(m.group(1))
|
|
m = re.search(r"open\.spotify\.com/album/([A-Za-z0-9]+)", url)
|
|
if m:
|
|
return fetch_spotify_album(m.group(1))
|
|
m = re.search(r"music\.apple\.com/.+?[?&]i=(\d+)", url)
|
|
if m:
|
|
return fetch_itunes_track(m.group(1))
|
|
m = re.search(r"music\.apple\.com/.+?/song/[^/]+/(\d+)", url)
|
|
if m:
|
|
return fetch_itunes_track(m.group(1))
|
|
raise SystemExit(f"unrecognized URL: {url}")
|
|
|
|
|
|
# ---------- Tagging ----------
|
|
|
|
def write_tags(filepath, meta):
|
|
"""Write artist/albumartist/album/title/track/date and embedded cover.
|
|
Drops watermark/junk TXXX frames as a side effect.
|
|
"""
|
|
artist_joined = "; ".join(meta["artists"])
|
|
albumartist = meta["albumartists"][0] if meta["albumartists"] else meta["artists"][0]
|
|
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
|
|
date = meta["date"] # YYYY-MM-DD or YYYY
|
|
|
|
m = MFile(filepath)
|
|
if m is None:
|
|
raise SystemExit(f"unsupported audio format: {filepath}")
|
|
|
|
if isinstance(m, VORBIS_LIKE):
|
|
_write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes)
|
|
elif isinstance(m, MP4):
|
|
_write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes)
|
|
else:
|
|
_write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes)
|
|
|
|
|
|
def _write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes):
|
|
m.tags["ARTIST"] = artist_joined
|
|
m.tags["ALBUMARTIST"] = albumartist
|
|
m.tags["ALBUM"] = meta["album"]
|
|
m.tags["TITLE"] = meta["title"]
|
|
m.tags["TRACKNUMBER"] = str(meta["track"])
|
|
m.tags["TRACKTOTAL"] = str(meta["tracktotal"])
|
|
m.tags["DATE"] = date
|
|
if cover_bytes and isinstance(m, FLAC):
|
|
m.clear_pictures()
|
|
pic = Picture()
|
|
pic.type = 3 # Cover (front)
|
|
pic.mime = "image/jpeg"
|
|
pic.data = cover_bytes
|
|
m.add_picture(pic)
|
|
m.save()
|
|
|
|
|
|
def _write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes):
|
|
m.tags["\xa9ART"] = artist_joined
|
|
m.tags["aART"] = albumartist
|
|
m.tags["\xa9alb"] = meta["album"]
|
|
m.tags["\xa9nam"] = meta["title"]
|
|
m.tags["\xa9day"] = date
|
|
m.tags["trkn"] = [(int(meta["track"]), int(meta["tracktotal"]))]
|
|
if cover_bytes:
|
|
m.tags["covr"] = [MP4Cover(cover_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
|
|
m.save()
|
|
|
|
|
|
def _write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes):
|
|
try:
|
|
t = ID3(filepath)
|
|
except ID3NoHeaderError:
|
|
t = ID3()
|
|
t.delall("TPE1"); t.add(TPE1(encoding=3, text=artist_joined))
|
|
t.delall("TPE2"); t.add(TPE2(encoding=3, text=albumartist))
|
|
t.delall("TALB"); t.add(TALB(encoding=3, text=meta["album"]))
|
|
t.delall("TIT2"); t.add(TIT2(encoding=3, text=meta["title"]))
|
|
t.delall("TRCK"); t.add(TRCK(encoding=3, text=f"{meta['track']}/{meta['tracktotal']}"))
|
|
t.delall("TDRC"); t.add(TDRC(encoding=3, text=date))
|
|
# Strip junk TXXX frames
|
|
for frame in list(t.getall("TXXX")):
|
|
if frame.desc in JUNK_TXXX:
|
|
t.delall(f"TXXX:{frame.desc}")
|
|
if cover_bytes:
|
|
t.delall("APIC")
|
|
t.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_bytes))
|
|
t.save(filepath)
|
|
|
|
|
|
def _download_cover(url):
|
|
if not url:
|
|
return None
|
|
return urllib.request.urlopen(url, timeout=20).read()
|
|
|
|
|
|
# ---------- Beets / Navidrome ----------
|
|
|
|
def host_to_container(host_path):
|
|
if host_path.startswith(LIBRARY):
|
|
return CONTAINER_PREFIX + host_path[len(LIBRARY):]
|
|
return host_path
|
|
|
|
|
|
def container_to_host(cp):
|
|
if cp.startswith(CONTAINER_PREFIX + "/"):
|
|
return LIBRARY + cp[len(CONTAINER_PREFIX):]
|
|
return cp
|
|
|
|
|
|
def resolve_input_path(arg):
|
|
"""Accept a host path, container path, or library-relative path; return
|
|
the absolute host path that actually exists on disk.
|
|
|
|
Falls back through NFC/NFD Unicode normalizations because terminal pastes
|
|
of Japanese/Korean/etc. paths often come in decomposed form even when the
|
|
filesystem stores them composed (or vice versa)."""
|
|
arg = arg.strip().strip('"').strip("'")
|
|
if arg.startswith(CONTAINER_PREFIX + "/"):
|
|
candidate = container_to_host(arg)
|
|
elif os.path.isabs(arg):
|
|
candidate = arg
|
|
else:
|
|
candidate = os.path.join(LIBRARY, arg)
|
|
|
|
if os.path.exists(candidate):
|
|
return candidate
|
|
for form in ("NFC", "NFD", "NFKC", "NFKD"):
|
|
norm = unicodedata.normalize(form, candidate)
|
|
if os.path.exists(norm):
|
|
return norm
|
|
# Last resort: walk component by component, picking the entry whose NFC
|
|
# form matches our NFC form. Handles the common case where one directory
|
|
# in the path differs in normalization but the rest is fine.
|
|
if not candidate.startswith("/"):
|
|
return candidate
|
|
parts = candidate.lstrip("/").split("/")
|
|
cur = "/"
|
|
for want in parts:
|
|
if not os.path.isdir(cur):
|
|
return candidate
|
|
want_nfc = unicodedata.normalize("NFC", want)
|
|
match = next(
|
|
(n for n in os.listdir(cur)
|
|
if unicodedata.normalize("NFC", n) == want_nfc),
|
|
None)
|
|
if match is None:
|
|
return candidate
|
|
cur = os.path.join(cur, match)
|
|
return cur
|
|
|
|
|
|
def beets_id_for(host_path):
|
|
cp = host_to_container(host_path)
|
|
r = subprocess.run(
|
|
["beet", "ls", "-f", "$id|||$path", f"path:{cp}"],
|
|
capture_output=True, text=True, check=True)
|
|
for line in r.stdout.splitlines():
|
|
if "|||" in line:
|
|
id_str, p = line.split("|||", 1)
|
|
if p == cp:
|
|
return int(id_str)
|
|
return None
|
|
|
|
|
|
def beet_update(beets_id):
|
|
# `beet update` prompts y/n on each diff; `yes y` auto-confirms.
|
|
subprocess.run(
|
|
f"yes y | beet update id:{beets_id}",
|
|
shell=True, check=False)
|
|
|
|
|
|
def beet_move(beets_id):
|
|
"""Beets path-format relocates the file under LIBRARY/<albumartist>/<album>/."""
|
|
subprocess.run(["beet", "move", f"id:{beets_id}"], check=False)
|
|
|
|
|
|
def beet_path(beets_id):
|
|
r = subprocess.run(
|
|
["beet", "ls", "-f", "$path", f"id:{beets_id}"],
|
|
capture_output=True, text=True, check=True)
|
|
cp = r.stdout.strip()
|
|
return container_to_host(cp) if cp else None
|
|
|
|
|
|
def strip_genre(filepath):
|
|
"""Blank GENRE so spotify-genre.py will refill it (skip-existing logic)."""
|
|
m = MFile(filepath)
|
|
if m is None or m.tags is None:
|
|
return
|
|
if isinstance(m, VORBIS_LIKE):
|
|
if "genre" in m.tags:
|
|
del m.tags["genre"]
|
|
m.save()
|
|
elif isinstance(m, MP4):
|
|
if "\xa9gen" in m.tags:
|
|
del m.tags["\xa9gen"]
|
|
m.save()
|
|
else:
|
|
try:
|
|
t = ID3(filepath)
|
|
t.delall("TCON")
|
|
t.save(filepath)
|
|
except ID3NoHeaderError:
|
|
pass
|
|
|
|
|
|
def refill_genre(beets_id):
|
|
subprocess.run(
|
|
[SPOTIFY_GENRE, "--apply", "--force", "--query", f"id:{beets_id}"],
|
|
check=False)
|
|
|
|
|
|
def write_cover_sidecar(filepath, cover_bytes):
|
|
if not cover_bytes:
|
|
return
|
|
sidecar = os.path.join(os.path.dirname(filepath), "cover.jpg")
|
|
with open(sidecar, "wb") as f:
|
|
f.write(cover_bytes)
|
|
|
|
|
|
def navidrome_scan():
|
|
qs = urllib.parse.urlencode({
|
|
"u": NAVIDROME_USER, "p": NAVIDROME_PASS,
|
|
"v": "1.16.0", "c": "fix-track-metadata", "f": "json",
|
|
})
|
|
try:
|
|
urllib.request.urlopen(f"{NAVIDROME_URL}?{qs}", timeout=10).read()
|
|
print("[navidrome] rescan triggered")
|
|
except Exception as e:
|
|
print(f"[navidrome] rescan failed: {e}")
|
|
|
|
|
|
# ---------- Main ----------
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("file", nargs="?",
|
|
help="audio file: host path, /music/... container path, "
|
|
"or library-relative path (Foo/Bar.flac)")
|
|
ap.add_argument("url", nargs="?", help="Spotify or iTunes URL")
|
|
ap.add_argument("--keep-genre", action="store_true",
|
|
help="don't strip + refill GENRE (default: refill via spotify-genre.py)")
|
|
args = ap.parse_args()
|
|
|
|
# Tab-completion on the file prompt — pasting unicode paths is finicky,
|
|
# tab-completing them is not.
|
|
try:
|
|
import readline
|
|
readline.set_completer_delims(" \t\n;")
|
|
def _path_complete(text, state):
|
|
base = text if text.startswith("/") else os.path.join(LIBRARY, text)
|
|
d, prefix = os.path.split(base)
|
|
if not os.path.isdir(d):
|
|
return None
|
|
matches = [n for n in os.listdir(d) if n.startswith(prefix)]
|
|
matches.sort()
|
|
if state >= len(matches):
|
|
return None
|
|
full = os.path.join(d, matches[state])
|
|
# Strip the LIBRARY prefix back off if user was typing relative
|
|
display = full[len(LIBRARY) + 1:] if (
|
|
not text.startswith("/") and full.startswith(LIBRARY + "/")) else full
|
|
return display + ("/" if os.path.isdir(full) else "")
|
|
readline.set_completer(_path_complete)
|
|
readline.parse_and_bind("tab: complete")
|
|
except ImportError:
|
|
pass
|
|
|
|
raw_file = args.file or input("file path: ").strip()
|
|
url = args.url or input("spotify/itunes URL: ").strip()
|
|
filepath = resolve_input_path(raw_file)
|
|
|
|
if not os.path.isfile(filepath):
|
|
# Diagnostics: show the bytes we tried + what's actually in the parent.
|
|
print(f"[error] not a file: {filepath}", file=sys.stderr)
|
|
print(f"[error] input bytes: {raw_file.encode('utf-8')!r}", file=sys.stderr)
|
|
print(f"[error] resolved bytes: {filepath.encode('utf-8')!r}", file=sys.stderr)
|
|
parent = os.path.dirname(filepath)
|
|
if os.path.isdir(parent):
|
|
print(f"[error] parent dir contents:", file=sys.stderr)
|
|
for n in sorted(os.listdir(parent)):
|
|
print(f" {n!r} ({n.encode('utf-8')!r})", file=sys.stderr)
|
|
else:
|
|
# Walk up to the deepest existing ancestor and list it
|
|
anc = parent
|
|
while anc and anc != "/" and not os.path.isdir(anc):
|
|
anc = os.path.dirname(anc)
|
|
if anc and os.path.isdir(anc):
|
|
print(f"[error] deepest existing dir: {anc}", file=sys.stderr)
|
|
print(f"[error] its contents:", file=sys.stderr)
|
|
for n in sorted(os.listdir(anc)):
|
|
print(f" {n!r}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
print(f"[fetch] {url}")
|
|
meta = fetch_metadata(url)
|
|
print(f" title: {meta['title']}")
|
|
print(f" artists: {'; '.join(meta['artists'])}")
|
|
print(f" album: {meta['album']}")
|
|
print(f" albumartist: {meta['albumartists'][0] if meta['albumartists'] else '?'}")
|
|
print(f" date: {meta['date']}")
|
|
print(f" track: {meta['track']}/{meta['tracktotal']}")
|
|
print(f" cover: {meta['cover_url'] or '(none)'}")
|
|
|
|
if not args.file:
|
|
ok = input("apply? [y/N] ").strip().lower()
|
|
if ok != "y":
|
|
print("aborted")
|
|
return
|
|
|
|
print("[tags] writing")
|
|
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
|
|
write_tags(filepath, meta)
|
|
write_cover_sidecar(filepath, cover_bytes)
|
|
|
|
bid = beets_id_for(filepath)
|
|
if bid is None:
|
|
print("[beets] file not in beets DB — skipping update/move/genre")
|
|
else:
|
|
print(f"[beets] update id:{bid}")
|
|
beet_update(bid)
|
|
print(f"[beets] move id:{bid}")
|
|
beet_move(bid)
|
|
new_path = beet_path(bid)
|
|
if new_path:
|
|
filepath = new_path
|
|
# Re-drop sidecar in the (possibly new) folder
|
|
write_cover_sidecar(filepath, cover_bytes)
|
|
print(f"[beets] now at {filepath}")
|
|
|
|
if not args.keep_genre:
|
|
print("[genre] stripping + refilling via spotify-genre.py")
|
|
strip_genre(filepath)
|
|
beet_update(bid)
|
|
refill_genre(bid)
|
|
beet_update(bid)
|
|
|
|
navidrome_scan()
|
|
print("[done]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|