rcdl 2.2.2__py3-none-any.whl → 3.0.0b23__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.
- rcdl/__init__.py +5 -0
- rcdl/__main__.py +15 -3
- rcdl/core/__init__.py +0 -0
- rcdl/core/adapters.py +241 -0
- rcdl/core/api.py +31 -9
- rcdl/core/config.py +133 -14
- rcdl/core/db.py +239 -191
- rcdl/core/db_queries.py +75 -44
- rcdl/core/downloader.py +184 -142
- rcdl/core/downloader_subprocess.py +261 -85
- rcdl/core/file_io.py +13 -6
- rcdl/core/fuse.py +115 -106
- rcdl/core/models.py +83 -34
- rcdl/core/opti.py +90 -0
- rcdl/core/parser.py +80 -78
- rcdl/gui/__init__.py +0 -0
- rcdl/gui/__main__.py +5 -0
- rcdl/gui/db_viewer.py +41 -0
- rcdl/gui/gui.py +54 -0
- rcdl/gui/video_manager.py +178 -0
- rcdl/interface/__init__.py +0 -0
- rcdl/interface/cli.py +100 -20
- rcdl/interface/ui.py +117 -116
- rcdl/utils.py +174 -5
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/METADATA +48 -15
- rcdl-3.0.0b23.dist-info/RECORD +28 -0
- rcdl/scripts/migrate_creators_json_txt.py +0 -37
- rcdl/scripts/migrate_old_format_to_db.py +0 -188
- rcdl/scripts/upload_pypi.py +0 -98
- rcdl-2.2.2.dist-info/RECORD +0 -22
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/WHEEL +0 -0
- {rcdl-2.2.2.dist-info → rcdl-3.0.0b23.dist-info}/entry_points.txt +0 -0
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
# core/downloader_subprocess.py
|
|
2
2
|
|
|
3
|
+
"""
|
|
4
|
+
Handle all subprocess call to external tool (yt-dlp, ffmpeg, ...)
|
|
5
|
+
"""
|
|
6
|
+
|
|
3
7
|
import subprocess
|
|
4
8
|
import logging
|
|
5
9
|
from pathlib import Path
|
|
6
10
|
import os
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
|
|
13
|
+
from rcdl.interface.ui import UI, NestedProgress
|
|
14
|
+
from rcdl.core import parser
|
|
15
|
+
from rcdl.core.models import Media, Post
|
|
9
16
|
from rcdl.core.config import Config
|
|
10
|
-
from rcdl.
|
|
17
|
+
from rcdl.utils import bytes_to_str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ytdlp_clear_cache():
|
|
21
|
+
"""Clear yt-dlp cache"""
|
|
22
|
+
cmd = ["yt-dlp", "--rm-cache-dir"]
|
|
23
|
+
subprocess.run(cmd, check=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def kill_aria2c():
|
|
27
|
+
"""Kill all aria2c process"""
|
|
28
|
+
cmd = ["pkill", "-f", "aria2c"]
|
|
29
|
+
subprocess.run(cmd, check=False)
|
|
11
30
|
|
|
12
31
|
|
|
13
32
|
def ytdlp_subprocess(
|
|
14
33
|
url: str,
|
|
15
34
|
filepath: Path | str,
|
|
16
35
|
):
|
|
17
|
-
"""Call yt-dlp in a subprocess"""
|
|
36
|
+
"""Call yt-dlp in a subprocess to download a video"""
|
|
37
|
+
|
|
18
38
|
cmd = [
|
|
19
39
|
"yt-dlp",
|
|
20
40
|
"-q",
|
|
@@ -26,53 +46,150 @@ def ytdlp_subprocess(
|
|
|
26
46
|
"aria2c",
|
|
27
47
|
]
|
|
28
48
|
|
|
29
|
-
logging.info(
|
|
49
|
+
logging.info("CMD: %s", " ".join(cmd))
|
|
30
50
|
|
|
31
|
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
51
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
32
52
|
if result.returncode != 0:
|
|
33
|
-
logging.error(
|
|
53
|
+
logging.error("yt-dlp failed to dl vid: %s", result.stderr)
|
|
34
54
|
|
|
35
55
|
return result.returncode
|
|
36
56
|
|
|
37
57
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
def ffprobe_get_duration(path: Path) -> int | None:
|
|
59
|
+
"""Get duration of a video in seconds with ffprobe
|
|
60
|
+
Return an int or None if command failed"""
|
|
61
|
+
cmd = [
|
|
62
|
+
"ffprobe",
|
|
63
|
+
"-v",
|
|
64
|
+
"error",
|
|
65
|
+
"-show_entries",
|
|
66
|
+
"format=duration",
|
|
67
|
+
"-of",
|
|
68
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
69
|
+
str(path),
|
|
70
|
+
]
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
cmd,
|
|
74
|
+
stdout=subprocess.PIPE,
|
|
75
|
+
stderr=subprocess.DEVNULL,
|
|
76
|
+
text=True,
|
|
77
|
+
check=True,
|
|
78
|
+
)
|
|
79
|
+
return int(float(result.stdout.strip()))
|
|
80
|
+
except subprocess.CalledProcessError as e:
|
|
81
|
+
UI.error(f"Failed to use ffprobe on {path} due to {e}")
|
|
82
|
+
return None
|
|
83
|
+
except (AttributeError, ValueError, OverflowError) as e:
|
|
84
|
+
UI.error(f"Failed to parse duration result of {path} due to {e}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_max_width_height(medias: list[Media], post: Post) -> tuple[int, int]:
|
|
89
|
+
"""Get width and height of all media in list. Return max within video found and config"""
|
|
90
|
+
|
|
91
|
+
cmd = [
|
|
92
|
+
"ffprobe",
|
|
93
|
+
"-v",
|
|
94
|
+
"error",
|
|
95
|
+
"-select_streams",
|
|
96
|
+
"v:0",
|
|
97
|
+
"-show_entries",
|
|
98
|
+
"stream=width,height",
|
|
99
|
+
"-of",
|
|
100
|
+
"csv=p=0",
|
|
101
|
+
]
|
|
102
|
+
width = 0
|
|
103
|
+
height = 0
|
|
104
|
+
max_width = 1920
|
|
105
|
+
max_height = 1080
|
|
106
|
+
for m in medias:
|
|
107
|
+
path = os.path.join(Config.creator_folder(post.user), m.file_path)
|
|
108
|
+
full_cmd = cmd + [path]
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
full_cmd, capture_output=True, text=True, check=True
|
|
113
|
+
)
|
|
114
|
+
w_str, h_str = result.stdout.strip().split(",")
|
|
115
|
+
|
|
116
|
+
width = min(int(w_str), max_width)
|
|
117
|
+
height = min(int(h_str), max_height)
|
|
118
|
+
except subprocess.CalledProcessError as e:
|
|
119
|
+
UI.error(f"Fail to use ffprobe to get width, height on {path} due to {e}")
|
|
120
|
+
except (AttributeError, ValueError, OverflowError) as e:
|
|
121
|
+
UI.error(f"Failed to parse duration for {path} due to {e}")
|
|
122
|
+
return (width, height)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_total_duration(medias: list[Media], post: Post) -> int:
|
|
126
|
+
"""Get total duration in ms of all medias in list"""
|
|
127
|
+
|
|
128
|
+
def _get_duration(path: str) -> int:
|
|
129
|
+
"""Get video duration in ms"""
|
|
130
|
+
|
|
131
|
+
cmd = [
|
|
132
|
+
"ffprobe",
|
|
133
|
+
"-v",
|
|
134
|
+
"error",
|
|
135
|
+
"-select_streams",
|
|
136
|
+
"v:0",
|
|
137
|
+
"-show_entries",
|
|
138
|
+
"format=duration",
|
|
139
|
+
"-of",
|
|
140
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
141
|
+
path,
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
145
|
+
return int(float(result.stdout.strip()) * 1000)
|
|
146
|
+
|
|
147
|
+
duration = 0
|
|
148
|
+
for m in medias:
|
|
149
|
+
path = os.path.join(Config.creator_folder(post.user), m.file_path)
|
|
150
|
+
duration += _get_duration(path)
|
|
151
|
+
return duration
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ffmpeg_concat_build_command(medias: list[Media], post: Post) -> dict:
|
|
155
|
+
"""Build the ffmpeg concat command"""
|
|
156
|
+
|
|
157
|
+
width, height = get_max_width_height(medias, post)
|
|
158
|
+
logging.info("Found (%s, %s) (width, height) for this group.", width, height)
|
|
159
|
+
if width == 0:
|
|
160
|
+
width = Config.MAX_WIDTH
|
|
161
|
+
if height == 0:
|
|
162
|
+
height = Config.MAX_HEIGHT
|
|
45
163
|
|
|
46
164
|
# output path
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
output_path = os.path.join(Config.creator_folder(v.creator_id), output_filename)
|
|
165
|
+
output_filename = parser.get_filename_fuse(post)
|
|
166
|
+
output_path = os.path.join(Config.creator_folder(post.user), output_filename)
|
|
50
167
|
|
|
51
168
|
# build cmd
|
|
52
169
|
cmd = ["ffmpeg", "-y", "-progress", "pipe:2", "-nostats"]
|
|
53
170
|
|
|
54
171
|
# inputs
|
|
55
|
-
for
|
|
56
|
-
input_path = os.path.join(Config.creator_folder(
|
|
172
|
+
for media in medias:
|
|
173
|
+
input_path = os.path.join(Config.creator_folder(post.user), media.file_path)
|
|
57
174
|
cmd.extend(["-i", input_path])
|
|
58
175
|
|
|
59
176
|
# filter complex
|
|
60
177
|
filter_lines = []
|
|
61
|
-
for idx in range(len(
|
|
178
|
+
for idx in range(len(medias)):
|
|
62
179
|
filter_lines.append(
|
|
63
180
|
f"[{idx}:v]"
|
|
64
181
|
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
65
182
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
|
66
|
-
f"fps={
|
|
183
|
+
f"fps={Config.FPS},setsar=1"
|
|
67
184
|
f"[v{idx}]"
|
|
68
185
|
)
|
|
69
186
|
|
|
70
187
|
# concat inputs
|
|
71
188
|
concat = []
|
|
72
|
-
for idx in range(len(
|
|
189
|
+
for idx in range(len(medias)):
|
|
73
190
|
concat.append(f"[v{idx}][{idx}:a]")
|
|
74
191
|
|
|
75
|
-
filter_lines.append(f"{''.join(concat)}concat=n={len(
|
|
192
|
+
filter_lines.append(f"{''.join(concat)}concat=n={len(medias)}:v=1:a=1[outv][outa]")
|
|
76
193
|
filter_complex = ";".join(filter_lines)
|
|
77
194
|
|
|
78
195
|
cmd.extend(
|
|
@@ -86,13 +203,13 @@ def ffmpeg_concat_build_command(videos: list[Video]) -> dict:
|
|
|
86
203
|
"-c:v",
|
|
87
204
|
"libx264",
|
|
88
205
|
"-preset",
|
|
89
|
-
|
|
206
|
+
Config.PRESET,
|
|
90
207
|
"-threads",
|
|
91
|
-
str(
|
|
208
|
+
str(Config.THREADS),
|
|
92
209
|
"-c:a",
|
|
93
210
|
"aac",
|
|
94
|
-
"-
|
|
95
|
-
"
|
|
211
|
+
"-movflags",
|
|
212
|
+
"+faststart",
|
|
96
213
|
output_path,
|
|
97
214
|
]
|
|
98
215
|
)
|
|
@@ -100,38 +217,38 @@ def ffmpeg_concat_build_command(videos: list[Video]) -> dict:
|
|
|
100
217
|
return {"cmd": cmd, "output_path": output_path}
|
|
101
218
|
|
|
102
219
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
220
|
+
def parse_line_ffmpeg_concat_into_advance(line: str) -> int | None:
|
|
221
|
+
line = line.strip()
|
|
222
|
+
if not line:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
progres_key = "out_time_ms"
|
|
226
|
+
if line.startswith(progres_key):
|
|
227
|
+
current_progress_str = line.replace(f"{progres_key}=", "").strip()
|
|
228
|
+
try:
|
|
229
|
+
current_progress_us = int(current_progress_str)
|
|
230
|
+
current_progress_ms = current_progress_us // 1000
|
|
231
|
+
return current_progress_ms
|
|
232
|
+
except ValueError as e:
|
|
233
|
+
logging.warning(
|
|
234
|
+
"Skipping invalid progress line: %r (%s)",
|
|
235
|
+
current_progress_str,
|
|
236
|
+
e,
|
|
237
|
+
)
|
|
238
|
+
return None
|
|
239
|
+
except Exception as e:
|
|
240
|
+
UI.error(f"Unexpected error while updating progress: {e}")
|
|
241
|
+
return None
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def ffmpeg_concat(medias: list[Media], post: Post, progress: NestedProgress):
|
|
246
|
+
"""Run ffmpeg concat command to merge video together"""
|
|
247
|
+
|
|
248
|
+
command_builder = ffmpeg_concat_build_command(medias, post)
|
|
131
249
|
cmd = command_builder["cmd"]
|
|
132
|
-
output_path = command_builder["output_path"]
|
|
133
250
|
|
|
134
|
-
logging.info(
|
|
251
|
+
logging.info("CMD: %s", " ".join(cmd))
|
|
135
252
|
|
|
136
253
|
ffmpeg_log = Config.CACHE_DIR / "ffmpeg.log"
|
|
137
254
|
with open(ffmpeg_log, "w", encoding="utf-8") as log_file:
|
|
@@ -146,45 +263,104 @@ def ffmpeg_concat(videos: list[Video]):
|
|
|
146
263
|
)
|
|
147
264
|
|
|
148
265
|
assert process.stderr is not None
|
|
149
|
-
total_duration = get_total_duration(
|
|
150
|
-
progress
|
|
266
|
+
total_duration = get_total_duration(medias, post)
|
|
267
|
+
progress.start_current(
|
|
268
|
+
description=f"{post.user}->{medias[0].file_path}", total=total_duration
|
|
269
|
+
)
|
|
270
|
+
|
|
151
271
|
last_progress = 0
|
|
152
|
-
UI.set_current_concat_progress(f"{videos[0].relative_path}", output_path)
|
|
153
272
|
|
|
154
273
|
for line in process.stderr:
|
|
155
274
|
line = line.strip()
|
|
156
|
-
if not line:
|
|
157
|
-
continue
|
|
158
|
-
|
|
159
275
|
print(line, file=log_file)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
current_progress_ms = current_progress_us // 1000
|
|
167
|
-
delta = current_progress_ms - last_progress
|
|
168
|
-
progress.advance(task, advance=delta)
|
|
169
|
-
last_progress = current_progress_ms
|
|
170
|
-
except Exception:
|
|
171
|
-
pass
|
|
276
|
+
current_progress_ms = parse_line_ffmpeg_concat_into_advance(line)
|
|
277
|
+
if current_progress_ms is None:
|
|
278
|
+
continue
|
|
279
|
+
delta = current_progress_ms - last_progress
|
|
280
|
+
progress.advance_current(step=delta)
|
|
281
|
+
last_progress = current_progress_ms
|
|
172
282
|
|
|
173
283
|
process.wait()
|
|
174
|
-
|
|
284
|
+
progress.finish_current()
|
|
175
285
|
|
|
286
|
+
UI.debug(f"Result: {process.returncode}")
|
|
176
287
|
if process.returncode != 0:
|
|
177
288
|
UI.error(f"Failed to concat videos. See ffmpeg log file {ffmpeg_log}")
|
|
178
|
-
with open(ffmpeg_log, "r") as f:
|
|
179
|
-
lines = f.
|
|
180
|
-
logging.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
logging.error("---END FFMPEG LOG---")
|
|
289
|
+
with open(ffmpeg_log, "r", encoding="utf-8") as f:
|
|
290
|
+
lines = f.read()
|
|
291
|
+
logging.warning("---FFMPEG LOG---")
|
|
292
|
+
logging.warning(lines)
|
|
293
|
+
logging.warning("---END FFMPEG LOG---")
|
|
184
294
|
return process.returncode
|
|
185
295
|
|
|
186
|
-
temp_output_path = output_path
|
|
187
|
-
new_output_path = temp_output_path.replace("tmp_", "")
|
|
188
|
-
os.replace(temp_output_path, new_output_path)
|
|
189
|
-
UI.info(f"Rename {output_path} -> {output_path}")
|
|
190
296
|
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def parse_line_into_pourcent(line: str) -> float | None:
|
|
300
|
+
line = line.strip()
|
|
301
|
+
if not line:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
if "%" in line:
|
|
305
|
+
try:
|
|
306
|
+
parts = line.split("%")
|
|
307
|
+
parts = parts[0].strip().split(" ")
|
|
308
|
+
pourcent = parts[-1]
|
|
309
|
+
flt_prcnt = float(pourcent)
|
|
310
|
+
return flt_prcnt
|
|
311
|
+
except Exception as e:
|
|
312
|
+
UI.error(f"Error parsing line {line}: {e}")
|
|
313
|
+
return None
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def handbrake_optimized(media: Media, user: str, progress: NestedProgress):
|
|
318
|
+
"""Optimize video size with handbrake software"""
|
|
319
|
+
|
|
320
|
+
handbrake_process = Config.HANDBRAKE_RUN_CMD.split(" ")
|
|
321
|
+
|
|
322
|
+
folder_path = Config.creator_folder(user)
|
|
323
|
+
video_path = os.path.join(folder_path, media.file_path)
|
|
324
|
+
|
|
325
|
+
output_path = video_path + ".opti.mp4"
|
|
326
|
+
|
|
327
|
+
cmd = ["-i", video_path, "-o", output_path, "--preset", "HQ 1080p30 Surround"]
|
|
328
|
+
|
|
329
|
+
full_cmd = handbrake_process + cmd
|
|
330
|
+
UI.debug(f"Running cmd '{full_cmd}'")
|
|
331
|
+
|
|
332
|
+
# -- process
|
|
333
|
+
process = subprocess.Popen(
|
|
334
|
+
full_cmd,
|
|
335
|
+
stdout=subprocess.PIPE,
|
|
336
|
+
stderr=subprocess.DEVNULL,
|
|
337
|
+
text=True,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
assert process.stdout is not None
|
|
341
|
+
progress.start_current(description="Optimizing", total=100)
|
|
342
|
+
progress.set_status(
|
|
343
|
+
f"{user}@({media.service}) -> ",
|
|
344
|
+
f"{media.file_path} ({bytes_to_str(media.file_size)})",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
current_progress = 0.0
|
|
348
|
+
|
|
349
|
+
for line in process.stdout:
|
|
350
|
+
float_pourcent = parse_line_into_pourcent(line)
|
|
351
|
+
if float_pourcent is None:
|
|
352
|
+
continue
|
|
353
|
+
delta = float_pourcent - current_progress
|
|
354
|
+
current_progress = float_pourcent
|
|
355
|
+
progress.advance_current(step=delta)
|
|
356
|
+
|
|
357
|
+
process.wait()
|
|
358
|
+
progress.finish_current()
|
|
359
|
+
# -- end process
|
|
360
|
+
|
|
361
|
+
if process.returncode == 0:
|
|
362
|
+
UI.debug("Return code: 0")
|
|
363
|
+
else:
|
|
364
|
+
UI.error(f"Return code: {process.returncode}")
|
|
365
|
+
|
|
366
|
+
return process.returncode
|
rcdl/core/file_io.py
CHANGED
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
# core/file_io.py
|
|
2
2
|
|
|
3
|
+
"""All write/read to file function (excluding sqlite database)"""
|
|
4
|
+
|
|
3
5
|
import json
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
def write_json(path, data, mode="w"):
|
|
7
|
-
|
|
9
|
+
"""Write dict data to json"""
|
|
10
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
8
11
|
json.dump(data, f, indent=4)
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
def load_json(path) -> dict:
|
|
12
|
-
|
|
15
|
+
"""Load data from json"""
|
|
16
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
13
17
|
data = json.load(f)
|
|
14
18
|
return data
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def load_txt(path) -> list[str]:
|
|
18
|
-
|
|
22
|
+
"""Read text from a .txt file.
|
|
23
|
+
Return list of stripped lines"""
|
|
24
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
19
25
|
lines = f.readlines()
|
|
20
|
-
for i in
|
|
21
|
-
lines[i] =
|
|
26
|
+
for i, line in enumerate(lines):
|
|
27
|
+
lines[i] = line.strip()
|
|
22
28
|
return lines
|
|
23
29
|
|
|
24
30
|
|
|
25
31
|
def write_txt(path, lines: list[str] | str, mode: str = "a"):
|
|
32
|
+
"""Write txt to .txt file"""
|
|
26
33
|
if isinstance(lines, str):
|
|
27
34
|
lines = [lines]
|
|
28
35
|
|
|
29
|
-
with open(path, mode) as f:
|
|
36
|
+
with open(path, mode, encoding="utf-8") as f:
|
|
30
37
|
for line in lines:
|
|
31
38
|
if not line.endswith("\n"):
|
|
32
39
|
f.write(line + "\n")
|