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