tcd2 3.2.2.post1__tar.gz
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.
- tcd2-3.2.2.post1/LICENSE +21 -0
- tcd2-3.2.2.post1/MANIFEST.in +3 -0
- tcd2-3.2.2.post1/PKG-INFO +57 -0
- tcd2-3.2.2.post1/README.md +30 -0
- tcd2-3.2.2.post1/pyproject.toml +52 -0
- tcd2-3.2.2.post1/setup.cfg +4 -0
- tcd2-3.2.2.post1/tcd/__init__.py +96 -0
- tcd2-3.2.2.post1/tcd/__main__.py +6 -0
- tcd2-3.2.2.post1/tcd/arguments.py +84 -0
- tcd2-3.2.2.post1/tcd/downloader.py +229 -0
- tcd2-3.2.2.post1/tcd/formats/__init__.py +0 -0
- tcd2-3.2.2.post1/tcd/formats/custom.py +30 -0
- tcd2-3.2.2.post1/tcd/formats/format.py +11 -0
- tcd2-3.2.2.post1/tcd/formats/srt.py +71 -0
- tcd2-3.2.2.post1/tcd/formats/ssa.py +167 -0
- tcd2-3.2.2.post1/tcd/formatter.py +32 -0
- tcd2-3.2.2.post1/tcd/logger.py +111 -0
- tcd2-3.2.2.post1/tcd/pipe.py +255 -0
- tcd2-3.2.2.post1/tcd/safedict.py +12 -0
- tcd2-3.2.2.post1/tcd/settings.py +116 -0
- tcd2-3.2.2.post1/tcd/settings.reference.json +92 -0
- tcd2-3.2.2.post1/tcd/singleton.py +13 -0
- tcd2-3.2.2.post1/tcd2.egg-info/PKG-INFO +57 -0
- tcd2-3.2.2.post1/tcd2.egg-info/SOURCES.txt +26 -0
- tcd2-3.2.2.post1/tcd2.egg-info/dependency_links.txt +1 -0
- tcd2-3.2.2.post1/tcd2.egg-info/entry_points.txt +2 -0
- tcd2-3.2.2.post1/tcd2.egg-info/requires.txt +4 -0
- tcd2-3.2.2.post1/tcd2.egg-info/top_level.txt +1 -0
tcd2-3.2.2.post1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Petter Kraabøl
|
|
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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tcd2
|
|
3
|
+
Version: 3.2.2.post1
|
|
4
|
+
Summary: Twitch Chat Downloader (unofficial fork)
|
|
5
|
+
Author: imeloben23
|
|
6
|
+
Maintainer: imeloben23
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
|
9
|
+
Project-URL: Original Project, https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
|
10
|
+
Keywords: Twitch
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: requests==2.27.1
|
|
23
|
+
Requires-Dist: twitch-python==0.0.20
|
|
24
|
+
Requires-Dist: pytz==2022.1
|
|
25
|
+
Requires-Dist: python-dateutil==2.8.2
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# tcd2
|
|
29
|
+
|
|
30
|
+
Unofficial fork of **Twitch Chat Downloader** (the `tcd` package) by Petter Kraabøl.
|
|
31
|
+
This distribution keeps the CLI name `tcd` but publishes under a new name (`tcd2`) on PyPI.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tcd2
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
tcd
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Download chat from VODs by video id
|
|
47
|
+
tcd --video 789654123,987456321 --format irc --output ~/Downloads
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Download chat from the first 10 VODs from multiple streamers
|
|
52
|
+
tcd --channel sodapoppin,nymn,lirik --first=10
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Credits
|
|
56
|
+
|
|
57
|
+
Original project: https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# tcd2
|
|
2
|
+
|
|
3
|
+
Unofficial fork of **Twitch Chat Downloader** (the `tcd` package) by Petter Kraabøl.
|
|
4
|
+
This distribution keeps the CLI name `tcd` but publishes under a new name (`tcd2`) on PyPI.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install tcd2
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
tcd
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Download chat from VODs by video id
|
|
20
|
+
tcd --video 789654123,987456321 --format irc --output ~/Downloads
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Download chat from the first 10 VODs from multiple streamers
|
|
25
|
+
tcd --channel sodapoppin,nymn,lirik --first=10
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Credits
|
|
29
|
+
|
|
30
|
+
Original project: https://github.com/PetterKraabol/Twitch-Chat-Downloader
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tcd2"
|
|
7
|
+
version = "3.2.2.post1"
|
|
8
|
+
description = "Twitch Chat Downloader (unofficial fork)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "imeloben23" }
|
|
15
|
+
]
|
|
16
|
+
maintainers = [
|
|
17
|
+
{ name = "imeloben23" }
|
|
18
|
+
]
|
|
19
|
+
keywords = ["Twitch"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: End Users/Desktop",
|
|
23
|
+
"Natural Language :: English",
|
|
24
|
+
"Programming Language :: Python :: 3.8",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12"
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"requests==2.27.1",
|
|
32
|
+
"twitch-python==0.0.20",
|
|
33
|
+
"pytz==2022.1",
|
|
34
|
+
"python-dateutil==2.8.2"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/PetterKraabol/Twitch-Chat-Downloader"
|
|
39
|
+
"Original Project" = "https://github.com/PetterKraabol/Twitch-Chat-Downloader"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
tcd = "tcd:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools]
|
|
45
|
+
include-package-data = true
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["."]
|
|
49
|
+
include = ["tcd*"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
tcd = ["settings.reference.json"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Callable
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .arguments import Arguments
|
|
9
|
+
from .downloader import Downloader
|
|
10
|
+
from .logger import Logger, Log
|
|
11
|
+
from .settings import Settings
|
|
12
|
+
|
|
13
|
+
__name__: str = 'tcd'
|
|
14
|
+
__version__: str = '3.2.2.post1'
|
|
15
|
+
__all__: List[Callable] = [Arguments, Settings, Downloader, Logger, Log]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
# Arguments
|
|
20
|
+
parser = argparse.ArgumentParser(description=f'Twitch Chat Downloader {__version__}')
|
|
21
|
+
parser.add_argument('-v', f'--{Arguments.Name.VIDEO}', type=str, help='Video IDs separated by commas')
|
|
22
|
+
parser.add_argument('-c', f'--{Arguments.Name.CHANNEL}', type=str, help='Channel names separated by commas')
|
|
23
|
+
parser.add_argument('-u', f'--{Arguments.Name.USER}', type=str, help='Messages from users, separated by commas')
|
|
24
|
+
parser.add_argument(f'--{Arguments.Name.FIRST}', type=int, default=None, help='Download chat from the last n VODs (default: all)')
|
|
25
|
+
parser.add_argument(f'--{Arguments.Name.CLIENT_ID.replace("_", "-")}', type=str, help='Twitch client ID')
|
|
26
|
+
parser.add_argument(f'--{Arguments.Name.CLIENT_SECRET.replace("_", "-")}', type=str, help='Twitch client secret')
|
|
27
|
+
parser.add_argument(f'--{Arguments.Name.VERBOSE}', action='store_true', help='Verbose output')
|
|
28
|
+
parser.add_argument('-q', f'--{Arguments.Name.QUIET}', action='store_true')
|
|
29
|
+
parser.add_argument('-o', f'--{Arguments.Name.OUTPUT}', type=str, help='Output directory', default='./')
|
|
30
|
+
parser.add_argument('-f', f'--{Arguments.Name.FORMAT}', type=str, help='Message format', default='default')
|
|
31
|
+
parser.add_argument(f'--{Arguments.Name.TIMEZONE}', type=str, help='Timezone name')
|
|
32
|
+
parser.add_argument(f'--includes', type=str, help='Messages must include specified text')
|
|
33
|
+
parser.add_argument(f'--{Arguments.Name.INIT}', action='store_true', help='Script setup')
|
|
34
|
+
parser.add_argument(f'--{Arguments.Name.VERSION}', action='store_true', help='Settings version')
|
|
35
|
+
parser.add_argument(f'--{Arguments.Name.FORMATS}', action='store_true', help='List available formats')
|
|
36
|
+
parser.add_argument(f'--{Arguments.Name.PREVIEW}', action='store_true', help='Preview output')
|
|
37
|
+
parser.add_argument(f'--{Arguments.Name.SETTINGS}', action='store_true', help='Print settings file location')
|
|
38
|
+
parser.add_argument(f'--{Arguments.Name.SETTINGS_FILE.replace("_", "-")}', type=str,
|
|
39
|
+
default=str(Path.home()) + '/.config/tcd/settings.json',
|
|
40
|
+
help='Use a custom settings file')
|
|
41
|
+
parser.add_argument(f'--{Arguments.Name.DEBUG}', action='store_true', help='Print debug messages')
|
|
42
|
+
parser.add_argument(f'--{Arguments.Name.LOG}', action='store_true', help='Save log file')
|
|
43
|
+
|
|
44
|
+
Arguments(parser.parse_args().__dict__)
|
|
45
|
+
Settings(Arguments().settings_file,
|
|
46
|
+
reference_filepath=f'{os.path.dirname(os.path.abspath(__file__))}/settings.reference.json')
|
|
47
|
+
|
|
48
|
+
# Print version number
|
|
49
|
+
if Arguments().print_version:
|
|
50
|
+
Logger().log(f'Twitch Chat Downloader {__version__}', retain=False)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Print settings file location
|
|
54
|
+
if Arguments().settings:
|
|
55
|
+
Logger().log(str(Settings().filepath))
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Client ID
|
|
59
|
+
Settings().config['client_id'] = Arguments().client_id or Settings().config.get('client_id', None) or input(
|
|
60
|
+
'Twitch client ID: ').strip()
|
|
61
|
+
Settings().config['client_secret'] = Arguments().client_secret or Settings().config.get('client_secret', None) or input(
|
|
62
|
+
'Twitch client secret: ').strip()
|
|
63
|
+
Settings().save()
|
|
64
|
+
|
|
65
|
+
Arguments().oauth_token = requests.post(f"https://id.twitch.tv/oauth2/token"
|
|
66
|
+
f"?client_id={Settings().config['client_id']}"
|
|
67
|
+
f"&client_secret={Settings().config['client_secret']}"
|
|
68
|
+
f"&grant_type=client_credentials").json()['access_token']
|
|
69
|
+
|
|
70
|
+
# List formats
|
|
71
|
+
if Arguments().print_formats:
|
|
72
|
+
for format_name in [f for f in Settings().config['formats'] if f not in ['all']]:
|
|
73
|
+
format_dictionary = Settings().config['formats'][format_name]
|
|
74
|
+
Logger().log(f'[{format_name}]', retain=False)
|
|
75
|
+
|
|
76
|
+
if 'comments' in format_dictionary:
|
|
77
|
+
print('comment: {}'.format(Settings().config['formats'][format_name]['comments']['format']))
|
|
78
|
+
|
|
79
|
+
if 'output' in format_dictionary:
|
|
80
|
+
print('output: {}'.format(Settings().config['formats'][format_name]['output']['format']))
|
|
81
|
+
|
|
82
|
+
Logger().log('\n', retain=False)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Downloader
|
|
86
|
+
if Arguments().video_ids or Arguments().channels:
|
|
87
|
+
|
|
88
|
+
if Arguments().video_ids:
|
|
89
|
+
Downloader().videos(Arguments().video_ids)
|
|
90
|
+
|
|
91
|
+
if Arguments().channels:
|
|
92
|
+
Downloader().channels(Arguments().channels)
|
|
93
|
+
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
parser.print_help()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Optional, Dict, Union, List
|
|
2
|
+
|
|
3
|
+
from .singleton import Singleton
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Arguments(metaclass=Singleton):
|
|
7
|
+
"""
|
|
8
|
+
Arguments singleton
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
class Name:
|
|
12
|
+
SETTINGS_FILE: str = 'settings_file'
|
|
13
|
+
SETTINGS: str = 'settings'
|
|
14
|
+
INIT: str = 'init'
|
|
15
|
+
VERBOSE: str = 'verbose'
|
|
16
|
+
QUIET: str = 'quiet'
|
|
17
|
+
PREVIEW: str = 'preview'
|
|
18
|
+
FORMATS: str = 'formats'
|
|
19
|
+
VERSION: str = 'version'
|
|
20
|
+
OUTPUT: str = 'output'
|
|
21
|
+
CLIENT_ID: str = 'client_id'
|
|
22
|
+
CLIENT_SECRET: str = 'client_secret'
|
|
23
|
+
CHANNEL: str = 'channel'
|
|
24
|
+
USER: str = 'user'
|
|
25
|
+
INCLUDES: str = 'includes'
|
|
26
|
+
FIRST: str = 'first'
|
|
27
|
+
VIDEO: str = 'video'
|
|
28
|
+
FORMAT: str = 'format'
|
|
29
|
+
TIMEZONE: str = 'timezone'
|
|
30
|
+
DEBUG: str = 'debug'
|
|
31
|
+
LOG: str = 'log'
|
|
32
|
+
|
|
33
|
+
def __init__(self, arguments: Optional[Dict[str, Union[str, bool, int]]] = None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize arguments
|
|
36
|
+
:param arguments: Arguments from cli (Optional to call singleton instance without parameters)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
if arguments is None:
|
|
40
|
+
print('Error: arguments were not provided')
|
|
41
|
+
exit()
|
|
42
|
+
|
|
43
|
+
# Required arguments and booleans
|
|
44
|
+
self.settings_file: str = arguments[Arguments.Name.SETTINGS_FILE]
|
|
45
|
+
self.settings: str = arguments[Arguments.Name.SETTINGS]
|
|
46
|
+
self.init: bool = arguments[Arguments.Name.INIT]
|
|
47
|
+
self.verbose: bool = arguments[Arguments.Name.VERBOSE]
|
|
48
|
+
self.debug: bool = arguments[Arguments.Name.DEBUG]
|
|
49
|
+
self.quiet: bool = arguments[Arguments.Name.QUIET]
|
|
50
|
+
self.preview: bool = arguments[Arguments.Name.PREVIEW]
|
|
51
|
+
self.print_formats: bool = arguments[Arguments.Name.FORMATS]
|
|
52
|
+
self.print_version: bool = arguments[Arguments.Name.VERSION]
|
|
53
|
+
self.output: str = arguments[Arguments.Name.OUTPUT]
|
|
54
|
+
self.log: bool = arguments[Arguments.Name.LOG]
|
|
55
|
+
|
|
56
|
+
# Optional or prompted arguments
|
|
57
|
+
self.client_id: Optional[str] = arguments[Arguments.Name.CLIENT_ID]
|
|
58
|
+
self.client_secret: Optional[str] = arguments[Arguments.Name.CLIENT_SECRET]
|
|
59
|
+
self.oauth_token: Optional[str] = None
|
|
60
|
+
self.first: Optional[int] = arguments[Arguments.Name.FIRST]
|
|
61
|
+
self.timezone: Optional[str] = arguments[Arguments.Name.TIMEZONE]
|
|
62
|
+
self.includes: Optional[str] = arguments[Arguments.Name.INCLUDES]
|
|
63
|
+
|
|
64
|
+
# Arguments that require some formatting
|
|
65
|
+
self.video_ids: List[int] = []
|
|
66
|
+
self.formats: List[str] = []
|
|
67
|
+
self.channels: List[str] = []
|
|
68
|
+
self.users: List[str] = []
|
|
69
|
+
|
|
70
|
+
# Videos
|
|
71
|
+
if arguments[Arguments.Name.VIDEO]:
|
|
72
|
+
self.video_ids = [int(video_id) for video_id in arguments[Arguments.Name.VIDEO].lower().split(',')]
|
|
73
|
+
|
|
74
|
+
# Formats
|
|
75
|
+
if arguments[Arguments.Name.FORMAT]:
|
|
76
|
+
self.formats: Optional[List[str]] = arguments[Arguments.Name.FORMAT].lower().split(',')
|
|
77
|
+
|
|
78
|
+
# Channels
|
|
79
|
+
if arguments[Arguments.Name.CHANNEL]:
|
|
80
|
+
self.channels = arguments[Arguments.Name.CHANNEL].lower().split(',')
|
|
81
|
+
|
|
82
|
+
# Users
|
|
83
|
+
if arguments[Arguments.Name.USER]:
|
|
84
|
+
self.users = arguments[Arguments.Name.USER].lower().split(',')
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import dateutil
|
|
9
|
+
from twitch import Helix
|
|
10
|
+
from twitch.helix import Video
|
|
11
|
+
|
|
12
|
+
from .arguments import Arguments
|
|
13
|
+
from .formatter import Formatter
|
|
14
|
+
from .logger import Logger, Log
|
|
15
|
+
from .pipe import Pipe
|
|
16
|
+
from .settings import Settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Downloader:
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.helix_api = Helix(client_id=Settings().config['client_id'], bearer_token=Arguments().oauth_token, use_cache=True)
|
|
23
|
+
|
|
24
|
+
self.formats: List[str] = []
|
|
25
|
+
self.whitelist: List[str] = []
|
|
26
|
+
self.blacklist: List[str] = []
|
|
27
|
+
|
|
28
|
+
# Populate format list according to whitelist and blacklist
|
|
29
|
+
if 'all' in Arguments().formats and 'all' in Settings().config['formats']:
|
|
30
|
+
self.blacklist = Settings().config['formats']['all']['whitelist'] or []
|
|
31
|
+
self.whitelist = Settings().config['formats']['all']['blacklist'] or []
|
|
32
|
+
|
|
33
|
+
# Append formats to list if they can be used
|
|
34
|
+
self.formats = [format_name for format_name in Settings().config['formats'].keys() if
|
|
35
|
+
self._can_use_format(format_name)]
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
self.formats = [format_name for format_name in Arguments().formats if self._can_use_format(format_name)]
|
|
39
|
+
|
|
40
|
+
def _can_use_format(self, format_name: str) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Check if format name should be used based on whitelist and blacklist
|
|
43
|
+
:param format_name: Name of format
|
|
44
|
+
:return: If format should be used
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# Lowercase format name
|
|
48
|
+
format_name = format_name.lower()
|
|
49
|
+
|
|
50
|
+
# Reserved format names
|
|
51
|
+
if format_name in ['all']:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# Format does not exist
|
|
55
|
+
if format_name not in Settings().config['formats'].keys():
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# Whitelisted formats
|
|
59
|
+
if self.whitelist and format_name not in self.whitelist:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# Blacklisted formats
|
|
63
|
+
if self.blacklist and format_name in self.blacklist:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def video(self, video: Video) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Download chat from video
|
|
71
|
+
:param video: Video object
|
|
72
|
+
:return: None
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Parse video duration
|
|
76
|
+
regex = re.compile(r'((?P<hours>\d+?)h)?((?P<minutes>\d+?)m)?((?P<seconds>\d+?)s)?')
|
|
77
|
+
parts = regex.match(video.duration).groupdict()
|
|
78
|
+
|
|
79
|
+
time_params = {}
|
|
80
|
+
for name, param in parts.items():
|
|
81
|
+
if param:
|
|
82
|
+
time_params[name] = int(param)
|
|
83
|
+
|
|
84
|
+
video_duration = datetime.timedelta(**time_params)
|
|
85
|
+
|
|
86
|
+
formatter = Formatter(video)
|
|
87
|
+
|
|
88
|
+
# Special case for JSON
|
|
89
|
+
# Build JSON object before writing it
|
|
90
|
+
if 'json' in self.formats:
|
|
91
|
+
Logger().log('Downloading JSON data', Log.VERBOSE)
|
|
92
|
+
output: str = Pipe(Settings().config['formats']['json']['output']).output(video.data)
|
|
93
|
+
os.makedirs(os.path.dirname(output), exist_ok=True)
|
|
94
|
+
|
|
95
|
+
data: dict = {
|
|
96
|
+
'video': video.data,
|
|
97
|
+
'comments': []
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for comment in video.comments:
|
|
101
|
+
|
|
102
|
+
# Skip unspecified users if a list is provided.
|
|
103
|
+
if Arguments().users and comment.commenter.name.lower() not in Arguments().users:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# If specified, only include messages that include a specified string
|
|
107
|
+
if Arguments().includes and Arguments().includes not in comment.message.body.lower():
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Add comment to dictionary
|
|
111
|
+
data['comments'].append(comment.data)
|
|
112
|
+
|
|
113
|
+
# Ignore comments that were posted after the VOD finished
|
|
114
|
+
if Settings().config['formats']['json'].get('comments', {}).get('ignore_new_comments', False):
|
|
115
|
+
comment_date = dateutil.parser.parse(comment.created_at)
|
|
116
|
+
vod_finish_date = dateutil.parser.parse(video.created_at) + video_duration
|
|
117
|
+
|
|
118
|
+
if comment_date > vod_finish_date:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if Logger().should_print_type(Log.PROGRESS):
|
|
122
|
+
self.draw_progress(current=comment.content_offset_seconds,
|
|
123
|
+
end=video_duration.seconds,
|
|
124
|
+
description='json')
|
|
125
|
+
|
|
126
|
+
with open(output, 'w', encoding='utf-8') as file:
|
|
127
|
+
json.dump(data, file, indent=4, sort_keys=True)
|
|
128
|
+
|
|
129
|
+
Logger().log(f'[json] {output}')
|
|
130
|
+
|
|
131
|
+
# For each format (ignore json this time)
|
|
132
|
+
for format_name in [x for x in self.formats if x not in ['json']]:
|
|
133
|
+
Logger().log(f'Formatting chat using: {format_name}', Log.VERBOSE)
|
|
134
|
+
|
|
135
|
+
# Get (formatted_comment, comment), output
|
|
136
|
+
comment_tuple, output = formatter.use(format_name)
|
|
137
|
+
|
|
138
|
+
# Create output directory and write to file
|
|
139
|
+
os.makedirs(os.path.dirname(output), exist_ok=True)
|
|
140
|
+
with open(output, '+w', encoding='utf-8') as file:
|
|
141
|
+
# Write Twitch URL as header at the beginning of the file
|
|
142
|
+
twitch_url = f"https://www.twitch.tv/videos/{video.id}?filter=archives&sort=time"
|
|
143
|
+
file.write(f"{twitch_url}\n\n")
|
|
144
|
+
|
|
145
|
+
# For every comment in video
|
|
146
|
+
for formatted_comment, comment in comment_tuple:
|
|
147
|
+
|
|
148
|
+
# Skip unspecified users if a list is provided.
|
|
149
|
+
if Arguments().users and comment.commenter.name.lower() not in Arguments().users:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# If specified, only include messages that include a specified string
|
|
153
|
+
if Arguments().includes and Arguments().includes.lower() not in comment.message.body.lower():
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Ignore comments that were posted after the VOD finished
|
|
157
|
+
if Settings().config['formats'][format_name].get('comments', {}).get('ignore_new_comments', False):
|
|
158
|
+
comment_date = dateutil.parser.parse(comment.created_at)
|
|
159
|
+
vod_finish_date = dateutil.parser.parse(video.created_at) + video_duration
|
|
160
|
+
|
|
161
|
+
if comment_date > vod_finish_date:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Draw progress
|
|
165
|
+
if comment and Logger().should_print_type(Log.PROGRESS):
|
|
166
|
+
self.draw_progress(current=comment.content_offset_seconds,
|
|
167
|
+
end=video_duration.seconds,
|
|
168
|
+
description=format_name)
|
|
169
|
+
|
|
170
|
+
# Display preview
|
|
171
|
+
Logger().log(formatted_comment, Log.PREVIEW)
|
|
172
|
+
|
|
173
|
+
# Write comment to file
|
|
174
|
+
file.write('{}\n'.format(formatted_comment))
|
|
175
|
+
|
|
176
|
+
Logger().log('[{}] {}'.format(format_name, output))
|
|
177
|
+
|
|
178
|
+
def videos(self, video_ids: List[int]) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Download multiple video ids
|
|
181
|
+
:param video_ids: List of video ids
|
|
182
|
+
:return: None
|
|
183
|
+
"""
|
|
184
|
+
for video in self.helix_api.videos(video_ids):
|
|
185
|
+
Logger().log(format('\n{}'.format(video.title)), Log.REGULAR)
|
|
186
|
+
self.video(video)
|
|
187
|
+
|
|
188
|
+
def channels(self, channels: List[str]) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Download videos from multiple channels
|
|
191
|
+
:param channels: List of channel names
|
|
192
|
+
:return: None
|
|
193
|
+
"""
|
|
194
|
+
Logger().log(f'Starting download with first={Arguments().first}', Log.DEBUG)
|
|
195
|
+
for channel, videos in self.helix_api.users(channels).videos(first=Arguments().first):
|
|
196
|
+
Logger().log(format('\n{}'.format(channel.display_name)), Log.REGULAR)
|
|
197
|
+
Logger().log(f'Videos object type: {type(videos)}', Log.DEBUG)
|
|
198
|
+
video_count = 0
|
|
199
|
+
try:
|
|
200
|
+
for video in videos:
|
|
201
|
+
video_count += 1
|
|
202
|
+
Logger().log(f'Processing video #{video_count}: {video.id}', Log.DEBUG)
|
|
203
|
+
Logger().log(format('\n{}'.format(video.title)), Log.REGULAR)
|
|
204
|
+
try:
|
|
205
|
+
self.video(video)
|
|
206
|
+
Logger().log(f'Successfully processed video {video.id}', Log.DEBUG)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
Logger().log(f'Error processing video {video.id}: {e}', Log.ERROR)
|
|
209
|
+
import traceback
|
|
210
|
+
traceback.print_exc()
|
|
211
|
+
# Continue to next video instead of stopping
|
|
212
|
+
continue
|
|
213
|
+
except Exception as e:
|
|
214
|
+
Logger().log(f'Error iterating videos: {e}', Log.ERROR)
|
|
215
|
+
import traceback
|
|
216
|
+
traceback.print_exc()
|
|
217
|
+
Logger().log(f'Processed {video_count} videos for channel {channel.display_name}', Log.VERBOSE)
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def draw_progress(current: float, end: float, description: str = 'Downloading') -> None:
|
|
221
|
+
"""
|
|
222
|
+
Draw download progress
|
|
223
|
+
:param current: Current chat position (seconds)
|
|
224
|
+
:param end: End position (seconds)
|
|
225
|
+
:param description: Progress description
|
|
226
|
+
:return:
|
|
227
|
+
"""
|
|
228
|
+
sys.stdout.write('[{}] {}%\r'.format(description, '%.2f' % min(current * 10 / end * 10, 100.00)))
|
|
229
|
+
sys.stdout.flush()
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Generator, Tuple
|
|
2
|
+
|
|
3
|
+
from twitch.helix import Video
|
|
4
|
+
from twitch.v5 import Comments, Comment
|
|
5
|
+
|
|
6
|
+
from tcd.formats.format import Format
|
|
7
|
+
from tcd.pipe import Pipe
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Custom(Format):
|
|
11
|
+
|
|
12
|
+
def __init__(self, video: Video, format_name: str):
|
|
13
|
+
super().__init__(video, format_name)
|
|
14
|
+
|
|
15
|
+
def use(self) -> Tuple[Generator[Tuple[str, Comment], None, None], str]:
|
|
16
|
+
"""
|
|
17
|
+
Use this format
|
|
18
|
+
:return: tuple(formatted comment, comment), output format
|
|
19
|
+
"""
|
|
20
|
+
# Format comments
|
|
21
|
+
comments = self.comment_generator(self.video.comments)
|
|
22
|
+
|
|
23
|
+
# Format output
|
|
24
|
+
output: str = Pipe(self.format_dictionary['output']).output(self.video.data)
|
|
25
|
+
|
|
26
|
+
return comments, output
|
|
27
|
+
|
|
28
|
+
def comment_generator(self, comments: Comments) -> Generator[Tuple[str, Comment], None, None]:
|
|
29
|
+
for comment in comments:
|
|
30
|
+
yield Pipe(self.format_dictionary['comments']).comment(comment.data), comment
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from twitch.helix import Video
|
|
2
|
+
|
|
3
|
+
from tcd.settings import Settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Format:
|
|
7
|
+
|
|
8
|
+
def __init__(self, video: Video, format_name: str):
|
|
9
|
+
self.video: Video = video
|
|
10
|
+
self.format_name: str = format_name
|
|
11
|
+
self.format_dictionary: dict = Settings().config['formats'][format_name]
|