talklib 1.4.0__tar.gz → 2.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: talklib
3
- Version: 1.4.0
3
+ Version: 2.0.2
4
4
  Summary: A package to automate processing of shows/segments airing on the TL
5
5
  Author-email: Ben Weddle <ben.weddle@gmail.com>
6
6
  Maintainer-email: Ben Weddle <ben.weddle@gmail.com>
@@ -28,25 +28,37 @@ Project-URL: Issues, https://github.com/talkinglibrary/talklib/issues
28
28
  Requires-Python: >=3.10
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE.txt
31
+ Requires-Dist: aiohappyeyeballs==2.4.0
31
32
  Requires-Dist: aiohttp==3.10.5
32
33
  Requires-Dist: aiohttp-retry==2.8.3
33
34
  Requires-Dist: aiosignal==1.3.1
35
+ Requires-Dist: annotated-types==0.7.0
34
36
  Requires-Dist: async-timeout==4.0.2
35
37
  Requires-Dist: attrs==22.1.0
38
+ Requires-Dist: bcrypt==4.2.0
39
+ Requires-Dist: boto3==1.35.18
40
+ Requires-Dist: botocore==1.35.18
36
41
  Requires-Dist: build==1.0.3
37
42
  Requires-Dist: certifi==2024.8.30
43
+ Requires-Dist: cffi==1.17.1
38
44
  Requires-Dist: charset-normalizer==2.1.1
39
45
  Requires-Dist: colorama==0.4.6
40
46
  Requires-Dist: coverage==6.5.0
47
+ Requires-Dist: cryptography==43.0.3
48
+ Requires-Dist: decorator==5.1.1
49
+ Requires-Dist: Deprecated==1.2.14
41
50
  Requires-Dist: docutils==0.20.1
42
51
  Requires-Dist: exceptiongroup==1.0.4
52
+ Requires-Dist: fabric==3.2.2
43
53
  Requires-Dist: ffmpeg-python==0.2.0
44
54
  Requires-Dist: frozenlist==1.4.1
45
55
  Requires-Dist: future==0.18.3
46
56
  Requires-Dist: idna==3.4
47
57
  Requires-Dist: importlib-metadata==7.0.1
48
58
  Requires-Dist: iniconfig==1.1.1
59
+ Requires-Dist: invoke==2.2.0
49
60
  Requires-Dist: jaraco.classes==3.3.0
61
+ Requires-Dist: jmespath==1.0.1
50
62
  Requires-Dist: keyring==24.3.0
51
63
  Requires-Dist: markdown-it-py==3.0.0
52
64
  Requires-Dist: mdurl==0.1.2
@@ -54,14 +66,20 @@ Requires-Dist: more-itertools==10.2.0
54
66
  Requires-Dist: multidict==6.0.4
55
67
  Requires-Dist: nh3==0.2.15
56
68
  Requires-Dist: packaging==21.3
69
+ Requires-Dist: paramiko==3.5.0
57
70
  Requires-Dist: pkginfo==1.9.6
58
71
  Requires-Dist: pluggy==1.0.0
72
+ Requires-Dist: pycparser==2.22
73
+ Requires-Dist: pydantic==2.9.1
74
+ Requires-Dist: pydantic_core==2.23.3
59
75
  Requires-Dist: Pygments==2.17.2
60
76
  Requires-Dist: PyJWT==2.6.0
77
+ Requires-Dist: PyNaCl==1.5.0
61
78
  Requires-Dist: pyparsing==3.0.9
62
79
  Requires-Dist: pyproject_hooks==1.0.0
63
80
  Requires-Dist: pytest==7.2.0
64
81
  Requires-Dist: pytest-cov==4.0.0
82
+ Requires-Dist: python-dateutil==2.9.0.post0
65
83
  Requires-Dist: pytz==2022.6
66
84
  Requires-Dist: pywin32-ctypes==0.2.2
67
85
  Requires-Dist: readme-renderer==42.0
@@ -69,10 +87,14 @@ Requires-Dist: requests==2.32.3
69
87
  Requires-Dist: requests-toolbelt==1.0.0
70
88
  Requires-Dist: rfc3986==2.0.0
71
89
  Requires-Dist: rich==13.7.0
90
+ Requires-Dist: s3transfer==0.10.2
91
+ Requires-Dist: six==1.16.0
72
92
  Requires-Dist: tomli==2.0.1
73
93
  Requires-Dist: twilio==9.0.2
74
94
  Requires-Dist: twine==4.0.2
95
+ Requires-Dist: typing_extensions==4.12.2
75
96
  Requires-Dist: urllib3==2.2.2
97
+ Requires-Dist: wrapt==1.16.0
76
98
  Requires-Dist: yarl==1.9.2
77
99
  Requires-Dist: zipp==3.17.0
78
100
 
