ttyplayer 0.3.0__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.
- ttyplayer/__init__.py +2 -0
- ttyplayer/cli.py +343 -0
- ttyplayer/control.py +244 -0
- ttyplayer/favorites.py +90 -0
- ttyplayer/history.py +64 -0
- ttyplayer/models.py +13 -0
- ttyplayer/player.py +652 -0
- ttyplayer/tui.py +618 -0
- ttyplayer/tui.tcss +125 -0
- ttyplayer/utils.py +80 -0
- ttyplayer/youtube.py +57 -0
- ttyplayer-0.3.0.dist-info/METADATA +222 -0
- ttyplayer-0.3.0.dist-info/RECORD +16 -0
- ttyplayer-0.3.0.dist-info/WHEEL +4 -0
- ttyplayer-0.3.0.dist-info/entry_points.txt +3 -0
- ttyplayer-0.3.0.dist-info/licenses/LICENSE +21 -0
ttyplayer/__init__.py
ADDED
ttyplayer/cli.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ttyplayer import control, favorites, history, player
|
|
12
|
+
from ttyplayer.utils import APP_NAME, data_path, format_time, parse_picks, unseen
|
|
13
|
+
|
|
14
|
+
app = typer.Typer()
|
|
15
|
+
|
|
16
|
+
# How to install mpv, per sys.platform prefix; the README's Install section and the
|
|
17
|
+
# install.sh / install.ps1 scripts repeat these, and tests keep all three in step.
|
|
18
|
+
MPV_INSTALL = {
|
|
19
|
+
"darwin": ["brew install mpv"],
|
|
20
|
+
"linux": [
|
|
21
|
+
"sudo apt-get install -y mpv",
|
|
22
|
+
"sudo dnf install -y mpv",
|
|
23
|
+
"sudo pacman -S --noconfirm mpv",
|
|
24
|
+
"sudo zypper install -y mpv",
|
|
25
|
+
"sudo apk add mpv",
|
|
26
|
+
],
|
|
27
|
+
"win32": ["winget install -e --id shinchiro.mpv", "scoop install mpv", "choco install mpv"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.callback()
|
|
32
|
+
def callback():
|
|
33
|
+
"""ttyplayer is a terminal YouTube player"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def version():
|
|
38
|
+
"""Print the version"""
|
|
39
|
+
typer.echo(f"{APP_NAME} {metadata.version(APP_NAME)}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def doctor():
|
|
44
|
+
"""Check that ttyplayer has everything it needs to play"""
|
|
45
|
+
passed = True
|
|
46
|
+
for ok, text in doctor_checks():
|
|
47
|
+
typer.echo(f"{'✓' if ok else '✗'} {text}")
|
|
48
|
+
passed = passed and ok
|
|
49
|
+
if not passed:
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def doctor_checks():
|
|
54
|
+
"""(ok, text) per check; nothing here touches the network."""
|
|
55
|
+
python = ".".join(map(str, sys.version_info[:3]))
|
|
56
|
+
yield sys.version_info >= (3, 13), f"Python {python}"
|
|
57
|
+
yield True, f"{APP_NAME} {metadata.version(APP_NAME)}"
|
|
58
|
+
yield check_yt_dlp()
|
|
59
|
+
yield check_mpv()
|
|
60
|
+
yield check_data_dir()
|
|
61
|
+
yield check_control_dir()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_yt_dlp():
|
|
65
|
+
try:
|
|
66
|
+
from yt_dlp.version import __version__
|
|
67
|
+
except ImportError as error:
|
|
68
|
+
return False, f"yt-dlp cannot be imported: {error}"
|
|
69
|
+
return True, f"yt-dlp {__version__}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_mpv():
|
|
73
|
+
path = shutil.which("mpv")
|
|
74
|
+
if path is None:
|
|
75
|
+
return False, f"mpv not found on PATH. {mpv_install_hint()}"
|
|
76
|
+
try:
|
|
77
|
+
return True, mpv_version(path)
|
|
78
|
+
except (OSError, subprocess.SubprocessError, IndexError) as error:
|
|
79
|
+
return False, f"mpv at {path} did not answer --version: {error}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mpv_version(path):
|
|
83
|
+
"""The first line `mpv --version` prints, e.g. mpv v0.39.0 Copyright ..."""
|
|
84
|
+
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=5, check=True)
|
|
85
|
+
return result.stdout.splitlines()[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mpv_install_hint(platform=sys.platform):
|
|
89
|
+
"""One line telling the user how to install mpv on this platform."""
|
|
90
|
+
for prefix, (command, *others) in MPV_INSTALL.items():
|
|
91
|
+
if platform.startswith(prefix):
|
|
92
|
+
alternatives = f" (or: {', '.join(others)})" if others else ""
|
|
93
|
+
return f"Install it with: {command}{alternatives}"
|
|
94
|
+
return "Install it from https://mpv.io/installation/"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_data_dir():
|
|
98
|
+
directory = data_path("")
|
|
99
|
+
try:
|
|
100
|
+
os.makedirs(directory, exist_ok=True)
|
|
101
|
+
tempfile.TemporaryFile(dir=directory).close()
|
|
102
|
+
except OSError as error:
|
|
103
|
+
return False, f"data dir {directory} is not writable: {error}"
|
|
104
|
+
return True, f"data dir {directory}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_control_dir():
|
|
108
|
+
endpoint = control.control_endpoint()
|
|
109
|
+
directory = os.path.dirname(endpoint.path)
|
|
110
|
+
try:
|
|
111
|
+
endpoint.prepare()
|
|
112
|
+
except OSError as error:
|
|
113
|
+
return False, f"control socket dir: {error}"
|
|
114
|
+
return True, f"control socket dir {directory}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def play(target: list[str], video: bool = False, limit: int = 5):
|
|
119
|
+
"""Play a YouTube link or playlist, or search and pick what to play.
|
|
120
|
+
|
|
121
|
+
Keys while playing: space pause, left/right or , . seek, up/down volume,
|
|
122
|
+
n next, p previous, q quit.
|
|
123
|
+
"""
|
|
124
|
+
start_playback(resolve(target, limit), video)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def search(query: list[str], limit: int = 5):
|
|
129
|
+
"""Search YouTube and list the results"""
|
|
130
|
+
from ttyplayer import youtube # yt-dlp loads on use, so doctor runs without it
|
|
131
|
+
|
|
132
|
+
videos = lookup(youtube.search, " ".join(query), limit)
|
|
133
|
+
exit_if_empty(videos)
|
|
134
|
+
print_videos(videos)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command(name="history")
|
|
138
|
+
def history_command(limit: int = 20, play: bool = False, video: bool = False, clear: bool = False):
|
|
139
|
+
"""List recently played videos; --play to pick some and play them again, --clear to forget"""
|
|
140
|
+
if clear:
|
|
141
|
+
typer.echo(f"Forgot {history.clear()} plays")
|
|
142
|
+
return
|
|
143
|
+
videos = history.load(limit=limit)
|
|
144
|
+
if not videos:
|
|
145
|
+
fail("Nothing played yet")
|
|
146
|
+
if play:
|
|
147
|
+
start_playback(pick_from(videos), video)
|
|
148
|
+
else:
|
|
149
|
+
print_videos(videos)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def favorite(target: list[str], limit: int = 5):
|
|
154
|
+
"""Add a YouTube link or playlist to favorites, or search and pick what to add"""
|
|
155
|
+
videos = resolve(target, limit)
|
|
156
|
+
exit_if_empty(videos)
|
|
157
|
+
for video in videos:
|
|
158
|
+
if favorites.add(video):
|
|
159
|
+
typer.echo(f"Favorited: {video.title}")
|
|
160
|
+
else:
|
|
161
|
+
typer.echo(f"Already a favorite: {video.title}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command(name="favorites")
|
|
165
|
+
def favorites_command(
|
|
166
|
+
play: bool = False, video: bool = False, remove: int | None = None, clear: bool = False
|
|
167
|
+
):
|
|
168
|
+
"""List favorites; --play to pick some and play them, --remove N to drop one, --clear to forget all"""
|
|
169
|
+
if clear:
|
|
170
|
+
typer.echo(f"Forgot {favorites.clear()} favorites")
|
|
171
|
+
return
|
|
172
|
+
if remove is not None:
|
|
173
|
+
removed = favorites.remove(remove)
|
|
174
|
+
if removed is None:
|
|
175
|
+
fail(f"No favorite number {remove}")
|
|
176
|
+
typer.echo(f"Removed: {removed.title}")
|
|
177
|
+
return
|
|
178
|
+
videos = favorites.load()
|
|
179
|
+
if not videos:
|
|
180
|
+
fail("No favorites yet")
|
|
181
|
+
if play:
|
|
182
|
+
start_playback(pick_from(videos), video)
|
|
183
|
+
else:
|
|
184
|
+
print_videos(videos)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def tui(video: bool = False):
|
|
189
|
+
"""Open the full-screen player: search, pick and play in one screen.
|
|
190
|
+
|
|
191
|
+
Keys: / search, Enter play, a add to queue, f favorite, m more results,
|
|
192
|
+
space pause, , . seek 5s, < > seek 30s, - + volume, M mute, n next,
|
|
193
|
+
p previous, 1-4 tabs, t theme, ? help, Ctrl-P commands, q quit.
|
|
194
|
+
"""
|
|
195
|
+
from ttyplayer import tui as screen # Textual loads only for this command
|
|
196
|
+
|
|
197
|
+
screen.TtyplayerApp(client_factory=player.MpvClient, resolve=screen.resolve, video=video).run()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def pause():
|
|
202
|
+
"""Pause or resume the ttyplayer playing in another terminal"""
|
|
203
|
+
remote("pause")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command(name="next")
|
|
207
|
+
def next_command():
|
|
208
|
+
"""Skip to the next video in the playing ttyplayer's queue"""
|
|
209
|
+
remote("next")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def prev():
|
|
214
|
+
"""Go back to the previous video in the playing ttyplayer's queue"""
|
|
215
|
+
remote("prev")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command()
|
|
219
|
+
def stop():
|
|
220
|
+
"""Stop the ttyplayer playing in another terminal"""
|
|
221
|
+
remote("stop")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def status():
|
|
226
|
+
"""Show what the playing ttyplayer is playing"""
|
|
227
|
+
typer.echo(player.status_line(remote("status")))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def remote(name):
|
|
231
|
+
"""Send name to the playing ttyplayer; its reply, or a one-line message and exit 1."""
|
|
232
|
+
try:
|
|
233
|
+
reply = control.send(name)
|
|
234
|
+
except control.ControlError:
|
|
235
|
+
fail("No ttyplayer is playing")
|
|
236
|
+
if not reply.get("ok"):
|
|
237
|
+
fail(reply.get("error", "The player refused"))
|
|
238
|
+
return reply
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve(target, limit):
|
|
242
|
+
"""Turn a link into its videos, or search words into the videos the user picks."""
|
|
243
|
+
from ttyplayer import youtube
|
|
244
|
+
|
|
245
|
+
target_text = " ".join(target)
|
|
246
|
+
if youtube.is_url(target_text):
|
|
247
|
+
return lookup(youtube.fetch, target_text)
|
|
248
|
+
|
|
249
|
+
def more(shown):
|
|
250
|
+
return unseen(lookup(youtube.search, target_text, len(shown) + limit), shown)
|
|
251
|
+
|
|
252
|
+
return pick_from(lookup(youtube.search, target_text, limit), more)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def pick_from(videos, more=None):
|
|
256
|
+
"""Show a numbered list and return the videos the user picked, in their order.
|
|
257
|
+
|
|
258
|
+
With more, the user can type m to list the next batch more(videos) returns.
|
|
259
|
+
"""
|
|
260
|
+
exit_if_empty(videos)
|
|
261
|
+
videos = list(videos)
|
|
262
|
+
print_videos(videos)
|
|
263
|
+
return [videos[pick - 1] for pick in ask_for_picks(videos, more)]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def ask_for_picks(videos, more=None):
|
|
267
|
+
"""Prompt until the answer is valid picks; m appends more's batch to videos and lists it."""
|
|
268
|
+
prompt = "Pick one or more numbers (1 or 1 3 5)"
|
|
269
|
+
if more:
|
|
270
|
+
prompt += ", m for more"
|
|
271
|
+
while True:
|
|
272
|
+
answer = typer.prompt(prompt)
|
|
273
|
+
if more and answer.strip().lower() == "m":
|
|
274
|
+
show_more(videos, more)
|
|
275
|
+
continue
|
|
276
|
+
picks = parse_picks(answer, len(videos))
|
|
277
|
+
if picks is not None:
|
|
278
|
+
return picks
|
|
279
|
+
typer.echo(f"Enter numbers between 1 and {len(videos)}, separated by spaces.", err=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def show_more(videos, more):
|
|
283
|
+
fresh = more(videos)
|
|
284
|
+
if not fresh:
|
|
285
|
+
typer.echo("No more results", err=True)
|
|
286
|
+
return
|
|
287
|
+
print_videos(fresh, start=len(videos) + 1)
|
|
288
|
+
videos.extend(fresh)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def start_playback(videos, with_video):
|
|
292
|
+
exit_if_empty(videos)
|
|
293
|
+
try:
|
|
294
|
+
client = player.MpvClient(with_video, on_play=history.record)
|
|
295
|
+
except FileNotFoundError:
|
|
296
|
+
fail(f"mpv is not installed. {mpv_install_hint()}")
|
|
297
|
+
except RuntimeError as error:
|
|
298
|
+
fail(str(error))
|
|
299
|
+
for video in videos:
|
|
300
|
+
client.add(video)
|
|
301
|
+
client.play_current()
|
|
302
|
+
client.run()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def print_videos(videos, start=1):
|
|
306
|
+
for number, video in enumerate(videos, start=start):
|
|
307
|
+
typer.echo(f"{number:>2}. {video.title} ({format_time(video.duration)}) {video.uploader}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def lookup(func, *args):
|
|
311
|
+
"""Run a youtube.* call, turning its errors into a one-line message and exit 1."""
|
|
312
|
+
from ttyplayer import youtube
|
|
313
|
+
|
|
314
|
+
began = time.monotonic()
|
|
315
|
+
try:
|
|
316
|
+
videos = func(*args)
|
|
317
|
+
except youtube.YouTubeError as error:
|
|
318
|
+
fail(f"YouTube lookup failed: {error}")
|
|
319
|
+
if player.timing():
|
|
320
|
+
typer.echo(f"lookup took {time.monotonic() - began:.1f}s", err=True)
|
|
321
|
+
return videos
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def exit_if_empty(videos):
|
|
325
|
+
if not videos:
|
|
326
|
+
fail("No videos found")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def fail(message):
|
|
330
|
+
typer.echo(message, err=True)
|
|
331
|
+
raise typer.Exit(code=1)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def utf8_streams():
|
|
335
|
+
"""Pipes and redirects on Windows default to a legacy code page; the ticks and meters need UTF-8."""
|
|
336
|
+
for stream in (sys.stdout, sys.stderr):
|
|
337
|
+
if stream.encoding.lower().replace("-", "") != "utf8":
|
|
338
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main():
|
|
342
|
+
utf8_streams()
|
|
343
|
+
app()
|
ttyplayer/control.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import socket
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
from ttyplayer.utils import APP_NAME, WINDOWS, data_path
|
|
12
|
+
|
|
13
|
+
CLIENT_TIMEOUT = 3 # seconds to wait for the player's reply
|
|
14
|
+
CONNECTION_TIMEOUT = 1 # seconds the server waits for one request line
|
|
15
|
+
ACCEPT_TIMEOUT = 0.2 # how often the server loop checks whether it was stopped
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ControlError(Exception):
|
|
19
|
+
"""No player answered on the control socket."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def control_path():
|
|
23
|
+
"""$XDG_RUNTIME_DIR/ttyplayer/control.sock, or a per-user dir under the temp dir."""
|
|
24
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
25
|
+
if runtime:
|
|
26
|
+
return os.path.join(runtime, APP_NAME, "control.sock")
|
|
27
|
+
return os.path.join(tempfile.gettempdir(), f"{APP_NAME}-{os.getuid()}", "control.sock")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def control_endpoint(path=None):
|
|
31
|
+
"""Where the player listens and clients connect: a Unix socket, or loopback TCP plus a token on Windows."""
|
|
32
|
+
return LoopbackEndpoint(path) if WINDOWS else SocketEndpoint(path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SocketEndpoint:
|
|
36
|
+
"""The control socket at path (control_path() by default), in a dir only this user can enter."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path=None):
|
|
39
|
+
self.path = path or control_path()
|
|
40
|
+
self.inode = None
|
|
41
|
+
|
|
42
|
+
def prepare(self):
|
|
43
|
+
private_dir(os.path.dirname(self.path))
|
|
44
|
+
|
|
45
|
+
def listen(self):
|
|
46
|
+
self.prepare()
|
|
47
|
+
if os.path.lexists(self.path):
|
|
48
|
+
os.unlink(self.path) # left behind by a player that crashed
|
|
49
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
50
|
+
try:
|
|
51
|
+
sock.bind(self.path)
|
|
52
|
+
sock.listen()
|
|
53
|
+
except OSError:
|
|
54
|
+
sock.close()
|
|
55
|
+
raise
|
|
56
|
+
# A newer player may replace the file; remove() must not remove that one.
|
|
57
|
+
self.inode = os.stat(self.path).st_ino
|
|
58
|
+
return sock
|
|
59
|
+
|
|
60
|
+
def connect(self):
|
|
61
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
62
|
+
sock.settimeout(CLIENT_TIMEOUT)
|
|
63
|
+
try:
|
|
64
|
+
sock.connect(self.path)
|
|
65
|
+
except OSError:
|
|
66
|
+
sock.close()
|
|
67
|
+
raise
|
|
68
|
+
return sock
|
|
69
|
+
|
|
70
|
+
def request(self, name):
|
|
71
|
+
return {"command": name}
|
|
72
|
+
|
|
73
|
+
def authorize(self, request):
|
|
74
|
+
return True # the dir's permissions already kept other users out
|
|
75
|
+
|
|
76
|
+
def remove(self):
|
|
77
|
+
try:
|
|
78
|
+
if os.stat(self.path).st_ino == self.inode:
|
|
79
|
+
os.unlink(self.path)
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LoopbackEndpoint:
|
|
85
|
+
"""127.0.0.1 on an ephemeral port; "<port> <token>" in path (%LOCALAPPDATA%\\ttyplayer\\control.txt).
|
|
86
|
+
|
|
87
|
+
Any local process can reach the port, so every request must carry the token,
|
|
88
|
+
which only processes that can read the user's profile can know.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, path=None):
|
|
92
|
+
self.path = path or str(data_path("control.txt"))
|
|
93
|
+
self.token = None
|
|
94
|
+
self.written = None
|
|
95
|
+
|
|
96
|
+
def prepare(self):
|
|
97
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
98
|
+
|
|
99
|
+
def listen(self):
|
|
100
|
+
self.prepare()
|
|
101
|
+
sock = socket.create_server(("127.0.0.1", 0))
|
|
102
|
+
self.token = secrets.token_hex(16)
|
|
103
|
+
self.written = f"{sock.getsockname()[1]} {self.token}"
|
|
104
|
+
try:
|
|
105
|
+
with open(self.path, "w") as file:
|
|
106
|
+
file.write(self.written)
|
|
107
|
+
except OSError:
|
|
108
|
+
sock.close()
|
|
109
|
+
raise
|
|
110
|
+
return sock
|
|
111
|
+
|
|
112
|
+
def connect(self):
|
|
113
|
+
with open(self.path) as file:
|
|
114
|
+
text = file.read()
|
|
115
|
+
try:
|
|
116
|
+
port, self.token = text.split()
|
|
117
|
+
address = ("127.0.0.1", int(port))
|
|
118
|
+
except ValueError:
|
|
119
|
+
raise OSError(f"{self.path} does not hold a port and a token") from None
|
|
120
|
+
return socket.create_connection(address, timeout=CLIENT_TIMEOUT)
|
|
121
|
+
|
|
122
|
+
def request(self, name):
|
|
123
|
+
return {"command": name, "token": self.token}
|
|
124
|
+
|
|
125
|
+
def authorize(self, request):
|
|
126
|
+
token = request.get("token")
|
|
127
|
+
if not isinstance(token, str):
|
|
128
|
+
return False
|
|
129
|
+
# As bytes: compare_digest refuses non-ASCII str, and JSON can carry lone surrogates.
|
|
130
|
+
return hmac.compare_digest(token.encode("utf-8", "surrogatepass"), self.token.encode())
|
|
131
|
+
|
|
132
|
+
def remove(self):
|
|
133
|
+
try:
|
|
134
|
+
with open(self.path) as file:
|
|
135
|
+
if file.read() != self.written:
|
|
136
|
+
return # a newer player wrote its own
|
|
137
|
+
os.unlink(self.path)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def ok(**fields):
|
|
143
|
+
return {"ok": True, **fields}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def failure(message):
|
|
147
|
+
return {"ok": False, "error": message}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- client -------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def send(name, path=None):
|
|
154
|
+
"""Send one command to the running player and return its reply."""
|
|
155
|
+
endpoint = control_endpoint(path)
|
|
156
|
+
try:
|
|
157
|
+
with endpoint.connect() as sock, sock.makefile("rw") as stream:
|
|
158
|
+
stream.write(json.dumps(endpoint.request(name)) + "\n")
|
|
159
|
+
stream.flush()
|
|
160
|
+
text = stream.readline()
|
|
161
|
+
except OSError as error: # no socket file, refused, or no reply in time
|
|
162
|
+
raise ControlError(str(error)) from error
|
|
163
|
+
if not text:
|
|
164
|
+
raise ControlError("the player closed the connection")
|
|
165
|
+
return json.loads(text)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- server -------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def private_dir(directory):
|
|
172
|
+
"""Create directory 0o700, then refuse it unless it is a real dir only this user can enter."""
|
|
173
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
174
|
+
info = os.lstat(directory) # lstat: a symlink could point anywhere
|
|
175
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
176
|
+
raise OSError(f"{directory} is not a directory")
|
|
177
|
+
if info.st_uid != os.getuid():
|
|
178
|
+
raise OSError(f"{directory} belongs to another user")
|
|
179
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
180
|
+
raise OSError(f"{directory} is open to other users")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def reply_to(line, handler, authorize):
|
|
184
|
+
"""The reply to one request line; a malformed or unauthorized line gets ok: false, never an exception."""
|
|
185
|
+
try:
|
|
186
|
+
request = json.loads(line)
|
|
187
|
+
name = request["command"]
|
|
188
|
+
except (ValueError, TypeError, KeyError):
|
|
189
|
+
return failure("malformed request")
|
|
190
|
+
if not isinstance(name, str):
|
|
191
|
+
return failure("malformed request")
|
|
192
|
+
if not authorize(request):
|
|
193
|
+
return failure("bad token")
|
|
194
|
+
return handler(name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Server:
|
|
198
|
+
"""Answers one request per connection on a daemon thread with handler(name) -> dict."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, handler, path=None):
|
|
201
|
+
self.handler = handler
|
|
202
|
+
self.endpoint = control_endpoint(path)
|
|
203
|
+
self.path = self.endpoint.path
|
|
204
|
+
self.sock = self.endpoint.listen()
|
|
205
|
+
self.sock.settimeout(ACCEPT_TIMEOUT)
|
|
206
|
+
self.stopped = threading.Event()
|
|
207
|
+
self.thread = threading.Thread(target=self.serve, daemon=True)
|
|
208
|
+
self.thread.start()
|
|
209
|
+
|
|
210
|
+
def serve(self):
|
|
211
|
+
while not self.stopped.is_set():
|
|
212
|
+
try:
|
|
213
|
+
conn, _ = self.sock.accept()
|
|
214
|
+
except TimeoutError:
|
|
215
|
+
continue
|
|
216
|
+
except OSError:
|
|
217
|
+
break
|
|
218
|
+
with conn:
|
|
219
|
+
self.answer(conn)
|
|
220
|
+
|
|
221
|
+
def answer(self, conn):
|
|
222
|
+
conn.settimeout(CONNECTION_TIMEOUT)
|
|
223
|
+
try:
|
|
224
|
+
# Undecodable bytes become U+FFFD, so they fail as malformed JSON, not here.
|
|
225
|
+
with conn.makefile("rw", encoding="utf-8", errors="replace") as stream:
|
|
226
|
+
reply = reply_to(stream.readline(), self.handler, self.endpoint.authorize)
|
|
227
|
+
stream.write(json.dumps(reply) + "\n")
|
|
228
|
+
except OSError:
|
|
229
|
+
pass # the client went away; wait for the next one
|
|
230
|
+
|
|
231
|
+
def stop(self):
|
|
232
|
+
self.stopped.set()
|
|
233
|
+
self.thread.join(timeout=1)
|
|
234
|
+
self.sock.close()
|
|
235
|
+
self.endpoint.remove()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def serve(handler, path=None):
|
|
239
|
+
"""Start a Server, or warn on stderr and return None: playing never depends on it."""
|
|
240
|
+
try:
|
|
241
|
+
return Server(handler, path)
|
|
242
|
+
except OSError as error:
|
|
243
|
+
print(f"Remote control is off: {error}", file=sys.stderr)
|
|
244
|
+
return None
|
ttyplayer/favorites.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Videos the user chose to keep, one JSON object per line, newest at the bottom."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ttyplayer.models import Video
|
|
8
|
+
from ttyplayer.utils import data_path, video_from_info
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def favorites_path() -> Path:
|
|
12
|
+
return data_path("favorites.jsonl")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add(video: Video, path: Path | None = None) -> bool:
|
|
16
|
+
"""Favorite a video. Returns False if it already was one."""
|
|
17
|
+
path = path or favorites_path()
|
|
18
|
+
if any(entry["id"] == video.id for entry in _entries(path)):
|
|
19
|
+
return False
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
entry = {
|
|
22
|
+
"id": video.id,
|
|
23
|
+
"title": video.title,
|
|
24
|
+
"uploader": video.uploader,
|
|
25
|
+
"duration": video.duration,
|
|
26
|
+
"favorited_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
27
|
+
}
|
|
28
|
+
with path.open("a", encoding="utf-8") as f:
|
|
29
|
+
f.write(json.dumps(entry) + "\n")
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def remove(number: int, path: Path | None = None) -> Video | None:
|
|
34
|
+
"""Drop the `number`th favorite as load() lists it (1-indexed). None if out of range."""
|
|
35
|
+
path = path or favorites_path()
|
|
36
|
+
videos = load(path)
|
|
37
|
+
if not 1 <= number <= len(videos):
|
|
38
|
+
return None
|
|
39
|
+
removed = videos[number - 1]
|
|
40
|
+
remove_id(removed.id, path)
|
|
41
|
+
return removed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove_id(video_id: str, path: Path | None = None) -> bool:
|
|
45
|
+
"""Drop the favorite with this id. Returns False if it was not one."""
|
|
46
|
+
path = path or favorites_path()
|
|
47
|
+
entries = _entries(path)
|
|
48
|
+
kept = [entry for entry in reversed(entries) if entry["id"] != video_id]
|
|
49
|
+
if len(kept) == len(entries):
|
|
50
|
+
return False
|
|
51
|
+
path.write_text("".join(json.dumps(entry) + "\n" for entry in kept), encoding="utf-8")
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ids(path: Path | None = None) -> set[str]:
|
|
56
|
+
"""The id of every favorite."""
|
|
57
|
+
return {entry["id"] for entry in _entries(path or favorites_path())}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clear(path: Path | None = None) -> int:
|
|
61
|
+
"""Forget every favorite. Returns how many there were."""
|
|
62
|
+
path = path or favorites_path()
|
|
63
|
+
count = len(load(path))
|
|
64
|
+
path.unlink(missing_ok=True)
|
|
65
|
+
return count
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load(path: Path | None = None, limit: int | None = None) -> list[Video]:
|
|
69
|
+
"""Most recently added first, each video once, at most `limit` of them (all if None)."""
|
|
70
|
+
path = path or favorites_path()
|
|
71
|
+
videos = [video_from_info(entry) for entry in _entries(path)]
|
|
72
|
+
return videos[:limit]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _entries(path: Path) -> list[dict]:
|
|
76
|
+
"""Stored entries newest first, one per video id, skipping corrupt lines."""
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return []
|
|
79
|
+
seen = set()
|
|
80
|
+
entries = []
|
|
81
|
+
for line in reversed(path.read_text(encoding="utf-8").splitlines()):
|
|
82
|
+
try:
|
|
83
|
+
entry = json.loads(line)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
if not isinstance(entry, dict) or "id" not in entry or entry["id"] in seen:
|
|
87
|
+
continue
|
|
88
|
+
seen.add(entry["id"])
|
|
89
|
+
entries.append(entry)
|
|
90
|
+
return entries
|