├── .github └── workflows │ ├── pythonpackage.yml │ └── pythonpublish.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── tests ├── __init__.py └── test_totalsize.py └── totalsize ├── __init__.py └── total.py /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | pull_request: 5 | branches: [ $default-branch ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install flit flake8 26 | flit install -s 27 | - name: Lint with flake8 28 | run: | 29 | # stop the build if there are Python syntax errors or undefined names 30 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 31 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 32 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 33 | - name: Test 34 | run: | 35 | python tests/test_totalsize.py 36 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install flit 20 | - name: Build and publish 21 | env: 22 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | flit publish 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # vscode 107 | .vscode/ 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 theychx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # totalsize 2 | Script that uses yt-dlp to calculate total size of all videos in a playlist (also works with single videos). 3 | # Installation 4 | 5 | ``` 6 | pip3 install totalsize 7 | ``` 8 | Totalsize requires python 3.6+. 9 | # Usage 10 | 11 | ``` 12 | usage: totalsize [-h] [-f FORMAT_FILTER] [-m] [-n] [-r NUM] [-c FILE] 13 | [--media] [--size] [--duration] [--views] [--likes] [--dislikes] [--percentage] 14 | [--cookies FILE] URL 15 | ``` 16 | See https://github.com/yt-dlp/yt-dlp#format-selection for details on formats. 17 | 18 | Specify the `-m` option for additional info on each video. 19 | 20 | Specify the `-n` option to suppress output of progress info. 21 | 22 | When specifying any of the raw data options, data will always be printed in this order: 23 | 24 | *media*, *size*, *duration*, *views*, *likes*, *dislikes*, *percentage* 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "totalsize" 7 | authors = [{name = "theychx", email = "theychx@fastmail.com"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | dynamic = ["version", "description"] 11 | dependencies = ["yt-dlp >=2022.5.18", "prettytable >=3.3.0"] 12 | requires-python = ">=3.6" 13 | classifiers = [ 14 | "License :: OSI Approved :: MIT License", 15 | "Development Status :: 3 - Alpha", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Topic :: Multimedia", 23 | "Topic :: Multimedia :: Sound/Audio", 24 | "Topic :: Multimedia :: Video", 25 | ] 26 | 27 | [project.urls] 28 | Home = "https://github.com/theychx/totalsize" 29 | 30 | [project.scripts] 31 | totalsize = "totalsize.total:cli" 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theychx/totalsize/578b85e313ec2f7902d46c01af935a0f90888630/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_totalsize.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | 4 | from totalsize.total import Playlist 5 | 6 | 7 | class TestTotal(unittest.TestCase): 8 | def test_get_totalsize_yt_channel(self): 9 | playlist = Playlist("https://www.youtube.com/channel/UCvAUb8YbRyXz_l9CXptKoQA/", "18") 10 | playlist.accum_info() 11 | self.assertEqual(playlist.totals.size, 430723724) 12 | self.assertEqual(playlist.number_of_media, 4) 13 | self.assertEqual(playlist.number_of_media_inacc, 1) 14 | self.assertEqual(playlist.number_of_media_nosize, 0) 15 | 16 | def test_get_totalsize_yt_playlist(self): 17 | playlist = Playlist( 18 | "https://www.youtube.com/watch?v=KIHBpp34JkA&list=PLGx22rG4Cm6dEFvkjmdSRpulz6l0M-g2u", "18" 19 | ) 20 | playlist.accum_info() 21 | self.assertEqual(playlist.totals.size, 202991202) 22 | self.assertEqual(playlist.number_of_media, 3) 23 | self.assertEqual(playlist.number_of_media_inacc, 1) 24 | self.assertEqual(playlist.number_of_media_nosize, 0) 25 | 26 | def test_get_totalsize_yt_video(self): 27 | playlist = Playlist("https://www.youtube.com/watch?v=KIHBpp34JkA", "18") 28 | playlist.accum_info() 29 | self.assertEqual(playlist.totals.size, 69574304) 30 | self.assertEqual(playlist.number_of_media, 1) 31 | self.assertEqual(playlist.number_of_media_inacc, 0) 32 | self.assertEqual(playlist.number_of_media_nosize, 0) 33 | 34 | def test_get_totalsize_bc_album(self): 35 | playlist = Playlist("https://spacedimensioncontroller.bandcamp.com/album/love-beyond-the-intersect", "best") 36 | playlist.accum_info() 37 | self.assertIsNone(playlist.totals.size) 38 | self.assertEqual(playlist.number_of_media, 11) 39 | self.assertEqual(playlist.number_of_media_inacc, 0) 40 | self.assertEqual(playlist.number_of_media_nosize, 11) 41 | 42 | def test_get_totalsize_invalid_url(self): 43 | playlist = Playlist("https://www.google.com", "best") 44 | self.assertIsNone(playlist.totals.size) 45 | self.assertEqual(playlist.number_of_media, 0) 46 | 47 | 48 | if __name__ == "__main__": 49 | sys.exit(unittest.main()) 50 | -------------------------------------------------------------------------------- /totalsize/__init__.py: -------------------------------------------------------------------------------- 1 | """Totalsize uses yt-dlp to calculate total size of all videos in a playlist""" 2 | 3 | __author__ = "theychx" 4 | __email__ = "theychx@fastmail.com" 5 | __version__ = "0.7.2" 6 | -------------------------------------------------------------------------------- /totalsize/total.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | import datetime 4 | import http.cookiejar 5 | import math 6 | import re 7 | import tempfile 8 | import time 9 | from pathlib import Path 10 | 11 | import yt_dlp 12 | from prettytable import PrettyTable, SINGLE_BORDER 13 | from yt_dlp.utils import DownloadError, ExtractorError, UnsupportedError 14 | 15 | DEFAULT_FORMAT = "bestvideo*+bestaudio/best" 16 | FORMAT_DOC_URL = "https://github.com/yt-dlp/yt-dlp#format-selection" 17 | FRAGMENTS_REGEX = re.compile(r"range/[\d]+-([\d]+)$") 18 | TEMPPATH = Path(tempfile.gettempdir(), "totalsize", "fragment") 19 | TIMEOUT = 30 20 | YTDL_OPTS = { 21 | "quiet": True, 22 | "no_warnings": True, 23 | "outtmpl": str(TEMPPATH), 24 | "socket_timeout": TIMEOUT, 25 | } 26 | DEFAULT_RETRIES = 10 27 | MULT_NAMES_BTS = ("B", "KB", "MB", "GB", "TB", "PB") 28 | MULT_NAMES_DEC = ("", "K", "M", "B") 29 | RAW_OPTS = ("media", "size", "duration", "views", "likes", "dislikes", "percentage") 30 | DL_ERRS = ("unable to download webpage", "this video is unavailable", "video unavailable.", "fragment") 31 | UNSUPPORTED_URL_ERRS = ("unsupported url", "live event will begin") 32 | NOT_AVAILABLE_VAL = -1 33 | CONTENT_FIELDS = ["Id", "Title", "Size"] 34 | CONTENT_MORE_FIELDS = ["Duration", "Views", "Likes", "Dislikes", "Percentage"] 35 | TOTALS_FIELDS = [" ", "Size"] 36 | INFO_FIELDS = ["Info", " "] 37 | TITLE_FIELD_SIZE = 58 38 | SIZE_STRING = "{0:>7.1f} {1}" 39 | SIZE_STRING_NO_MULT = "{0:>7}" 40 | TOTAL_SIZE_TXT = "Total size of media files" 41 | TOTAL_MEDIA_TXT = "Total number of media files" 42 | TOTAL_INACC_TXT = "Total number of media files with inaccurate reported size" 43 | TOTAL_NO_SIZE_TXT = "Total number of media files with no reported size" 44 | ABORT_TXT = "\nAborted by user." 45 | ABORT_INCOMPLETE_TXT = ABORT_TXT + " Results will be incomplete!" 46 | SUPPRESS_TXT = "Suppress normal output, and print raw {}." 47 | 48 | 49 | class TotalsizeError(Exception): 50 | pass 51 | 52 | 53 | class Entry: 54 | def __init__(self, mid, title, inaccurate, size, duration, views, likes, dislikes): 55 | self.mid = mid 56 | self.title = title 57 | self.inaccurate = inaccurate 58 | self.size = size 59 | self.duration = duration 60 | self.views = views 61 | self.likes = likes 62 | self.dislikes = dislikes 63 | 64 | @property 65 | def truncated_title(self): 66 | title = self.title 67 | if title is None: 68 | return None 69 | return title[: TITLE_FIELD_SIZE - 3] + "..." if len(title) > TITLE_FIELD_SIZE else title 70 | 71 | @property 72 | def likes_percentage(self): 73 | if self.likes is None or self.dislikes is None: 74 | return None 75 | return (self.likes / (self.likes + self.dislikes)) * 100 76 | 77 | @property 78 | def readable_size(self): 79 | return self._readable_amount(self.size, byte=True) 80 | 81 | @property 82 | def readable_duration(self): 83 | return str(datetime.timedelta(seconds=round(self.duration))) if self.duration is not None else None 84 | 85 | @property 86 | def readable_views(self): 87 | return self._readable_amount(self.views) 88 | 89 | @property 90 | def readable_likes(self): 91 | return self._readable_amount(self.likes) 92 | 93 | @property 94 | def readable_dislikes(self): 95 | return self._readable_amount(self.dislikes) 96 | 97 | @property 98 | def readable_likes_percentage(self): 99 | likes_percentage = self.likes_percentage 100 | return "{:.1f}%".format(likes_percentage) if likes_percentage is not None else None 101 | 102 | def _readable_amount(self, amount, byte=False): 103 | if amount is None: 104 | return None 105 | mult = 1024 if byte else 1000 106 | mult_names = MULT_NAMES_BTS if byte else MULT_NAMES_DEC 107 | if amount == 0: 108 | return SIZE_STRING_NO_MULT.format(0) 109 | 110 | ind = int(math.floor(math.log(amount, mult))) 111 | pwr = math.pow(mult, ind) 112 | mname = mult_names[ind] 113 | size = round(amount / pwr, ndigits=1) if pwr > 1 else amount 114 | fstr = SIZE_STRING if mname else SIZE_STRING_NO_MULT 115 | return fstr.format(size, mname) 116 | 117 | 118 | FAKE_ENTRY = Entry(None, "fake", False, None, None, None, None, None) 119 | 120 | 121 | class Playlist: 122 | def __init__(self, url, format_sel, retries=0, cookies_path=None): 123 | opts = YTDL_OPTS 124 | if cookies_path: 125 | opts["cookiefile"] = str(cookies_path) 126 | self._retries = retries 127 | self._ydl = yt_dlp.YoutubeDL(opts) 128 | TEMPPATH.parent.mkdir(exist_ok=True) 129 | 130 | try: 131 | self._selector = self._ydl.build_format_selector(format_sel) 132 | except ValueError: 133 | raise TotalsizeError("Invalid format filter") 134 | 135 | try: 136 | preinfo = self._ydl.extract_info(url, process=False) 137 | if preinfo.get("ie_key"): 138 | preinfo = self._ydl.extract_info(preinfo["url"], process=False) 139 | except (DownloadError, UnsupportedError): 140 | raise TotalsizeError("Resource not found") 141 | 142 | self._medias = preinfo.get("entries") or [preinfo] 143 | self.entries = [] 144 | 145 | @property 146 | def totals(self): 147 | if not self.entries: 148 | return FAKE_ENTRY 149 | info = { 150 | "mid": None, 151 | "title": "Totals", 152 | "inaccurate": any(e.inaccurate for e in self.entries), 153 | "size": sum(e.size for e in self.entries if e.size) or None, 154 | "duration": sum(e.duration for e in self.entries if e.duration) or None, 155 | "views": sum(e.views for e in self.entries if e.views) or None, 156 | "likes": sum(e.likes for e in self.entries if e.likes) or None, 157 | "dislikes": sum(e.dislikes for e in self.entries if e.dislikes) or None, 158 | } 159 | return Entry(**info) 160 | 161 | @property 162 | def number_of_media(self): 163 | return len(self.entries) 164 | 165 | @property 166 | def number_of_media_inacc(self): 167 | return sum(1 for e in self.entries if e.inaccurate) 168 | 169 | @property 170 | def number_of_media_nosize(self): 171 | return sum(1 for e in self.entries if e.size is None) 172 | 173 | def accum_info(self): 174 | for _ in self.gen_info(): 175 | pass 176 | 177 | def gen_info(self): 178 | for media in self._medias: 179 | attempt_retries = 0 180 | unsupported = False 181 | media_info = {} 182 | inaccurate, size = (False, None) 183 | 184 | while attempt_retries <= self._retries: 185 | if attempt_retries > 0: 186 | time.sleep(TIMEOUT) 187 | try: 188 | media_info = self._get_media_info(media) 189 | inaccurate, size = self._get_size(media_info) 190 | except UnsupportedError: 191 | unsupported = True 192 | break 193 | except (DownloadError, ExtractorError) as err: 194 | serr = str(err).lower() 195 | if any(e in serr for e in DL_ERRS): 196 | attempt_retries += 1 197 | continue 198 | elif any(e in serr for e in UNSUPPORTED_URL_ERRS): 199 | unsupported = True 200 | break 201 | else: 202 | break 203 | 204 | if unsupported: 205 | continue 206 | 207 | info = { 208 | "mid": media.get("id"), 209 | "title": media.get("title"), 210 | "inaccurate": inaccurate, 211 | "size": size, 212 | "duration": media_info.get("duration"), 213 | "views": media_info.get("view_count"), 214 | "likes": media_info.get("like_count"), 215 | "dislikes": media_info.get("dislike_count"), 216 | } 217 | self.entries.append(Entry(**info)) 218 | yield 1 219 | 220 | def _get_media_info(self, media): 221 | return self._ydl.process_ie_result(media, download=False) 222 | 223 | def _get_size(self, media_info): 224 | try: 225 | best = next(self._selector(media_info)) 226 | except StopIteration: 227 | raise TotalsizeError("Invalid format filter") 228 | except KeyError: 229 | best = media_info 230 | 231 | return self._calc_size(best.get("requested_formats") or [best]) 232 | 233 | def _calc_size(self, info): 234 | media_sum = 0 235 | inaccurate = False 236 | 237 | for media in info: 238 | filesize = media.get("filesize") 239 | filesize_approx = media.get("filesize_approx") 240 | fragments = media.get("fragments") 241 | 242 | if filesize: 243 | media_sum += filesize 244 | elif filesize_approx: 245 | media_sum += round(filesize_approx) 246 | inaccurate = True 247 | elif fragments: 248 | try: 249 | media_sum += sum(f["filesize"] for f in fragments) 250 | except KeyError: 251 | pass 252 | else: 253 | continue 254 | 255 | fmatch = re.match(FRAGMENTS_REGEX, fragments[-1]["path"]) 256 | if fmatch: 257 | media_sum += int(fmatch.group(1)) 258 | else: 259 | lfrags = len(fragments) 260 | if lfrags < 2: 261 | return (False, None) 262 | fragm_url = media["fragment_base_url"] + fragments[2 if lfrags > 2 else 1]["path"] 263 | self._ydl.extract_info(fragm_url) 264 | media_sum += TEMPPATH.stat().st_size * (lfrags - 1) 265 | TEMPPATH.unlink() 266 | inaccurate = True 267 | else: 268 | return (False, None) 269 | return (inaccurate, media_sum) 270 | 271 | 272 | def validate_cookiefile(cookies_path): 273 | if not cookies_path.is_file(): 274 | raise TotalsizeError("Cookie file does not exist") 275 | try: 276 | cjar = http.cookiejar.MozillaCookieJar() 277 | cjar.load(cookies_path, ignore_discard=True, ignore_expires=True) 278 | except (http.cookiejar.LoadError, UnicodeDecodeError): 279 | raise TotalsizeError("Cookie file is not formatted correctly") 280 | 281 | 282 | def gen_csv_rows(entries, more_info=False): 283 | for entry in entries: 284 | row = [entry.title, entry.size] 285 | if more_info: 286 | row += [entry.duration, entry.views, entry.likes, entry.dislikes, entry.likes_percentage] 287 | yield row 288 | 289 | 290 | def write_to_csv(csv_path, rows): 291 | try: 292 | with csv_path.open("x", newline="") as csvfile: 293 | csv_writer = csv.writer(csvfile, quoting=csv.QUOTE_MINIMAL) 294 | csv_writer.writerows(rows) 295 | except PermissionError: 296 | raise TotalsizeError("Insufficient file permissions") 297 | except FileExistsError: 298 | raise TotalsizeError("File already exists") 299 | except FileNotFoundError: 300 | raise TotalsizeError("Invalid path") 301 | 302 | 303 | def gen_row(entry, more_info=False): 304 | row = [entry.mid] if entry.mid else [] 305 | row += [ 306 | entry.truncated_title or "", 307 | f"{'~' if entry.inaccurate else ' '}{entry.readable_size or 'no size'}", 308 | ] 309 | if more_info: 310 | row += [ 311 | entry.readable_duration or "", 312 | entry.readable_views or "", 313 | entry.readable_likes or "", 314 | entry.readable_dislikes or "", 315 | entry.readable_likes_percentage or "", 316 | ] 317 | return row 318 | 319 | 320 | def gen_empty_table(fields): 321 | table = PrettyTable() 322 | table.align = "r" 323 | table.set_style(SINGLE_BORDER) 324 | table.field_names = fields 325 | return table 326 | 327 | 328 | def print_report(playlist, more_info=False, no_progress=False): 329 | interupted = False 330 | processed_media = 0 331 | content_fields = CONTENT_FIELDS + CONTENT_MORE_FIELDS if more_info else CONTENT_FIELDS 332 | content_table = gen_empty_table(content_fields) 333 | total_fields = TOTALS_FIELDS + CONTENT_MORE_FIELDS if more_info else TOTALS_FIELDS 334 | totals_table = gen_empty_table(total_fields) 335 | 336 | try: 337 | for processed in playlist.gen_info(): 338 | processed_media += processed 339 | if not no_progress: 340 | print(f"Processed {processed_media} mediafile{'s' if processed_media != 1 else ''}", end="\r") 341 | except KeyboardInterrupt: 342 | interupted = True 343 | 344 | if playlist.number_of_media == 0: 345 | return 346 | 347 | content_table.add_rows([gen_row(e, more_info=more_info) for e in playlist.entries]) 348 | 349 | print(content_table) 350 | if interupted: 351 | print(ABORT_INCOMPLETE_TXT) 352 | 353 | # Do not display 'totals' and 'info' tables for one video 354 | if playlist.number_of_media == 1: 355 | return 356 | 357 | totals_table.add_row(gen_row(playlist.totals, more_info=more_info)) 358 | print(totals_table) 359 | 360 | info_table = gen_empty_table(INFO_FIELDS) 361 | info_table.add_rows( 362 | [ 363 | [TOTAL_MEDIA_TXT, playlist.number_of_media], 364 | [TOTAL_INACC_TXT, playlist.number_of_media_inacc], 365 | [TOTAL_NO_SIZE_TXT, playlist.number_of_media_nosize], 366 | ] 367 | ) 368 | print(info_table) 369 | 370 | 371 | def print_raw_data(playlist, raw_opts): 372 | playlist.accum_info() 373 | totals = playlist.totals 374 | fields = { 375 | "media": playlist.number_of_media, 376 | "size": totals.size or NOT_AVAILABLE_VAL, 377 | "duration": totals.duration or NOT_AVAILABLE_VAL, 378 | "views": totals.views if totals.views is not None else NOT_AVAILABLE_VAL, 379 | "likes": totals.likes if totals.likes is not None else NOT_AVAILABLE_VAL, 380 | "dislikes": totals.dislikes if totals.dislikes is not None else NOT_AVAILABLE_VAL, 381 | "percentage": totals.likes_percentage if totals.likes_percentage is not None else NOT_AVAILABLE_VAL, 382 | } 383 | for sel_opt in raw_opts: 384 | print(fields[sel_opt]) 385 | 386 | 387 | def cli(): 388 | parser = argparse.ArgumentParser(description="Calculate total size of media playlist contents.") 389 | parser.add_argument("url", metavar="URL", type=str, help="playlist/media url") 390 | parser.add_argument( 391 | "-f", 392 | "--format-filter", 393 | type=str, 394 | default=DEFAULT_FORMAT, 395 | help='Custom format filter. See {} for details. The default is "{}".'.format(FORMAT_DOC_URL, DEFAULT_FORMAT), 396 | ) 397 | parser.add_argument( 398 | "-m", "--more-info", action="store_true", help="Display more info on each media file (if available)." 399 | ) 400 | parser.add_argument( 401 | "-n", "--no-progress", action="store_true", help="Do not display progress count during processing." 402 | ) 403 | parser.add_argument( 404 | "-r", 405 | "--retries", 406 | metavar="NUM", 407 | type=int, 408 | default=DEFAULT_RETRIES, 409 | help="Max number of connection retries. The default is {}.".format(DEFAULT_RETRIES), 410 | ) 411 | parser.add_argument("-c", "--csv-file", metavar="FILE", type=str, help="Write data to csv file.") 412 | parser.add_argument("--media", action="store_true", help=SUPPRESS_TXT.format("media count")) 413 | parser.add_argument("--size", action="store_true", help=SUPPRESS_TXT.format("total size (bytes)")) 414 | parser.add_argument("--duration", action="store_true", help=SUPPRESS_TXT.format("total duration (seconds)")) 415 | parser.add_argument("--views", action="store_true", help=SUPPRESS_TXT.format("views count")) 416 | parser.add_argument("--likes", action="store_true", help=SUPPRESS_TXT.format("likes count")) 417 | parser.add_argument("--dislikes", action="store_true", help=SUPPRESS_TXT.format("dislikes count")) 418 | parser.add_argument("--percentage", action="store_true", help=SUPPRESS_TXT.format("likes/dislikes percentage")) 419 | parser.add_argument("--cookies", metavar="FILE", default=None, type=str, help="Loads cookie file.") 420 | 421 | args = parser.parse_args() 422 | err_msg = None 423 | 424 | try: 425 | csv_path = cookies_path = None 426 | sel_raw_opts = [key for key, value in vars(args).items() if key in RAW_OPTS and value] 427 | sorted(sel_raw_opts, key=lambda x: RAW_OPTS.index(x)) 428 | 429 | if args.csv_file: 430 | csv_path = Path(args.csv_file) 431 | write_to_csv(csv_path, gen_csv_rows([FAKE_ENTRY])) 432 | csv_path.unlink() 433 | 434 | if args.cookies: 435 | cookies_path = Path(args.cookies) 436 | validate_cookiefile(cookies_path) 437 | 438 | playlist = Playlist(args.url, args.format_filter, retries=args.retries, cookies_path=cookies_path) 439 | if sel_raw_opts: 440 | print_raw_data(playlist, sel_raw_opts) 441 | else: 442 | print_report(playlist, more_info=args.more_info, no_progress=args.no_progress) 443 | 444 | if csv_path: 445 | write_to_csv(csv_path, gen_csv_rows(playlist.entries, more_info=args.more_info)) 446 | except KeyboardInterrupt: 447 | err_msg = ABORT_TXT 448 | except TotalsizeError as err: 449 | err_msg = str(err) 450 | 451 | if err_msg: 452 | parser.exit(status=1, message=f"Error: {err_msg}.\n") 453 | 454 | 455 | if __name__ == "__main__": 456 | cli() 457 | --------------------------------------------------------------------------------