Repository: streamlink/streamlink

Issue:
No filename displayed in HLS livestream downloading since 4.3.0
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

In 4.2.0 and earlier versions, when downloading hls livestream, the destination filename is always being displayed in the latest prompted progress line.

But from 4.3.0, the filename is no longer displayed, only size/duration/speed. Like this:

![image](https://user-images.githubusercontent.com/48208459/185729234-dac6b527-ed89-4173-bd9e-1afe97b5a20c.png)

For users who need to download multiple streams in the same time, this change might make it much harder to monitor the progress of different files.

### Debug log

```text
N/A
```



Discussion:
This was removed intentionally, because it was showing a truncated file name instead of the full path which already gets logged.

#4656 - cli: rewrite progress output (4.3.0)
> Output format changes:
> - Remove "prefix" from output format (full path already gets logged)

#4336 - cli: log resolved file output path (3.2.0)
> Streamlink currently doesn't log the full path when writing the output to a file. This is an issue when the output path is dynamically generated from the input argument via the stream's metadata, because the user doesn't know where the data was actually written to. The output path gets already logged if the file already exists, but that's not really helpful.
> This was removed intentionally, because it was showing a truncated file name instead of the full path which already gets logged.
> 
> #4656 - cli: rewrite progress output (4.3.0)
> 
> > Output format changes:
> > 
> > * Remove "prefix" from output format (full path already gets logged)
> 
> #4336 - cli: log resolved file output path (3.2.0)
> 
> > Streamlink currently doesn't log the full path when writing the output to a file. This is an issue when the output path is dynamically generated from the input argument via the stream's metadata, because the user doesn't know where the data was actually written to. The output path gets already logged if the file already exists, but that's not really helpful.

Thank you for answering but I'm not sure I've fully understood what you mean - Would it be fixed or it's so far not considered as an issue to be adjusted?

If not, how can I customize to let the filename displayed in the progress output? It's really important to me. :-[
> Would it be fixed or it's so far not considered as an issue to be adjusted?
> how can I customize to let the filename displayed in the progress output

You can't customize the progress output, at least not without modifying the code. Letting the user customize it via additional command line arguments - while possible and probably not even hard to implement due to the Formatter class that's already used for the `--output` stuff - would be a total overkill. The progress output is not meant as a stable interface for parsing, in case that's what you're asking for. And for humans, the old file name implementation was bad and was removed because of that (see below).

Since you posted (a screenshot) of a debug log with download progress output, I just want to mention that the current implementation still has room for improvement, as it doesn't clear the progress output when the logger writes new lines, so lots of unnecessary progress output lines remain in between the log output. Both logging/output mechanisms work independently of each other while writing to the same stdout stream, which is the reason for this.

----

This is the output of 4.3.0, and as you can see, it's reduced to the actual useful information, while the full output path already gets logged (implemented in 3.2.0). This is important when the output path contains metadata variables or when the output file has a long name.

```
$ streamlink youtube.com/hospitalrecords best -o /dev/null
[cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
[cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (hls)
[cli][info] Writing output to
/dev/null
[download] Written 8.11 MiB (11s @ 720.81 KiB/s)
...

$ streamlink youtube.com/hospitalrecords best -o '/tmp/records/{author} - {title}.ts'
[cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
[cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (hls)
[cli][info] Writing output to
/tmp/records/Hospital Records - Drum & Bass Non-Stop Liquid - To Chill _ Relax Too.ts
[download] Written 5.74 MiB (8s @ 733.96 KiB/s)
...
```

In the earlier versions, the same commands show this output, which is not useful at all:

```
$ streamlink youtube.com/hospitalrecords best -o /dev/null
[cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
[cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (hls)
[cli][info] Writing output to
/dev/null
[download][null] Written 4.5 MB (5s @ 772.5 KB/s)
...

$ streamlink youtube.com/hospitalrecords best -o '/tmp/records/{author} - {title}.ts'
[cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
[cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (hls)
[cli][info] Writing output to
/tmp/records/Hospital Records - Drum & Bass Non-Stop Liquid - To Chill _ Relax Too.ts
[download][..To Chill _ Relax Too.ts] Written 4.4 MB (5s @ 764.2 KB/s)
...
```
> > Would it be fixed or it's so far not considered as an issue to be adjusted?
> > how can I customize to let the filename displayed in the progress output
> 
> You can't customize the progress output, at least not without modifying the code. Letting the user customize it via additional command line arguments - while possible and probably not even hard to implement due to the Formatter class that's already used for the `--output` stuff - would be a total overkill. The progress output is not meant as a stable interface for parsing, in case that's what you're asking for. And for humans, the old file name implementation was bad and was removed because of that (see below).
> 
> Since you posted (a screenshot) of a debug log with download progress output, I just want to mention that the current implementation still has room for improvement, as it doesn't clear the progress output when the logger writes new lines, so lots of unnecessary progress output lines remain in between the log output. Both logging/output mechanisms work independently of each other while writing to the same stdout stream, which is the reason for this.
> 
> This is the output of 4.3.0, and as you can see, it's reduced to the actual useful information, while the full output path already gets logged (implemented in 3.2.0). This is important when the output path contains metadata variables or when the output file has a long name.
> 
> ```
> $ streamlink youtube.com/hospitalrecords best -o /dev/null
> [cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
> [cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
> [cli][info] Opening stream: 1080p (hls)
> [cli][info] Writing output to
> /dev/null
> [download] Written 8.11 MiB (11s @ 720.81 KiB/s)
> ...
> 
> $ streamlink youtube.com/hospitalrecords best -o '/tmp/records/{author} - {title}.ts'
> [cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
> [cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
> [cli][info] Opening stream: 1080p (hls)
> [cli][info] Writing output to
> /tmp/records/Hospital Records - Drum & Bass Non-Stop Liquid - To Chill _ Relax Too.ts
> [download] Written 5.74 MiB (8s @ 733.96 KiB/s)
> ...
> ```
> 
> In the earlier versions, the same commands show this output, which is not useful at all:
> 
> ```
> $ streamlink youtube.com/hospitalrecords best -o /dev/null
> [cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
> [cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
> [cli][info] Opening stream: 1080p (hls)
> [cli][info] Writing output to
> /dev/null
> [download][null] Written 4.5 MB (5s @ 772.5 KB/s)
> ...
> 
> $ streamlink youtube.com/hospitalrecords best -o '/tmp/records/{author} - {title}.ts'
> [cli][info] Found matching plugin youtube for URL youtube.com/hospitalrecords
> [cli][info] Available streams: 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
> [cli][info] Opening stream: 1080p (hls)
> [cli][info] Writing output to
> /tmp/records/Hospital Records - Drum & Bass Non-Stop Liquid - To Chill _ Relax Too.ts
> [download][..To Chill _ Relax Too.ts] Written 4.4 MB (5s @ 764.2 KB/s)
> ...
> ```

Again, thank you for your patient reply. However, as a long-time Streamlink user with little development experience, I would like to state some opinions based on my situation.

As I said in the previous reply, displaying the file name in the progress output is very important for users who need to download multiple files at the same time. Once there is a network problem or some other, I can quickly find the source of the problem through the information. It may display in incomplete ways, but as long as it is distinguishable to the users from other concurrent files, it will always be useful, even it is not so in the eyes of the development team. Therefore, even if the development team thinks that it is not perfect at present, there are still users who need it.

Second, while for open source community softwares like Streamlink, the development team is not obligated to meet the needs of all users, the deprecation of a feature that involves display/output might still should be considered a breaking update. Based on your previous reply, maybe this is just "throwing away a buggy and useless feature" for the development team. However, for users like me who were used to seeing it in the previous versions, I still felt very shocked when I didn't see this deprecation reminder in the Changelog, but found it abandoned during use.

And, based on your last reply, maybe I can understand it this way: In Info-level output, the full filename record displayed before the download starts will not be flushed by the progress information in Debug-level, and it might possibly be a temporary solution to see filenames on the screen. But in contradiction to this, Streamlink is a software that is being developed at a high speed and with the help of community feedback. Users like me are used to enabling the Debug mode in default. It not only helps us to quickly locate problems, but also provides timely and useful information during feedback to you. So still, perhaps the ultimate and best solution for me, is to display filenames even in Debug mode.

It might bother you to read such a bunch of words and may you kindly excuse me for that, but please don't judge any of your work as "not useful at all" without listening to users first. We appreciate your team and your work, as much as how you love them.
> the deprecation of a feature that involves display/output might still should be considered a breaking update

It is not, because it's not part of a stable interface. By that logic, we weren't able to change or fix anything without bumping the major version in very release. The entire `streamlink_cli` module is probably not even considered stable apart from the available CLI arguments.

But since I don't want to just decline your request and close the thread, I can take a look at appending the output path to the progress output, so that it can make use of all the available space (which it was probably not allowed to).
> > the deprecation of a feature that involves display/output might still should be considered a breaking update
> 
> It is not, because it's not part of a stable interface. By that logic, we weren't able to change or fix anything without bumping the major version in very release. The entire `streamlink_cli` module is probably not even considered stable apart from the available CLI arguments.
> 
> But since I don't want to just decline your request and close the thread, I can take a look at appending the output path to the progress output, so that it can make use of all the available space (which it was probably not allowed to).

I appreciate your kindness and look forward to the upcoming updates. Thank you so much.

Resolving patch:
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -32,7 +32,6 @@
 from streamlink_cli.output import FileOutput, PlayerOutput
 from streamlink_cli.utils import Formatter, HTTPServer, datetime, ignored
 from streamlink_cli.utils.progress import Progress
-from streamlink_cli.utils.terminal import TerminalOutput
 
 
 ACCEPTABLE_ERRNO = (errno.EPIPE, errno.EINVAL, errno.ECONNRESET)
@@ -398,7 +397,10 @@ def read_stream(stream, output, prebuffer, formatter: Formatter, chunk_size=8192
         iter(partial(stream.read, chunk_size), b"")
     )
     if show_progress or show_record_progress:
-        progress = Progress(output=TerminalOutput(sys.stderr))
+        progress = Progress(
+            sys.stderr,
+            output.filename or output.record.filename,
+        )
         stream_iterator = progress.iter(stream_iterator)
 
     try:
diff --git a/src/streamlink_cli/utils/progress.py b/src/streamlink_cli/utils/progress.py
--- a/src/streamlink_cli/utils/progress.py
+++ b/src/streamlink_cli/utils/progress.py
@@ -1,35 +1,166 @@
+import os
 from collections import deque
 from math import floor
+from pathlib import PurePath
+from shutil import get_terminal_size
+from string import Formatter as StringFormatter
 from threading import Event, RLock, Thread
 from time import time
-from typing import Deque, Iterable, Iterator, Optional, Tuple
+from typing import Callable, Deque, Dict, Iterable, Iterator, List, Optional, TextIO, Tuple, Union
 
-from streamlink_cli.utils.terminal import TerminalOutput
+from streamlink.compat import is_win32
+
+
+_stringformatter = StringFormatter()
+_TFormat = Iterable[Iterable[Tuple[str, Optional[str], Optional[str], Optional[str]]]]
 
 
 class ProgressFormatter:
-    FORMATS: Iterable[str] = (
+    # Store formats as a tuple of lists of parsed format strings,
+    # so when iterating, we don't have to parse over and over again.
+    # Reserve at least 15 characters for the path, so it can be truncated with enough useful information.
+    FORMATS: _TFormat = tuple(list(_stringformatter.parse(fmt)) for fmt in (
+        "[download] Written {written} to {path:15} ({elapsed} @ {speed})",
         "[download] Written {written} ({elapsed} @ {speed})",
         "[download] {written} ({elapsed} @ {speed})",
         "[download] {written} ({elapsed})",
         "[download] {written}",
-    )
-    FORMATS_NOSPEED: Iterable[str] = (
+    ))
+    FORMATS_NOSPEED: _TFormat = tuple(list(_stringformatter.parse(fmt)) for fmt in (
+        "[download] Written {written} to {path:15} ({elapsed})",
         "[download] Written {written} ({elapsed})",
         "[download] {written} ({elapsed})",
         "[download] {written}",
+    ))
+
+    # Use U+2026 (HORIZONTAL ELLIPSIS) to be able to distinguish between "." and ".." when truncating relative paths
+    ELLIPSIS: str = "…"
+
+    # widths generated from
+    # https://www.unicode.org/Public/4.0-Update/EastAsianWidth-4.0.0.txt
+    # See https://github.com/streamlink/streamlink/pull/2032
+    WIDTHS: Iterable[Tuple[int, int]] = (
+        (13, 1),
+        (15, 0),
+        (126, 1),
+        (159, 0),
+        (687, 1),
+        (710, 0),
+        (711, 1),
+        (727, 0),
+        (733, 1),
+        (879, 0),
+        (1154, 1),
+        (1161, 0),
+        (4347, 1),
+        (4447, 2),
+        (7467, 1),
+        (7521, 0),
+        (8369, 1),
+        (8426, 0),
+        (9000, 1),
+        (9002, 2),
+        (11021, 1),
+        (12350, 2),
+        (12351, 1),
+        (12438, 2),
+        (12442, 0),
+        (19893, 2),
+        (19967, 1),
+        (55203, 2),
+        (63743, 1),
+        (64106, 2),
+        (65039, 1),
+        (65059, 0),
+        (65131, 2),
+        (65279, 1),
+        (65376, 2),
+        (65500, 1),
+        (65510, 2),
+        (120831, 1),
+        (262141, 2),
+        (1114109, 1),
     )
 
+    # On Windows, we need one less space, or we overflow the line for some reason.
+    gap = 1 if is_win32 else 0
+
+    @classmethod
+    def term_width(cls):
+        return get_terminal_size().columns - cls.gap
+
+    @classmethod
+    def _get_width(cls, ordinal: int) -> int:
+        """Return the width of a specific unicode character when it would be displayed."""
+        for unicode, width in cls.WIDTHS:  # pragma: no branch
+            if ordinal <= unicode:
+                return width
+        return 1  # pragma: no cover
+
     @classmethod
-    def format(cls, max_size: int, formats: Iterable[str] = FORMATS, **params) -> str:
-        output = ""
+    def width(cls, value: str):
+        """Return the overall width of a string when it would be displayed."""
+        return sum(map(cls._get_width, map(ord, value)))
+
+    @classmethod
+    def cut(cls, value: str, max_width: int) -> str:
+        """Cut off the beginning of a string until its display width fits into the output size."""
+        current = value
+        for i in range(len(value)):  # pragma: no branch
+            current = value[i:]
+            if cls.width(current) <= max_width:
+                break
+        return current
+
+    @classmethod
+    def format(cls, formats: _TFormat, params: Dict[str, Union[str, Callable[[int], str]]]) -> str:
+        term_width = cls.term_width()
+        static: List[str] = []
+        variable: List[Tuple[int, Callable[[int], str], int]] = []
 
         for fmt in formats:
-            output = fmt.format(**params)
-            if len(output) <= max_size:
+            static.clear()
+            variable.clear()
+            length = 0
+            # Get literal texts, static segments and variable segments from the parsed format
+            # and calculate the overall length of the literal texts and static segments after substituting them.
+            for literal_text, field_name, format_spec, conversion in fmt:
+                static.append(literal_text)
+                length += len(literal_text)
+                if field_name is None:
+                    continue
+                if field_name not in params:
+                    break
+                value_or_callable = params[field_name]
+                if not callable(value_or_callable):
+                    static.append(value_or_callable)
+                    length += len(value_or_callable)
+                else:
+                    variable.append((len(static), value_or_callable, int(format_spec or 0)))
+                    static.append("")
+            else:
+                # No variable segments? Just check if the resulting string fits into the size constraints.
+                if not variable:
+                    if length > term_width:
+                        continue
+                    else:
+                        break
+
+                # Get the available space for each variable segment (share space equally and round down).
+                max_width = int((term_width - length) / len(variable))
+                # If at least one variable segment doesn't fit, continue with the next format.
+                if max_width < 1 or any(max_width < min_width for _, __, min_width in variable):
+                    continue
+                # All variable segments fit, so finally format them, but continue with the next format if there's an error.
+                # noinspection PyBroadException
+                try:
+                    for idx, fn, _ in variable:
+                        static[idx] = fn(max_width)
+                except Exception:
+                    continue
                 break
 
-        return output
+        return "".join(static)
 
     @staticmethod
     def _round(num: float, n: int = 2) -> float:
@@ -69,19 +200,37 @@ def format_time(cls, elapsed: float) -> str:
         else:
             return f"{hours}{minutes}{int(elapsed % 60):1d}s"
 
+    @classmethod
+    def format_path(cls, path: PurePath, max_width: int) -> str:
+        # Quick check if the path fits
+        string = str(path)
+        width = cls.width(string)
+        if width <= max_width:
+            return string
+
+        # Since the path doesn't fit, we always need to add an ellipsis.
+        # On Windows, we also need to add the "drive" part (which is an empty string on PurePosixPath)
+        max_width -= cls.width(path.drive) + cls.width(cls.ELLIPSIS)
+
+        # Ignore the path's first part, aka the "anchor" (drive + root)
+        parts = os.path.sep.join(path.parts[1:])
+        truncated = cls.cut(parts, max_width)
+
+        return f"{path.drive}{cls.ELLIPSIS}{truncated}"
+
 
 class Progress(Thread):
     def __init__(
         self,
-        output: Optional[TerminalOutput] = None,
-        formatter: Optional[ProgressFormatter] = None,
+        stream: TextIO,
+        path: PurePath,
         interval: float = 0.25,
         history: int = 20,
         threshold: int = 2,
     ):
         """
-        :param output: The output class
-        :param formatter: The formatter class
+        :param stream: The output stream
+        :param path: The path that's being written
         :param interval: Time in seconds between updates
         :param history: Number of seconds of how long download speed history is kept
         :param threshold: Number of seconds until download speed is shown
@@ -91,14 +240,10 @@ def __init__(
         self._wait = Event()
         self._lock = RLock()
 
-        if output is None:
-            output = TerminalOutput()
-        if formatter is None:
-            formatter = ProgressFormatter()
-
-        self.output: TerminalOutput = output
-        self.formatter: ProgressFormatter = formatter
+        self.formatter = ProgressFormatter()
 
+        self.stream: TextIO = stream
+        self.path: PurePath = path
         self.interval: float = interval
         self.history: Deque[Tuple[float, int]] = deque(maxlen=int(history / interval))
         self.threshold: int = int(threshold / interval)
@@ -131,7 +276,7 @@ def run(self):
             while not self._wait.wait(self.interval):
                 self.update()
         finally:
-            self.output.end()
+            self.print_end()
 
     def update(self):
         with self._lock:
@@ -150,12 +295,25 @@ def update(self):
                 formats = formatter.FORMATS
                 speed = formatter.format_filesize(sum(size for _, size in history) / (now - history[0][0]), "/s")
 
-            status = self.formatter.format(
-                self.output.term_width() - 1,
-                formats,
+            params = dict(
                 written=formatter.format_filesize(self.overall),
                 elapsed=formatter.format_time(now - self.started),
                 speed=speed,
+                path=lambda max_width: formatter.format_path(self.path, max_width),
             )
 
-            self.output.print_inplace(status)
+            status = formatter.format(formats, params)
+
+            self.print_inplace(status)
+
+    def print_inplace(self, msg: str):
+        """Clears the previous line and prints a new one."""
+        term_width = self.formatter.term_width()
+        spacing = term_width - self.formatter.width(msg)
+
+        self.stream.write(f"\r{msg}{' ' * max(0, spacing)}")
+        self.stream.flush()
+
+    def print_end(self):
+        self.stream.write("\n")
+        self.stream.flush()
diff --git a/src/streamlink_cli/utils/terminal.py b/src/streamlink_cli/utils/terminal.py
deleted file mode 100644
--- a/src/streamlink_cli/utils/terminal.py
+++ /dev/null
@@ -1,100 +0,0 @@
-from shutil import get_terminal_size
-from sys import stderr
-from typing import TextIO
-
-from streamlink.compat import is_win32
-
-
-class TerminalOutput:
-    # widths generated from
-    # https://www.unicode.org/Public/4.0-Update/EastAsianWidth-4.0.0.txt
-    # See https://github.com/streamlink/streamlink/pull/2032
-    WIDTHS = (
-        (13, 1),
-        (15, 0),
-        (126, 1),
-        (159, 0),
-        (687, 1),
-        (710, 0),
-        (711, 1),
-        (727, 0),
-        (733, 1),
-        (879, 0),
-        (1154, 1),
-        (1161, 0),
-        (4347, 1),
-        (4447, 2),
-        (7467, 1),
-        (7521, 0),
-        (8369, 1),
-        (8426, 0),
-        (9000, 1),
-        (9002, 2),
-        (11021, 1),
-        (12350, 2),
-        (12351, 1),
-        (12438, 2),
-        (12442, 0),
-        (19893, 2),
-        (19967, 1),
-        (55203, 2),
-        (63743, 1),
-        (64106, 2),
-        (65039, 1),
-        (65059, 0),
-        (65131, 2),
-        (65279, 1),
-        (65376, 2),
-        (65500, 1),
-        (65510, 2),
-        (120831, 1),
-        (262141, 2),
-        (1114109, 1),
-    )
-
-    def __init__(self, stream: TextIO = stderr):
-        self.stream = stream
-
-    @classmethod
-    def _get_width(cls, ordinal: int) -> int:
-        """Returns the width of a specific unicode character when it would be displayed."""
-        for unicode, width in cls.WIDTHS:  # pragma: no branch
-            if ordinal <= unicode:
-                return width
-        return 1  # pragma: no cover
-
-    @classmethod
-    def term_width(cls):
-        return get_terminal_size().columns
-
-    @classmethod
-    def width(cls, value: str):
-        """Returns the overall width of a string when it would be displayed."""
-        return sum(map(cls._get_width, map(ord, value)))
-
-    @classmethod
-    def cut(cls, value: str, max_len: int) -> str:
-        """Cuts off the beginning of a string until its display width fits into the output size."""
-        current = value
-        for i in range(len(value)):  # pragma: no branch
-            current = value[i:]
-            if cls.width(current) <= max_len:
-                break
-        return current
-
-    def print_inplace(self, msg: str):
-        """Clears the previous line and prints a new one."""
-        term_width = self.term_width()
-        spacing = term_width - self.width(msg)
-
-        # On Windows, we need one less space, or we overflow the line for some reason.
-        if is_win32:
-            spacing -= 1
-
-        self.stream.write(f"\r{msg}")
-        self.stream.write(" " * max(0, spacing))
-        self.stream.flush()
-
-    def end(self):
-        self.stream.write("\n")
-        self.stream.flush()


Test patch:
diff --git a/tests/cli/utils/test_progress.py b/tests/cli/utils/test_progress.py
--- a/tests/cli/utils/test_progress.py
+++ b/tests/cli/utils/test_progress.py
@@ -1,19 +1,29 @@
+from io import StringIO
+from pathlib import PurePosixPath, PureWindowsPath
 from time import time
+from unittest.mock import Mock, call, patch
 
 import freezegun
 import pytest
 
 from streamlink_cli.utils.progress import Progress, ProgressFormatter
-from streamlink_cli.utils.terminal import TerminalOutput
+from tests import posix_only, windows_only
 
 
 class TestProgressFormatter:
     @pytest.fixture(scope="class")
     def params(self):
-        return dict(written="WRITTEN", elapsed="ELAPSED", speed="SPEED")
+        return dict(
+            written="WRITTEN",
+            elapsed="ELAPSED",
+            speed="SPEED",
+            path=lambda *_: "PATH",
+        )
 
-    @pytest.mark.parametrize("max_size,expected", [
-        (99, "[download] Written WRITTEN (ELAPSED @ SPEED)"),
+    @pytest.mark.parametrize("term_width,expected", [
+        (99, "[download] Written WRITTEN to PATH (ELAPSED @ SPEED)"),
+        (63, "[download] Written WRITTEN to PATH (ELAPSED @ SPEED)"),
+        (62, "[download] Written WRITTEN (ELAPSED @ SPEED)"),
         (44, "[download] Written WRITTEN (ELAPSED @ SPEED)"),
         (43, "[download] WRITTEN (ELAPSED @ SPEED)"),
         (36, "[download] WRITTEN (ELAPSED @ SPEED)"),
@@ -22,19 +32,33 @@ def params(self):
         (27, "[download] WRITTEN"),
         (1, "[download] WRITTEN"),
     ])
-    def test_format(self, max_size, params, expected):
-        assert ProgressFormatter.format(max_size, ProgressFormatter.FORMATS, **params) == expected
-
-    @pytest.mark.parametrize("max_size,expected", [
-        (99, "[download] Written WRITTEN (ELAPSED)"),
+    def test_format(self, params, term_width, expected):
+        with patch("streamlink_cli.utils.progress.ProgressFormatter.term_width", lambda: term_width):
+            assert ProgressFormatter.format(ProgressFormatter.FORMATS, params) == expected
+
+    @pytest.mark.parametrize("term_width,expected", [
+        (99, "[download] Written WRITTEN to PATH (ELAPSED)"),
+        (55, "[download] Written WRITTEN to PATH (ELAPSED)"),
+        (54, "[download] Written WRITTEN (ELAPSED)"),
         (36, "[download] Written WRITTEN (ELAPSED)"),
         (35, "[download] WRITTEN (ELAPSED)"),
         (28, "[download] WRITTEN (ELAPSED)"),
         (27, "[download] WRITTEN"),
         (1, "[download] WRITTEN"),
     ])
-    def test_format_nospeed(self, max_size, params, expected):
-        assert ProgressFormatter.format(max_size, ProgressFormatter.FORMATS_NOSPEED, **params) == expected
+    def test_format_nospeed(self, params, term_width, expected):
+        with patch("streamlink_cli.utils.progress.ProgressFormatter.term_width", lambda: term_width):
+            assert ProgressFormatter.format(ProgressFormatter.FORMATS_NOSPEED, params) == expected
+
+    def test_format_missing(self, params):
+        with patch("streamlink_cli.utils.progress.ProgressFormatter.term_width", lambda: 99):
+            assert ProgressFormatter.format(ProgressFormatter.FORMATS, {"written": "0"}) == "[download] 0"
+
+    def test_format_error(self, params):
+        with patch("streamlink_cli.utils.progress.ProgressFormatter.term_width", lambda: 99):
+            params = dict(**params)
+            params["path"] = Mock(side_effect=ValueError("fail"))
+            assert ProgressFormatter.format(ProgressFormatter.FORMATS, params) == "[download] Written WRITTEN (ELAPSED @ SPEED)"
 
     @pytest.mark.parametrize("size,expected", [
         (0, "0 bytes"),
@@ -77,66 +101,151 @@ def test_format_filesize(self, size, expected):
     def test_format_time(self, elapsed, expected):
         assert ProgressFormatter.format_time(elapsed) == expected
 
+    _path_posix = PurePosixPath("/foobar/baz/some file name")
+    _path_windows = PureWindowsPath("C:\\foobar\\baz\\some file name")
+    _path_windows_unc = PureWindowsPath("\\\\?\\foobar\\baz\\some file name")
+
+    @pytest.mark.parametrize("path,max_width,expected", [
+        pytest.param(_path_posix, 26, "/foobar/baz/some file name", id="posix - full path"),
+        pytest.param(_path_posix, 25, "…oobar/baz/some file name", id="posix - truncated by 1"),
+        pytest.param(_path_posix, 24, "…obar/baz/some file name", id="posix - truncated by 2"),
+        pytest.param(_path_posix, 23, "…bar/baz/some file name", id="posix - truncated by 3"),
+        pytest.param(_path_posix, 22, "…ar/baz/some file name", id="posix - truncated by 4"),
+        pytest.param(_path_posix, 21, "…r/baz/some file name", id="posix - truncated by 5"),
+        pytest.param(_path_posix, 20, "…/baz/some file name", id="posix - truncated by 6"),
+        pytest.param(_path_posix, 19, "…baz/some file name", id="posix - truncated by 7 (cuts off separator)"),
+        pytest.param(_path_posix, 16, "…/some file name", id="posix - truncated (all parts except name)"),
+        pytest.param(_path_posix, 15, "…some file name", id="posix - truncated (name without separator)"),
+        pytest.param(_path_posix, 14, "…ome file name", id="posix - truncated name"),
+        pytest.param(_path_windows, 28, "C:\\foobar\\baz\\some file name", id="windows - full path"),
+        pytest.param(_path_windows, 27, "C:…oobar\\baz\\some file name", id="windows - truncated by 1"),
+        pytest.param(_path_windows, 26, "C:…obar\\baz\\some file name", id="windows - truncated by 2"),
+        pytest.param(_path_windows, 25, "C:…bar\\baz\\some file name", id="windows - truncated by 3"),
+        pytest.param(_path_windows, 24, "C:…ar\\baz\\some file name", id="windows - truncated by 4"),
+        pytest.param(_path_windows, 23, "C:…r\\baz\\some file name", id="windows - truncated by 5"),
+        pytest.param(_path_windows, 22, "C:…\\baz\\some file name", id="windows - truncated by 6"),
+        pytest.param(_path_windows, 21, "C:…baz\\some file name", id="windows - truncated by 7 (cuts off separator)"),
+        pytest.param(_path_windows, 18, "C:…\\some file name", id="windows - truncated (all parts except name)"),
+        pytest.param(_path_windows, 17, "C:…some file name", id="windows - truncated (name without separator)"),
+        pytest.param(_path_windows, 16, "C:…ome file name", id="windows - truncated name"),
+        pytest.param(_path_windows_unc, 29, "\\\\?\\foobar\\baz\\some file name", id="windows UNC - full path"),
+        pytest.param(_path_windows_unc, 28, "\\\\?\\…obar\\baz\\some file name", id="windows UNC - truncated by 1"),
+        pytest.param(_path_windows_unc, 20, "\\\\?\\…\\some file name", id="windows UNC - truncated (all parts except name)"),
+        pytest.param(_path_windows_unc, 19, "\\\\?\\…some file name", id="windows UNC - truncated (name without separator)"),
+        pytest.param(_path_windows_unc, 18, "\\\\?\\…ome file name", id="windows UNC - truncated name"),
+    ])
+    def test_format_path(self, path, max_width, expected):
+        with patch("os.path.sep", "\\" if type(path) is PureWindowsPath else "/"):
+            assert ProgressFormatter.format_path(path, max_width) == expected
 
-class FakeOutput(TerminalOutput):
-    def __init__(self):
-        super().__init__()
-        self.buffer = []
-
-    # noinspection PyMethodMayBeStatic
-    def term_width(self):
-        return 50
 
-    def print_inplace(self, msg):
-        self.buffer.append(msg)
+class TestWidth:
+    @pytest.mark.parametrize("chars,expected", [
+        ("ABCDEFGHIJ", 10),
+        ("A你好世界こんにちは안녕하세요B", 30),
+        ("·「」『』【】-=！@#￥%……&×（）", 30),
+    ])
+    def test_width(self, chars, expected):
+        assert ProgressFormatter.width(chars) == expected
 
-    def end(self):  # pragma: no cover
-        self.buffer.append("\n")
+    @pytest.mark.parametrize("prefix,max_len,expected", [
+        ("你好世界こんにちは안녕하세요CD", 10, "녕하세요CD"),
+        ("你好世界こんにちは안녕하세요CD", 9, "하세요CD"),
+        ("你好世界こんにちは안녕하세요CD", 23, "こんにちは안녕하세요CD"),
+    ])
+    def test_cut(self, prefix, max_len, expected):
+        assert ProgressFormatter.cut(prefix, max_len) == expected
+
+
+class TestPrint:
+    @pytest.fixture(autouse=True)
+    def _terminal_size(self):
+        with patch("streamlink_cli.utils.progress.get_terminal_size") as mock_get_terminal_size:
+            mock_get_terminal_size.return_value = Mock(columns=10)
+            yield
+
+    @pytest.fixture
+    def stream(self):
+        return StringIO()
+
+    @pytest.fixture
+    def progress(self, stream: StringIO):
+        yield Progress(stream, Mock())
+
+    @posix_only
+    def test_print_posix(self, progress: Progress, stream: StringIO):
+        progress.print_inplace("foo")
+        progress.print_inplace("barbaz")
+        progress.print_inplace("0123456789")
+        progress.print_inplace("abcdefghijk")
+        progress.print_end()
+        assert stream.getvalue() == "\rfoo       \rbarbaz    \r0123456789\rabcdefghijk\n"
+
+    @windows_only
+    def test_print_windows(self, progress: Progress, stream: StringIO):
+        progress.print_inplace("foo")
+        progress.print_inplace("barbaz")
+        progress.print_inplace("0123456789")
+        progress.print_inplace("abcdefghijk")
+        progress.print_end()
+        assert stream.getvalue() == "\rfoo      \rbarbaz   \r0123456789\rabcdefghijk\n"
 
 
 class TestProgress:
     def test_download_speed(self):
         kib = b"\x00" * 1024
-        output = FakeOutput()
+        output_write = Mock()
         progress = Progress(
-            output=output,
+            Mock(write=output_write),
+            PurePosixPath("../../the/path/where/we/write/to"),
             interval=1,
             history=3,
             threshold=2,
         )
 
-        with freezegun.freeze_time("2000-01-01T00:00:00Z") as frozen_time:
+        with freezegun.freeze_time("2000-01-01T00:00:00Z") as frozen_time, \
+             patch("os.path.sep", "/"), \
+             patch("streamlink_cli.utils.progress.ProgressFormatter.term_width", Mock(return_value=70)) as mock_width:
             progress.started = time()
-            assert not output.buffer
+            assert not output_write.call_args_list
 
             progress.update()
-            assert output.buffer[-1] == "[download] Written 0 bytes (0s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 0 bytes to ../../the/path/where/we/write/to (0s)   ")
 
             frozen_time.tick()
             progress.put(kib * 1)
             progress.update()
-            assert output.buffer[-1] == "[download] Written 1.00 KiB (1s @ 1.00 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 1.00 KiB to …th/where/we/write/to (1s @ 1.00 KiB/s)")
 
             frozen_time.tick()
+            mock_width.return_value = 65
             progress.put(kib * 3)
             progress.update()
-            assert output.buffer[-1] == "[download] Written 4.00 KiB (2s @ 2.00 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 4.00 KiB to …ere/we/write/to (2s @ 2.00 KiB/s)")
 
             frozen_time.tick()
+            mock_width.return_value = 60
             progress.put(kib * 5)
             progress.update()
-            assert output.buffer[-1] == "[download] Written 9.00 KiB (3s @ 4.50 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 9.00 KiB (3s @ 4.50 KiB/s)               ")
 
             frozen_time.tick()
             progress.put(kib * 7)
             progress.update()
-            assert output.buffer[-1] == "[download] Written 16.00 KiB (4s @ 7.50 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 16.00 KiB (4s @ 7.50 KiB/s)              ")
 
             frozen_time.tick()
             progress.put(kib * 5)
             progress.update()
-            assert output.buffer[-1] == "[download] Written 21.00 KiB (5s @ 8.50 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 21.00 KiB (5s @ 8.50 KiB/s)              ")
 
             frozen_time.tick()
             progress.update()
-            assert output.buffer[-1] == "[download] Written 21.00 KiB (6s @ 6.00 KiB/s)"
+            assert output_write.call_args_list[-1] \
+                   == call("\r[download] Written 21.00 KiB (6s @ 6.00 KiB/s)              ")
diff --git a/tests/cli/utils/test_terminal.py b/tests/cli/utils/test_terminal.py
deleted file mode 100644
--- a/tests/cli/utils/test_terminal.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from io import StringIO
-
-import pytest
-
-from streamlink_cli.utils.terminal import TerminalOutput
-from tests import posix_only, windows_only
-
-
-class TestWidth:
-    @pytest.mark.parametrize("chars,expected", [
-        ("ABCDEFGHIJ", 10),
-        ("A你好世界こんにちは안녕하세요B", 30),
-        ("·「」『』【】-=！@#￥%……&×（）", 30),
-    ])
-    def test_width(self, chars, expected):
-        assert TerminalOutput.width(chars) == expected
-
-    @pytest.mark.parametrize("prefix,max_len,expected", [
-        ("你好世界こんにちは안녕하세요CD", 10, "녕하세요CD"),
-        ("你好世界こんにちは안녕하세요CD", 9, "하세요CD"),
-        ("你好世界こんにちは안녕하세요CD", 23, "こんにちは안녕하세요CD"),
-    ])
-    def test_cut(self, prefix, max_len, expected):
-        assert TerminalOutput.cut(prefix, max_len) == expected
-
-
-class TestOutput:
-    @pytest.fixture(autouse=True)
-    def _terminal_size(self, monkeypatch: pytest.MonkeyPatch):
-        class TerminalSize:
-            columns = 10
-
-        terminalsize = TerminalSize()
-        monkeypatch.setattr("streamlink_cli.utils.terminal.get_terminal_size", lambda: terminalsize)
-
-    @pytest.fixture
-    def stream(self):
-        return StringIO()
-
-    @pytest.fixture
-    def output(self, stream: StringIO):
-        yield TerminalOutput(stream)
-
-    @posix_only
-    def test_print_posix(self, output: TerminalOutput, stream: StringIO):
-        output.print_inplace("foo")
-        output.print_inplace("barbaz")
-        output.print_inplace("0123456789")
-        output.print_inplace("abcdefghijk")
-        output.end()
-        assert stream.getvalue() == "\rfoo       \rbarbaz    \r0123456789\rabcdefghijk\n"
-
-    @windows_only
-    def test_print_windows(self, output: TerminalOutput, stream: StringIO):
-        output.print_inplace("foo")
-        output.print_inplace("barbaz")
-        output.print_inplace("0123456789")
-        output.print_inplace("abcdefghijk")
-        output.end()
-        assert stream.getvalue() == "\rfoo      \rbarbaz   \r0123456789\rabcdefghijk\n"