@@ -82,7 +104,7 @@ Requires-Dist: zipp==3.17.0
82
104
  [![GitHub issues](https://img.shields.io/github/issues/Nashville-Public-Library/talklib.png)](https://github.com/Nashville-Public-Library/talklib/issues)
83
105
  [![last-commit](https://img.shields.io/github/last-commit/Nashville-Public-Library/talklib)](https://github.com/Nashville-Public-Library/talklib/commits/master)
84
106
 
85
- ## A package to automate processing of shows/segments airing on the TL
107
+ ## A package to automate processing TL shows/segments and podcasts
86
108
 
87
109
  [Skip to Examples](#examples)
88
110
 
@@ -102,7 +124,9 @@ Use this module to process the following types of shows/segments:
102
124
  ## Requirements
103
125
 
104
126
  ### -[Python](https://www.python.org/downloads/)
105
- Use Python 3.10 or higher
127
+ Use Python 3.10 or higher.
128
+
129
+ Make sure to select "add to PATH" during installation.
106
130
 
107
131
  ### -[FFmpeg](https://www.ffmpeg.org/download.html#build-windows)
108
132
  You need Windows binaries for both FFmpeg & FFprobe installed on the PC and added to the PATH:
@@ -121,11 +145,13 @@ See [below](#disable-twilio) for how to disable Twilio.
121
145
  ### -Environment Variables
122
146
  This package uses Environment Variables to help with portability and keep sensitive info separated. The entire list of these is in the `ev.py` file. Make sure to set **all** of these on your PC(s). They are case-sensitive!
123
147
 
124
- **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
148
+ > **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
125
149
 
126
150
  ---
127
151
  ## Installation
128
152
 
153
+ AFTER you have all of the above [requirements](#requirements), inst
154
+
129
155
  - Open a terminal/command prompt
130
156
  - ````bash
131
157
  pip install talklib
@@ -147,7 +173,10 @@ Before we begin, a general note:
147
173
  - Instead, we tell WR to run a Batch script (`.bat` file) which in turn will run the Python script (`.py` file).
148
174
  - Ensure the Batch & Python scripts are in the same directory.
149
175
  - A sample `.bat` file (`Example.bat`) is included in the [misc](https://github.com/Nashville-Public-Library/misc/tree/main/talklib_examples) repo.
150
- - PLEASE NOTE: the `.bat` file will run all Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
176
+ - Download this file and place it in the same folder as your Python file.
177
+ - Right-Click the file and click `Unblock` so that it can be executed.
178
+ - It is a best practice to give the `.bat` and `.py` files the same name, though it is not necessary.
179
+ - PLEASE NOTE: the `.bat` file will run **all** Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
151
180
 
152
181
  Here is what an example directory structure should look like:
153
182
  ````
@@ -156,7 +185,7 @@ D:\wireready
156
185
  -WP.bat
157
186
  -WP.py
158
187
  ````
159
- You would tell WR to run the `WP.bat` file, which would run the `WP.py` file.
188
+ You would schedule WR to run the `WP.bat` file, which would run the `WP.py` file.
160
189
 
161
190
  ----
162
191
 
@@ -318,34 +347,6 @@ optional
318
347
  - be careful with this!
319
348
  - default is 21
320
349
 
321
- ### Notes about formatting:
322
-
323
- if you're new to Python, here're some reminders
324
-
325
- ### string
326
- - must be enclosed in quotes (single or double; Python doesn't care)
327
-
328
- ### boolean
329
- - either True or False
330
- - do not enclose in quotes
331
- - must be capitalized
332
- - correct: True
333
- - incorrect: true
334
-
335
- ### number
336
- - in our case, these can be either type int OR float, meaning either whole numbers OR decimal numbers are allowed
337
- - OK: 5
338
- - also OK: 5.4
339
- - do not enclose in quotes
340
-
341
- ## Methods
342
-
343
- `run()`
344
-
345
- required
346
- - executes the script with the attributes you set
347
- - should be the last line in your script
348
-
349
350
  ## Examples<a id="examples"></a>
350
351
  ### RSS Example
351
352
 
@@ -4,7 +4,7 @@
4
4
  [![GitHub issues](https://img.shields.io/github/issues/Nashville-Public-Library/talklib.png)](https://github.com/Nashville-Public-Library/talklib/issues)
5
5
  [![last-commit](https://img.shields.io/github/last-commit/Nashville-Public-Library/talklib)](https://github.com/Nashville-Public-Library/talklib/commits/master)
6
6
 
7
- ## A package to automate processing of shows/segments airing on the TL
7
+ ## A package to automate processing TL shows/segments and podcasts
8
8
 
9
9
  [Skip to Examples](#examples)
10
10
 
@@ -24,7 +24,9 @@ Use this module to process the following types of shows/segments:
24
24
  ## Requirements
25
25
 
26
26
  ### -[Python](https://www.python.org/downloads/)
27
- Use Python 3.10 or higher
27
+ Use Python 3.10 or higher.
28
+
29
+ Make sure to select "add to PATH" during installation.
28
30
 
29
31
  ### -[FFmpeg](https://www.ffmpeg.org/download.html#build-windows)
30
32
  You need Windows binaries for both FFmpeg & FFprobe installed on the PC and added to the PATH:
@@ -43,11 +45,13 @@ See [below](#disable-twilio) for how to disable Twilio.
43
45
  ### -Environment Variables
44
46
  This package uses Environment Variables to help with portability and keep sensitive info separated. The entire list of these is in the `ev.py` file. Make sure to set **all** of these on your PC(s). They are case-sensitive!
45
47
 
46
- **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
48
+ > **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
47
49
 
48
50
  ---
49
51
  ## Installation
50
52
 
53
+ AFTER you have all of the above [requirements](#requirements), inst
54
+
51
55
  - Open a terminal/command prompt
52
56
  - ````bash
53
57
  pip install talklib
@@ -69,7 +73,10 @@ Before we begin, a general note:
69
73
  - Instead, we tell WR to run a Batch script (`.bat` file) which in turn will run the Python script (`.py` file).
70
74
  - Ensure the Batch & Python scripts are in the same directory.
71
75
  - A sample `.bat` file (`Example.bat`) is included in the [misc](https://github.com/Nashville-Public-Library/misc/tree/main/talklib_examples) repo.
72
- - PLEASE NOTE: the `.bat` file will run all Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
76
+ - Download this file and place it in the same folder as your Python file.
77
+ - Right-Click the file and click `Unblock` so that it can be executed.
78
+ - It is a best practice to give the `.bat` and `.py` files the same name, though it is not necessary.
79
+ - PLEASE NOTE: the `.bat` file will run **all** Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
73
80
 
74
81
  Here is what an example directory structure should look like:
75
82
  ````
@@ -78,7 +85,7 @@ D:\wireready
78
85
  -WP.bat
79
86
  -WP.py
80
87
  ````
81
- You would tell WR to run the `WP.bat` file, which would run the `WP.py` file.
88
+ You would schedule WR to run the `WP.bat` file, which would run the `WP.py` file.
82
89
 
83
90
  ----
84
91
 
@@ -240,34 +247,6 @@ optional
240
247
  - be careful with this!
241
248
  - default is 21
242
249
 
243
- ### Notes about formatting:
244
-
245
- if you're new to Python, here're some reminders
246
-
247
- ### string
248
- - must be enclosed in quotes (single or double; Python doesn't care)
249
-
250
- ### boolean
251
- - either True or False
252
- - do not enclose in quotes
253
- - must be capitalized
254
- - correct: True
255
- - incorrect: true
256
-
257
- ### number
258
- - in our case, these can be either type int OR float, meaning either whole numbers OR decimal numbers are allowed
259
- - OK: 5
260
- - also OK: 5.4
261
- - do not enclose in quotes
262
-
263
- ## Methods
264
-
265
- `run()`
266
-
267
- required
268
- - executes the script with the attributes you set
269
- - should be the last line in your script
270
-
271
250
  ## Examples<a id="examples"></a>
272
251
  ### RSS Example
273
252
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "talklib"
3
- version = "1.4.0"
3
+ version = "2.0.2"
4
4
  description = "A package to automate processing of shows/segments airing on the TL"
5
5
  readme = "README.md"
6
6
  license = {file = "LICENSE.txt"}
@@ -1,22 +1,34 @@
1
+ aiohappyeyeballs==2.4.0
1
2
  aiohttp==3.10.5
2
3
  aiohttp-retry==2.8.3
3
4
  aiosignal==1.3.1
5
+ annotated-types==0.7.0
4
6
  async-timeout==4.0.2
5
7
  attrs==22.1.0
8
+ bcrypt==4.2.0
9
+ boto3==1.35.18
10
+ botocore==1.35.18
6
11
  build==1.0.3
7
12
  certifi==2024.8.30
13
+ cffi==1.17.1
8
14
  charset-normalizer==2.1.1
9
15
  colorama==0.4.6
10
16
  coverage==6.5.0
17
+ cryptography==43.0.3
18
+ decorator==5.1.1
19
+ Deprecated==1.2.14
11
20
  docutils==0.20.1
12
21
  exceptiongroup==1.0.4
22
+ fabric==3.2.2
13
23
  ffmpeg-python==0.2.0
14
24
  frozenlist==1.4.1
15
25
  future==0.18.3
16
26
  idna==3.4
17
27
  importlib-metadata==7.0.1
18
28
  iniconfig==1.1.1
29
+ invoke==2.2.0
19
30
  jaraco.classes==3.3.0
31
+ jmespath==1.0.1
20
32
  keyring==24.3.0
21
33
  markdown-it-py==3.0.0
22
34
  mdurl==0.1.2
@@ -24,14 +36,20 @@ more-itertools==10.2.0
24
36
  multidict==6.0.4
25
37
  nh3==0.2.15
26
38
  packaging==21.3
39
+ paramiko==3.5.0
27
40
  pkginfo==1.9.6
28
41
  pluggy==1.0.0
42
+ pycparser==2.22
43
+ pydantic==2.9.1
44
+ pydantic_core==2.23.3
29
45
  Pygments==2.17.2
30
46
  PyJWT==2.6.0
47
+ PyNaCl==1.5.0
31
48
  pyparsing==3.0.9
32
49
  pyproject_hooks==1.0.0
33
50
  pytest==7.2.0
34
51
  pytest-cov==4.0.0
52
+ python-dateutil==2.9.0.post0
35
53
  pytz==2022.6
36
54
  pywin32-ctypes==0.2.2
37
55
  readme-renderer==42.0
@@ -39,9 +57,13 @@ requests==2.32.3
39
57
  requests-toolbelt==1.0.0
40
58
  rfc3986==2.0.0
41
59
  rich==13.7.0
60
+ s3transfer==0.10.2
61
+ six==1.16.0
42
62
  tomli==2.0.1
43
63
  twilio==9.0.2
44
64
  twine==4.0.2
65
+ typing_extensions==4.12.2
45
66
  urllib3==2.2.2
67
+ wrapt==1.16.0
46
68
  yarl==1.9.2
47
69
  zipp==3.17.0
@@ -1,3 +1,4 @@
1
1
  from talklib.show import TLShow
2
2
  from talklib.notify import Syslog
3
- from talklib.ffmpeg import FFMPEG
3
+ from talklib.ffmpeg import FFMPEG
4
+ from talklib.pod import TLPod
@@ -51,6 +51,7 @@ class EV:
51
51
  self.twilio_to = sort_twilio_to() # to where should texts/calls be sent
52
52
  self.icecast_user = os.environ['icecast_user'] # our icecast username
53
53
  self.icecast_pass = os.environ['icecast_pass'] # our icecast password
54
+ self.pod_server_uname = os.environ['pod_server_uname']
54
55
 
55
56
 
56
57
  def sort_destinations():
@@ -5,6 +5,7 @@ class FFMPEG:
5
5
  input_file: str = None,
6
6
  output_file: str = None,
7
7
  breakaway: int|float = 0,
8
+ compression: bool = True,
8
9
  compression_level: int|float = 21,
9
10
  sample_rate: int = 44100,
10
11
  audio_channels: int = 1
@@ -13,6 +14,7 @@ class FFMPEG:
13
14
  self.input_file = input_file
14
15
  self.output_file = output_file
15
16
  self.breakaway = breakaway
17
+ self.compression = compression
16
18
  self.compression_level = compression_level
17
19
  self.sample_rate = sample_rate
18
20
  self.audio_channels = audio_channels
@@ -29,9 +31,12 @@ class FFMPEG:
29
31
  command = {}
30
32
  command.update({'ar': self.sample_rate})
31
33
  command.update({'ac': self.audio_channels})
32
- command.update({'af': f'loudnorm=I=-{self.compression_level}'})
34
+ if self.compression:
35
+ command.update({'af': f'loudnorm=I=-{self.compression_level}'})
33
36
  if self.breakaway:
34
37
  command.update({'t': self.breakaway})
38
+ if self.output_file.endswith('mp3'):
39
+ command.update({'b:a': "96k"})
35
40
  command.update({'y': None})
36
41
  command.update({'filename': self.output_file})
37
42
 
@@ -94,7 +94,11 @@ class Notify:
94
94
  format['Subject'] = subject
95
95
  format['From'] = self.EV.fromEmail
96
96
  format['To'] = email
97
-
98
- mail = smtplib.SMTP(host=self.EV.mail_server)
99
- mail.send_message(format)
100
- mail.quit()
97
+ try:
98
+ mail = smtplib.SMTP(host=self.EV.mail_server)
99
+ mail.send_message(format)
100
+ mail.quit()
101
+ except ConnectionRefusedError as e:
102
+ print(e)
103
+ except TimeoutError as e:
104
+ print(e)
@@ -0,0 +1,381 @@
1
+ from datetime import datetime
2
+ import xml.etree.ElementTree as ET
3
+ import glob
4
+ import math
5
+ import os
6
+ import re
7
+ import time
8
+ from typing import ClassVar, Type
9
+
10
+ from fabric import Connection, Result
11
+ from pydantic import BaseModel, Field, model_validator
12
+
13
+ from talklib.ev import EV
14
+ from talklib.notify import Notify
15
+ from talklib.ffmpeg import FFMPEG
16
+ from talklib.utils import today_is_weekday
17
+
18
+ class Notifications(BaseModel):
19
+ prefix: ClassVar[str] = None # prefix all messages with identifier for the show/podcast
20
+ notify: Type[Notify] = Notify()
21
+
22
+ def prep_syslog(self, message: str, level: str = 'info'):
23
+ '''send message to syslog server'''
24
+ message = f'{self.prefix}: {message}'
25
+ print(message)
26
+ self.notify.send_syslog(message=message, level=level)
27
+
28
+ def prep_send_mail(self, message: str, subject: str):
29
+ '''send email to TL gmail account via relay address'''
30
+ subject = f'{subject}: {self.prefix}'
31
+ self.notify.send_mail(subject=subject, message=message)
32
+
33
+ def send_sms_if_enabled(self, message: str):
34
+ '''send sms via twilio IF twilio_enable is set to True'''
35
+ self.notify.send_sms(message=message)
36
+
37
+ def send_notifications(self, message: str, subject: str, syslog_level: str = 'error'):
38
+ '''we generally only want to send SMS via Twilio if today is on a weekend'''
39
+ if today_is_weekday():
40
+ self.prep_send_mail(message=message, subject=subject)
41
+ self.prep_syslog(message=message, level=syslog_level)
42
+ else:
43
+ self.send_sms_if_enabled(message=message)
44
+ self.prep_send_mail(message=message, subject=subject)
45
+ self.prep_syslog(message=message, level=syslog_level)
46
+
47
+ class SSH(BaseModel):
48
+ server: str = "assets.library.nashville.org"
49
+ user: str = EV().pod_server_uname
50
+ connection: Type[Connection] = Connection(host=server, user=user)
51
+ notifications: Notifications = Notifications()
52
+
53
+ def upload_file(self, file: str, folder: str) -> None:
54
+ self.check_folder_exists(folder=folder)
55
+
56
+ try:
57
+ self.notifications.prep_syslog(message=f"Attempting to upload '{file}' to {folder}/")
58
+ self.connection.put(local=file, remote=f"talkinglibrary/shows/{folder}", preserve_mode=False)
59
+ self.notifications.prep_syslog(message=f"Successfully uploaded '{file}' to {folder}/")
60
+ return
61
+ except (FileNotFoundError, Exception) as e:
62
+ to_send = f"unable to upload '{file}': {e}"
63
+ self.notifications.send_notifications(message=to_send, subject='Error')
64
+ raise e
65
+
66
+ def download_file(self, file: str, folder: str) -> None:
67
+ try:
68
+ self.notifications.prep_syslog(message=f"Attempting to download '{file}' from {folder}/")
69
+ self.connection.get(remote=f"talkinglibrary/shows/{folder}/{file}")
70
+ self.notifications.prep_syslog(message=f"Successfully downloaded '{file}' to {os.getcwd()}")
71
+ return file
72
+ except Exception as e:
73
+ to_send = f"unable to download '{file}': {e}"
74
+ self.notifications.send_notifications(message=to_send, subject='Error')
75
+ raise e
76
+
77
+ def delete_file(self, file: str, folder: str) -> None:
78
+ try:
79
+ self.notifications.prep_syslog(message=f"Attempting to delete '{file}' from {folder}/")
80
+ self.connection.sftp().remove(f"talkinglibrary/shows/{folder}/{file}")
81
+ self.notifications.prep_syslog(message=f"Successfully deleted '{file}' from {folder}/")
82
+ return
83
+ except (Exception, FileNotFoundError) as e:
84
+ self.notifications.send_notifications(
85
+ message=f"Unable to delete '{file}' from {folder}: {e}. Continuing automation...",
86
+ subject="Error")
87
+
88
+ def get_folders(self) -> list:
89
+ ret_val: list = []
90
+ result: Result = self.connection.run("cd talkinglibrary/shows && ls", hide=True)
91
+ result_stdout:str = result.stdout
92
+ folders: list[str] = result_stdout.rsplit("\n")
93
+ for folder in folders:
94
+ if folder != "":
95
+ ret_val.append(folder.lower())
96
+ return ret_val
97
+
98
+ def get_files_from_folder(self, folder: str) -> list:
99
+ ret_val: list = []
100
+ result: Result = self.connection.run(f"cd talkinglibrary/shows/{folder} && ls", hide=True)
101
+ result_stdout:str = result.stdout
102
+ files: list[str] = result_stdout.rsplit("\n")
103
+ for file in files:
104
+ if file != "": # exclude files without names?
105
+ ret_val.append(file.lower())
106
+ return ret_val
107
+
108
+ def get_all_files(self) -> list:
109
+ ret_val: list = []
110
+ result: Result = self.connection.run("cd talkinglibrary/shows && ls -R", hide=True)
111
+ result_stdout:str = result.stdout
112
+ files: list[str] = result_stdout.rsplit("\n")
113
+ for file in files:
114
+ if not file.startswith("./") and file != "": # exclude folders and files without names
115
+ ret_val.append(file.lower())
116
+ return ret_val
117
+
118
+ def check_folder_exists(self, folder: str) -> bool:
119
+ self.notifications.prep_syslog(message=f"checking if {folder}/ exists on server...")
120
+ folders = self.get_folders()
121
+ if folder.lower() in folders:
122
+ self.notifications.prep_syslog(message=f"{folder}/ exists!")
123
+ return True
124
+
125
+ to_send = f"cannot find folder titled: {folder}/ on server"
126
+ self.notifications.send_notifications(message=to_send, subject='Error')
127
+ raise Exception (to_send)
128
+
129
+
130
+ class Episode(BaseModel):
131
+ feed_file: str = Field(min_length=1, default=None)
132
+ audio_filename: str = Field(min_length=1, default=None)
133
+ bucket_folder: str = Field(min_length=1, default=None)
134
+ episode_title: str = Field(min_length=1, default=None)
135
+ notifications: Notifications = Notifications()
136
+ max_episodes: int = Field(default=None)
137
+ categories: list = Field(default=None)
138
+
139
+ def pub_date(self) -> str:
140
+ timezone = time.timezone/60/60 # 60 seconds per minute, 60 minutes per hour
141
+ timezone = round(timezone)
142
+ if time.localtime().tm_isdst: # if DST is currently active
143
+ timezone-=1
144
+
145
+ pub_date = datetime.now().strftime(f'%a, %d %b %Y %H:%M:%S -0{timezone}00')
146
+ self.notifications.prep_syslog(message=f"pubDate will be: {pub_date}")
147
+ return pub_date
148
+
149
+ def size_in_bytes(self, filename) -> str:
150
+ size_in_bytes = os.path.getsize(filename)
151
+ size_in_bytes = str(size_in_bytes)
152
+ self.notifications.prep_syslog(message=f"enclosure lenth will be {size_in_bytes}")
153
+ return size_in_bytes
154
+
155
+ def enclosure(self) -> str:
156
+ enclosure = f"https://assets.library.nashville.org/talkinglibrary/shows/{self.bucket_folder}/{self.audio_filename}"
157
+ self.notifications.prep_syslog(message=f"enclosure will be {enclosure}")
158
+ return enclosure
159
+
160
+ def itunes_duration(self) -> str:
161
+ ffmpeg = FFMPEG(input_file=self.audio_filename)
162
+ duration = ffmpeg.get_length_in_minutes()
163
+ seconds, minutes = math.modf(duration)
164
+
165
+ minutes = round(minutes)
166
+
167
+ seconds = seconds*60
168
+ seconds = round(seconds)
169
+
170
+ result = f"{minutes}:{seconds:02}"
171
+ self.notifications.prep_syslog(message=f"itunes:duration will be {result}")
172
+ return result
173
+
174
+ def add_new_episode(self):
175
+ '''Create an 'item' element. Then create all of the necessary sub elements and append them to the item element'''
176
+ ET.register_namespace(prefix="atom", uri="http://www.w3.org/2005/Atom")
177
+ ET.register_namespace(prefix="itunes", uri="http://www.itunes.com/dtds/podcast-1.0.dtd")
178
+ feed = ET.parse(self.feed_file)
179
+ root = feed.getroot()
180
+ root = root.find('channel')
181
+
182
+ item = ET.Element('item')
183
+
184
+ title = ET.Element('title')
185
+ title.text = self.episode_title
186
+ item.append(title)
187
+
188
+ pubDate = ET.Element('pubDate')
189
+ pubDate.text = self.pub_date()
190
+ item.append(pubDate)
191
+
192
+ enclosure = ET.Element('enclosure')
193
+ enclosure.set('url', self.enclosure())
194
+ enclosure.set('length', self.size_in_bytes(self.audio_filename))
195
+ enclosure.set('type', 'audio/mpeg')
196
+ item.append(enclosure)
197
+
198
+ guid = ET.Element('guid')
199
+ guid.set('isPermaLink', 'false')
200
+ guid.text = self.audio_filename # this is just the name of the audio file. useful for deleting the file later on...
201
+ item.append(guid)
202
+
203
+ itunes_duration_element = ET.Element("itunes:duration")
204
+ itunes_duration_element.text = self.itunes_duration()
205
+ item.append(itunes_duration_element)
206
+
207
+ for category in self.categories:
208
+ category_element = ET.Element("category")
209
+ category_element.text = category
210
+ self.notifications.prep_syslog(message=f"adding category: {category}")
211
+ item.append(category_element)
212
+
213
+ # insert the new 'item' element as the first item, but below all the other channel elements
214
+ items = root.findall('item')
215
+ if items:
216
+ # If there are existing items (there usually will be), add the new item before to the top
217
+ first_item = items[0]
218
+ index = list(root).index(first_item)
219
+ self.notifications.prep_syslog(message=f"adding {item} to feed at {index}")
220
+ root.insert(index, item)
221
+ else:
222
+ # If no items exist, add the new item to the bottom (after the other channel elements)
223
+ root.append(item)
224
+
225
+ last_build_date = root.find('lastBuildDate')
226
+ last_build_date.text = self.pub_date() # fine to use this same pub date, as the format for both is the same
227
+
228
+ ET.indent(feed) # makes the XML pretty looking
229
+ self.notifications.prep_syslog(message=f"writing feed file...")
230
+ feed.write(self.feed_file, encoding="utf-8", xml_declaration=True)
231
+
232
+ def remove_old_episodes(self):
233
+ ET.register_namespace(prefix="atom", uri="http://www.w3.org/2005/Atom")
234
+ ET.register_namespace(prefix="itunes", uri="http://www.itunes.com/dtds/podcast-1.0.dtd")
235
+ feed = ET.parse(self.feed_file)
236
+ root = feed.getroot()
237
+ root = root.find('channel')
238
+ items = root.findall('item')
239
+ number_of_items = len(items)
240
+ index = -1
241
+ while number_of_items > self.max_episodes:
242
+ self.notifications.prep_syslog(message=f"There are currently {number_of_items} items in the feed, which is above {self.max_episodes}")
243
+ item_to_remove = items[index] # locate last item in feed
244
+ guid = item_to_remove.find('guid').text # grab the guid (filename) so we can delete the file from the server
245
+ self.notifications.prep_syslog(f'removing from feed: {item_to_remove}')
246
+ root.remove(item_to_remove)
247
+
248
+ SSH().delete_file(folder=self.bucket_folder, file=guid)
249
+
250
+ ET.indent(feed)
251
+ self.notifications.prep_syslog(message=f"writing to feed file...")
252
+ feed.write(self.feed_file, encoding="utf-8", xml_declaration=True)
253
+ number_of_items-=1
254
+ index-=1
255
+
256
+
257
+ class TLPod(BaseModel):
258
+ '''
259
+ everything should be in lower case!
260
+
261
+ display_name: generic name for the show/program. WIll be displayed as the
262
+ episode "Title" in the podcast feed. type=string
263
+
264
+ filename_to_match: the base name of the show we want to match. do not include the date.
265
+ for example, to match RollingStone091322, use 'RollingStone'. type=string
266
+
267
+ bucket_folder: the name of the folder on S3 where the audio and RSS files are stored.
268
+ should be lower case. type=string
269
+
270
+ max_episodes_in_feed: the max number of episodes that should be in the feed after you add the episode.
271
+ '''
272
+ display_name: str = Field(min_length=1)
273
+ filename_to_match: str = Field(min_length=1)
274
+ categories: list = Field(default=[])
275
+ bucket_folder: str = Field(default=None)
276
+ max_episodes_in_feed: int = Field(ge=1, default=5)
277
+ override_filename: bool = False
278
+ audio_folders:list = EV().destinations
279
+ notifications: Type[Notifications] = Notifications()
280
+ episode: Type[Episode] = Episode(notifications=notifications)
281
+ ffmpeg: Type[FFMPEG] = FFMPEG()
282
+ ssh: Type[SSH] = SSH(notifications=notifications)
283
+
284
+ @model_validator(mode='after')
285
+ def post_update(self):
286
+ '''
287
+ the name of the bucket folder should match the base name of the file. If bucket_folder is not explicitly set
288
+ by the user, use the filename. However, if filename_override is being used, strip out the digits first.
289
+ '''
290
+ if not self.bucket_folder:
291
+ if self.override_filename:
292
+ self.bucket_folder = re.sub(pattern="[0-9]", string=self.filename_to_match.lower(), repl='')
293
+ else:
294
+ self.bucket_folder = self.filename_to_match.lower()
295
+
296
+ prefix = f"{self.display_name} (podcast)"
297
+ Notifications.prefix = prefix
298
+
299
+ return self
300
+
301
+ def get_filename_to_match(self) -> str:
302
+ if self.override_filename:
303
+ self.notifications.prep_syslog(message="filename override is turned ON")
304
+ return self.filename_to_match.lower()
305
+ today_date: str = datetime.now().strftime("%m%d%y") # this is how we date our programs: MMDDYY
306
+ return (self.filename_to_match + today_date).lower()
307
+
308
+ def match_file(self):
309
+ '''match the name of the program that has today's date in the filename'''
310
+ to_match = self.get_filename_to_match()
311
+ self.notifications.prep_syslog(message=f"looking for file to match: {to_match}")
312
+ for dest in self.audio_folders:
313
+ self.notifications.prep_syslog(message=f"searching for {to_match} in {dest}...")
314
+ files = glob.glob(f"{dest}/*.wav")
315
+ for file in files:
316
+ if to_match in file.lower():
317
+ self.notifications.prep_syslog(message="found matching file!")
318
+ return file
319
+ to_send = f"There was a problem podcasting {self.display_name}. Cannot find matched file {to_match} in {self.audio_folders}"
320
+ self.notifications.send_notifications(message=to_send, subject='Error')
321
+ raise FileNotFoundError
322
+
323
+ def convert(self, file:str):
324
+ output_filename = file.split('.')
325
+ output_filename = output_filename[0]
326
+ output_filename = os.path.basename(output_filename).lower()
327
+ output_filename = output_filename + '.mp3'
328
+ self.notifications.prep_syslog(message=f"Converted audio file will be {output_filename}")
329
+
330
+ self.ffmpeg.input_file = file
331
+ self.ffmpeg.output_file = output_filename
332
+ self.ffmpeg.compression = False # this is for podcasts. these files should already be edited
333
+
334
+ ffmpeg_commands = self.ffmpeg.get_commands()
335
+ self.notifications.prep_syslog(message=f"FFmppeg commands: {ffmpeg_commands}")
336
+
337
+ self.notifications.prep_syslog(message=f"Converting {file} to mp3...")
338
+ try:
339
+ output = self.ffmpeg.convert()
340
+ self.notifications.prep_syslog(message="File successfully converted")
341
+ return output
342
+ except Exception as ffmpeg_exception:
343
+ self.notifications.send_notifications(message=f'FFmpeg error: {ffmpeg_exception}', subject='Error')
344
+ raise ffmpeg_exception
345
+
346
+ def delete_local_file(self, file: str):
347
+ try:
348
+ self.notifications.prep_syslog(message=f"Attempting to delete local file: {file}")
349
+ os.remove(file)
350
+ self.notifications.prep_syslog(message=f"Successfully deleted local file: {file}")
351
+ return
352
+ except Exception:
353
+ self.notifications.prep_syslog(message=f"Unable to delete local file: {file}")
354
+
355
+ def run(self):
356
+ self.notifications.prep_syslog(message="Starting up...")
357
+
358
+ self.ssh.check_folder_exists(folder=self.bucket_folder)
359
+
360
+ audio_file = self.match_file()
361
+ converted_file = self.convert(file=audio_file)
362
+
363
+ feed_file = self.ssh.download_file(folder=self.bucket_folder, file='feed.xml') # all XML files on server should have the same name
364
+
365
+ self.episode.feed_file = feed_file
366
+ self.episode.audio_filename = converted_file
367
+ self.episode.bucket_folder = self.bucket_folder
368
+ self.episode.categories = self.categories
369
+ self.episode.episode_title = f"{self.display_name} ({datetime.now().strftime('%a, %d %B')})"
370
+ self.episode.max_episodes = self.max_episodes_in_feed
371
+
372
+ self.episode.add_new_episode()
373
+ self.episode.remove_old_episodes()
374
+
375
+ self.ssh.upload_file(folder=self.bucket_folder, file=converted_file)
376
+ self.ssh.upload_file(folder=self.bucket_folder, file=feed_file)
377
+
378
+ self.delete_local_file(file=feed_file)
379
+ self.delete_local_file(file=converted_file)
380
+
381
+ self.notifications.prep_syslog(message="All done.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: talklib
3
- Version: 1.4.0
3
+ Version: 2.0.2
4
4
  Summary: A package to automate processing of shows/segments airing on the TL
5
5
  Author-email: Ben Weddle <ben.weddle@gmail.com>
6
6
  Maintainer-email: Ben Weddle <ben.weddle@gmail.com>
@@ -28,25 +28,37 @@ Project-URL: Issues, https://github.com/talkinglibrary/talklib/issues
28
28
  Requires-Python: >=3.10
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE.txt
31
+ Requires-Dist: aiohappyeyeballs==2.4.0
31
32
  Requires-Dist: aiohttp==3.10.5
32
33
  Requires-Dist: aiohttp-retry==2.8.3
33
34
  Requires-Dist: aiosignal==1.3.1
35
+ Requires-Dist: annotated-types==0.7.0
34
36
  Requires-Dist: async-timeout==4.0.2
35
37
  Requires-Dist: attrs==22.1.0
38
+ Requires-Dist: bcrypt==4.2.0
39
+ Requires-Dist: boto3==1.35.18
40
+ Requires-Dist: botocore==1.35.18
36
41
  Requires-Dist: build==1.0.3
37
42
  Requires-Dist: certifi==2024.8.30
43
+ Requires-Dist: cffi==1.17.1
38
44
  Requires-Dist: charset-normalizer==2.1.1
39
45
  Requires-Dist: colorama==0.4.6
40
46
  Requires-Dist: coverage==6.5.0
47
+ Requires-Dist: cryptography==43.0.3
48
+ Requires-Dist: decorator==5.1.1
49
+ Requires-Dist: Deprecated==1.2.14
41
50
  Requires-Dist: docutils==0.20.1
42
51
  Requires-Dist: exceptiongroup==1.0.4
52
+ Requires-Dist: fabric==3.2.2
43
53
  Requires-Dist: ffmpeg-python==0.2.0
44
54
  Requires-Dist: frozenlist==1.4.1
45
55
  Requires-Dist: future==0.18.3
46
56
  Requires-Dist: idna==3.4
47
57
  Requires-Dist: importlib-metadata==7.0.1
48
58
  Requires-Dist: iniconfig==1.1.1
59
+ Requires-Dist: invoke==2.2.0
49
60
  Requires-Dist: jaraco.classes==3.3.0
61
+ Requires-Dist: jmespath==1.0.1
50
62
  Requires-Dist: keyring==24.3.0
51
63
  Requires-Dist: markdown-it-py==3.0.0
52
64
  Requires-Dist: mdurl==0.1.2
@@ -54,14 +66,20 @@ Requires-Dist: more-itertools==10.2.0
54
66
  Requires-Dist: multidict==6.0.4
55
67
  Requires-Dist: nh3==0.2.15
56
68
  Requires-Dist: packaging==21.3
69
+ Requires-Dist: paramiko==3.5.0
57
70
  Requires-Dist: pkginfo==1.9.6
58
71
  Requires-Dist: pluggy==1.0.0
72
+ Requires-Dist: pycparser==2.22
73
+ Requires-Dist: pydantic==2.9.1
74
+ Requires-Dist: pydantic_core==2.23.3
59
75
  Requires-Dist: Pygments==2.17.2
60
76
  Requires-Dist: PyJWT==2.6.0
77
+ Requires-Dist: PyNaCl==1.5.0
61
78
  Requires-Dist: pyparsing==3.0.9
62
79
  Requires-Dist: pyproject_hooks==1.0.0
63
80
  Requires-Dist: pytest==7.2.0
64
81
  Requires-Dist: pytest-cov==4.0.0
82
+ Requires-Dist: python-dateutil==2.9.0.post0
65
83
  Requires-Dist: pytz==2022.6
66
84
  Requires-Dist: pywin32-ctypes==0.2.2
67
85
  Requires-Dist: readme-renderer==42.0
@@ -69,10 +87,14 @@ Requires-Dist: requests==2.32.3
69
87
  Requires-Dist: requests-toolbelt==1.0.0
70
88
  Requires-Dist: rfc3986==2.0.0
71
89
  Requires-Dist: rich==13.7.0
90
+ Requires-Dist: s3transfer==0.10.2
91
+ Requires-Dist: six==1.16.0
72
92
  Requires-Dist: tomli==2.0.1
73
93
  Requires-Dist: twilio==9.0.2
74
94
  Requires-Dist: twine==4.0.2
95
+ Requires-Dist: typing_extensions==4.12.2
75
96
  Requires-Dist: urllib3==2.2.2
97
+ Requires-Dist: wrapt==1.16.0
76
98
  Requires-Dist: yarl==1.9.2
77
99
  Requires-Dist: zipp==3.17.0
78
100
 
@@ -82,7 +104,7 @@ Requires-Dist: zipp==3.17.0
82
104
  [![GitHub issues](https://img.shields.io/github/issues/Nashville-Public-Library/talklib.png)](https://github.com/Nashville-Public-Library/talklib/issues)
83
105
  [![last-commit](https://img.shields.io/github/last-commit/Nashville-Public-Library/talklib)](https://github.com/Nashville-Public-Library/talklib/commits/master)
84
106
 
85
- ## A package to automate processing of shows/segments airing on the TL
107
+ ## A package to automate processing TL shows/segments and podcasts
86
108
 
87
109
  [Skip to Examples](#examples)
88
110
 
@@ -102,7 +124,9 @@ Use this module to process the following types of shows/segments:
102
124
  ## Requirements
103
125
 
104
126
  ### -[Python](https://www.python.org/downloads/)
105
- Use Python 3.10 or higher
127
+ Use Python 3.10 or higher.
128
+
129
+ Make sure to select "add to PATH" during installation.
106
130
 
107
131
  ### -[FFmpeg](https://www.ffmpeg.org/download.html#build-windows)
108
132
  You need Windows binaries for both FFmpeg & FFprobe installed on the PC and added to the PATH:
@@ -121,11 +145,13 @@ See [below](#disable-twilio) for how to disable Twilio.
121
145
  ### -Environment Variables
122
146
  This package uses Environment Variables to help with portability and keep sensitive info separated. The entire list of these is in the `ev.py` file. Make sure to set **all** of these on your PC(s). They are case-sensitive!
123
147
 
124
- **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
148
+ > **ONCE YOU SET/CHANGE/UPDATE ENVIRONMENT VARIABLES ON YOUR PC, YOU NEED TO RESTART WIREREADY**
125
149
 
126
150
  ---
127
151
  ## Installation
128
152
 
153
+ AFTER you have all of the above [requirements](#requirements), inst
154
+
129
155
  - Open a terminal/command prompt
130
156
  - ````bash
131
157
  pip install talklib
@@ -147,7 +173,10 @@ Before we begin, a general note:
147
173
  - Instead, we tell WR to run a Batch script (`.bat` file) which in turn will run the Python script (`.py` file).
148
174
  - Ensure the Batch & Python scripts are in the same directory.
149
175
  - A sample `.bat` file (`Example.bat`) is included in the [misc](https://github.com/Nashville-Public-Library/misc/tree/main/talklib_examples) repo.
150
- - PLEASE NOTE: the `.bat` file will run all Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
176
+ - Download this file and place it in the same folder as your Python file.
177
+ - Right-Click the file and click `Unblock` so that it can be executed.
178
+ - It is a best practice to give the `.bat` and `.py` files the same name, though it is not necessary.
179
+ - PLEASE NOTE: the `.bat` file will run **all** Python files in the folder. This is one reason it is best to separate your Python files into different folders, each with its own `.bat` file.
151
180
 
152
181
  Here is what an example directory structure should look like:
153
182
  ````
@@ -156,7 +185,7 @@ D:\wireready
156
185
  -WP.bat
157
186
  -WP.py
158
187
  ````
159
- You would tell WR to run the `WP.bat` file, which would run the `WP.py` file.
188
+ You would schedule WR to run the `WP.bat` file, which would run the `WP.py` file.
160
189
 
161
190
  ----
162
191
 
@@ -318,34 +347,6 @@ optional
318
347
  - be careful with this!
319
348
  - default is 21
320
349
 
321
- ### Notes about formatting:
322
-
323
- if you're new to Python, here're some reminders
324
-
325
- ### string
326
- - must be enclosed in quotes (single or double; Python doesn't care)
327
-
328
- ### boolean
329
- - either True or False
330
- - do not enclose in quotes
331
- - must be capitalized
332
- - correct: True
333
- - incorrect: true
334
-
335
- ### number
336
- - in our case, these can be either type int OR float, meaning either whole numbers OR decimal numbers are allowed
337
- - OK: 5
338
- - also OK: 5.4
339
- - do not enclose in quotes
340
-
341
- ## Methods
342
-
343
- `run()`
344
-
345
- required
346
- - executes the script with the attributes you set
347
- - should be the last line in your script
348
-
349
350
  ## Examples<a id="examples"></a>
350
351
  ### RSS Example
351
352
 
@@ -6,6 +6,7 @@ src/talklib/__init__.py
6
6
  src/talklib/ev.py
7
7
  src/talklib/ffmpeg.py
8
8
  src/talklib/notify.py
9
+ src/talklib/pod.py
9
10
  src/talklib/show.py
10
11
  src/talklib/utils.py
11
12
  src/talklib.egg-info/PKG-INFO
@@ -1,22 +1,34 @@
1
+ aiohappyeyeballs==2.4.0
1
2
  aiohttp==3.10.5
2
3
  aiohttp-retry==2.8.3
3
4
  aiosignal==1.3.1
5
+ annotated-types==0.7.0
4
6
  async-timeout==4.0.2
5
7
  attrs==22.1.0
8
+ bcrypt==4.2.0
9
+ boto3==1.35.18
10
+ botocore==1.35.18
6
11
  build==1.0.3
7
12
  certifi==2024.8.30
13
+ cffi==1.17.1
8
14
  charset-normalizer==2.1.1
9
15
  colorama==0.4.6
10
16
  coverage==6.5.0
17
+ cryptography==43.0.3
18
+ decorator==5.1.1
19
+ Deprecated==1.2.14
11
20
  docutils==0.20.1
12
21
  exceptiongroup==1.0.4
22
+ fabric==3.2.2
13
23
  ffmpeg-python==0.2.0
14
24
  frozenlist==1.4.1
15
25
  future==0.18.3
16
26
  idna==3.4
17
27
  importlib-metadata==7.0.1
18
28
  iniconfig==1.1.1
29
+ invoke==2.2.0
19
30
  jaraco.classes==3.3.0
31
+ jmespath==1.0.1
20
32
  keyring==24.3.0
21
33
  markdown-it-py==3.0.0
22
34
  mdurl==0.1.2
@@ -24,14 +36,20 @@ more-itertools==10.2.0
24
36
  multidict==6.0.4
25
37
  nh3==0.2.15
26
38
  packaging==21.3
39
+ paramiko==3.5.0
27
40
  pkginfo==1.9.6
28
41
  pluggy==1.0.0
42
+ pycparser==2.22
43
+ pydantic==2.9.1
44
+ pydantic_core==2.23.3
29
45
  Pygments==2.17.2
30
46
  PyJWT==2.6.0
47
+ PyNaCl==1.5.0
31
48
  pyparsing==3.0.9
32
49
  pyproject_hooks==1.0.0
33
50
  pytest==7.2.0
34
51
  pytest-cov==4.0.0
52
+ python-dateutil==2.9.0.post0
35
53
  pytz==2022.6
36
54
  pywin32-ctypes==0.2.2
37
55
  readme-renderer==42.0
@@ -39,9 +57,13 @@ requests==2.32.3
39
57
  requests-toolbelt==1.0.0
40
58
  rfc3986==2.0.0
41
59
  rich==13.7.0
60
+ s3transfer==0.10.2
61
+ six==1.16.0
42
62
  tomli==2.0.1
43
63
  twilio==9.0.2
44
64
  twine==4.0.2
65
+ typing_extensions==4.12.2
45
66
  urllib3==2.2.2
67
+ wrapt==1.16.0
46
68
  yarl==1.9.2
47
69
  zipp==3.17.0
File without changes
File without changes
File without changes
File without changes