tcd2 3.2.2.post1__py3-none-any.whl → 3.2.2.post2__py3-none-any.whl

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.
tcd/__init__.py CHANGED
@@ -11,7 +11,7 @@ from .logger import Logger, Log
11
11
  from .settings import Settings
12
12
 
13
13
  __name__: str = 'tcd'
14
- __version__: str = '3.2.2.post1'
14
+ __version__: str = '3.2.2.post2'
15
15
  __all__: List[Callable] = [Arguments, Settings, Downloader, Logger, Log]
16
16
 
17
17
 
tcd/comments.py ADDED
@@ -0,0 +1,13 @@
1
+ from typing import Iterable
2
+
3
+ from twitch.helix import Video
4
+ from twitch.v5 import Comment
5
+
6
+ from .graphql_comments import GraphQLComments
7
+
8
+
9
+ def get_comments(video: Video) -> Iterable[Comment]:
10
+ """
11
+ Return an iterator of comments for a video using Twitch GraphQL.
12
+ """
13
+ return GraphQLComments(video.id)
tcd/downloader.py CHANGED
@@ -9,11 +9,12 @@ import dateutil
9
9
  from twitch import Helix
10
10
  from twitch.helix import Video
11
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
12
+ from .arguments import Arguments
13
+ from .comments import get_comments
14
+ from .formatter import Formatter
15
+ from .logger import Logger, Log
16
+ from .pipe import Pipe
17
+ from .settings import Settings
17
18
 
18
19
 
19
20
  class Downloader:
@@ -97,7 +98,7 @@ class Downloader:
97
98
  'comments': []
98
99
  }
99
100
 
100
- for comment in video.comments:
101
+ for comment in get_comments(video):
101
102
 
102
103
  # Skip unspecified users if a list is provided.
103
104
  if Arguments().users and comment.commenter.name.lower() not in Arguments().users:
tcd/formats/custom.py CHANGED
@@ -1,10 +1,10 @@
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
1
+ from typing import Generator, Tuple, Iterable
2
+
3
+ from twitch.helix import Video
4
+ from twitch.v5 import Comment
5
+
6
+ from tcd.formats.format import Format
7
+ from tcd.pipe import Pipe
8
8
 
9
9
 
10
10
  class Custom(Format):
@@ -12,19 +12,19 @@ class Custom(Format):
12
12
  def __init__(self, video: Video, format_name: str):
13
13
  super().__init__(video, format_name)
14
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)
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.comments())
22
22
 
23
23
  # Format output
24
24
  output: str = Pipe(self.format_dictionary['output']).output(self.video.data)
25
25
 
26
26
  return comments, output
27
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
28
+ def comment_generator(self, comments: Iterable[Comment]) -> Generator[Tuple[str, Comment], None, None]:
29
+ for comment in comments:
30
+ yield Pipe(self.format_dictionary['comments']).comment(comment.data), comment
tcd/formats/format.py CHANGED
@@ -1,11 +1,21 @@
1
- from twitch.helix import Video
1
+ from typing import Iterable
2
+
3
+ from twitch.helix import Video
4
+ from twitch.v5 import Comment
5
+
6
+ from tcd.comments import get_comments
7
+ from tcd.settings import Settings
8
+
9
+
10
+ class Format:
2
11
 
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]
12
+ def __init__(self, video: Video, format_name: str):
13
+ self.video: Video = video
14
+ self.format_name: str = format_name
15
+ self.format_dictionary: dict = Settings().config['formats'][format_name]
16
+
17
+ def comments(self) -> Iterable[Comment]:
18
+ """
19
+ Return an iterator for video comments using GraphQL.
20
+ """
21
+ return get_comments(self.video)
tcd/formats/srt.py CHANGED
@@ -1,12 +1,12 @@
1
- import datetime
2
- from typing import Tuple, Generator
3
-
4
- from twitch.helix import Video
5
- from twitch.v5 import Comment, Comments
6
-
7
- from tcd.formats.format import Format
8
- from tcd.pipe import Pipe
9
- from tcd.safedict import SafeDict
1
+ import datetime
2
+ from typing import Tuple, Generator, Iterable
3
+
4
+ from twitch.helix import Video
5
+ from twitch.v5 import Comment
6
+
7
+ from tcd.formats.format import Format
8
+ from tcd.pipe import Pipe
9
+ from tcd.safedict import SafeDict
10
10
 
11
11
 
12
12
  class SRT(Format):
