From b24bc0eb2990bc89caa0865a8a4ba5445d668b39 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 23 Jan 2022 09:14:40 +0100 Subject: [PATCH] Add option to define target filename --- CHANGELOG.md | 6 +++ README.md | 36 ++++++++++++++++ tests/test_utils.py | 13 ++++++ twitchdl/commands/download.py | 77 +++++++++++++++++++++-------------- twitchdl/console.py | 5 +++ twitchdl/utils.py | 17 +++++--- 6 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 tests/test_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725f38..6bda4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ Twitch Downloader change log ============================ +1.18.0 (TBA) +------------------- + +* Added `--output` option to `download` command which allows setting output file + template + 1.17.1 (2022-01-19) ------------------- diff --git a/README.md b/README.md index 25cbf30..185754f 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,42 @@ Setting quality to `source` will download the best available quality: twitch-dl download -q source 221837124 ``` +### Overriding file name + +The target filename can be defined by passing the `--output` option followed by +the desired file name, e.g. `--output strim.mkv`. + +The filename uses +[Python format string syntax](https://docs.python.org/3/library/string.html#format-string-syntax) +and may contain placeholders in curly braces which will be replaced with +relevant information tied to the downloaded video. + +The supported placeholders are: + +| Placeholder | Description | Example | +| ----------------- | ------------------------------ | ------------------------------ | +| `{id}` | Video ID | 1255522958 | +| `{title}` | Video title | Dark Souls 3 First playthrough | +| `{title_slug}` | Slugified video title | dark_souls_3_first_playthrough | +| `{datetime}` | Video date and time | 2022-01-07T04:00:27Z | +| `{date}` | Video date | 2022-01-07 | +| `{time}` | Video time | 04:00:27Z | +| `{channel}` | Channel name | KatLink | +| `{channel_login}` | Channel login | katlink | +| `{format}` | File extension, see `--format` | mkv | +| `{game}` | Game name | Dark Souls III | +| `{game_slug}` | Slugified game name | dark_souls_iii | + + +A couple of examples: + +Pattern: `"{date}_{id}_{channel_login}_{title_slug}.{format}"`
+Expands to: `2022-01-07_1255522958_katlink_dark_souls_3_first_playthrough.mkv`
+*This is the default.* + +Pattern: `"{channel} - {game} - {title}.{format}"`
+Expands to: `KatLink - Dark Souls III - Dark Souls 3 First playthrough.mkv` + ### Listing clips List clips for the given period: diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9f99922 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,13 @@ +from twitchdl.utils import titlify, slugify + + +def test_titlify(): + assert titlify("Foo Bar Baz.") == "Foo Bar Baz." + assert titlify("Foo (Bar) [Baz]") == "Foo (Bar) [Baz]" + assert titlify("Foo@{} Bar Baz!\"#$%&/=?*+'🔪") == "Foo Bar Baz" + + +def test_slugify(): + assert slugify("Foo Bar Baz") == "foo_bar_baz" + assert slugify(" Foo Bar Baz ") == "foo_bar_baz" + assert slugify("Foo@{}[] Bar Baz!\"#$%&/()=?*+'🔪") == "foo_bar_baz" diff --git a/twitchdl/commands/download.py b/twitchdl/commands/download.py index ccbd6c5..eac905f 100644 --- a/twitchdl/commands/download.py +++ b/twitchdl/commands/download.py @@ -70,36 +70,56 @@ def _join_vods(playlist_path, target, overwrite, video): raise ConsoleError("Joining files failed") -def _video_target_filename(video, format): - match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", video['publishedAt']) - date = "".join(match.groups()) +def _video_target_filename(video, args): + date, time = video['publishedAt'].split("T") - name = "_".join([ - date, - video['id'], - video['creator']['login'], - utils.slugify(video['title']), - ]) + subs = { + "channel": video["creator"]["displayName"], + "channel_login": video["creator"]["login"], + "date": date, + "datetime": video["publishedAt"], + "format": args.format, + "game": video["game"]["name"], + "game_slug": utils.slugify(video["game"]["name"]), + "id": video["id"], + "time": time, + "title": utils.titlify(video["title"]), + "title_slug": utils.slugify(video["title"]), + } - return name + "." + format + try: + return args.output.format(**subs) + except KeyError as e: + supported = ", ".join(subs.keys()) + raise ConsoleError("Invalid key {} used in --output. Supported keys are: {}".format(e, supported)) -def _clip_target_filename(clip): +def _clip_target_filename(clip, args): + date, time = clip["createdAt"].split("T") + url = clip["videoQualities"][0]["sourceURL"] _, ext = path.splitext(url) ext = ext.lstrip(".") - match = re.search(r"^(\d{4})-(\d{2})-(\d{2})T", clip["createdAt"]) - date = "".join(match.groups()) + subs = { + "channel": clip["broadcaster"]["displayName"], + "channel_login": clip["broadcaster"]["login"], + "date": date, + "datetime": clip["createdAt"], + "format": ext, + "game": clip["game"]["name"], + "game_slug": utils.slugify(clip["game"]["name"]), + "id": clip["id"], + "time": time, + "title": utils.titlify(clip["title"]), + "title_slug": utils.slugify(clip["title"]), + } - name = "_".join([ - date, - clip["id"], - clip["broadcaster"]["login"], - utils.slugify(clip["title"]), - ]) - - return "{}.{}".format(name, ext) + try: + return args.output.format(**subs) + except KeyError as e: + supported = ", ".join(subs.keys()) + raise ConsoleError("Invalid key {} used in --output. Supported keys are: {}".format(e, supported)) def _get_vod_paths(playlist, start, end): @@ -194,12 +214,6 @@ def _download_clip(slug, args): if not clip: raise ConsoleError("Clip '{}' not found".format(slug)) - print_out("Fetching access token...") - access_token = twitch.get_clip_access_token(slug) - - if not access_token: - raise ConsoleError("Access token not found for slug '{}'".format(slug)) - print_out("Found: {} by {}, playing {} ({})".format( clip["title"], clip["broadcaster"]["displayName"], @@ -207,11 +221,12 @@ def _download_clip(slug, args): utils.format_duration(clip["durationSeconds"]) )) + target = _clip_target_filename(clip, args) + print_out("Target: {}".format(target)) + url = get_clip_authenticated_url(slug, args.quality) print_out("Selected URL: {}".format(url)) - target = _clip_target_filename(clip) - print_out("Downloading clip...") download_file(url, target) @@ -231,6 +246,9 @@ def _download_video(video_id, args): print_out("Found: {} by {}".format( video['title'], video['creator']['displayName'])) + target = _video_target_filename(video, args) + print_out("Output: {}".format(target)) + print_out("Fetching access token...") access_token = twitch.get_access_token(video_id) @@ -277,7 +295,6 @@ def _download_video(video_id, args): return print_out("\n\nJoining files...") - target = _video_target_filename(video, args.format) _join_vods(playlist_path, target, args.overwrite, video) if args.keep: diff --git a/twitchdl/console.py b/twitchdl/console.py index 97f02bd..d0a772d 100644 --- a/twitchdl/console.py +++ b/twitchdl/console.py @@ -168,6 +168,11 @@ COMMANDS = [ "help": "Overwrite the target file if it already exists without prompting.", "action": "store_true", "default": False, + }), + (["-o", "--output"], { + "help": "Output file name template. See docs for details.", + "type": str, + "default": "{date}_{id}_{channel_login}_{title_slug}.{format}" }) ], ), diff --git a/twitchdl/utils.py b/twitchdl/utils.py index 1339f24..cc823e6 100644 --- a/twitchdl/utils.py +++ b/twitchdl/utils.py @@ -55,12 +55,17 @@ def read_int(msg, min, max, default): def slugify(value): - re_pattern = re.compile(r'[^\w\s-]', flags=re.U) - re_spaces = re.compile(r'[-\s]+', flags=re.U) - value = str(value) - value = unicodedata.normalize('NFKC', value) - value = re_pattern.sub('', value).strip().lower() - return re_spaces.sub('_', value) + value = unicodedata.normalize('NFKC', str(value)) + value = re.sub(r'[^\w\s_-]', '', value) + value = re.sub(r'[\s_-]+', '_', value) + return value.strip("_").lower() + + +def titlify(value): + value = unicodedata.normalize('NFKC', str(value)) + value = re.sub(r'[^\w\s\[\]().-]', '', value) + value = re.sub(r'\s+', ' ', value) + return value.strip() VIDEO_PATTERNS = [