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 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