@@ -18,12 +18,12 @@ class SRT(Format):
18
18
  """
19
19
  super().__init__(video, format_name='srt')
20
20
 
21
- def use(self) -> Tuple[Generator[Tuple[str, Comment], None, None], str]:
22
- """
23
- Use SRT format
24
- :return: Comment generator and output string
25
- """
26
- return self.subtitles(self.video.comments), Pipe(self.format_dictionary['output']).output(self.video.data)
21
+ def use(self) -> Tuple[Generator[Tuple[str, Comment], None, None], str]:
22
+ """
23
+ Use SRT format
24
+ :return: Comment generator and output string
25
+ """
26
+ return self.subtitles(self.comments()), Pipe(self.format_dictionary['output']).output(self.video.data)
27
27
 
28
28
  @staticmethod
29
29
  def format_timestamp(time: datetime.timedelta) -> str:
@@ -45,12 +45,12 @@ class SRT(Format):
45
45
 
46
46
  return f'{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d},{milliseconds:03d}'
47
47
 
48
- def subtitles(self, comments: Comments) -> Generator[Tuple[str, Comment], None, None]:
49
- """
50
- Subtitle generator
51
- :param comments: Comments to turn into subtitles
52
- :return: Generator with subtitles and subtitle data
53
- """
48
+ def subtitles(self, comments: Iterable[Comment]) -> Generator[Tuple[str, Comment], None, None]:
49
+ """
50
+ Subtitle generator
51
+ :param comments: Comments to turn into subtitles
52
+ :return: Generator with subtitles and subtitle data
53
+ """
54
54
  for index, comment in enumerate(comments):
55
55
  # Stat and stop timestamps. Add a millisecond for timedelta to include millisecond digits
56
56
  start = datetime.timedelta(seconds=comment.content_offset_seconds)
tcd/formats/ssa.py CHANGED
@@ -1,9 +1,9 @@
1
- import datetime
2
- from itertools import chain
3
- from typing import Tuple, Generator, List, Optional
1
+ import datetime
2
+ from itertools import chain
3
+ from typing import Tuple, Generator, List, Optional, Iterable
4
4
 
5
- from twitch.helix import Video
6
- from twitch.v5 import Comment, Comments
5
+ from twitch.helix import Video
6
+ from twitch.v5 import Comment
7
7
 
8
8
  from tcd.formats.format import Format
9
9
  from tcd.pipe import Pipe
@@ -32,8 +32,8 @@ class SSA(Format):
32
32
  Line generator
33
33
  :return:
34
34
  """
35
- for line in chain(self.prefix(), self.dialogues(self.video.comments)):
36
- yield line
35
+ for line in chain(self.prefix(), self.dialogues(self.comments())):
36
+ yield line
37
37
 
38
38
  @staticmethod
39
39
  def format_timestamp(time: datetime.timedelta) -> str:
@@ -55,7 +55,7 @@ class SSA(Format):
55
55
 
56
56
  return f'{int(hours):01d}:{int(minutes):02d}:{int(seconds):02d}.{centiseconds:02d}'
57
57
 
