trackhound 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trackhound/__init__.py +13 -0
- trackhound/cli.py +133 -0
- trackhound/engine/__init__.py +35 -0
- trackhound/engine/batch.py +123 -0
- trackhound/engine/downloader.py +642 -0
- trackhound/engine/i18n.py +214 -0
- trackhound/engine/logs.py +35 -0
- trackhound/engine/loudness.py +231 -0
- trackhound/engine/matcher.py +250 -0
- trackhound/engine/models.py +54 -0
- trackhound/engine/net.py +43 -0
- trackhound/engine/sources.py +778 -0
- trackhound/engine/spotify.py +250 -0
- trackhound/gui.py +1017 -0
- trackhound/i18n.py +101 -0
- trackhound/logs.py +123 -0
- trackhound/watch.py +72 -0
- trackhound/web/app.js +1726 -0
- trackhound/web/i18n.js +402 -0
- trackhound/web/icon.ico +0 -0
- trackhound/web/index.html +539 -0
- trackhound/web/style.css +535 -0
- trackhound-1.3.0.dist-info/METADATA +89 -0
- trackhound-1.3.0.dist-info/RECORD +28 -0
- trackhound-1.3.0.dist-info/WHEEL +5 -0
- trackhound-1.3.0.dist-info/entry_points.txt +5 -0
- trackhound-1.3.0.dist-info/licenses/LICENSE +338 -0
- trackhound-1.3.0.dist-info/top_level.txt +1 -0
trackhound/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Download albums, singles, playlists and tracks by link, tagged and with cover art.
|
|
2
|
+
|
|
3
|
+
The engine (trackhound.engine) turns a link into files; the window (gui) and
|
|
4
|
+
the command line (cli) are built on top of it.
|
|
5
|
+
|
|
6
|
+
Copyright (C) 2026 mojave333. Trackhound is free software under the terms of
|
|
7
|
+
the GNU General Public License, version 2 or (at your option) any later
|
|
8
|
+
version; see the LICENSE file. It comes with no warranty.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# The single source of truth for the version: the release workflow refuses to
|
|
12
|
+
# build when the pushed tag says something else.
|
|
13
|
+
__version__ = "1.3.0"
|
trackhound/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, logs
|
|
11
|
+
from .i18n import LANGUAGES, set_language, t
|
|
12
|
+
from .engine import batch
|
|
13
|
+
from .engine.downloader import (DEFAULT_OUTPUT_DIR, FOLDER_NAMES, FORMATS, TRACK_NAMES, Downloader,
|
|
14
|
+
Options, use_proxy)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _language(argv: list[str] | None) -> str:
|
|
18
|
+
"""--lang, read before argparse so that even the help speaks it."""
|
|
19
|
+
words = list(sys.argv[1:] if argv is None else argv)
|
|
20
|
+
for index, word in enumerate(words):
|
|
21
|
+
if word == "--lang" and index + 1 < len(words):
|
|
22
|
+
return words[index + 1]
|
|
23
|
+
if word.startswith("--lang="):
|
|
24
|
+
return word.split("=", 1)[1]
|
|
25
|
+
return "system"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rate(value: str, parser: argparse.ArgumentParser) -> int:
|
|
29
|
+
""""500K", "2M", "1.5m" — bytes per second; empty means no limit."""
|
|
30
|
+
if not value:
|
|
31
|
+
return 0
|
|
32
|
+
m = re.fullmatch(r"\s*(\d+(?:[.,]\d+)?)\s*([kmg]?)b?/?s?\s*", value, re.I)
|
|
33
|
+
if not m:
|
|
34
|
+
parser.error(t("непонятная скорость: {value}. Примеры: 500K, 2M", value=value))
|
|
35
|
+
scale = {"": 1, "k": 1024, "m": 1024 ** 2, "g": 1024 ** 3}[m.group(2).lower()]
|
|
36
|
+
return int(float(m.group(1).replace(",", ".")) * scale)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
# Every message here is in Russian, which a console or a redirect on an
|
|
41
|
+
# English Windows cannot encode; --help must not die over that.
|
|
42
|
+
for stream in (sys.stdout, sys.stderr):
|
|
43
|
+
if hasattr(stream, "reconfigure"):
|
|
44
|
+
stream.reconfigure(errors="replace")
|
|
45
|
+
|
|
46
|
+
# The language is settled before argparse builds its help
|
|
47
|
+
set_language(_language(argv))
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="trackhound",
|
|
50
|
+
description=t("Скачивание альбомов, синглов и треков по ссылке из Spotify, Apple Music, "
|
|
51
|
+
"Deezer, YouTube, SoundCloud, Last.fm и сайтов вроде Bandcamp."),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("links", nargs="*",
|
|
54
|
+
help=t("ссылки на альбомы, плейлисты или треки — либо название: "
|
|
55
|
+
"«Исполнитель - Альбом»"))
|
|
56
|
+
parser.add_argument("-o", "--output", type=Path, default=DEFAULT_OUTPUT_DIR,
|
|
57
|
+
help=t("папка для музыки (по умолчанию {path})", path=DEFAULT_OUTPUT_DIR))
|
|
58
|
+
parser.add_argument("-f", "--format", choices=FORMATS, default="m4a",
|
|
59
|
+
help=t("формат файлов (по умолчанию m4a — без перекодирования)"))
|
|
60
|
+
parser.add_argument("-t", "--threads", type=int, default=3,
|
|
61
|
+
help=t("сколько треков качать одновременно (по умолчанию 3)"))
|
|
62
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
63
|
+
help=t("только показать найденные совпадения, ничего не скачивать"))
|
|
64
|
+
parser.add_argument("--cookies-from-browser", default="", metavar=t("БРАУЗЕР"),
|
|
65
|
+
help=t("брать cookies из браузера (chrome, edge, firefox…): нужно для видео "
|
|
66
|
+
"с возрастным ограничением"))
|
|
67
|
+
parser.add_argument("--names", choices=TRACK_NAMES, default="auto",
|
|
68
|
+
help=t("имена файлов: auto — исполнитель только у гостей, artist — всегда, "
|
|
69
|
+
"title — только номер и название"))
|
|
70
|
+
parser.add_argument("--folders", choices=FOLDER_NAMES, default="flat",
|
|
71
|
+
help=t("папки альбомов: flat — «Исполнитель - Альбом (Год)», "
|
|
72
|
+
"nested — «Исполнитель\Альбом (Год)», album — «Альбом (Год)»"))
|
|
73
|
+
parser.add_argument("--limit-rate", default="", metavar=t("СКОРОСТЬ"),
|
|
74
|
+
help=t("ограничить скорость: 500K, 2M (по умолчанию без ограничения)"))
|
|
75
|
+
parser.add_argument("--proxy", default="", metavar=t("АДРЕС"),
|
|
76
|
+
help=t("прокси для метаданных и загрузки: http://127.0.0.1:1080, socks5://…"))
|
|
77
|
+
parser.add_argument("--lang", choices=LANGUAGES, default="system", metavar=t("ЯЗЫК"),
|
|
78
|
+
help=t("язык интерфейса и сообщений"))
|
|
79
|
+
parser.add_argument("--from-file", action="append", default=[], type=Path, metavar=t("ФАЙЛ"),
|
|
80
|
+
help=t("взять ссылки и названия из файла: .txt по одной на строку или CSV-выгрузка плейлиста"))
|
|
81
|
+
parser.add_argument("--replaygain", action="store_true",
|
|
82
|
+
help=t("измерить громкость и записать теги ReplayGain; сам звук не меняется"))
|
|
83
|
+
parser.add_argument("--check", action="store_true",
|
|
84
|
+
help=t("показать версию и какие ffmpeg и Deno нашлись, ничего не скачивая"))
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
logs.setup(console=False) # warnings already reach the console as text
|
|
87
|
+
|
|
88
|
+
options = Options(args.output.expanduser(), args.format, max(1, args.threads), args.dry_run,
|
|
89
|
+
args.cookies_from_browser, args.names, args.folders,
|
|
90
|
+
_rate(args.limit_rate, parser), args.proxy, args.replaygain)
|
|
91
|
+
use_proxy(options.proxy)
|
|
92
|
+
downloader = Downloader(options, log=lambda message: print(message, flush=True))
|
|
93
|
+
|
|
94
|
+
if args.check:
|
|
95
|
+
print(f"Trackhound {__version__}")
|
|
96
|
+
print(f"ffmpeg: {downloader.ffmpeg or t('не найден')}")
|
|
97
|
+
for name in ("deno", "node"):
|
|
98
|
+
print(f"{name}: {downloader.js_runtimes.get(name, {}).get('path', t('не найден'))}")
|
|
99
|
+
print(t("папка для музыки: {path}", path=options.output_dir))
|
|
100
|
+
print(f"yt-dlp: {logs.package_version('yt_dlp')}")
|
|
101
|
+
print(t("журнал: {path}", path=logs.log_file()))
|
|
102
|
+
for problem in downloader.environment_problems():
|
|
103
|
+
print(f"⚠ {problem}")
|
|
104
|
+
return 0
|
|
105
|
+
for list_file in args.from_file:
|
|
106
|
+
try:
|
|
107
|
+
entries, skipped = batch.read(list_file.read_bytes(), list_file.name)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
parser.error(t("не прочитать {file}: {error}", file=list_file, error=e))
|
|
110
|
+
print(t("{file}: взято {count}, не разобрано строк: {skipped}",
|
|
111
|
+
file=list_file.name, count=len(entries), skipped=skipped))
|
|
112
|
+
args.links.extend(entries)
|
|
113
|
+
if not args.links:
|
|
114
|
+
parser.error(t("укажите ссылку или название (или --check, чтобы проверить установку)"))
|
|
115
|
+
|
|
116
|
+
for problem in downloader.environment_problems():
|
|
117
|
+
print(f"⚠ {problem}", file=sys.stderr)
|
|
118
|
+
|
|
119
|
+
failed = 0
|
|
120
|
+
try:
|
|
121
|
+
for link in args.links:
|
|
122
|
+
try:
|
|
123
|
+
report = downloader.download_link(link)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"✗ {e}", file=sys.stderr)
|
|
126
|
+
failed += 1
|
|
127
|
+
continue
|
|
128
|
+
print(report.summary(args.dry_run), end="\n\n")
|
|
129
|
+
failed += len(report.failed)
|
|
130
|
+
except KeyboardInterrupt:
|
|
131
|
+
print(t("\nОстановлено."), file=sys.stderr)
|
|
132
|
+
return 130
|
|
133
|
+
return 1 if failed else 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The engine: a link or a name in, tagged music files out.
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from trackhound.engine import Downloader, Options, resolve
|
|
5
|
+
|
|
6
|
+
release = resolve("https://www.deezer.com/album/302127")
|
|
7
|
+
print(release.album.name, [track.title for track in release.tracks])
|
|
8
|
+
|
|
9
|
+
report = Downloader(Options(output_dir=Path("Music"))).download_link(
|
|
10
|
+
"https://open.spotify.com/album/2noRn2Aes5aoNVsU6iWThc")
|
|
11
|
+
print(report.summary())
|
|
12
|
+
|
|
13
|
+
What is importable from here is the public interface, and it keeps working
|
|
14
|
+
across minor versions. The modules behind it (trackhound.engine.sources,
|
|
15
|
+
.matcher and the rest) may change in any release.
|
|
16
|
+
|
|
17
|
+
The engine knows nothing about the window or the command line, and sets up no
|
|
18
|
+
logging of its own: it writes to the "trackhound" logger and reports progress
|
|
19
|
+
through the callbacks given to Downloader. Its messages speak Russian or
|
|
20
|
+
English, the system's language unless set_language() says otherwise.
|
|
21
|
+
ffmpeg and a JavaScript runtime (Deno, or Node.js 22+) are looked for on PATH.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from . import batch
|
|
25
|
+
from .downloader import (DEFAULT_OUTPUT_DIR, FOLDER_NAMES, FORMATS, TRACK_NAMES, Downloader,
|
|
26
|
+
DownloaderError, Options, Report, find_tool, use_proxy)
|
|
27
|
+
from .i18n import set_language
|
|
28
|
+
from .models import Album, Release, SourceError, Track
|
|
29
|
+
from .sources import resolve, search, search_track
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_OUTPUT_DIR", "FOLDER_NAMES", "FORMATS", "TRACK_NAMES",
|
|
33
|
+
"Album", "Downloader", "DownloaderError", "Options", "Release", "Report", "SourceError", "Track",
|
|
34
|
+
"batch", "find_tool", "resolve", "search", "search_track", "set_language", "use_proxy",
|
|
35
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Reading a list of things to download out of a file.
|
|
2
|
+
|
|
3
|
+
Two shapes are understood. A plain text file holds one entry per line: a link,
|
|
4
|
+
or a name the way it would be typed into the window, with blank lines and lines
|
|
5
|
+
starting with # ignored. A CSV file is what the playlist exporters write —
|
|
6
|
+
Exportify, TuneMyMusic, Soundiiz — and is read by its column names: a link or
|
|
7
|
+
URI column when there is one, otherwise the track and artist columns.
|
|
8
|
+
|
|
9
|
+
A row of a CSV export is a single song, so an entry built from its names is
|
|
10
|
+
marked "track:". Searching by name prefers an album over a track, which is
|
|
11
|
+
right for something typed into the window and wrong here: a playlist that has
|
|
12
|
+
the title track of an album would otherwise download the whole album.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import io
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
LIMIT = 500 # entries taken from one file
|
|
22
|
+
TRACK_PREFIX = "track:"
|
|
23
|
+
_LINK_RE = re.compile(r"https?://\S+|spotify:(?:album|track|playlist):[A-Za-z0-9]{22}", re.I)
|
|
24
|
+
|
|
25
|
+
# Header names; both sides are reduced by _key() before they are compared
|
|
26
|
+
_LINK_COLUMNS = ("Track URI", "Spotify URI", "URI", "Track URL", "Spotify URL", "URL", "Link",
|
|
27
|
+
"Spotify Track URI", "Spotify - id")
|
|
28
|
+
_TRACK_COLUMNS = ("Track Name", "Track", "Title", "Song", "Song Name", "Name")
|
|
29
|
+
_ARTIST_COLUMNS = ("Artist Name(s)", "Artist Name", "Artists", "Artist", "Album Artist")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse(text: str, name: str = "") -> tuple[list[str], int]:
|
|
33
|
+
"""The entries in a list, in order and without repeats, and how many lines
|
|
34
|
+
were not understood."""
|
|
35
|
+
text = text.lstrip("")
|
|
36
|
+
lower = name.lower()
|
|
37
|
+
if lower.endswith(".csv") or (not lower.endswith(".txt") and _looks_like_csv(text)):
|
|
38
|
+
entries, skipped = _from_csv(text)
|
|
39
|
+
else:
|
|
40
|
+
entries, skipped = _from_lines(text)
|
|
41
|
+
seen, unique = set(), []
|
|
42
|
+
for entry in entries:
|
|
43
|
+
key = entry.casefold()
|
|
44
|
+
if key not in seen:
|
|
45
|
+
seen.add(key)
|
|
46
|
+
unique.append(entry)
|
|
47
|
+
return unique[:LIMIT], skipped
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read(data: bytes, name: str = "") -> tuple[list[str], int]:
|
|
51
|
+
"""Like parse(), from the bytes of a file in whatever encoding it came in."""
|
|
52
|
+
for encoding in ("utf-8-sig", "cp1251"):
|
|
53
|
+
try:
|
|
54
|
+
return parse(data.decode(encoding), name)
|
|
55
|
+
except UnicodeDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
return parse(data.decode("latin-1"), name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _key(name: str) -> str:
|
|
61
|
+
"""A header reduced to its letters, so "Artist Name(s)" and "artist names" meet."""
|
|
62
|
+
return " ".join(re.sub(r"[^\w ]+", "", (name or "").lower()).split())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _from_lines(text: str) -> tuple[list[str], int]:
|
|
66
|
+
entries, skipped = [], 0
|
|
67
|
+
for raw in text.splitlines():
|
|
68
|
+
line = raw.strip()
|
|
69
|
+
if not line or line.startswith("#"):
|
|
70
|
+
continue
|
|
71
|
+
links = _LINK_RE.findall(line)
|
|
72
|
+
if links:
|
|
73
|
+
entries.extend(link.rstrip(".,;)") for link in links)
|
|
74
|
+
elif len(line) >= 2:
|
|
75
|
+
entries.append(" ".join(line.split()))
|
|
76
|
+
else:
|
|
77
|
+
skipped += 1
|
|
78
|
+
return entries, skipped
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _looks_like_csv(text: str) -> bool:
|
|
82
|
+
first = next((line for line in text.splitlines() if line.strip()), "")
|
|
83
|
+
if not any(mark in first for mark in (",", ";", "\t")):
|
|
84
|
+
return False
|
|
85
|
+
cells = {_key(cell) for cell in re.split(r"[,;\t]", first)}
|
|
86
|
+
known = {_key(name) for name in _TRACK_COLUMNS + _LINK_COLUMNS}
|
|
87
|
+
return bool(cells & known)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _from_csv(text: str) -> tuple[list[str], int]:
|
|
91
|
+
try:
|
|
92
|
+
dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t")
|
|
93
|
+
except csv.Error:
|
|
94
|
+
dialect = csv.excel
|
|
95
|
+
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
|
|
96
|
+
columns = {_key(name): name for name in reader.fieldnames or []}
|
|
97
|
+
|
|
98
|
+
def column(candidates):
|
|
99
|
+
return next((columns[_key(name)] for name in candidates if _key(name) in columns), None)
|
|
100
|
+
|
|
101
|
+
link_col, track_col, artist_col = column(_LINK_COLUMNS), column(_TRACK_COLUMNS), column(_ARTIST_COLUMNS)
|
|
102
|
+
|
|
103
|
+
entries, skipped = [], 0
|
|
104
|
+
for row in reader:
|
|
105
|
+
link = (row.get(link_col) or "").strip() if link_col else ""
|
|
106
|
+
if _LINK_RE.fullmatch(link):
|
|
107
|
+
entries.append(link)
|
|
108
|
+
continue
|
|
109
|
+
track = " ".join((row.get(track_col) or "").split()) if track_col else ""
|
|
110
|
+
if track:
|
|
111
|
+
# Exporters put every artist in one cell; a search for the song goes
|
|
112
|
+
# by the first of them.
|
|
113
|
+
artists = (row.get(artist_col) or "").strip() if artist_col else ""
|
|
114
|
+
artist = re.split(r"\s*[,;]\s*", artists)[0] if artists else ""
|
|
115
|
+
entries.append(f"{TRACK_PREFIX}{artist} - {track}" if artist else f"{TRACK_PREFIX}{track}")
|
|
116
|
+
continue
|
|
117
|
+
found = next((match.group(0) for cell in row.values() if isinstance(cell, str)
|
|
118
|
+
for match in [_LINK_RE.search(cell)] if match), None)
|
|
119
|
+
if found:
|
|
120
|
+
entries.append(found)
|
|
121
|
+
elif any(isinstance(cell, str) and cell.strip() for cell in row.values()):
|
|
122
|
+
skipped += 1
|
|
123
|
+
return entries, skipped
|