58
- def dialogues(self, comments: Comments) -> Generator[Tuple[str, Comments], None, None]:
58
+ def dialogues(self, comments: Iterable[Comment]) -> Generator[Tuple[str, Comment], None, None]:
59
59
  """
60
60
  Format comments as SSA dialogues
61
61
  :param comments: Comment to format
@@ -0,0 +1,211 @@
1
+ """
2
+ GraphQL-based comments module for Twitch VODs.
3
+ This replaces the deprecated V5 API with Twitch's GraphQL API.
4
+ """
5
+ from typing import Union, Generator, Dict, Any, List
6
+ import threading
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+
9
+ import requests
10
+ import twitch.v5 as v5
11
+
12
+
13
+ class GraphQLComments:
14
+ """
15
+ Fetches VOD comments using Twitch's GraphQL API.
16
+ """
17
+
18
+ GRAPHQL_ENDPOINT = 'https://gql.twitch.tv/gql'
19
+ # Public client ID used by Twitch web
20
+ CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
21
+ # SHA256 hash for the VideoCommentsByOffsetOrCursor persisted query
22
+ QUERY_HASH = 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a'
23
+
24
+ def __init__(self, video_id: Union[str, int], num_threads: int = 4):
25
+ self._video_id: str = str(video_id)
26
+ self._num_threads: int = num_threads
27
+ self._session = requests.Session()
28
+ self._session_lock = threading.Lock()
29
+
30
+ def _graphql_request(self, variables: Dict[str, Any]) -> Dict[str, Any]:
31
+ query = [{
32
+ "operationName": "VideoCommentsByOffsetOrCursor",
33
+ "variables": variables,
34
+ "extensions": {
35
+ "persistedQuery": {
36
+ "version": 1,
37
+ "sha256Hash": self.QUERY_HASH
38
+ }
39
+ }
40
+ }]
41
+
42
+ headers = {
43
+ 'Client-Id': self.CLIENT_ID,
44
+ 'Content-Type': 'application/json'
45
+ }
46
+
47
+ with self._session_lock:
48
+ response = self._session.post(self.GRAPHQL_ENDPOINT, json=query, headers=headers)
49
+ response.raise_for_status()
50
+ return response.json()
51
+
52
+ def _fetch_chunk(self, start_offset: float) -> List[Dict[str, Any]]:
53
+ comments: List[Dict[str, Any]] = []
54
+ variables = {
55
+ "videoID": self._video_id,
56
+ "contentOffsetSeconds": start_offset
57
+ }
58
+
59
+ try:
60
+ response_data = self._graphql_request(variables)
61
+ if not response_data:
62
+ return comments
63
+
64
+ data = response_data[0].get('data', {})
65
+ video_data = data.get('video')
66
+ if not video_data:
67
+ return comments
68
+
69
+ comments_data = video_data.get('comments')
70
+ if not comments_data:
71
+ return comments
72
+
73
+ edges = comments_data.get('edges', [])
74
+ for edge in edges:
75
+ comments.append(self._convert_graphql_comment_to_v5_format(edge))
76
+ except Exception as exc:
77
+ import sys
78
+ print(f"Error fetching chunk at offset {start_offset}: {exc}", file=sys.stderr)
79
+
80
+ return comments
81
+
82
+ def _convert_graphql_comment_to_v5_format(self, comment_node: Dict[str, Any]) -> Dict[str, Any]:
83
+ comment = comment_node.get('node', {})
84
+ commenter = comment.get('commenter', {})
85
+ message = comment.get('message', {})
86
+
87
+ fragments = []
88
+ emoticons = []
89
+ full_message_text = []
90
+
91
+ for fragment in message.get('fragments', []):
92
+ fragment_text = fragment.get('text', '')
93
+ full_message_text.append(fragment_text)
94
+ fragment_data = {'text': fragment_text}
95
+
96
+ emote = fragment.get('emote')
97
+ if emote:
98
+ emoticon_data = {
99
+ '_id': emote.get('emoteID'),
100
+ 'begin': len(''.join([f['text'] for f in fragments])),
101
+ 'end': None,
102
+ 'emoticon_id': emote.get('emoteID'),
103
+ 'emoticon_set_id': None
104
+ }
105
+ emoticon_data['end'] = emoticon_data['begin'] + len(fragment_data['text'])
106
+ emoticons.append(emoticon_data)
107
+ fragment_data['emoticon'] = emoticon_data
108
+
109
+ fragments.append(fragment_data)
110
+
111
+ message_body = ''.join(full_message_text)
112
+
113
+ user_badges = []
114
+ for badge in message.get('userBadges', []):
115
+ user_badges.append({
116
+ '_id': badge.get('setID'),
117
+ 'version': badge.get('version')
118
+ })
119
+
120
+ v5_comment = {
121
+ '_id': comment.get('id'),
122
+ 'created_at': comment.get('createdAt'),
123
+ 'updated_at': comment.get('updatedAt', comment.get('createdAt')),
124
+ 'channel_id': None,
125
+ 'content_type': 'video',
126
+ 'content_id': self._video_id,
127
+ 'content_offset_seconds': float(comment.get('contentOffsetSeconds', 0)),
128
+ 'commenter': {
129
+ 'display_name': commenter.get('displayName'),
130
+ '_id': commenter.get('id'),
131
+ 'name': commenter.get('login'),
132
+ 'type': None,
133
+ 'bio': None,
134
+ 'created_at': None,
135
+ 'updated_at': None,
136
+ 'logo': None
137
+ } if commenter else None,
138
+ 'source': 'chat',
139
+ 'state': 'published',
140
+ 'message': {
141
+ 'body': message_body,
142
+ 'emoticons': emoticons,
143
+ 'fragments': fragments,
144
+ 'is_action': False,
145
+ 'user_badges': user_badges,
146
+ 'user_color': message.get('userColor')
147
+ },
148
+ 'more_replies': False
149
+ }
150
+
151
+ return v5_comment
152
+
153
+ def __iter__(self) -> Generator['v5.Comment', None, None]:
154
+ initial_chunk = self._fetch_chunk(0.0)
155
+ if not initial_chunk:
156
+ return
157
+
158
+ chunk_interval = 3600
159
+ all_comments: List[Dict[str, Any]] = []
160
+ current_offset = 0.0
161
+ max_offset = 0.0
162
+
163
+ while True:
164
+ chunk_offsets = []
165
+ for i in range(self._num_threads):
166
+ offset = current_offset + (i * chunk_interval)
167
+ chunk_offsets.append(offset)
168
+
169
+ chunks_data = []
170
+ with ThreadPoolExecutor(max_workers=self._num_threads) as executor:
171
+ future_to_offset = {
172
+ executor.submit(self._fetch_chunk, offset): offset
173
+ for offset in chunk_offsets
174
+ }
175
+
176
+ for future in as_completed(future_to_offset):
177
+ offset = future_to_offset[future]
178
+ try:
179
+ chunk_comments = future.result()
180
+ if chunk_comments:
181
+ chunks_data.append((offset, chunk_comments))
182
+ except Exception as exc:
183
+ import sys
184
+ print(f"Error downloading chunk at {offset}: {exc}", file=sys.stderr)
185
+
186
+ chunks_data.sort(key=lambda x: x[0])
187
+
188
+ got_new_data = False
189
+ for _, chunk_comments in chunks_data:
190
+ for comment_data in chunk_comments:
191
+ comment_offset = float(comment_data.get('content_offset_seconds', 0))
192
+ if comment_offset <= max_offset:
193
+ continue
194
+
195
+ all_comments.append(comment_data)
196
+ max_offset = max(max_offset, comment_offset)
197
+ got_new_data = True
198
+
199
+ if not got_new_data:
200
+ break
201
+
202
+ current_offset = max_offset
203
+
204
+ all_comments.sort(key=lambda x: x.get('content_offset_seconds', 0))
205
+
206
+ from twitch.api import API
207
+ minimal_api = API(base_url='https://api.twitch.tv/v5/',
208
+ client_id=self.CLIENT_ID)
209
+
210
+ for comment_data in all_comments:
211
+ yield v5.Comment(api=minimal_api, data=comment_data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcd2
3
- Version: 3.2.2.post1
3
+ Version: 3.2.2.post2
4
4
  Summary: Twitch Chat Downloader (unofficial fork)
5
5
  Author: imeloben23
6
6
  Maintainer: imeloben23
@@ -0,0 +1,24 @@
1
+ tcd/__init__.py,sha256=UR76UQ1ofdIZtdYsWegE5FcU9e2NYeTd9HvUHEBsXR4,4944
2
+ tcd/__main__.py,sha256=r-uXw3zgKZ6lSdY61Q36HFSMTjzc6shN_W6F2x7gkDc,88
3
+ tcd/arguments.py,sha256=Vl9PnEPelrksAV8BNpYlHPqm0JE51qgR7_9yEZpaLmU,3283
4
+ tcd/comments.py,sha256=UCBJMs1nfedH3hCAn24r9L0Zi35VEMDuFG9tvOtIXA0,314
5
+ tcd/downloader.py,sha256=_RHe0EZy2USklXFj-oIbIpEDmBR6SuIdVFThAQlfv2U,9558
6
+ tcd/formatter.py,sha256=FilDWcys7nD-5b96eTYxMBA3TeHujOrnv8Is-nYn8GY,903
7
+ tcd/graphql_comments.py,sha256=vlMDyrzGH5tr2Xp5x95dq9oVWda1XgY88TfyZTK_JAQ,7476
8
+ tcd/logger.py,sha256=nnIrv0_-1ACy-32oel0PsfhMxc0s9H7Qu80SeyAN8rU,3362
9
+ tcd/pipe.py,sha256=U3VuSES3r_wG0zsq0eY_S2QJ4RoumKrYqpv8mI4_FLA,10567
10
+ tcd/safedict.py,sha256=_2EM-9LGA3JrSvQb7Z75aNeBDFQBYbsBa3TREaSNvOU,263
11
+ tcd/settings.py,sha256=_yxe1eC7jltc5dRRCZkx7ImyG5D_tWnnJnRCC58zsBY,4091
12
+ tcd/settings.reference.json,sha256=zQS5Bb7ZlaZSOMbLBG00IZvKNRXkQM6rQ_4vLUlnmGY,3230
13
+ tcd/singleton.py,sha256=PGSVYql6sCbrTsZVCZOTUufndCBfqRy0-6OQCaXIbII,371
14
+ tcd/formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ tcd/formats/custom.py,sha256=Jr2tVZgBJo_nPxY7-PhJLIDJeuZ0H_zMCY5phMqtgmY,964
16
+ tcd/formats/format.py,sha256=LBgBKwQdcozvsgwfxFRoACVsZDvyi4C2IJK-cIkurEM,567
17
+ tcd/formats/srt.py,sha256=RQL17u0nvV9pIMzbS2miFDvVDLfSCSjrpYLJUiSbXRs,2643
18
+ tcd/formats/ssa.py,sha256=uiHfJ50vJsewJYYztsdJIvLimoCLUUrVNYnMuwtrXNg,7064
19
+ tcd2-3.2.2.post2.dist-info/licenses/LICENSE,sha256=Ch-ZtxzUuhHcxKI4Yq4L_en60uZKBBpMamPeImjzpQM,1093
20
+ tcd2-3.2.2.post2.dist-info/METADATA,sha256=AxKDvI-nuUriIj7mOPl6BdF2kcv0jG9MtGmmMiH-704,1622
21
+ tcd2-3.2.2.post2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
22
+ tcd2-3.2.2.post2.dist-info/entry_points.txt,sha256=47ljm618xZuZ07uL5o9HYnJ8ZN8m0kFptoMIjzYy8cI,33
23
+ tcd2-3.2.2.post2.dist-info/top_level.txt,sha256=HF0kJO8nLMAOZIROY5suwglVvTWTXhkycR2TjicOJSA,4
24
+ tcd2-3.2.2.post2.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- tcd/__init__.py,sha256=56y8gP6QGKvOpl2Tl6UB_OW8ewlq4y9PEtwoiNmzvfI,4944
2
- tcd/__main__.py,sha256=r-uXw3zgKZ6lSdY61Q36HFSMTjzc6shN_W6F2x7gkDc,88
3
- tcd/arguments.py,sha256=Vl9PnEPelrksAV8BNpYlHPqm0JE51qgR7_9yEZpaLmU,3283
4
- tcd/downloader.py,sha256=-EU4Nh_nvDARXG3Lc5pbDI1Dm4RHUS4XXtIStizzf8k,9524
5
- tcd/formatter.py,sha256=FilDWcys7nD-5b96eTYxMBA3TeHujOrnv8Is-nYn8GY,903
6
- tcd/logger.py,sha256=nnIrv0_-1ACy-32oel0PsfhMxc0s9H7Qu80SeyAN8rU,3362
7
- tcd/pipe.py,sha256=U3VuSES3r_wG0zsq0eY_S2QJ4RoumKrYqpv8mI4_FLA,10567
8
- tcd/safedict.py,sha256=_2EM-9LGA3JrSvQb7Z75aNeBDFQBYbsBa3TREaSNvOU,263
9
- tcd/settings.py,sha256=_yxe1eC7jltc5dRRCZkx7ImyG5D_tWnnJnRCC58zsBY,4091
10
- tcd/settings.reference.json,sha256=zQS5Bb7ZlaZSOMbLBG00IZvKNRXkQM6rQ_4vLUlnmGY,3230
11
- tcd/singleton.py,sha256=PGSVYql6sCbrTsZVCZOTUufndCBfqRy0-6OQCaXIbII,371
12
- tcd/formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- tcd/formats/custom.py,sha256=_s8vY1CuYPLSwbV4KMRph0QHTekNNmERPh5DgyE9MaI,976
14
- tcd/formats/format.py,sha256=BzUJluE-SUVQ5XGayVEsnOD14TKirb5YGo9nvS-md04,309
15
- tcd/formats/srt.py,sha256=W7sRVq4opVNhFvPy94w1lBK_QSJ1qFhreh-0devRPOE,2659
16
- tcd/formats/ssa.py,sha256=9uB_5IcUxjECs55iuTfKgGAzGDHMASIzl5HkRSeG-e8,7068
17
- tcd2-3.2.2.post1.dist-info/licenses/LICENSE,sha256=Ch-ZtxzUuhHcxKI4Yq4L_en60uZKBBpMamPeImjzpQM,1093
18
- tcd2-3.2.2.post1.dist-info/METADATA,sha256=R2KQ2VN8xOhxS-fQHaM9ZKCArKAspEh6L-6xF_Yb6fk,1622
19
- tcd2-3.2.2.post1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- tcd2-3.2.2.post1.dist-info/entry_points.txt,sha256=47ljm618xZuZ07uL5o9HYnJ8ZN8m0kFptoMIjzYy8cI,33
21
- tcd2-3.2.2.post1.dist-info/top_level.txt,sha256=HF0kJO8nLMAOZIROY5suwglVvTWTXhkycR2TjicOJSA,4
22
- tcd2-3.2.2.post1.dist-info/RECORD,,